1 /**
2  * Finding associations between MIME types and applications.
3  * Authors:
4  *  $(LINK2 https://github.com/FreeSlave, 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.range;
22     import std.traits;
23 
24     import xdgpaths;
25     import isfreedesktop;
26     import findexecutable;
27 }
28 
29 public import desktopfile.file;
30 
31 private @nogc @trusted auto parseMimeTypeName(String)(String name) pure nothrow if (isSomeString!String && is(ElementEncodingType!String : char))
32 {
33     alias Tuple!(String, "media", String, "subtype") MimeTypeName;
34 
35     String media;
36     String subtype;
37 
38     size_t i;
39     for (i=0; i<name.length; ++i) {
40         if (name[i] == '/') {
41             media = name[0..i];
42             subtype = name[i+1..$];
43             break;
44         }
45     }
46 
47     return MimeTypeName(media, subtype);
48 }
49 
50 unittest
51 {
52     auto t = parseMimeTypeName("text/plain");
53     assert(t.media == "text" && t.subtype == "plain");
54 
55     t = parseMimeTypeName("not mime type");
56     assert(t.media == string.init && t.subtype == string.init);
57 }
58 
59 private @nogc @trusted bool allSymbolsAreValid(scope const(char)[] name) nothrow pure
60 {
61     import std.ascii : isAlpha, isDigit;
62     for (size_t i=0; i<name.length; ++i) {
63         char c = name[i];
64         if (!(c.isAlpha || c.isDigit || c == '-' || c == '+' || c == '.' || c == '_')) {
65             return false;
66         }
67     }
68     return true;
69 }
70 
71 private @nogc @safe bool isValidMimeTypeName(scope const(char)[] name) nothrow pure
72 {
73     auto t = parseMimeTypeName(name);
74     return t.media.length && t.subtype.length && allSymbolsAreValid(t.media) && allSymbolsAreValid(t.subtype);
75 }
76 
77 unittest
78 {
79     assert( isValidMimeTypeName("text/plain"));
80     assert( isValidMimeTypeName("text/plain2"));
81     assert( isValidMimeTypeName("text/vnd.type"));
82     assert( isValidMimeTypeName("x-scheme-handler/http"));
83     assert(!isValidMimeTypeName("not mime type"));
84     assert(!isValidMimeTypeName("not()/valid"));
85     assert(!isValidMimeTypeName("not/valid{}"));
86     assert(!isValidMimeTypeName("text/"));
87     assert(!isValidMimeTypeName("/plain"));
88     assert(!isValidMimeTypeName("/"));
89 }
90 
91 private @trusted void validateMimeType(string groupName, string mimeType, string value) {
92     if (!isValidMimeTypeName(mimeType)) {
93         throw new IniLikeEntryException("Invalid MIME type name", groupName, mimeType, value);
94     }
95 }
96 
97 static if (isFreedesktop)
98 {
99     version(unittest) {
100         import std.process : environment;
101 
102         package struct EnvGuard
103         {
104             this(string env, string newValue) {
105                 envVar = env;
106                 envValue = environment.get(env);
107                 environment[env] = newValue;
108             }
109 
110             ~this() {
111                 if (envValue is null) {
112                     environment.remove(envVar);
113                 } else {
114                     environment[envVar] = envValue;
115                 }
116             }
117 
118             string envVar;
119             string envValue;
120         }
121     }
122 
123     private enum mimeAppsList = "mimeapps.list";
124     private enum applicationsMimeAppsList = "applications/mimeapps.list";
125 
126     /// Get desktop prefixes for mimeapps.list overrides.
127     @safe auto getDesktopPrefixes() nothrow
128     {
129         import std.process : environment;
130         import std.uni : toLower;
131         import std.utf : byCodeUnit;
132 
133         string xdgCurrentDesktop;
134         collectException(environment.get("XDG_CURRENT_DESKTOP"), xdgCurrentDesktop);
135         return xdgCurrentDesktop.byCodeUnit.splitter(':').map!(prefix => prefix.toLower);
136     }
137 
138     ///
139     unittest
140     {
141         auto currentDesktopGuard = EnvGuard("XDG_CURRENT_DESKTOP", "LXQT");
142         assert(equal(getDesktopPrefixes(), ["lxqt"]));
143 
144         environment["XDG_CURRENT_DESKTOP"] = "unity:GNOME";
145         assert(equal(getDesktopPrefixes(), ["unity", "gnome"]));
146     }
147 
148     private @safe string[] getDesktopPrefixesArray() nothrow
149     {
150         string[] toReturn;
151         collectException(getDesktopPrefixes().array, toReturn);
152         return toReturn;
153     }
154 
155     /**
156      * Find all writable mimeapps.list files locations including specific for the current desktop.
157      * Found paths are not checked for existence or write access.
158      *
159      * $(BLUE This function is Freedesktop only).
160      * See_Also: $(D userMimeAppsListPaths), $(D userAppDataMimeAppsListPaths)
161      */
162     deprecated @safe string[] writableMimeAppsListPaths() nothrow
163     {
164         return userMimeAppsListPaths() ~ userAppDataMimeAppsListPaths();
165     }
166 
167     /// ditto, but using user-provided prefix.
168     deprecated @safe string[] writableMimeAppsListPaths(const string[] desktopPrefixes) nothrow
169     {
170         return userMimeAppsListPaths(desktopPrefixes) ~ userAppDataMimeAppsListPaths(desktopPrefixes);
171     }
172 
173     /**
174      * Find mimeapps.list files locations for user overrides including specific for the current desktop.
175      * Found paths are not checked for existence or write access.
176      *
177      * $(BLUE This function is Freedesktop only).
178      * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s02.html, File name and location)
179      */
180     @safe string[] userMimeAppsListPaths() nothrow
181     {
182         return userMimeAppsListPaths(getDesktopPrefixesArray());
183     }
184 
185     ///
186     unittest
187     {
188         auto configHomeGuard = EnvGuard("XDG_CONFIG_HOME", "/home/user/config");
189         auto configDirsGuard = EnvGuard("XDG_CONFIG_DIRS", "/etc/xdg");
190 
191         auto dataHomeGuard = EnvGuard("XDG_DATA_HOME", "/home/user/data");
192         auto dataDirsGuard = EnvGuard("XDG_DATA_DIRS", "/usr/local/data:/usr/data");
193 
194         auto currentDesktopGuard = EnvGuard("XDG_CURRENT_DESKTOP", "unity:GNOME");
195 
196         assert(userMimeAppsListPaths() == ["/home/user/config/unity-mimeapps.list", "/home/user/config/gnome-mimeapps.list", "/home/user/config/mimeapps.list"]);
197     }
198 
199     /// ditto, but using user-provided desktop prefixes (can be empty, in which case desktop-specific mimeapps.list locations are not included).
200     @safe string[] userMimeAppsListPaths(const string[] desktopPrefixes) nothrow
201     {
202         string[] toReturn;
203         string configHome = xdgConfigHome();
204         if (configHome.length) {
205             foreach(string desktopPrefix; desktopPrefixes)
206             {
207                 toReturn ~= buildPath(configHome, desktopPrefix ~ "-" ~ mimeAppsList);
208             }
209             toReturn ~= buildPath(configHome, mimeAppsList);
210         }
211         return toReturn;
212     }
213 
214     /**
215      * Find mimeapps.list files deprecated locations for user overrides including specific for the current desktop.
216      * Found paths are not checked for existence or write access.
217      * These locations are kept for compatibility.
218      *
219      * $(BLUE This function is Freedesktop only).
220      * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s02.html, File name and location)
221      */
222     @safe string[] userAppDataMimeAppsListPaths() nothrow
223     {
224         return userAppDataMimeAppsListPaths(getDesktopPrefixesArray());
225     }
226 
227     ///
228     unittest
229     {
230         auto dataHomeGuard = EnvGuard("XDG_DATA_HOME", "/home/user/data");
231         auto currentDesktopGuard = EnvGuard("XDG_CURRENT_DESKTOP", "unity:GNOME");
232 
233         assert(userAppDataMimeAppsListPaths() == ["/home/user/data/applications/unity-mimeapps.list", "/home/user/data/applications/gnome-mimeapps.list", "/home/user/data/applications/mimeapps.list"]);
234     }
235 
236     /// ditto, but using user-provided desktop prefixes (can be empty, in which case desktop-specific mimeapps.list locations are not included).
237     @safe string[] userAppDataMimeAppsListPaths(const string[] desktopPrefixes) nothrow
238     {
239         string[] toReturn;
240         string appHome = xdgDataHome("applications");
241         if (appHome.length) {
242             foreach(string desktopPrefix; desktopPrefixes)
243             {
244                 toReturn ~= buildPath(appHome, desktopPrefix ~ "-" ~ mimeAppsList);
245             }
246             toReturn ~= buildPath(appHome, mimeAppsList);
247         }
248         return toReturn;
249     }
250 
251     /**
252      * Find mimeapps.list files locations for sysadmin and ISV overrides including specific for the current desktop.
253      * Found paths are not checked for existence.
254      *
255      * $(BLUE This function is Freedesktop only).
256      * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s02.html, File name and location)
257      */
258     @safe string[] vendorMimeAppsListPaths() nothrow
259     {
260         return vendorMimeAppsListPaths(getDesktopPrefixesArray());
261     }
262 
263     ///
264     unittest
265     {
266         auto configDirsGuard = EnvGuard("XDG_CONFIG_DIRS", "/etc/xdg");
267         auto currentDesktopGuard = EnvGuard("XDG_CURRENT_DESKTOP", "unity:GNOME");
268 
269         assert(vendorMimeAppsListPaths() == ["/etc/xdg/unity-mimeapps.list", "/etc/xdg/gnome-mimeapps.list", "/etc/xdg/mimeapps.list"]);
270     }
271 
272     /// ditto, but using user-provided desktop prefixes (can be empty, in which case desktop-specific mimeapps.list locations are not included).
273     @safe string[] vendorMimeAppsListPaths(const string[] desktopPrefixes) nothrow
274     {
275         string[] toReturn;
276         string[] configDirs = xdgConfigDirs();
277 
278         foreach(string desktopPrefix; desktopPrefixes)
279         {
280             foreach(configDir; configDirs)
281             {
282                 toReturn ~= buildPath(configDir, desktopPrefix ~ "-" ~ mimeAppsList);
283             }
284         }
285         foreach(configDir; configDirs)
286         {
287             toReturn ~= buildPath(configDir, mimeAppsList);
288         }
289         return toReturn;
290     }
291 
292     /**
293      * Find mimeapps.list files locations for distribution provided defaults including specific for the current desktop.
294      * Found paths are not checked for existence.
295      *
296      * $(BLUE This function is Freedesktop only).
297      * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s02.html, File name and location)
298      */
299     @safe string[] distributionMimeAppsListPaths() nothrow
300     {
301         return distributionMimeAppsListPaths(getDesktopPrefixesArray());
302     }
303 
304     ///
305     unittest
306     {
307         auto dataDirsGuard = EnvGuard("XDG_DATA_DIRS", "/usr/local/data:/usr/data");
308         auto currentDesktopGuard = EnvGuard("XDG_CURRENT_DESKTOP", "unity:GNOME");
309 
310         assert(distributionMimeAppsListPaths() == [
311                 "/usr/local/data/applications/unity-mimeapps.list", "/usr/data/applications/unity-mimeapps.list",
312                 "/usr/local/data/applications/gnome-mimeapps.list", "/usr/data/applications/gnome-mimeapps.list",
313                 "/usr/local/data/applications/mimeapps.list",       "/usr/data/applications/mimeapps.list"]);
314     }
315 
316     /// ditto, but using user-provided desktop prefixes (can be empty, in which case desktop-specific mimeapps.list locations are not included).
317     @safe string[] distributionMimeAppsListPaths(const string[] desktopPrefixes) nothrow
318     {
319         string[] toReturn;
320         string[] appDataDirs = xdgDataDirs("applications");
321 
322         foreach(string desktopPrefix; desktopPrefixes)
323         {
324             foreach(appDataDir; appDataDirs)
325             {
326                 toReturn ~= buildPath(appDataDir, desktopPrefix ~ "-" ~ mimeAppsList);
327             }
328         }
329         foreach(appDataDir; appDataDirs)
330         {
331             toReturn ~= buildPath(appDataDir, mimeAppsList);
332         }
333         return toReturn;
334     }
335 
336     /**
337      * Find all known mimeapps.list files locations.
338      * Found paths are not checked for existence.
339      *
340      * $(BLUE This function is Freedesktop only).
341      * Returns: Paths of mimeapps.list files in the system.
342      * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s02.html, File name and location)
343      */
344     @safe string[] mimeAppsListPaths() nothrow
345     {
346         return mimeAppsListPaths(getDesktopPrefixesArray());
347     }
348 
349     /// ditto, but using user-provided desktop prefixes (can be empty, in which case desktop-specific mimeapps.list locations are not included).
350     @safe string[] mimeAppsListPaths(const string[] desktopPrefixes) nothrow
351     {
352         return userMimeAppsListPaths(desktopPrefixes) ~ vendorMimeAppsListPaths(desktopPrefixes) ~
353             userAppDataMimeAppsListPaths(desktopPrefixes) ~ distributionMimeAppsListPaths(desktopPrefixes);
354     }
355 
356     /**
357      * Find all known mimeinfo.cache files locations. Found paths are not checked for existence.
358      *
359      * $(BLUE This function is Freedesktop only).
360      * Returns: Paths of mimeinfo.cache files in the system.
361      */
362     @safe string[] mimeInfoCachePaths() nothrow
363     {
364         return xdgAllDataDirs("applications/mimeinfo.cache");
365     }
366 
367     ///
368     unittest
369     {
370         auto dataHomeGuard = EnvGuard("XDG_DATA_HOME", "/home/user/data");
371         auto dataDirsGuard = EnvGuard("XDG_DATA_DIRS", "/usr/local/data:/usr/data");
372 
373         assert(mimeInfoCachePaths() == [
374             "/home/user/data/applications/mimeinfo.cache",
375             "/usr/local/data/applications/mimeinfo.cache", "/usr/data/applications/mimeinfo.cache"
376         ]);
377     }
378 }
379 
380 /**
381  * $(D inilike.file.IniLikeGroup) subclass for easy access to the list of applications associated with given type.
382  */
383 final class MimeAppsGroup : IniLikeGroup
384 {
385     protected @nogc @safe this(string groupName) nothrow {
386         super(groupName);
387     }
388 
389     /**
390      * Split string list of desktop ids into range.
391      * See_Also: $(D joinApps)
392      */
393     static @trusted auto splitApps(string apps) {
394         return std.algorithm.splitter(apps, ";").filter!(s => !s.empty);
395     }
396 
397     ///
398     unittest
399     {
400         assert(splitApps("kde4-kate.desktop;kde4-kwrite.desktop;geany.desktop;").equal(["kde4-kate.desktop", "kde4-kwrite.desktop", "geany.desktop"]));
401     }
402 
403     /**
404      * Join list of desktop ids into string.
405      * See_Also: $(D splitApps)
406      */
407     static @trusted string joinApps(Range)(Range apps, bool trailingSemicolon = true) if (isInputRange!Range && is(ElementType!Range : string))
408     {
409         auto result = apps.filter!( s => !s.empty ).joiner(";");
410         if (result.empty) {
411             return null;
412         } else {
413             return text(result) ~ (trailingSemicolon ? ";" : "");
414         }
415     }
416 
417     ///
418     unittest
419     {
420         assert(joinApps(["kde4-kate.desktop", "kde4-kwrite.desktop", "geany.desktop"]) == "kde4-kate.desktop;kde4-kwrite.desktop;geany.desktop;");
421         assert(joinApps((string[]).init) == string.init);
422         assert(joinApps(["geany.desktop"], false) == "geany.desktop");
423         assert(joinApps(["geany.desktop"], true) == "geany.desktop;");
424         assert(joinApps((string[]).init, true) == string.init);
425     }
426 
427     /**
428      * List applications for given mimeType.
429      * Returns: Range of $(B Desktop id)s for mimeType.
430      */
431     @safe auto appsForMimeType(string mimeType) const {
432         return splitApps(unescapedValue(mimeType));
433     }
434 
435     /**
436      * Delete desktopId from the list of desktop ids for mimeType.
437      */
438     @trusted void deleteAssociation(string mimeType, string desktopId)
439     {
440         auto appsStr = unescapedValue(mimeType);
441         if (appsStr.length) {
442             const bool hasSemicolon = appsStr[$-1] == ';';
443             auto apps = splitApps(appsStr);
444             if (apps.canFind(desktopId)) {
445                 auto without = apps.filter!(d => d != desktopId);
446                 if (without.empty) {
447                     removeEntry(mimeType);
448                 } else {
449                     setUnescapedValue(mimeType, MimeAppsGroup.joinApps(without, hasSemicolon));
450                 }
451             }
452         }
453     }
454 
455     /**
456      * Set list of desktop ids for mimeType. This overwrites existing list completely.
457      * Can be used to set the list of added assocations rearranged in client code to manage the preference order.
458      */
459     @safe void setAssocations(Range)(string mimeType, Range desktopIds) if (isInputRange!Range && is(ElementType!Range : string))
460     {
461         setUnescapedValue(mimeType, joinApps(desktopIds));
462     }
463 
464 protected:
465     @trusted override void validateKey(string key, string value) const {
466         validateMimeType(groupName(), key, value);
467     }
468 }
469 
470 /**
471  * Class represenation of single mimeapps.list file containing information about MIME type associations and default applications.
472  */
473 final class MimeAppsListFile : IniLikeFile
474 {
475     /**
476      * Read mimeapps.list file.
477      * Throws:
478      *  $(B ErrnoException) if file could not be opened.
479      *  $(D inilike.file.IniLikeReadException) if error occured while reading the file or "MIME Cache" group is missing.
480      */
481     @trusted this(string fileName, ReadOptions readOptions = ReadOptions.init)
482     {
483         this(iniLikeFileReader(fileName), fileName, readOptions);
484     }
485 
486     /**
487      * Read MIME type associations from $(D inilike.range.IniLikeReader), e.g. acquired from $(D inilike.range.iniLikeFileReader) or $(D inilike.range.iniLikeStringReader).
488      * Throws:
489      *  $(D inilike.file.IniLikeReadException) if error occured while parsing or "MIME Cache" group is missing.
490      */
491     this(IniLikeReader)(IniLikeReader reader, string fileName = null, ReadOptions readOptions = ReadOptions.init)
492     {
493         super(reader, fileName, readOptions);
494         _defaultApps = cast(MimeAppsGroup)group("Default Applications");
495         _addedApps = cast(MimeAppsGroup)group("Added Associations");
496         _removedApps = cast(MimeAppsGroup)group("Removed Associations");
497     }
498 
499     this()
500     {
501         super();
502     }
503 
504     /**
505      * Access "Desktop Applications" group of default associations.
506      * Returns: $(D MimeAppsGroup) for "Desktop Applications" group or null if file does not have such group.
507      * See_Also: $(D addedAssociations)
508      */
509     @safe inout(MimeAppsGroup) defaultApplications() nothrow inout pure {
510         return _defaultApps;
511     }
512 
513     /**
514      * Access "Added Associations" group of explicitly added associations.
515      * Returns: $(D MimeAppsGroup) for "Added Associations" group or null if file does not have such group.
516      * See_Also: $(D defaultApplications), $(D removedAssociations)
517      */
518     @safe inout(MimeAppsGroup) addedAssociations() nothrow inout pure {
519         return _addedApps;
520     }
521 
522     /**
523      * Access "Removed Associations" group of explicitily removed associations.
524      * Returns: $(D MimeAppsGroup) for "Removed Associations" group or null if file does not have such group.
525      * See_Also: $(D addedAssociations)
526      */
527     @safe inout(MimeAppsGroup) removedAssociations() nothrow inout pure {
528         return _removedApps;
529     }
530 
531     /**
532      * Set desktopId as default application for mimeType.
533      * Set it as first element in the list of added associations.
534      * Delete it from removed associations if listed.
535      * Note: This only changes the object, but not file itself.
536      */
537     @trusted void setDefaultApplication(string mimeType, string desktopId)
538     {
539         if (mimeType.empty || desktopId.empty) {
540             return;
541         }
542         ensureDefaultApplications();
543         _defaultApps.setUnescapedValue(mimeType, desktopId);
544         ensureAddedAssociations();
545         auto added = _addedApps.appsForMimeType(mimeType);
546         auto withoutThis = added.filter!(d => d != desktopId);
547         auto resorted = only(desktopId).chain(withoutThis);
548         _addedApps.setUnescapedValue(mimeType, MimeAppsGroup.joinApps(resorted));
549 
550         if (_removedApps !is null) {
551             _removedApps.deleteAssociation(mimeType, desktopId);
552         }
553     }
554 
555     ///
556     unittest
557     {
558         MimeAppsListFile appsList = new MimeAppsListFile();
559         appsList.setDefaultApplication("text/plain", "geany.desktop");
560         assert(appsList.defaultApplications() !is null);
561         assert(appsList.addedAssociations() !is null);
562         assert(appsList.defaultApplications().appsForMimeType("text/plain").equal(["geany.desktop"]));
563         assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"]));
564 
565         appsList.setDefaultApplication("image/png", null);
566         assert(!appsList.defaultApplications().contains("image/png"));
567 
568         string contents =
569 `[Default Applications]
570 text/plain=kde4-kate.desktop`;
571 
572         appsList = new MimeAppsListFile(iniLikeStringReader(contents));
573         appsList.setDefaultApplication("text/plain", "geany.desktop");
574         assert(appsList.defaultApplications().appsForMimeType("text/plain").equal(["geany.desktop"]));
575         assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"]));
576 
577         contents =
578 `[Default Applications]
579 text/plain=kde4-kate.desktop
580 [Added Associations]
581 text/plain=kde4-kate.desktop;emacs.desktop;`;
582 
583         appsList = new MimeAppsListFile(iniLikeStringReader(contents));
584         appsList.setDefaultApplication("text/plain", "geany.desktop");
585         assert(appsList.defaultApplications().appsForMimeType("text/plain").equal(["geany.desktop"]));
586         assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop", "kde4-kate.desktop", "emacs.desktop"]));
587 
588         contents =
589 `[Default Applications]
590 text/plain=emacs.desktop
591 [Added Associations]
592 text/plain=emacs.desktop;kde4-kate.desktop;geany.desktop;`;
593 
594         appsList = new MimeAppsListFile(iniLikeStringReader(contents));
595         appsList.setDefaultApplication("text/plain", "geany.desktop");
596         assert(appsList.defaultApplications().appsForMimeType("text/plain").equal(["geany.desktop"]));
597         assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop", "emacs.desktop", "kde4-kate.desktop"]));
598 
599         contents =
600 `[Default Applications]
601 text/plain=emacs.desktop
602 [Added Associations]
603 text/plain=emacs.desktop;kde4-kate.desktop;
604 [Removed Associations]
605 text/plain=kde4-okular.desktop;geany.desktop`;
606 
607         appsList = new MimeAppsListFile(iniLikeStringReader(contents));
608         appsList.setDefaultApplication("text/plain", "geany.desktop");
609         assert(appsList.removedAssociations() !is null);
610         assert(appsList.defaultApplications().appsForMimeType("text/plain").equal(["geany.desktop"]));
611         assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop", "emacs.desktop", "kde4-kate.desktop"]));
612         assert(appsList.removedAssociations().appsForMimeType("text/plain").equal(["kde4-okular.desktop"]));
613     }
614 
615     /**
616      * Add desktopId as association for mimeType.
617      * Delete it from removed associations if listed.
618      * Note: This only changes the object, but not file itself.
619      */
620     @trusted void addAssociation(string mimeType, string desktopId)
621     {
622         if (mimeType.empty || desktopId.empty) {
623             return;
624         }
625         ensureAddedAssociations();
626         auto added = _addedApps.appsForMimeType(mimeType);
627         if (!added.canFind(desktopId)) {
628             _addedApps.setUnescapedValue(mimeType, MimeAppsGroup.joinApps(chain(added, only(desktopId))));
629         }
630         if (_removedApps) {
631             _removedApps.deleteAssociation(mimeType, desktopId);
632         }
633     }
634 
635     ///
636     unittest
637     {
638         MimeAppsListFile appsList = new MimeAppsListFile();
639         appsList.addAssociation("text/plain", "geany.desktop");
640         assert(appsList.addedAssociations() !is null);
641         assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"]));
642 
643         appsList.addAssociation("image/png", null);
644         assert(!appsList.addedAssociations().contains("image/png"));
645 
646         string contents =
647 `[Added Associations]
648 text/plain=kde4-kate.desktop;emacs.desktop
649 [Removed Associations]
650 text/plain=kde4-okular.desktop;geany.desktop;`;
651 
652         appsList = new MimeAppsListFile(iniLikeStringReader(contents));
653         appsList.addAssociation("text/plain", "geany.desktop");
654         assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["kde4-kate.desktop", "emacs.desktop", "geany.desktop"]));
655         assert(appsList.removedAssociations().appsForMimeType("text/plain").equal(["kde4-okular.desktop"]));
656 
657         contents =
658 `[Removed Associations]
659 text/plain=geany.desktop;`;
660 
661         appsList = new MimeAppsListFile(iniLikeStringReader(contents));
662         appsList.addAssociation("text/plain", "geany.desktop");
663         assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"]));
664         assert(!appsList.removedAssociations().contains("text/plain"));
665     }
666 
667     /**
668      * Explicitly remove desktopId association for mimeType.
669      * Delete it from added associations and default applications.
670      * Note: This only changes the object, but not file itself.
671      */
672     @trusted void removeAssociation(string mimeType, string desktopId)
673     {
674         if (mimeType.empty || desktopId.empty) {
675             return;
676         }
677         ensureRemovedAssociations();
678         auto removed = _removedApps.appsForMimeType(mimeType);
679         if (!removed.canFind(desktopId)) {
680             _removedApps.setUnescapedValue(mimeType, MimeAppsGroup.joinApps(chain(removed, only(desktopId))));
681         }
682         if (_addedApps) {
683             _addedApps.deleteAssociation(mimeType, desktopId);
684         }
685         if (_defaultApps) {
686             _defaultApps.deleteAssociation(mimeType, desktopId);
687         }
688     }
689 
690     /**
691      * Set list of desktop ids as assocations for $(D mimeType). This overwrites existing assocations.
692      * Note: This only changes the object, but not file itself.
693      * See_Also: $(D MimeAppsGroup.setAssocations)
694      */
695     @trusted void setAddedAssocations(Range)(string mimeType, Range desktopIds) if (isInputRange!Range && is(ElementType!Range : string))
696     {
697         ensureAddedAssociations();
698         _addedApps.setAssocations(mimeType, desktopIds);
699     }
700 
701     ///
702     unittest
703     {
704         MimeAppsListFile appsList = new MimeAppsListFile();
705         appsList.removeAssociation("text/plain", "geany.desktop");
706         assert(appsList.removedAssociations() !is null);
707         assert(appsList.removedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"]));
708 
709         appsList.removeAssociation("image/png", null);
710         assert(!appsList.removedAssociations().contains("image/png"));
711 
712         string contents =
713 `[Default Applications]
714 text/plain=geany.desktop
715 [Added Associations]
716 text/plain=emacs.desktop;geany.desktop;kde4-kate.desktop;`;
717 
718         appsList = new MimeAppsListFile(iniLikeStringReader(contents));
719         appsList.removeAssociation("text/plain", "geany.desktop");
720         assert(appsList.removedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"]));
721         assert(appsList.addedAssociations().appsForMimeType("text/plain").equal(["emacs.desktop", "kde4-kate.desktop"]));
722         assert(!appsList.defaultApplications().contains("text/plain"));
723 
724         contents =
725 `[Added Associations]
726 text/plain=geany.desktop;`;
727 
728         appsList = new MimeAppsListFile(iniLikeStringReader(contents));
729         appsList.removeAssociation("text/plain", "geany.desktop");
730         assert(appsList.removedAssociations().appsForMimeType("text/plain").equal(["geany.desktop"]));
731         assert(!appsList.addedAssociations().contains("text/plain"));
732     }
733 
734     ///Create empty "Default Applications" group if it does not exist.
735     @trusted final void ensureDefaultApplications() {
736         ensureGroupExists(_defaultApps, "Default Applications");
737     }
738 
739     ///Create empty "Added Associations" group if it does not exist.
740     @trusted final void ensureAddedAssociations() {
741         ensureGroupExists(_addedApps, "Added Associations");
742     }
743 
744     ///Create empty "Removed Associations" group if it does not exist.
745     @trusted final void ensureRemovedAssociations() {
746         ensureGroupExists(_removedApps, "Removed Associations");
747     }
748 
749 protected:
750     @trusted override IniLikeGroup createGroupByName(string groupName)
751     {
752         if (groupName == "Default Applications") {
753             return new MimeAppsGroup(groupName);
754         } else if (groupName == "Added Associations") {
755             return new MimeAppsGroup(groupName);
756         } else if (groupName == "Removed Associations") {
757             return new MimeAppsGroup(groupName);
758         } else {
759             return null;
760         }
761     }
762 private:
763     @trusted final void ensureGroupExists(ref MimeAppsGroup group, string name)
764     {
765         if (group is null) {
766             group = new MimeAppsGroup(name);
767             insertGroup(group);
768         }
769     }
770 
771     MimeAppsGroup _addedApps;
772     MimeAppsGroup _removedApps;
773     MimeAppsGroup _defaultApps;
774 }
775 
776 ///
777 unittest
778 {
779     string content =
780 `[Added Associations]
781 text/plain=geany.desktop;kde4-kwrite.desktop;
782 image/png=kde4-gwenview.desktop;gthumb.desktop;
783 
784 [Removed Associations]
785 text/plain=libreoffice-writer.desktop;
786 
787 [Default Applications]
788 text/plain=kde4-kate.desktop
789 x-scheme-handler/http=chromium.desktop;iceweasel.desktop;
790 
791 
792 [Unknown group]
793 Key=Value`;
794     auto mimeAppsList = new MimeAppsListFile(iniLikeStringReader(content));
795     assert(mimeAppsList.addedAssociations() !is null);
796     assert(mimeAppsList.removedAssociations() !is null);
797     assert(mimeAppsList.defaultApplications() !is null);
798     assert(mimeAppsList.group("Unknown group") is null);
799 
800     assert(mimeAppsList.addedAssociations().appsForMimeType("text/plain").equal(["geany.desktop", "kde4-kwrite.desktop"]));
801     assert(mimeAppsList.removedAssociations().appsForMimeType("text/plain").equal(["libreoffice-writer.desktop"]));
802     assert(mimeAppsList.defaultApplications().appsForMimeType("x-scheme-handler/http").equal(["chromium.desktop", "iceweasel.desktop"]));
803 
804     content =
805 `[Default Applications]
806 text/plain=geany.desktop
807 notmimetype=value
808 `;
809     assertThrown!IniLikeReadException(new MimeAppsListFile(iniLikeStringReader(content)));
810     assertNotThrown(mimeAppsList = new MimeAppsListFile(iniLikeStringReader(content), null, IniLikeFile.ReadOptions(IniLikeGroup.InvalidKeyPolicy.save)));
811     assert(mimeAppsList.defaultApplications().escapedValue("notmimetype") == "value");
812 }
813 
814 /**
815  * Class represenation of single mimeinfo.cache file containing information about MIME type associations.
816  * Note: Unlike $(D MimeAppsListFile) this class does not provide functions for associations update.
817  *  This is because mimeinfo.cache files should be updated by $(B update-desktop-database) utility from
818  *  $(LINK2 https://www.freedesktop.org/wiki/Software/desktop-file-utils/, desktop-file-utils).
819  */
820 final class MimeInfoCacheFile : IniLikeFile
821 {
822     /**
823      * Read MIME Cache from file.
824      * Throws:
825      *  $(B ErrnoException) if file could not be opened.
826      *  $(D inilike.file.IniLikeReadException) if error occured while reading the file or "MIME Cache" group is missing.
827      */
828     @trusted this(string fileName, ReadOptions readOptions = ReadOptions.init)
829     {
830         this(iniLikeFileReader(fileName), fileName, readOptions);
831     }
832 
833     /**
834      * Constructs MimeInfoCacheFile with empty MIME Cache group.
835      */
836     @safe this() {
837         super();
838         _mimeCache = new MimeAppsGroup("MIME Cache");
839         insertGroup(_mimeCache);
840     }
841 
842     ///
843     unittest
844     {
845         auto micf = new MimeInfoCacheFile();
846         assert(micf.mimeCache() !is null);
847     }
848 
849     /**
850      * Read MIME Cache from $(D inilike.range.IniLikeReader), e.g. acquired from $(D inilike.range.iniLikeFileReader) or $(D inilike.range.iniLikeStringReader).
851      * Throws:
852      *  $(D inilike.file.IniLikeReadException) if error occured while parsing or "MIME Cache" group is missing.
853      */
854     this(IniLikeReader)(IniLikeReader reader, string fileName = null, ReadOptions readOptions = ReadOptions.init)
855     {
856         super(reader, fileName, readOptions);
857         _mimeCache = cast(MimeAppsGroup)group("MIME Cache");
858         enforce(_mimeCache !is null, new IniLikeReadException("No \"MIME Cache\" group", 0));
859     }
860 
861     /**
862      * Access "MIME Cache" group.
863      */
864     @safe inout(MimeAppsGroup) mimeCache() nothrow inout pure {
865         return _mimeCache;
866     }
867 
868     /**
869      * Alias for easy access to "MIME Cache" group.
870      */
871     alias mimeCache this;
872 
873 protected:
874     @trusted override IniLikeGroup createGroupByName(string groupName)
875     {
876         if (groupName == "MIME Cache") {
877             return new MimeAppsGroup(groupName);
878         } else {
879             return null;
880         }
881     }
882 private:
883     MimeAppsGroup _mimeCache;
884 }
885 
886 ///
887 unittest
888 {
889     string content =
890 `[Some group]
891 Key=Value
892 `;
893     assertThrown!IniLikeReadException(new MimeInfoCacheFile(iniLikeStringReader(content)));
894 
895     content =
896 `[MIME Cache]
897 text/plain=geany.desktop;kde4-kwrite.desktop;
898 image/png=kde4-gwenview.desktop;gthumb.desktop;
899 `;
900 
901     auto mimeInfoCache = new MimeInfoCacheFile(iniLikeStringReader(content));
902     assert(mimeInfoCache.appsForMimeType("text/plain").equal(["geany.desktop", "kde4-kwrite.desktop"]));
903     assert(mimeInfoCache.appsForMimeType("image/png").equal(["kde4-gwenview.desktop", "gthumb.desktop"]));
904     assert(mimeInfoCache.appsForMimeType("application/nonexistent").empty);
905 
906     content =
907 `[MIME Cache]
908 text/plain=geany.desktop;
909 notmimetype=value
910 `;
911     assertThrown!IniLikeReadException(new MimeInfoCacheFile(iniLikeStringReader(content)));
912     assertNotThrown(mimeInfoCache = new MimeInfoCacheFile(iniLikeStringReader(content), null, IniLikeFile.ReadOptions(IniLikeGroup.InvalidKeyPolicy.save)));
913     assert(mimeInfoCache.mimeCache.escapedValue("notmimetype") == "value");
914 }
915 
916 /**
917  * Create $(D MimeAppsListFile) objects for paths.
918  * Returns: Array of $(D MimeAppsListFile) objects read from paths. If some could not be read it's not included in the results.
919  */
920 @trusted MimeAppsListFile[] mimeAppsListFiles(const(string)[] paths) nothrow
921 {
922     return paths.map!(function(string path) {
923         MimeAppsListFile file;
924         collectException(new MimeAppsListFile(path), file);
925         return file;
926     }).filter!(file => file !is null).array;
927 }
928 
929 ///
930 unittest
931 {
932     assert(mimeAppsListFiles(["test/applications/mimeapps.list"]).length == 1);
933 }
934 
935 static if (isFreedesktop)
936 {
937     /**
938      * ditto, but automatically read $(D MimeAppsListFile) objects from determined system paths.
939      *
940      * $(BLUE This function is Freedesktop only).
941      */
942     @safe MimeAppsListFile[] mimeAppsListFiles() nothrow {
943         return mimeAppsListFiles(mimeAppsListPaths());
944     }
945 }
946 
947 /**
948  * Create $(D MimeInfoCacheFile) objects for paths.
949  * Returns: Array of $(D MimeInfoCacheFile) objects read from paths. If some could not be read it's not included in the results.
950  */
951 @trusted MimeInfoCacheFile[] mimeInfoCacheFiles(const(string)[] paths) nothrow
952 {
953     return paths.map!(function(string path) {
954         MimeInfoCacheFile file;
955         collectException(new MimeInfoCacheFile(path), file);
956         return file;
957     }).filter!(file => file !is null).array;
958 }
959 
960 ///
961 unittest
962 {
963     assert(mimeInfoCacheFiles(["test/applications/mimeinfo.cache"]).length == 1);
964 }
965 
966 static if (isFreedesktop)
967 {
968     /**
969      * ditto, but automatically read MimeInfoCacheFile objects from determined system paths.
970      *
971      * $(BLUE This function is Freedesktop only).
972      */
973     @safe MimeInfoCacheFile[] mimeInfoCacheFiles() nothrow {
974         return mimeInfoCacheFiles(mimeInfoCachePaths());
975     }
976 }
977 
978 /**
979  * Interface for desktop file provider.
980  * See_Also: $(D findAssociatedApplications), $(D findKnownAssociatedApplications), $(D findDefaultApplication)
981  */
982 interface IDesktopFileProvider
983 {
984     /**
985      * Retrieve $(D desktopfile.file.DesktopFile) by desktopId
986      * Returns: Found $(D desktopfile.file.DesktopFile) or null if not found.
987      */
988     const(DesktopFile) getByDesktopId(string desktopId);
989 }
990 
991 /**
992  * Implementation of simple desktop file provider.
993  */
994 class DesktopFileProvider : IDesktopFileProvider
995 {
996 private:
997     static struct DesktopFileItem
998     {
999         DesktopFile desktopFile;
1000         string baseDir;
1001     }
1002 
1003 public:
1004     /**
1005      * Construct using given application paths.
1006      * Params:
1007      *  applicationsPaths = Paths of applications/ directories where .desktop files are stored. These should be all known paths even if they don't exist at the time.
1008      *  binPaths = Paths where executable files are stored.
1009      *  options = Options used to read desktop files.
1010      */
1011     @trusted this(in string[] applicationsPaths, in string[] binPaths, DesktopFile.DesktopReadOptions options = DesktopFile.DesktopReadOptions.init) {
1012         _baseDirs = applicationsPaths.dup;
1013         _readOptions = options;
1014         _binPaths = binPaths.dup;
1015     }
1016 
1017     /// ditto, but determine binPaths from $(B PATH) environment variable automatically.
1018     @trusted this(in string[] applicationsPaths, DesktopFile.DesktopReadOptions options = DesktopFile.DesktopReadOptions.init) {
1019         this(applicationsPaths, binPaths().array, options);
1020     }
1021 
1022     /**
1023      * Get DesktopFile by desktop id.
1024      *
1025      * This implementation searches $(B applicationsPaths) given in constructor to find, parse and cache .desktop file.
1026      * If found file has $(D TryExec) value it also checks if executable can be found in $(B binPaths).
1027      * Note: Exec value is left unchecked.
1028      */
1029     override const(DesktopFile) getByDesktopId(string desktopId)
1030     {
1031         auto itemIn = desktopId in _cache;
1032         if (itemIn) {
1033             return itemIn.desktopFile;
1034         } else {
1035             auto foundItem = getDesktopFileItem(desktopId);
1036             if (foundItem.desktopFile !is null) {
1037                 _cache[desktopId] = foundItem;
1038                 return foundItem.desktopFile;
1039             }
1040         }
1041         return null;
1042     }
1043 private:
1044     DesktopFileItem getDesktopFileItem(string desktopId)
1045     {
1046         string baseDir;
1047         string filePath = findDesktopFilePath(desktopId, baseDir);
1048         if (filePath.length) {
1049             try {
1050                 auto desktopFile = new DesktopFile(filePath, _readOptions);
1051                 string tryExec = desktopFile.tryExecValue();
1052                 if (tryExec.length) {
1053                     string executable = findExecutable(tryExec, _binPaths);
1054                     if (executable.empty) {
1055                         return DesktopFileItem.init;
1056                     }
1057                 }
1058 
1059                 return DesktopFileItem(desktopFile, baseDir);
1060             } catch(Exception e) {
1061                 return DesktopFileItem.init;
1062             }
1063         }
1064         return DesktopFileItem.init;
1065     }
1066 
1067     string findDesktopFilePath(string desktopId, out string dir)
1068     {
1069         foreach(baseDir; _baseDirs) {
1070             auto filePath = findDesktopFile(desktopId, only(baseDir));
1071             if (filePath.length) {
1072                 dir = baseDir;
1073                 return filePath;
1074             }
1075         }
1076         return null;
1077     }
1078 
1079     DesktopFileItem[string] _cache;
1080     string[] _baseDirs;
1081     DesktopFile.DesktopReadOptions _readOptions;
1082     string[] _binPaths;
1083 }
1084 
1085 private enum FindAssocFlag {
1086     none = 0,
1087     onlyFirst = 1,
1088     ignoreRemovedGroup = 2
1089 }
1090 
1091 private string[] listAssociatedApplicationsImpl(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, FindAssocFlag flag = FindAssocFlag.none)
1092 {
1093     string[] removed;
1094     string[] desktopIds;
1095 
1096     foreach(mimeAppsListFile; mimeAppsListFiles) {
1097         if (mimeAppsListFile is null) {
1098             continue;
1099         }
1100 
1101         auto removedAppsGroup = mimeAppsListFile.removedAssociations();
1102         if (removedAppsGroup !is null && !(flag & FindAssocFlag.ignoreRemovedGroup)) {
1103             removed ~= removedAppsGroup.appsForMimeType(mimeType).array;
1104         }
1105         auto addedAppsGroup = mimeAppsListFile.addedAssociations();
1106         if (addedAppsGroup !is null) {
1107             foreach(desktopId; addedAppsGroup.appsForMimeType(mimeType)) {
1108                 if (removed.canFind(desktopId) || desktopIds.canFind(desktopId)) {
1109                     continue;
1110                 }
1111                 desktopIds ~= desktopId;
1112             }
1113         }
1114     }
1115 
1116     foreach(mimeInfoCacheFile; mimeInfoCacheFiles) {
1117         if (mimeInfoCacheFile is null) {
1118             continue;
1119         }
1120 
1121         foreach(desktopId; mimeInfoCacheFile.appsForMimeType(mimeType)) {
1122             if (removed.canFind(desktopId) || desktopIds.canFind(desktopId)) {
1123                 continue;
1124             }
1125             desktopIds ~= desktopId;
1126         }
1127     }
1128 
1129     return desktopIds;
1130 }
1131 
1132 private const(DesktopFile)[] findAssociatedApplicationsImpl(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, IDesktopFileProvider desktopFileProvider, FindAssocFlag flag = FindAssocFlag.none)
1133 {
1134     const(DesktopFile)[] desktopFiles;
1135     foreach(desktopId; listAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, flag)) {
1136         auto desktopFile = desktopFileProvider.getByDesktopId(desktopId);
1137         if (desktopFile && desktopFile.desktopEntry.escapedValue("Exec").length != 0) {
1138             if (flag & FindAssocFlag.onlyFirst) {
1139                 return [desktopFile];
1140             }
1141             desktopFiles ~= desktopFile;
1142         }
1143     }
1144     return desktopFiles;
1145 }
1146 /**
1147  * List associated applications for given MIME type.
1148  * Params:
1149  *  mimeType = MIME type or uri scheme handler in question.
1150  *  mimeAppsListFiles = Range of $(D MimeAppsListFile) objects to use in searching.
1151  *  mimeInfoCacheFiles = Range of $(D MimeInfoCacheFile) objects to use in searching.
1152  * Returns: Array of desktop ids of applications that are capable of opening of given MIME type or url of given scheme.
1153  * Note: It does not check if returned desktop ids point to valid .desktop files.
1154  * See_Also: $(D findAssociatedApplications)
1155  */
1156 string[] listAssociatedApplications(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles) if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile))
1157     && isForwardRange!CacheRange && is(ElementType!CacheRange : const(MimeInfoCacheFile)))
1158 {
1159     return listAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles);
1160 }
1161 
1162 /**
1163  * List all known associated applications for given MIME type, including explicitly removed by user.
1164  * Params:
1165  *  mimeType = MIME type or uri scheme handler in question.
1166  *  mimeAppsListFiles = Range of $(D MimeAppsListFile) objects to use in searching.
1167  *  mimeInfoCacheFiles = Range of $(D MimeInfoCacheFile) objects to use in searching.
1168  * Returns: Array of desktop ids of applications that are capable of opening of given MIME type or url of given scheme.
1169  * Note: It does not check if returned desktop ids point to valid .desktop files.
1170  * See_Also: $(D listAssociatedApplications), $(D findKnownAssociatedApplications)
1171  */
1172 string[] listKnownAssociatedApplications(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles)
1173 if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile))
1174     && isForwardRange!CacheRange && is(ElementType!CacheRange : const(MimeInfoCacheFile)))
1175 {
1176     return listAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, FindAssocFlag.ignoreRemovedGroup);
1177 }
1178 
1179 /**
1180  * List explicitily set default applications for given MIME type.
1181  * Params:
1182  *  mimeType = MIME type or uri scheme handler in question.
1183  *  mimeAppsListFiles = Range of $(D MimeAppsListFile) objects to use in searching.
1184  * Returns: Array of desktop ids of default applications.
1185  * Note: It does not check if returned desktop ids point to valid .desktop files.
1186  * See_Also: $(D findDefaultApplication)
1187  */
1188 string[] listDefaultApplications(ListRange)(string mimeType, ListRange mimeAppsListFiles)
1189 if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile)))
1190 {
1191     string[] defaultApplications;
1192     foreach(mimeAppsListFile; mimeAppsListFiles) {
1193         if (mimeAppsListFile is null) {
1194             continue;
1195         }
1196         auto defaultAppsGroup = mimeAppsListFile.defaultApplications();
1197         if (defaultAppsGroup !is null) {
1198             foreach(desktopId; defaultAppsGroup.appsForMimeType(mimeType)) {
1199                 defaultApplications ~= desktopId;
1200             }
1201         }
1202     }
1203     return defaultApplications;
1204 }
1205 
1206 /**
1207  * Find associated applications for given MIME type.
1208  * Params:
1209  *  mimeType = MIME type or uri scheme handler in question.
1210  *  mimeAppsListFiles = Range of $(D MimeAppsListFile) objects to use in searching.
1211  *  mimeInfoCacheFiles = Range of $(D MimeInfoCacheFile) objects to use in searching.
1212  *  desktopFileProvider = Desktop file provider instance. Must be non-null.
1213  * Returns: Array of found $(D desktopfile.file.DesktopFile) objects capable of opening file of given MIME type or url of given scheme.
1214  * Note: If no applications found for this mimeType, you may consider to use this function on parent MIME type.
1215  * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s04.html, Adding/removing associations), $(D findKnownAssociatedApplications), $(D listAssociatedApplications)
1216  */
1217 const(DesktopFile)[] findAssociatedApplications(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, IDesktopFileProvider desktopFileProvider)
1218 if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile))
1219     && isForwardRange!CacheRange && is(ElementType!CacheRange : const(MimeInfoCacheFile)))
1220 in {
1221     assert(desktopFileProvider !is null);
1222 }
1223 body {
1224     return findAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, desktopFileProvider);
1225 }
1226 
1227 ///
1228 version(mimeappsFileTest) unittest
1229 {
1230     auto desktopProvider = new DesktopFileProvider(["test/applications"]);
1231     auto mimeAppsList = new MimeAppsListFile("test/applications/mimeapps.list");
1232     auto mimeInfoCache = new MimeInfoCacheFile("test/applications/mimeinfo.cache");
1233     assert(findAssociatedApplications("text/plain", [null, mimeAppsList], [null, mimeInfoCache], desktopProvider)
1234         .map!(d => d.displayName()).equal(["Geany", "Kate", "Emacs"]));
1235     assert(findAssociatedApplications("application/nonexistent", [mimeAppsList], [mimeInfoCache], desktopProvider).length == 0);
1236     assert(findAssociatedApplications("application/x-data", [mimeAppsList], [mimeInfoCache], desktopProvider).length == 0);
1237 }
1238 
1239 /**
1240  * Find all known associated applications for given MIME type, including explicitly removed by user.
1241  * Params:
1242  *  mimeType = MIME type or uri scheme handler in question.
1243  *  mimeAppsListFiles = Range of $(D MimeAppsListFile) objects to use in searching.
1244  *  mimeInfoCacheFiles = Range of $(D MimeInfoCacheFile) objects to use in searching.
1245  *  desktopFileProvider = Desktop file provider instance. Must be non-null.
1246  * Returns: Array of found $(D desktopfile.file.DesktopFile) objects capable of opening file of given MIME type or url of given scheme.
1247  * See_Also: $(D findAssociatedApplications), $(D listKnownAssociatedApplications)
1248  */
1249 const(DesktopFile)[] findKnownAssociatedApplications(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, IDesktopFileProvider desktopFileProvider)
1250 if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile))
1251     && isForwardRange!CacheRange && is(ElementType!CacheRange : const(MimeInfoCacheFile)))
1252 in {
1253     assert(desktopFileProvider !is null);
1254 }
1255 body {
1256     return findAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, desktopFileProvider, FindAssocFlag.ignoreRemovedGroup);
1257 }
1258 
1259 ///
1260 version(mimeappsFileTest) unittest
1261 {
1262     auto desktopProvider = new DesktopFileProvider(["test/applications"]);
1263     auto mimeAppsList = new MimeAppsListFile("test/applications/mimeapps.list");
1264     auto mimeInfoCache = new MimeInfoCacheFile("test/applications/mimeinfo.cache");
1265     assert(findKnownAssociatedApplications("text/plain", [null, mimeAppsList], [null, mimeInfoCache], desktopProvider)
1266         .map!(d => d.displayName()).equal(["Geany", "Kate", "Emacs", "Okular"]));
1267 }
1268 
1269 /**
1270  * Find default application for given MIME type.
1271  * Params:
1272  *  mimeType = MIME type or uri scheme handler in question.
1273  *  mimeAppsListFiles = Range of $(D MimeAppsListFile) objects to use in searching.
1274  *  mimeInfoCacheFiles = Range of $(D MimeInfoCacheFile) objects to use in searching.
1275  *  desktopFileProvider = Desktop file provider instance. Must be non-null.
1276  * Returns: Found $(D desktopfile.file.DesktopFile) or null if not found.
1277  * Note: You may consider calling this function on parent MIME types (recursively until application is found) if it fails for the original mimeType.
1278  *      Retrieving parent MIME types is out of the scope of this library. $(LINK2 https://github.com/FreeSlave/mime, MIME library) is available for that purpose.
1279  *      Check the $(LINK2 https://github.com/FreeSlave/mimeapps/blob/master/examples/open.d, Open example).
1280  * See_Also: $(LINK2 https://specifications.freedesktop.org/mime-apps-spec/latest/ar01s03.html, Default Application)
1281  */
1282 const(DesktopFile) findDefaultApplication(ListRange, CacheRange)(string mimeType, ListRange mimeAppsListFiles, CacheRange mimeInfoCacheFiles, IDesktopFileProvider desktopFileProvider)
1283 if(isForwardRange!ListRange && is(ElementType!ListRange : const(MimeAppsListFile))
1284     && isForwardRange!CacheRange && is(ElementType!CacheRange : const(MimeInfoCacheFile)))
1285 in {
1286     assert(desktopFileProvider !is null);
1287 }
1288 body {
1289     foreach(desktopId; listDefaultApplications(mimeType, mimeAppsListFiles)) {
1290         auto desktopFile = desktopFileProvider.getByDesktopId(desktopId);
1291         if (desktopFile !is null && desktopFile.desktopEntry.escapedValue("Exec").length != 0) {
1292             return desktopFile;
1293         }
1294     }
1295 
1296     auto desktopFiles = findAssociatedApplicationsImpl(mimeType, mimeAppsListFiles, mimeInfoCacheFiles, desktopFileProvider, FindAssocFlag.onlyFirst);
1297     return desktopFiles.length ? desktopFiles.front : null;
1298 }
1299 
1300 ///
1301 version(mimeappsFileTest) unittest
1302 {
1303     auto desktopProvider = new DesktopFileProvider(["test/applications"]);
1304     auto mimeAppsList = new MimeAppsListFile("test/applications/mimeapps.list");
1305     auto mimeInfoCache = new MimeInfoCacheFile("test/applications/mimeinfo.cache");
1306     assert(findDefaultApplication("text/plain", [null, mimeAppsList], [null, mimeInfoCache], desktopProvider).displayName() == "Geany");
1307     assert(findDefaultApplication("image/png", [mimeAppsList], [mimeInfoCache], desktopProvider).displayName() == "Gwenview");
1308     assert(findDefaultApplication("application/pdf", [mimeAppsList], [mimeInfoCache], desktopProvider).displayName() == "Okular");
1309     assert(findDefaultApplication("application/nonexistent", [mimeAppsList], [mimeInfoCache], desktopProvider) is null);
1310     assert(findDefaultApplication("application/x-data", [mimeAppsList], [mimeInfoCache], desktopProvider) is null);
1311 }
1312 
1313 /**
1314  * Struct used for construction of file assocation update query.
1315  * This allows to reuse the same query many times or for many mimeapps.list files.
1316  */
1317 struct AssociationUpdateQuery
1318 {
1319     // Operation on MIME type application assocication.
1320     private static struct Operation
1321     {
1322         // Type of operation
1323         enum Type : ubyte {
1324             add,
1325             remove,
1326             setDefault,
1327             setAdded
1328         }
1329 
1330         string mimeType;
1331         string desktopId;
1332         Type type;
1333     }
1334 
1335     /**
1336      * See_Also: $(D MimeAppsListFile.addAssociation)
1337      */
1338     @safe ref typeof(this) addAssociation(string mimeType, string desktopId) return nothrow
1339     {
1340         _operations ~= Operation(mimeType, desktopId, Operation.Type.add);
1341         return this;
1342     }
1343     /**
1344      * See_Also: $(D MimeAppsListFile.setAddedAssocations)
1345      */
1346     @safe ref typeof(this) setAddedAssocations(Range)(string mimeType, Range desktopIds) return if (isInputRange!Range && is(ElementType!Range : string))
1347     {
1348         _operations ~= Operation(mimeType, MimeAppsGroup.joinApps(desktopIds), Operation.Type.setAdded);
1349         return this;
1350     }
1351     /**
1352      * See_Also: $(D MimeAppsListFile.removeAssociation)
1353      */
1354     @safe ref typeof(this) removeAssociation(string mimeType, string desktopId) return nothrow
1355     {
1356         _operations ~= Operation(mimeType, desktopId, Operation.Type.remove);
1357         return this;
1358     }
1359     /**
1360      * See_Also: $(D MimeAppsListFile.setDefaultApplication)
1361      */
1362     @safe ref typeof(this) setDefaultApplication(string mimeType, string desktopId) return nothrow
1363     {
1364         _operations ~= Operation(mimeType, desktopId, Operation.Type.setDefault);
1365         return this;
1366     }
1367 
1368     /**
1369      * Apply query to $(D MimeAppsListFile).
1370      */
1371     @safe void apply(scope MimeAppsListFile file) const
1372     {
1373         foreach(op; _operations)
1374         {
1375             final switch(op.type)
1376             {
1377                 case Operation.Type.add:
1378                     file.addAssociation(op.mimeType, op.desktopId);
1379                     break;
1380                 case Operation.Type.remove:
1381                     file.removeAssociation(op.mimeType, op.desktopId);
1382                     break;
1383                 case Operation.Type.setDefault:
1384                     file.setDefaultApplication(op.mimeType, op.desktopId);
1385                     break;
1386                 case Operation.Type.setAdded:
1387                     file.setAddedAssocations(op.mimeType, MimeAppsGroup.splitApps(op.desktopId));
1388                     break;
1389             }
1390         }
1391     }
1392 private:
1393     Operation[] _operations;
1394 }
1395 
1396 ///
1397 unittest
1398 {
1399     AssociationUpdateQuery query;
1400     query.addAssociation("text/plain", "geany.desktop");
1401     query.removeAssociation("text/plain", "kde4-okular.desktop");
1402     query.setDefaultApplication("text/plain", "kde4-kate.desktop");
1403     query.setAddedAssocations("image/png", ["kde4-gwenview.desktop", "gthumb.desktop"]);
1404 
1405     auto file = new MimeAppsListFile();
1406     query.apply(file);
1407     file.addedAssociations().appsForMimeType("text/plain").equal(["kde4-kate.desktop", "geany.desktop"]);
1408     file.defaultApplications().appsForMimeType("text/plain").equal(["kde4-kate.desktop"]);
1409     file.removedAssociations().appsForMimeType("text/plain").equal(["kde4-okular.desktop"]);
1410     file.addedAssociations().appsForMimeType("image/png").equal(["kde4-gwenview.desktop", "gthumb.desktop"]);
1411 }
1412 
1413 /**
1414  * Apply query for file with fileName. This should be mimeapps.list file.
1415  * If file does not exist it will be created.
1416  * Throws:
1417  *   $(D inilike.file.IniLikeReadException) if errors occured during the file reading.
1418  *   $(B ErrnoException) if errors occured during the file writing.
1419  */
1420 @trusted void updateAssociations(string fileName, ref scope const AssociationUpdateQuery query)
1421 {
1422     MimeAppsListFile file;
1423     if (fileName.exists) {
1424         file = new MimeAppsListFile(fileName);
1425     } else {
1426         file = new MimeAppsListFile();
1427     }
1428     query.apply(file);
1429     file.saveToFile(fileName);
1430 }
1431 
1432 static if (isFreedesktop)
1433 {
1434     /**
1435      * Change MIME Applications Associations for the current user by applying the provided query.
1436      *
1437      * For compatibility purposes it overwrites user overrides in the deprecated location too.
1438      *
1439      * $(BLUE This function is Freedesktop only).
1440      * Note: It will not overwrite desktop-specific overrides.
1441      * See_Also: $(D userMimeAppsListPaths), $(D userAppDataMimeAppsListPaths)
1442      */
1443     @safe void updateAssociations(ref scope const AssociationUpdateQuery query)
1444     {
1445         foreach(fileName; userMimeAppsListPaths(string[].init) ~ userAppDataMimeAppsListPaths(string[].init)) {
1446             updateAssociations(fileName, query);
1447         }
1448     }
1449 }