1 /**
2  * Finding associations between MIME types and applications.
3  * Authors: 
4  *  $(LINK2 https://github.com/MyLittleRobo, Roman Chistokhodov)
5  * Copyright:
6  *  Roman Chistokhodov, 2016
7  * License: 
8  *  $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0).
9  * See_Also: 
10  *  $(LINK2 https://www.freedesktop.org/wiki/Specifications/mime-apps-spec/, MIME Applications Associations)
11  */
12 
13 module mimeapps;
14 
15 private {
16     import std.algorithm;
17     import std.array;
18     import std.exception;
19     import std.file;
20     import std.path;
21     import std.traits;
22     
23     import xdgpaths;
24     import isfreedesktop;
25 }
26 
27 public import desktopfile.file;
28 
29 private @trusted auto parseMimeTypeName(String)(String name) if (isSomeString!String && is(ElementEncodingType!String : char))
30 {
31     alias Tuple!(String, "media", String, "subtype") MimeTypeName;
32     
33     String media;
34     String subtype;
35     
36     size_t i;
37     for (i=0; i<name.length; ++i) {
38         if (name[i] == '/') {
39             media = name[0..i];
40             subtype = name[i+1..$];
41             break;
42         }
43     }
44     
45     return MimeTypeName(media, subtype);
46 }
47 
48 unittest
49 {
50     auto t = parseMimeTypeName("text/plain");
51     assert(t.media == "text" && t.subtype == "plain");
52     
53     t = parseMimeTypeName("not mime type");
54     assert(t.media == string.init && t.subtype == string.init);
55 }
56 
57 private @nogc @trusted bool allSymbolsAreValid(const(char)[] name) nothrow pure
58 {
59     import std.ascii : isAlpha, isDigit;
60     for (size_t i=0; i<name.length; ++i) {
61         char c = name[i];
62         if (!(c.isAlpha || c.isDigit || c == '-' || c == '+' || c == '.' || c == '_')) {
63             return false;
64         }
65     }
66     return true;
67 }
68 
69 private @nogc @safe bool isValidMimeTypeName(const(char)[] name) nothrow pure
70 {
71     auto t = parseMimeTypeName(name);
72     return t.media.length && t.subtype.length && allSymbolsAreValid(t.media) && allSymbolsAreValid(t.subtype);
73 }
74 
75 unittest
76 {
77     assert( isValidMimeTypeName("text/plain"));
78     assert( isValidMimeTypeName("text/plain2"));
79     assert( isValidMimeTypeName("text/vnd.type"));
80     assert( isValidMimeTypeName("x-scheme-handler/http"));
81     assert(!isValidMimeTypeName("not mime type"));
82     assert(!isValidMimeTypeName("not()/valid"));
83     assert(!isValidMimeTypeName("not/valid{}"));
84     assert(!isValidMimeTypeName("text/"));
85     assert(!isValidMimeTypeName("/plain"));
86     assert(!isValidMimeTypeName("/"));
87 }
88 
89 private @trusted void validateMimeType(string mimeType) {
90     if (!isValidMimeTypeName(mimeType)) {
91         throw new Exception("Invalid MIME type name");
92     }
93 }
94 
95 static if (isFreedesktop)
96 {
97     /**
98      * Find all known mimeapps.list files locations. Found paths are not checked for existence.
99      * Returns: Paths of mimeapps.list files in the system.
100      * Note: This function is available only on Freedesktop.
101      * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s02.html, File name and location)
102      */
103     @safe string[] mimeAppsListPaths() nothrow
104     {
105         enum mimeAppsList = "mimeapps.list";
106         enum applicationsMimeAppsList = "applications/mimeapps.list";
107         string configHome = xdgConfigHome(mimeAppsList);
108         string appHome = xdgDataHome(applicationsMimeAppsList);
109         
110         string[] configPaths = xdgConfigDirs(mimeAppsList);
111         string[] appPaths = xdgDataDirs(applicationsMimeAppsList);
112         
113         string[] toReturn;
114         if (configHome.length) {
115             toReturn ~= configHome;
116         }
117         if (appHome.length) {
118             toReturn ~= appHome;
119         }
120         return toReturn ~ configPaths ~ appPaths;
121     }
122     
123     /**
124      * Find all known mimeinfo.cache files locations. Found paths are not checked for existence.
125      * Returns: Paths of mimeinfo.cache files in the system.
126      * Note: This function is available only on Freedesktop.
127      */
128     @safe string[] mimeInfoCachePaths() nothrow
129     {
130         return xdgAllDataDirs("applications/mimeinfo.cache");
131     }
132 }
133 
134 /**
135  * IniLikeGroup subclass for easy access to the list of applications associated with given type.
136  */
137 final class MimeAppsGroup : IniLikeGroup
138 {
139     protected @nogc @safe this(string groupName) nothrow {
140         super(groupName);
141     }
142     
143     /**
144      * Split string list of desktop ids into range.
145      */
146     static @trusted auto splitApps(string apps) {
147         return std.algorithm.splitter(apps, ";").filter!(s => !s.empty);
148     }
149     
150     ///
151     unittest
152     {
153         assert(splitApps("kde4-kate.desktop;kde4-kwrite.desktop;geany.desktop;").equal(["kde4-kate.desktop", "kde4-kwrite.desktop", "geany.desktop"]));
154     }
155     
156     /**
157      * List applications for given mimeType.
158      * Returns: Range of $(B Desktop id)s for mimeType.
159      */
160     @safe auto appsForMimeType(string mimeType) const {
161         return splitApps(value(mimeType));
162     }
163     
164 protected:
165     @trusted override void validateKeyValue(string key, string value) const {
166         validateMimeType(key);
167     }
168 }
169 
170 /**
171  * Class represenation of single mimeapps.list file containing information about MIME type associations and default applications.
172  */
173 final class MimeAppsListFile : IniLikeFile
174 {   
175     /**
176      * Read mimeapps.list file.
177      * Throws:
178      *  $(B ErrnoException) if file could not be opened.
179      *  $(B IniLikeException) if error occured while reading the file or "MIME Cache" group is missing.
180      */
181     @trusted this(string fileName) 
182     {
183         this(iniLikeFileReader(fileName), fileName);
184     }
185     
186     /**
187      * Read MIME type associations from IniLikeReader, e.g. acquired from iniLikeFileReader or iniLikeStringReader.
188      * Throws:
189      *  $(B IniLikeException) if error occured while parsing or "MIME Cache" group is missing.
190      */
191     this(IniLikeReader)(IniLikeReader reader, string fileName = null)
192     {
193         super(reader, fileName);
194     }
195     
196     /**
197      * Access "Desktop Applications" group.
198      * Returns: MimeAppsGroup for "Desktop Applications" group or null if file does not have such group.
199      */
200     @safe inout(MimeAppsGroup) defaultApplications() nothrow inout {
201         return _defaultApps;
202     }
203     
204     /**
205      * Access "Added Associations" group.
206      * Returns: MimeAppsGroup for "Added Associations" group or null if file does not have such group.
207      */
208     @safe inout(MimeAppsGroup) addedAssociations() nothrow inout {
209         return _addedApps;
210     }
211     
212     /**
213      * Access "Removed Associations" group.
214      * Returns: MimeAppsGroup for "Removed Associations" group or null if file does not have such group.
215      */
216     @safe inout(MimeAppsGroup) removedAssociations() nothrow inout {
217         return _removedApps;
218     }
219     
220 protected:
221     @trusted override void addCommentForGroup(string comment, IniLikeGroup currentGroup, string groupName) {
222         return;
223     }
224     
225     @trusted override void addKeyValueForGroup(string key, string value, IniLikeGroup currentGroup, string groupName)
226     {
227         if (currentGroup) {
228             if (currentGroup.contains(key)) {
229                 return;
230             }
231             currentGroup[key] = value;
232         }
233     }
234     
235     @trusted override IniLikeGroup createGroup(string groupName)
236     {
237         auto existent = group(groupName);
238         if (existent !is null) {
239             return existent;
240         } else {
241             if (groupName == "Default Applications") {
242                 _defaultApps = new MimeAppsGroup(groupName);
243                 return _defaultApps;
244             } else if (groupName == "Added Associations") {
245                 _addedApps = new MimeAppsGroup(groupName);
246                 return _addedApps;
247             } else if (groupName == "Removed Associations") {
248                 _removedApps = new MimeAppsGroup(groupName);
249                 return _removedApps;
250             } else {
251                 return null;
252             }
253         }
254     }
255     
256 private:
257     MimeAppsGroup _addedApps;
258     MimeAppsGroup _removedApps;
259     MimeAppsGroup _defaultApps;
260 }
261 
262 ///
263 unittest
264 {
265     string content = 
266 `[Added Associations]
267 text/plain=geany.desktop;kde4-kwrite.desktop;
268 image/png=kde4-gwenview.desktop;gthumb.desktop;
269 
270 [Removed Associations]
271 text/plain=libreoffice-writer.desktop;
272 
273 [Default Applications]
274 text/plain=kde4-kate.desktop
275 x-scheme-handler/http=chromium.desktop;iceweasel.desktop;
276 `;
277     auto mimeAppsList = new MimeAppsListFile(iniLikeStringReader(content));
278     assert(mimeAppsList.addedAssociations() !is null);
279     assert(mimeAppsList.removedAssociations() !is null);
280     assert(mimeAppsList.defaultApplications() !is null);
281     
282     assert(mimeAppsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop", "kde4-kwrite.desktop"]));
283     assert(mimeAppsList.removedAssociations().appsForMimeType("text/plain").equal(["libreoffice-writer.desktop"]));
284     assert(mimeAppsList.defaultApplications().appsForMimeType("x-scheme-handler/http").equal(["chromium.desktop", "iceweasel.desktop"]));
285 }
286 
287 /**
288  * Class represenation of single mimeinfo.cache file containing information about MIME type associations.
289  */
290 final class MimeInfoCacheFile : IniLikeFile
291 {    
292     /**
293      * Read MIME Cache from file.
294      * Throws:
295      *  $(B ErrnoException) if file could not be opened.
296      *  $(B IniLikeException) if error occured while reading the file or "MIME Cache" group is missing.
297      */
298     @trusted this(string fileName) 
299     {
300         this(iniLikeFileReader(fileName), fileName);
301     }
302     
303     /**
304      * Constructs MimeInfoCacheFile with empty MIME Cache group.
305      */
306     @safe this() {
307         super();
308         addGroup("MIME Cache");
309     }
310     
311     ///
312     unittest
313     {
314         auto micf = new MimeInfoCacheFile();
315         assert(micf.mimeCache() !is null);
316     }
317     
318     /**
319      * Read MIME Cache from IniLikeReader, e.g. acquired from iniLikeFileReader or iniLikeStringReader.
320      * Throws:
321      *  $(B IniLikeException) if error occured while parsing or "MIME Cache" group is missing.
322      */
323     this(IniLikeReader)(IniLikeReader reader, string fileName = null)
324     {
325         super(reader, fileName);
326         enforce(_mimeCache !is null, new IniLikeException("No \"MIME Cache\" group", 0));
327     }
328     
329     /**
330      * Access "MIME Cache" group.
331      */
332     @safe inout(MimeAppsGroup) mimeCache() nothrow inout {
333         return _mimeCache;
334     }
335     
336     /**
337      * Alias for easy access to "MIME Cache" group.
338      */
339     alias mimeCache this;
340 
341 protected:
342     @trusted override void addCommentForGroup(string comment, IniLikeGroup currentGroup, string groupName) {
343         return;
344     }
345     
346     @trusted override void addKeyValueForGroup(string key, string value, IniLikeGroup currentGroup, string groupName)
347     {
348         if (currentGroup) {
349             if (currentGroup.contains(key)) {
350                 return;
351             }
352             currentGroup[key] = value;
353         }
354     }
355     
356     @trusted override IniLikeGroup createGroup(string groupName)
357     {
358         auto existent = group(groupName);
359         if (existent !is null) {
360             return existent;
361         } else {
362             if (groupName == "MIME Cache") {
363                 _mimeCache = new MimeAppsGroup(groupName);
364                 return _mimeCache;
365             } else {
366                 return null;
367             }
368         }
369     }
370 private:
371     MimeAppsGroup _mimeCache;
372 }
373 
374 ///
375 unittest
376 {
377     string content = 
378 `[Some group]
379 Key=Value
380 `;
381     assertThrown!IniLikeException(new MimeInfoCacheFile(iniLikeStringReader(content)));
382     
383     content = 
384 `[MIME Cache]
385 text/plain=geany.desktop;kde4-kwrite.desktop;
386 image/png=kde4-gwenview.desktop;gthumb.desktop;
387 `;
388 
389     auto mimeInfoCache = new MimeInfoCacheFile(iniLikeStringReader(content));
390     assert(mimeInfoCache.appsForMimeType("text/plain").equal(["geany.desktop", "kde4-kwrite.desktop"]));
391     assert(mimeInfoCache.appsForMimeType("image/png").equal(["kde4-gwenview.desktop", "gthumb.desktop"]));
392     assert(mimeInfoCache.appsForMimeType("application/nonexistent").empty);
393     
394     content =
395 `[MIME Cache]
396 text/plain=geany.desktop;
397 notmimetype=value
398 `;
399     assertThrown!IniLikeException(new MimeInfoCacheFile(iniLikeStringReader(content)));
400 }
401 
402 /**
403  * Create MimeAppsListFile objects for paths.
404  * Returns: Array of MimeAppsListFile objects read from paths. If some could not be read it's not included in the results.
405  */
406 @trusted MimeAppsListFile[] mimeAppsListFiles(const(string)[] paths) nothrow
407 {
408     return paths.map!(function(string path) {
409         MimeAppsListFile file;
410         collectException(new MimeAppsListFile(path), file);
411         return file;
412     }).filter!(file => file !is null).array;
413 }
414 
415 static if (isFreedesktop)
416 {
417     /**
418      * ditto, but automatically read MimeAppsListFile objects from determined system paths.
419      * Note: Available only on Freedesktop.
420      */
421     @safe MimeAppsListFile[] mimeAppsListFiles() nothrow {
422         return mimeAppsListFiles(mimeAppsListPaths());
423     }
424 }
425 
426 /**
427  * Create MimeInfoCacheFile objects for paths.
428  * Returns: Array of MimeInfoCacheFile objects read from paths. If some could not be read it's not included in the results.
429  */
430 @trusted MimeInfoCacheFile[] mimeInfoCacheFiles(const(string)[] paths) nothrow
431 {
432     return paths.map!(function(string path) {
433         MimeInfoCacheFile file;
434         collectException(new MimeInfoCacheFile(path), file);
435         return file;
436     }).filter!(file => file !is null).array;
437 }
438 
439 static if (isFreedesktop)
440 {
441     /**
442      * ditto, but automatically read MimeInfoCacheFile objects from determined system paths.
443      * Note: Available only on Freedesktop.
444      */
445     @safe MimeInfoCacheFile[] mimeInfoCacheFiles() nothrow {
446         return mimeInfoCacheFiles(mimeInfoCachePaths());
447     }
448 }
449 
450 /**
451  * Interface for desktop file provider.
452  * See_Also: findAssociatedApplications, findKnownAssociatedApplications, findDefaultApplication
453  */
454 interface IDesktopFileProvider
455 {
456     /**
457      * Retrieve DesktopFile by desktopId
458      * Returns: Found DesktopFile or null if not found.
459      */
460     const(DesktopFile) getByDesktopId(string desktopId);
461     
462     /**
463      * Update internal information, e.g. re-read cached .desktop files if needed.
464      */
465     void update();
466 }
467 
468 /**
469  * Implementation of desktop file provider.
470  */
471 class DesktopFileProvider : IDesktopFileProvider
472 {
473 private:
474     import std.datetime : SysTime;
475     
476     static struct DesktopFileItem
477     {
478         DesktopFile desktopFile;
479         SysTime time;
480         string baseDir;
481         SysTime baseDirTime;
482     }
483     
484     static struct BaseDirItem
485     {
486         string path;
487         SysTime time;
488         bool valid;
489     }
490     
491 public:
492     /**
493      * Construct using applicationsPaths. Automatically calls update.
494      * Params:
495      *  applicationsPaths = Paths of applications/ directories where .desktop files are stored. 
496      *  options = Options used to read desktop files.
497      * These should be all known paths even if they don't exist at the time.
498      */
499     @trusted this(const(string)[] applicationsPaths, DesktopFile.ReadOptions options = DesktopFile.defaultReadOptions) {
500         _baseDirItems = applicationsPaths.map!(p => BaseDirItem(p, SysTime.init, false)).array;
501         _readOptions = options;
502         update();
503     }
504     
505     override const(DesktopFile) getByDesktopId(string desktopId)
506     {
507         auto itemIn = desktopId in _cache;
508         if (itemIn) {
509             return itemIn.desktopFile;
510         } else {
511             auto foundItem = getDesktopFileItem(desktopId);
512             if (foundItem.desktopFile !is null) {
513                 _cache[desktopId] = foundItem;
514                 return foundItem.desktopFile;
515             }
516         }
517         return null;
518     }
519     
520     override void update()
521     {
522         foreach (ref item; _baseDirItems) {
523             try {
524                 SysTime accessTime;
525                 getTimes(item.path, accessTime, item.time);
526                 item.valid = item.path.isDir();
527             } catch(Exception e) {
528                 item.valid = false;
529             }
530         }
531         
532         foreach(desktopId, item; _cache) {
533             SysTime modifyTime;
534             BaseDirItem baseDirItem;
535             string filePath = findDesktopFilePath(desktopId, modifyTime, baseDirItem);
536             
537             if (filePath.length) {
538                 if (item.time != modifyTime || item.baseDir != baseDirItem.path) {
539                     try {
540                         auto desktopFile = new DesktopFile(filePath, _readOptions);
541                         _cache[desktopId] = DesktopFileItem(desktopFile, modifyTime, baseDirItem.path, baseDirItem.time);
542                     } catch(Exception e) {
543                         _cache.remove(desktopId);
544                     }
545                 }
546             } else {
547                 _cache.remove(desktopId);
548             }
549         }
550     }
551     
552 private:
553     DesktopFileItem getDesktopFileItem(string desktopId)
554     {
555         SysTime modifyTime;
556         BaseDirItem baseDirItem;
557         string filePath = findDesktopFilePath(desktopId, modifyTime, baseDirItem);
558         if (filePath.length) {
559             try {
560                 auto desktopFile = new DesktopFile(filePath, _readOptions);
561                 return DesktopFileItem(desktopFile, modifyTime, baseDirItem.path, baseDirItem.time);
562             } catch(Exception e) {
563                 return DesktopFileItem.init;
564             }
565         }
566         return DesktopFileItem.init;
567     }
568     
569     string findDesktopFilePath(string desktopId, out SysTime modifyTime, out BaseDirItem dirItem)
570     {
571         foreach(baseDirItem; _baseDirItems) {
572             if (!baseDirItem.valid) {
573                 continue;
574             }
575             
576             auto filePath = buildPath(baseDirItem.path, desktopId);
577             bool fileExists = filePath.exists;
578             if (!fileExists && filePath.canFind('-')) {
579                 //try subdirectory
580                 filePath = buildPath(baseDirItem.path, desktopId.replace("-", "/"));
581                 fileExists = filePath.exists;
582             }
583             if (fileExists) {
584                 try {
585                     SysTime accessTime;
586                     getTimes(filePath, accessTime, modifyTime);
587                     dirItem = baseDirItem;
588                     return filePath;
589                 } catch(Exception e) {
590                     return null;
591                 }
592             }
593         }
594         return null;
595     }
596     
597     DesktopFileItem[string] _cache;
598     BaseDirItem[] _baseDirItems;
599     DesktopFile.ReadOptions _readOptions;
600 }
601 
602 private enum FindAssocFlag {
603     none = 0,
604     onlyFirst = 1,
605     ignoreRemovedGroup = 2
606 }
607 
608 private const(DesktopFile)[] findAssociatedApplicationsImpl(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, IDesktopFileProvider desktopFileProvider, FindAssocFlag flag = FindAssocFlag.none)
609 {
610     string[] removed;
611     const(DesktopFile)[] desktopFiles;
612     foreach(mimeAppsListFile; mimeAppsListFiles) {
613         if (mimeAppsListFile is null) {
614             continue;
615         }
616         
617         auto removedAppsGroup = mimeAppsListFile.removedAssociations();
618         if (removedAppsGroup !is null && !(flag & FindAssocFlag.ignoreRemovedGroup)) {
619             removed ~= removedAppsGroup.appsForMimeType(mimeType).array;
620         }
621         auto addedAppsGroup = mimeAppsListFile.addedAssociations();
622         if (addedAppsGroup !is null) {
623             foreach(desktopId; addedAppsGroup.appsForMimeType(mimeType)) {
624                 if (removed.canFind(desktopId)) {
625                     continue;
626                 }
627                 auto desktopFile = desktopFileProvider.getByDesktopId(desktopId);
628                 if (desktopFile) {
629                     if (flag & FindAssocFlag.onlyFirst) {
630                         return [desktopFile];
631                     }
632                     desktopFiles ~= desktopFile;
633                 }
634                 removed ~= desktopId;
635             }
636         }
637     }
638     
639     foreach(mimeInfoCacheFile; mimeInfoCacheFiles) {
640         if (mimeInfoCacheFile is null) {
641             continue;
642         }
643         
644         foreach(desktopId; mimeInfoCacheFile.appsForMimeType(mimeType)) {
645             if (removed.canFind(desktopId)) {
646                 continue;
647             }
648             auto desktopFile = desktopFileProvider.getByDesktopId(desktopId);
649             if (desktopFile) {
650                 if (flag & FindAssocFlag.onlyFirst) {
651                     return [desktopFile];
652                 }
653                 desktopFiles ~= desktopFile;
654             }
655             removed ~= desktopId;
656         }
657     }
658     
659     return desktopFiles;
660 }
661 
662 /**
663  * Find associated applications for mimeType.
664  * Params:
665  *  mimeType = MIME type or uri scheme handler in question.
666  *  mimeAppsListFiles = Range of MimeAppsListFile objects to use in searching.
667  *  mimeInfoCacheFiles = Range of MimeInfoCacheFile objects to use in searching.
668  *  desktopFileProvider = desktop file provider instance.
669  * Returns: Array of found $(B DesktopFile) object capable of opening file of given MIME type or url of given scheme.
670  * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s03.html, Adding/removing associations)
671  */
672 const(DesktopFile)[] findAssociatedApplications(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, IDesktopFileProvider desktopFileProvider)
673 if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile)) 
674     && isForwardRange!CacheRange && is(ElementType!CacheRange : const(MimeInfoCacheFile)))
675 in {
676     assert(desktopFileProvider !is null);
677 }
678 body {
679     return findAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, desktopFileProvider);
680 }
681 
682 /**
683  * Find all known associated applications for mimeType, including explicitly removed by user.
684  * Params:
685  *  mimeType = MIME type or uri scheme handler in question.
686  *  mimeAppsListFiles = Range of MimeAppsListFile objects to use in searching.
687  *  mimeInfoCacheFiles = Range of MimeInfoCacheFile objects to use in searching.
688  *  desktopFileProvider = desktop file provider instance.
689  * Returns: Array of found $(B DesktopFile) object capable of opening file of given MIME type or url of given scheme.
690  */
691 const(DesktopFile)[] findKnownAssociatedApplications(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, IDesktopFileProvider desktopFileProvider)
692 {
693     return findAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, desktopFileProvider, FindAssocFlag.ignoreRemovedGroup);
694 }
695 
696 /**
697  * Find default application for mimeType.
698  * Params:
699  *  mimeType = MIME type or uri scheme handler in question.
700  *  mimeAppsListFiles = Range of MimeAppsListFile objects to use in searching.
701  *  mimeInfoCacheFiles = Range of MimeInfoCacheFile objects to use in searching.
702  *  desktopFileProvider = desktop file provider instance. Must be non-null.
703  * Returns: Found $(B DesktopFile) or null if not found.
704  * Note: In real world you probably will need to call this function on parent MIME type if it fails for original mimeType.
705  * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s04.html, Default Application)
706  */
707 const(DesktopFile) findDefaultApplication(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, IDesktopFileProvider desktopFileProvider)
708 if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile)) 
709     && isForwardRange!CacheRange && is(ElementType!CacheRange : const(MimeInfoCacheFile)))
710 in {
711     assert(desktopFileProvider !is null);
712 }
713 body {
714     foreach(mimeAppsListFile; mimeAppsListFiles) {
715         if (mimeAppsListFile is null) {
716             continue;
717         }
718         auto defaultAppsGroup = mimeAppsListFile.defaultApplications();
719         if (defaultAppsGroup !is null) {
720             foreach(desktopId; defaultAppsGroup.appsForMimeType(mimeType)) {
721                 auto desktopFile = desktopFileProvider.getByDesktopId(desktopId);
722                 if (desktopFile !is null) {
723                     return desktopFile;
724                 }
725             }
726         }
727     }
728     
729     auto desktopFiles = findAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, desktopFileProvider, FindAssocFlag.onlyFirst);
730     return desktopFiles.length ? desktopFiles.front : null;
731 }