1 /**
2  * Class representation of desktop file.
3  * Authors:
4  *  $(LINK2 https://github.com/FreeSlave, Roman Chistokhodov)
5  * Copyright:
6  *  Roman Chistokhodov, 2015-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/desktop-entry-spec/, Desktop Entry Specification)
11  */
12 
13 module desktopfile.file;
14 
15 public import inilike.file;
16 public import desktopfile.utils;
17 
18 private @trusted void validateDesktopKeyImpl(string groupName, string key, string value) {
19     if (!isValidDesktopFileKey(key)) {
20         throw new IniLikeEntryException("key is invalid", groupName, key, value);
21     }
22 }
23 
24 /**
25  * Subclass of $(D inilike.file.IniLikeGroup) for easy access to desktop action.
26  */
27 final class DesktopAction : IniLikeGroup
28 {
29 protected:
30     @trusted override void validateKey(string key, string value) const {
31         validateDesktopKeyImpl(groupName(), key, value);
32     }
33 public:
34     package @nogc @safe this(string groupName) nothrow {
35         super(groupName);
36     }
37 
38     /**
39      * Label that will be shown to the user.
40      * Returns: The unescaped value associated with "Name" key.
41      */
42     @safe string displayName() const nothrow pure {
43         return unescapedValue("Name");
44     }
45 
46     /**
47      * Label that will be shown to the user in given locale.
48      * Returns: The unescaped value associated with "Name" key and given locale.
49      * See_Also: $(D displayName)
50      */
51     @safe string localizedDisplayName(string locale) const nothrow pure {
52         return unescapedValue("Name", locale);
53     }
54 
55     /**
56      * Icon name of action.
57      * Returns: The unescaped value associated with "Icon" key.
58      */
59     @safe string iconName() const nothrow pure {
60         return unescapedValue("Icon");
61     }
62 
63     /**
64      * Returns: Localized icon name.
65      * See_Also: $(D iconName)
66      */
67     @safe string localizedIconName(string locale) const nothrow pure {
68         return unescapedValue("Icon", locale);
69     }
70 
71     /**
72      * Returns: The unescaped value associated with "Exec" key.
73      */
74     @safe string execValue() const nothrow pure {
75         return unescapedValue("Exec");
76     }
77 
78     /**
79      * Expand "Exec" value into the array of command line arguments to use to start the action.
80      * It applies unquoting and unescaping.
81      * See_Also: $(D execValue), $(D desktopfile.utils.expandExecArgs), $(D start)
82      */
83     @safe string[] expandExecValue(scope const(string)[] urls = null, string locale = null) const
84     {
85         return expandExecArgs(unquoteExec(execValue()), urls, localizedIconName(locale), localizedDisplayName(locale));
86     }
87 
88     /**
89      * Start this action with provided urls.
90      * Throws:
91      *  $(B ProcessException) on failure to start the process.
92      *  $(D desktopfile.utils.DesktopExecException) if exec string is invalid.
93      * See_Also: $(D execValue), $(D desktopfile.utils.spawnApplication)
94      */
95     @safe void start(scope const(string)[] urls, string locale = null) const
96     {
97         auto unquotedArgs = unquoteExec(execValue());
98 
99         SpawnParams params;
100         params.urls = urls;
101         params.iconName = localizedIconName(locale);
102         params.displayName = localizedDisplayName(locale);
103 
104         return spawnApplication(unquotedArgs, params);
105     }
106 
107     /// ditto, but using a single url
108     @safe void start(string url, string locale) const
109     {
110         return start([url], locale);
111     }
112 
113     /// ditto, but without any urls.
114     @safe void start(string locale = null) const {
115         return start(string[].init, locale);
116     }
117 }
118 
119 /**
120  * Subclass of $(D inilike.file.IniLikeGroup) for easy accessing of Desktop Entry properties.
121  */
122 final class DesktopEntry : IniLikeGroup
123 {
124     ///Desktop entry type
125     enum Type
126     {
127         Unknown, ///Desktop entry is unknown type
128         Application, ///Desktop describes application
129         Link, ///Desktop describes URL
130         Directory ///Desktop entry describes directory settings
131     }
132 
133     protected @nogc @safe this() nothrow {
134         super("Desktop Entry");
135     }
136 
137     /**
138      * Type of desktop entry.
139      * Returns: Type of desktop entry.
140      */
141     @nogc @safe Type type() const nothrow pure {
142         string t = escapedValue("Type");
143         if (t.length) {
144             if (t == "Application") {
145                 return Type.Application;
146             } else if (t == "Link") {
147                 return Type.Link;
148             } else if (t == "Directory") {
149                 return Type.Directory;
150             }
151         }
152         return Type.Unknown;
153     }
154 
155     ///
156     unittest
157     {
158         string contents = "[Desktop Entry]\nType=Application";
159         auto desktopFile = new DesktopFile(iniLikeStringReader(contents));
160         assert(desktopFile.type == Type.Application);
161 
162         desktopFile.desktopEntry.setEscapedValue("Type", "Link");
163         assert(desktopFile.type == Type.Link);
164 
165         desktopFile.desktopEntry.setEscapedValue("Type", "Directory");
166         assert(desktopFile.type == Type.Directory);
167     }
168 
169     /**
170      * Sets "Type" field to type
171      * Note: Setting the $(D Type.Unknown) removes type field.
172      */
173     @safe Type type(Type t) {
174         final switch(t) {
175             case Type.Application:
176                 setEscapedValue("Type", "Application");
177                 break;
178             case Type.Link:
179                 setEscapedValue("Type", "Link");
180                 break;
181             case Type.Directory:
182                 setEscapedValue("Type", "Directory");
183                 break;
184             case Type.Unknown:
185                 this.removeEntry("Type");
186                 break;
187         }
188         return t;
189     }
190 
191     ///
192     unittest
193     {
194         auto desktopFile = new DesktopFile();
195         desktopFile.type = Type.Application;
196         assert(desktopFile.desktopEntry.escapedValue("Type") == "Application");
197         desktopFile.type = Type.Link;
198         assert(desktopFile.desktopEntry.escapedValue("Type") == "Link");
199         desktopFile.type = Type.Directory;
200         assert(desktopFile.desktopEntry.escapedValue("Type") == "Directory");
201 
202         desktopFile.type = Type.Unknown;
203         assert(desktopFile.desktopEntry.escapedValue("Type").empty);
204     }
205 
206     /**
207      * Specific name of the application, for example "Qupzilla".
208      * Returns: The unescaped value associated with "Name" key.
209      * See_Also: $(D localizedDisplayName)
210      */
211     @safe string displayName() const nothrow pure {
212         return unescapedValue("Name");
213     }
214 
215     /**
216      * Set "Name" to name escaping the value if needed.
217      */
218     @safe string displayName(string name) {
219         return setUnescapedValue("Name", name);
220     }
221 
222     /**
223      * Returns: Localized name.
224      * See_Also: $(D displayName)
225      */
226     @safe string localizedDisplayName(string locale) const nothrow pure {
227         return unescapedValue("Name", locale);
228     }
229 
230     /**
231      * Generic name of the application, for example "Web Browser".
232      * Returns: The unescaped value associated with "GenericName" key.
233      * See_Also: $(D localizedGenericName)
234      */
235     @safe string genericName() const nothrow pure {
236         return unescapedValue("GenericName");
237     }
238 
239     /**
240      * Set "GenericName" to name escaping the value if needed.
241      */
242     @safe string genericName(string name) {
243         return setUnescapedValue("GenericName", name);
244     }
245     /**
246      * Returns: Localized generic name
247      * See_Also: $(D genericName)
248      */
249     @safe string localizedGenericName(string locale) const nothrow pure {
250         return unescapedValue("GenericName", locale);
251     }
252 
253     /**
254      * Tooltip for the entry, for example "View sites on the Internet".
255      * Returns: The unescaped value associated with "Comment" key.
256      * See_Also: $(D localizedComment)
257      */
258     @safe string comment() const nothrow pure {
259         return unescapedValue("Comment");
260     }
261 
262     /**
263      * Set "Comment" to commentary escaping the value if needed.
264      */
265     @safe string comment(string commentary) {
266         return setUnescapedValue("Comment", commentary);
267     }
268 
269     /**
270      * Returns: Localized comment
271      * See_Also: $(D comment)
272      */
273     @safe string localizedComment(string locale) const nothrow pure {
274         return unescapedValue("Comment", locale);
275     }
276 
277     /**
278      * Exec value of desktop file.
279      * Returns: The unescaped value associated with "Exec" key.
280      * See_Also: $(D expandExecValue), $(D startApplication), $(D tryExecValue)
281      */
282     @safe string execValue() const nothrow pure {
283         return unescapedValue("Exec");
284     }
285 
286     /**
287      * Set "Exec" to exec escaping the value if needed.
288      * See_Also: $(D desktopfile.utils.ExecBuilder).
289      */
290     @safe string execValue(string exec) {
291         return setUnescapedValue("Exec", exec);
292     }
293 
294     /**
295      * URL to access.
296      * Returns: The unescaped value associated with "URL" key.
297      */
298     @safe string url() const nothrow pure {
299         return unescapedValue("URL");
300     }
301 
302     /**
303      * Set "URL" to link escaping the value if needed.
304      */
305     @safe string url(string link) {
306         return setUnescapedValue("URL", link);
307     }
308 
309     ///
310     unittest
311     {
312         auto df = new DesktopFile(iniLikeStringReader("[Desktop Entry]\nType=Link\nURL=https://github.com/"));
313         assert(df.url() == "https://github.com/");
314     }
315 
316     /**
317      * Value used to determine if the program is actually installed.
318      *
319      * If the path is not an absolute path, the file should be looked up in the $(B PATH) environment variable. If the file is not present or if it is not executable, the entry may be ignored (not be used in menus, for example).
320      * Returns: The unescaped value associated with "TryExec" key.
321      * See_Also: $(D execValue)
322      */
323     @safe string tryExecValue() const nothrow pure {
324         return unescapedValue("TryExec");
325     }
326 
327     /**
328      * Set TryExec value escaping it if needed.
329      * Throws:
330      *  $(B IniLikeEntryException) if tryExec is not abolute path nor base name.
331      */
332     @safe string tryExecValue(string tryExec) {
333         if (!tryExec.isAbsolute && tryExec.baseName != tryExec) {
334             throw new IniLikeEntryException("TryExec must be absolute path or base name", groupName(), "TryExec", tryExec);
335         }
336         return setUnescapedValue("TryExec", tryExec);
337     }
338 
339     ///
340     unittest
341     {
342         auto df = new DesktopFile();
343         assertNotThrown(df.tryExecValue = "base");
344         version(Posix) {
345             assertNotThrown(df.tryExecValue = "/absolute/path");
346         }
347         assertThrown(df.tryExecValue = "not/absolute");
348         assertThrown(df.tryExecValue = "./relative");
349     }
350 
351     /**
352      * Icon to display in file manager, menus, etc.
353      * Returns: The unescaped value associated with "Icon" key.
354      * Note: This function returns Icon as it's defined in .desktop file.
355      *  It does not provide any lookup of actual icon file on the system if the name if not an absolute path.
356      *  To find the path to icon file refer to $(LINK2 http://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html, Icon Theme Specification) or consider using $(LINK2 https://github.com/FreeSlave/icontheme, icontheme library).
357      */
358     @safe string iconName() const nothrow pure {
359         return unescapedValue("Icon");
360     }
361 
362     /**
363      * Set Icon value.
364      * Throws:
365      *  $(B IniLikeEntryException) if icon is not abolute path nor base name.
366      */
367     @safe string iconName(string icon) {
368         if (!icon.isAbsolute && icon.baseName != icon) {
369             throw new IniLikeEntryException("Icon must be absolute path or base name", groupName(), "Icon", icon);
370         }
371         return setUnescapedValue("Icon", icon);
372     }
373 
374     ///
375     unittest
376     {
377         auto df = new DesktopFile();
378         assertNotThrown(df.iconName = "base");
379         version(Posix) {
380             assertNotThrown(df.iconName = "/absolute/path");
381         }
382         assertThrown(df.iconName = "not/absolute");
383         assertThrown(df.iconName = "./relative");
384     }
385 
386     /**
387      * Returns: Localized icon name
388      * See_Also: $(D iconName)
389      */
390     @safe string localizedIconName(string locale) const nothrow pure {
391         return unescapedValue("Icon", locale);
392     }
393 
394     /**
395      * NoDisplay means "this application exists, but don't display it in the menus".
396      * Returns: The value associated with "NoDisplay" key converted to bool using $(D inilike.common.isTrue).
397      */
398     @nogc @safe bool noDisplay() const nothrow pure {
399         return isTrue(escapedValue("NoDisplay"));
400     }
401 
402     ///setter
403     @safe bool noDisplay(bool notDisplay) {
404         setEscapedValue("NoDisplay", boolToString(notDisplay));
405         return notDisplay;
406     }
407 
408     /**
409      * Hidden means the user deleted (at his level) something that was present (at an upper level, e.g. in the system dirs).
410      * It's strictly equivalent to the .desktop file not existing at all, as far as that user is concerned.
411      * Returns: The value associated with "Hidden" key converted to bool using $(D inilike.common.isTrue).
412      */
413     @nogc @safe bool hidden() const nothrow pure {
414         return isTrue(escapedValue("Hidden"));
415     }
416 
417     ///setter
418     @safe bool hidden(bool hide) {
419         setEscapedValue("Hidden", boolToString(hide));
420         return hide;
421     }
422 
423     /**
424      * A boolean value specifying if D-Bus activation is supported for this application.
425      * Returns: The value associated with "DBusActivable" key converted to bool using $(D inilike.common.isTrue).
426      */
427     @nogc @safe bool dbusActivable() const nothrow pure {
428         return isTrue(escapedValue("DBusActivatable"));
429     }
430 
431     ///setter
432     @safe bool dbusActivable(bool activable) {
433         setEscapedValue("DBusActivatable", boolToString(activable));
434         return activable;
435     }
436 
437     /**
438      * A boolean value specifying if an application uses Startup Notification Protocol.
439      * Returns: The value associated with "StartupNotify" key converted to bool using $(D inilike.common.isTrue).
440      */
441     @nogc @safe bool startupNotify() const nothrow pure {
442         return isTrue(escapedValue("StartupNotify"));
443     }
444 
445     ///setter
446     @safe bool startupNotify(bool notify) {
447         setEscapedValue("StartupNotify", boolToString(notify));
448         return notify;
449     }
450 
451     /**
452      * The working directory to run the program in.
453      * Returns: The unescaped value associated with "Path" key.
454      */
455     @safe string workingDirectory() const nothrow pure {
456         return unescapedValue("Path");
457     }
458 
459     /**
460      * Set Path value.
461      * Throws:
462      *  $(D IniLikeEntryException) if wd is not valid path or wd is not abolute path.
463      */
464     @safe string workingDirectory(string wd) {
465         if (!wd.isValidPath) {
466             throw new IniLikeEntryException("Working directory must be valid path", groupName(), "Path", wd);
467         }
468         version(Posix) {
469             if (!wd.isAbsolute) {
470                 throw new IniLikeEntryException("Working directory must be absolute path", groupName(), "Path", wd);
471             }
472         }
473         return setUnescapedValue("Path", wd);
474     }
475 
476     ///
477     unittest
478     {
479         auto df = new DesktopFile();
480         version(Posix) {
481             assertNotThrown(df.workingDirectory = "/valid");
482             assertThrown(df.workingDirectory = "not absolute");
483         }
484         assertThrown(df.workingDirectory = "/foo\0/bar");
485     }
486 
487     /**
488      * Whether the program runs in a terminal window.
489      * Returns: The value associated with "Terminal" key converted to bool using $(D inilike.common.isTrue).
490      */
491     @nogc @safe bool terminal() const nothrow pure {
492         return isTrue(escapedValue("Terminal"));
493     }
494     ///setter
495     @safe bool terminal(bool t) {
496         setEscapedValue("Terminal", boolToString(t));
497         return t;
498     }
499 
500     /**
501      * Categories this program belongs to.
502      * Returns: The range of multiple values associated with "Categories" key.
503      */
504     @safe auto categories() const nothrow pure {
505         return DesktopFile.splitValues(unescapedValue("Categories"));
506     }
507 
508     /**
509      * Sets the list of values for the "Categories" list.
510      */
511     string categories(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
512         return setUnescapedValue("Categories", DesktopFile.joinValues(values));
513     }
514 
515     /**
516      * A list of strings which may be used in addition to other metadata to describe this entry.
517      * Returns: The range of multiple values associated with "Keywords" key.
518      */
519     @safe auto keywords() const nothrow pure {
520         return DesktopFile.splitValues(unescapedValue("Keywords"));
521     }
522 
523     /**
524      * A list of localied strings which may be used in addition to other metadata to describe this entry.
525      * Returns: The range of multiple values associated with "Keywords" key in given locale.
526      */
527     @safe auto localizedKeywords(string locale) const nothrow pure {
528         return DesktopFile.splitValues(unescapedValue("Keywords", locale));
529     }
530 
531     /**
532      * Sets the list of values for the "Keywords" list.
533      */
534     string keywords(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
535         return setUnescapedValue("Keywords", DesktopFile.joinValues(values));
536     }
537 
538     /**
539      * The MIME type(s) supported by this application.
540      * Returns: The range of multiple values associated with "MimeType" key.
541      */
542     @safe auto mimeTypes() nothrow const pure {
543         return DesktopFile.splitValues(unescapedValue("MimeType"));
544     }
545 
546     /**
547      * Sets the list of values for the "MimeType" list.
548      */
549     string mimeTypes(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
550         return setUnescapedValue("MimeType", DesktopFile.joinValues(values));
551     }
552 
553     /**
554      * Actions supported by application.
555      * Returns: Range of multiple values associated with "Actions" key.
556      * Note: This only depends on "Actions" value, not on actually presented sections in desktop file.
557      * See_Also: $(D byAction), $(D action)
558      */
559     @safe auto actions() nothrow const pure {
560         return DesktopFile.splitValues(unescapedValue("Actions"));
561     }
562 
563     /**
564      * Sets the list of values for "Actions" list.
565      */
566     string actions(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
567         return setUnescapedValue("Actions", DesktopFile.joinValues(values));
568     }
569 
570     /**
571      * A list of strings identifying the desktop environments that should display a given desktop entry.
572      * Returns: The range of multiple values associated with "OnlyShowIn" key.
573      * See_Also: $(D notShowIn), $(D showIn)
574      */
575     @safe auto onlyShowIn() nothrow const pure {
576         return DesktopFile.splitValues(unescapedValue("OnlyShowIn"));
577     }
578 
579     ///setter
580     string onlyShowIn(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
581         return setUnescapedValue("OnlyShowIn", DesktopFile.joinValues(values));
582     }
583 
584     /**
585      * A list of strings identifying the desktop environments that should not display a given desktop entry.
586      * Returns: The range of multiple values associated with "NotShowIn" key.
587      * See_Also: $(D onlyShowIn), $(D showIn)
588      */
589     @safe auto notShowIn() nothrow const pure {
590         return DesktopFile.splitValues(unescapedValue("NotShowIn"));
591     }
592 
593     ///setter
594     string notShowIn(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
595         return setUnescapedValue("NotShowIn", DesktopFile.joinValues(values));
596     }
597 
598     /**
599      * Check if desktop file should be shown in menu of specific desktop environment.
600      * Params:
601      *  desktopEnvironment = Name of desktop environment, usually detected by $(B XDG_CURRENT_DESKTOP) variable.
602      * See_Also: $(LINK2 https://specifications.freedesktop.org/menu-spec/latest/apb.html, Registered OnlyShowIn Environments), $(D notShowIn), $(D onlyShowIn)
603      */
604     @trusted bool showIn(string desktopEnvironment)
605     {
606         if (notShowIn().canFind(desktopEnvironment)) {
607             return false;
608         }
609         auto onlyIn = onlyShowIn();
610         return onlyIn.empty || onlyIn.canFind(desktopEnvironment);
611     }
612 
613     ///
614     unittest
615     {
616         auto df = new DesktopFile();
617         df.notShowIn = ["GNOME", "MATE"];
618         assert(df.showIn("KDE"));
619         assert(df.showIn("awesome"));
620         assert(df.showIn(""));
621         assert(!df.showIn("GNOME"));
622         df.onlyShowIn = ["LXDE", "XFCE"];
623         assert(df.showIn("LXDE"));
624         assert(df.showIn("XFCE"));
625         assert(!df.showIn(""));
626         assert(!df.showIn("awesome"));
627         assert(!df.showIn("KDE"));
628         assert(!df.showIn("MATE"));
629     }
630 
631 protected:
632     @trusted override void validateKey(string key, string value) const {
633         validateDesktopKeyImpl(groupName(), key, value);
634     }
635 }
636 
637 /**
638  * Represents .desktop file.
639  */
640 final class DesktopFile : IniLikeFile
641 {
642 public:
643     /**
644      * Alias for backward compatibility.
645      */
646     alias DesktopEntry.Type Type;
647 
648     /**
649     * Policy about reading Desktop Action groups.
650     */
651     enum ActionGroupPolicy : ubyte {
652         skip, ///Don't save Desktop Action groups.
653         preserve ///Save Desktop Action groups.
654     }
655 
656     /**
657      * Policy about reading extension groups (those start with 'X-').
658      */
659     enum ExtensionGroupPolicy : ubyte {
660         skip, ///Don't save extension groups.
661         preserve ///Save extension groups.
662     }
663 
664     /**
665      * Policy about reading groups with names which meaning is unknown, i.e. it's not extension nor Desktop Action.
666      */
667     enum UnknownGroupPolicy : ubyte {
668         skip, ///Don't save unknown groups.
669         preserve, ///Save unknown groups.
670         throwError ///Throw error when unknown group is encountered.
671     }
672 
673     ///Options to manage desktop file reading
674     static struct DesktopReadOptions
675     {
676         ///Base $(D inilike.file.IniLikeFile.ReadOptions).
677         IniLikeFile.ReadOptions baseOptions;
678 
679         alias baseOptions this;
680 
681         /**
682          * Set policy about unknown groups. By default they are skipped without errors.
683          * Note that all groups still need to be preserved if desktop file must be rewritten.
684          */
685         UnknownGroupPolicy unknownGroupPolicy = UnknownGroupPolicy.skip;
686 
687         /**
688          * Set policy about extension groups. By default they are all preserved.
689          * Set it to skip if you're not willing to support any extensions in your applications.
690          * Note that all groups still need to be preserved if desktop file must be rewritten.
691          */
692         ExtensionGroupPolicy extensionGroupPolicy = ExtensionGroupPolicy.preserve;
693 
694         /**
695          * Set policy about desktop action groups. By default they are all preserved.
696          * Note that all groups still need to be preserved if desktop file must be rewritten.
697          */
698         ActionGroupPolicy actionGroupPolicy = ActionGroupPolicy.preserve;
699 
700         ///Setting parameters in any order, leaving not mentioned ones in default state.
701         @nogc @safe this(Args...)(Args args) nothrow pure {
702             foreach(arg; args) {
703                 alias Unqual!(typeof(arg)) ArgType;
704                 static if (is(ArgType == IniLikeFile.ReadOptions)) {
705                     baseOptions = arg;
706                 } else static if (is(ArgType == UnknownGroupPolicy)) {
707                     unknownGroupPolicy = arg;
708                 } else static if (is(ArgType == ExtensionGroupPolicy)) {
709                     extensionGroupPolicy = arg;
710                 } else static if (is(ArgType == ActionGroupPolicy)) {
711                     actionGroupPolicy = arg;
712                 } else {
713                     baseOptions.assign(arg);
714                 }
715             }
716         }
717 
718         ///
719         unittest
720         {
721             DesktopReadOptions options;
722 
723             options = DesktopReadOptions(
724                 ExtensionGroupPolicy.skip,
725                 UnknownGroupPolicy.preserve,
726                 ActionGroupPolicy.skip,
727                 DuplicateKeyPolicy.skip,
728                 DuplicateGroupPolicy.preserve,
729                 No.preserveComments
730             );
731             assert(options.unknownGroupPolicy == UnknownGroupPolicy.preserve);
732             assert(options.actionGroupPolicy == ActionGroupPolicy.skip);
733             assert(options.extensionGroupPolicy == ExtensionGroupPolicy.skip);
734             assert(options.duplicateGroupPolicy == DuplicateGroupPolicy.preserve);
735             assert(options.duplicateKeyPolicy == DuplicateKeyPolicy.skip);
736             assert(!options.preserveComments);
737         }
738     }
739 
740     ///
741     unittest
742     {
743         string contents =
744 `[Desktop Entry]
745 Key=Value
746 Actions=Action1;
747 [Desktop Action Action1]
748 Name=Action1 Name
749 Key=Value`;
750 
751         alias DesktopFile.DesktopReadOptions DesktopReadOptions;
752 
753         auto df = new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(ActionGroupPolicy.skip));
754         assert(df.action("Action1") is null);
755 
756         contents =
757 `[Desktop Entry]
758 Key=Value
759 Actions=Action1;
760 [X-SomeGroup]
761 Key=Value`;
762 
763         df = new DesktopFile(iniLikeStringReader(contents));
764         assert(df.group("X-SomeGroup") !is null);
765 
766         df = new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(ExtensionGroupPolicy.skip));
767         assert(df.group("X-SomeGroup") is null);
768 
769         contents =
770 `[Desktop Entry]
771 Valid=Key
772 $=Invalid`;
773 
774         auto thrown = collectException!IniLikeReadException(new DesktopFile(iniLikeStringReader(contents)));
775         assert(thrown !is null);
776         assert(thrown.entryException !is null);
777         assert(thrown.entryException.key == "$");
778         assert(thrown.entryException.value == "Invalid");
779 
780         assertNotThrown(new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(IniLikeGroup.InvalidKeyPolicy.skip)));
781 
782         df = new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(IniLikeGroup.InvalidKeyPolicy.save));
783         assert(df.desktopEntry.escapedValue("$") == "Invalid");
784 
785         contents =
786 `[Desktop Entry]
787 Name=Name
788 [Unknown]
789 Key=Value`;
790 
791         assertThrown(new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(UnknownGroupPolicy.throwError)));
792 
793         assertNotThrown(df = new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(UnknownGroupPolicy.preserve)));
794         assert(df.group("Unknown") !is null);
795 
796         df = new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(UnknownGroupPolicy.skip));
797         assert(df.group("Unknown") is null);
798 
799         contents =
800 `[Desktop Entry]
801 Name=One
802 [Desktop Entry]
803 Name=Two`;
804 
805         df = new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(DuplicateGroupPolicy.preserve));
806         assert(df.displayName() == "One");
807         assert(df.byGroup().map!(g => g.escapedValue("Name")).equal(["One", "Two"]));
808     }
809 
810 protected:
811     ///Check if groupName is name of Desktop Action group.
812     @trusted static bool isActionName(string groupName)
813     {
814         return groupName.startsWith("Desktop Action ");
815     }
816 
817     @trusted override IniLikeGroup createGroupByName(string groupName) {
818         if (groupName == "Desktop Entry") {
819             return new DesktopEntry();
820         } else if (groupName.startsWith("X-")) {
821             if (_options.extensionGroupPolicy == ExtensionGroupPolicy.skip) {
822                 return null;
823             } else {
824                 return createEmptyGroup(groupName);
825             }
826         } else if (isActionName(groupName)) {
827             if (_options.actionGroupPolicy == ActionGroupPolicy.skip) {
828                 return null;
829             } else {
830                 return new DesktopAction(groupName);
831             }
832         } else {
833             final switch(_options.unknownGroupPolicy) {
834                 case UnknownGroupPolicy.skip:
835                     return null;
836                 case UnknownGroupPolicy.preserve:
837                     return createEmptyGroup(groupName);
838                 case UnknownGroupPolicy.throwError:
839                     throw new IniLikeException("Invalid group name: '" ~ groupName ~ "'. Must start with 'Desktop Action ' or 'X-'");
840             }
841         }
842     }
843 
844 public:
845     /**
846      * Reads desktop file from file.
847      * Throws:
848      *  $(B ErrnoException) if file could not be opened.
849      *  $(D inilike.file.IniLikeReadException) if error occured while reading the file or "Desktop Entry" group is missing.
850      */
851     @trusted this(string fileName, DesktopReadOptions options = DesktopReadOptions.init) {
852         this(iniLikeFileReader(fileName), options, fileName);
853     }
854 
855     /**
856      * Reads desktop file from IniLikeReader, e.g. acquired from iniLikeFileReader or iniLikeStringReader.
857      * Throws:
858      *  $(D inilike.file.IniLikeReadException) if error occured while parsing or "Desktop Entry" group is missing.
859      */
860     this(IniLikeReader)(IniLikeReader reader, DesktopReadOptions options = DesktopReadOptions.init, string fileName = null)
861     {
862         _options = options;
863         super(reader, fileName, options.baseOptions);
864         _desktopEntry = cast(DesktopEntry)group("Desktop Entry");
865         enforce(_desktopEntry !is null, new IniLikeReadException("No \"Desktop Entry\" group", 0));
866     }
867 
868     /**
869      * Reads desktop file from IniLikeReader, e.g. acquired from iniLikeFileReader or iniLikeStringReader.
870      * Throws:
871      *  $(D inilike.file.IniLikeReadException) if error occured while parsing or "Desktop Entry" group is missing.
872      */
873     this(IniLikeReader)(IniLikeReader reader, string fileName, DesktopReadOptions options = DesktopReadOptions.init)
874     {
875         this(reader, options, fileName);
876     }
877 
878     /**
879      * Constructs DesktopFile with "Desktop Entry" group and Version set to 1.1
880      */
881     @safe this() {
882         super();
883         _desktopEntry = new DesktopEntry();
884         insertGroup(_desktopEntry);
885         _desktopEntry.setEscapedValue("Version", "1.1");
886     }
887 
888     ///
889     unittest
890     {
891         auto df = new DesktopFile();
892         assert(df.desktopEntry());
893         assert(df.desktopEntry().escapedValue("Version") == "1.1");
894         assert(df.categories().empty);
895         assert(df.type() == DesktopFile.Type.Unknown);
896     }
897 
898     /**
899      * Removes group by name. You can't remove "Desktop Entry" group with this function.
900      */
901     @safe override bool removeGroup(string groupName) nothrow {
902         if (groupName == "Desktop Entry") {
903             return false;
904         }
905         return super.removeGroup(groupName);
906     }
907 
908     ///
909     unittest
910     {
911         auto df = new DesktopFile();
912         df.addGenericGroup("X-Action");
913         assert(df.group("X-Action") !is null);
914         df.removeGroup("X-Action");
915         assert(df.group("X-Action") is null);
916         df.removeGroup("Desktop Entry");
917         assert(df.desktopEntry() !is null);
918     }
919 
920     /**
921      * Type of desktop entry.
922      * Returns: Type of desktop entry.
923      * See_Also: $(D DesktopEntry.type)
924      */
925     @nogc @safe Type type() const nothrow {
926         auto t = desktopEntry().type();
927         if (t == Type.Unknown && fileName().endsWith(".directory")) {
928             return Type.Directory;
929         }
930         return t;
931     }
932 
933     @safe Type type(Type t) {
934         return desktopEntry().type(t);
935     }
936 
937     ///
938     unittest
939     {
940         auto desktopFile = new DesktopFile(iniLikeStringReader("[Desktop Entry]"), ".directory");
941         assert(desktopFile.type == DesktopFile.Type.Directory);
942     }
943 
944     static if (isFreedesktop) {
945         /**
946         * See $(LINK2 https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s02.html#desktop-file-id, Desktop File ID)
947         * Returns: Desktop file ID or empty string if file does not have an ID.
948         * Note: This function retrieves applications paths each time it's called and therefore can impact performance. To avoid this issue use overload with argument.
949         * See_Also: $(D desktopfile.paths.applicationsPaths),  $(D desktopfile.utils.desktopId)
950         */
951         @safe string id() const nothrow {
952             return desktopId(fileName);
953         }
954 
955         ///
956         unittest
957         {
958             import desktopfile.paths;
959 
960             string contents = "[Desktop Entry]\nType=Directory";
961             auto df = new DesktopFile(iniLikeStringReader(contents), "/home/user/data/applications/test/example.desktop");
962             auto dataHomeGuard = EnvGuard("XDG_DATA_HOME", "/home/user/data");
963             assert(df.id() == "test-example.desktop");
964         }
965     }
966 
967     /**
968      * See $(LINK2 https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s02.html#desktop-file-id, Desktop File ID)
969      * Params:
970      *  appPaths = range of base application paths to check if this file belongs to one of them.
971      * Returns: Desktop file ID or empty string if file does not have an ID.
972      * See_Also: $(D desktopfile.paths.applicationsPaths), $(D desktopfile.utils.desktopId)
973      */
974     string id(Range)(Range appPaths) const nothrow if (isInputRange!Range && is(ElementType!Range : string))
975     {
976         return desktopId(fileName, appPaths);
977     }
978 
979     ///
980     unittest
981     {
982         string contents =
983 `[Desktop Entry]
984 Name=Program
985 Type=Directory`;
986 
987         string[] appPaths;
988         string filePath, nestedFilePath, wrongFilePath;
989 
990         version(Windows) {
991             appPaths = [`C:\ProgramData\KDE\share\applications`, `C:\Users\username\.kde\share\applications`];
992             filePath = `C:\ProgramData\KDE\share\applications\example.desktop`;
993             nestedFilePath = `C:\ProgramData\KDE\share\applications\kde\example.desktop`;
994             wrongFilePath = `C:\ProgramData\desktop\example.desktop`;
995         } else {
996             appPaths = ["/usr/share/applications", "/usr/local/share/applications"];
997             filePath = "/usr/share/applications/example.desktop";
998             nestedFilePath = "/usr/share/applications/kde/example.desktop";
999             wrongFilePath = "/etc/desktop/example.desktop";
1000         }
1001 
1002         auto df = new DesktopFile(iniLikeStringReader(contents), nestedFilePath);
1003         assert(df.id(appPaths) == "kde-example.desktop");
1004 
1005         df = new DesktopFile(iniLikeStringReader(contents), filePath);
1006         assert(df.id(appPaths) == "example.desktop");
1007 
1008         df = new DesktopFile(iniLikeStringReader(contents), wrongFilePath);
1009         assert(df.id(appPaths).empty);
1010 
1011         df = new DesktopFile(iniLikeStringReader(contents));
1012         assert(df.id(appPaths).empty);
1013     }
1014 
1015     private static struct SplitValues
1016     {
1017         @trusted this(string value) nothrow pure {
1018             _value = value;
1019             next();
1020         }
1021         @nogc @trusted string front() const nothrow pure {
1022             return _current;
1023         }
1024         @trusted void popFront() nothrow pure {
1025             next();
1026         }
1027         @nogc @trusted bool empty() const nothrow pure {
1028             return _value.empty && _current.empty;
1029         }
1030         @nogc @trusted @property auto save() const nothrow pure {
1031             SplitValues values;
1032             values._value = _value;
1033             values._current = _current;
1034             return values;
1035         }
1036     private:
1037         void next() nothrow pure {
1038             size_t i=0;
1039             for (; i<_value.length && ( (_value[i] != ';') || (i && _value[i-1] == '\\' && _value[i] == ';')); ++i) {
1040                 //pass
1041             }
1042             _current = _value[0..i].replace("\\;", ";");
1043             _value = i == _value.length ? _value[_value.length..$] : _value[i+1..$];
1044         }
1045         string _value;
1046         string _current;
1047     }
1048 
1049     static assert(isForwardRange!SplitValues);
1050 
1051     /**
1052      * Some keys can have multiple values, separated by semicolon. This function helps to parse such kind of strings into the range.
1053      * Returns: The range of multiple nonempty values.
1054      * Note: Returned range unescapes ';' character automatically.
1055      * See_Also: $(D joinValues)
1056      */
1057     @trusted static auto splitValues(string values) nothrow pure {
1058         return SplitValues(values).filter!(s => !s.empty);
1059     }
1060 
1061     ///
1062     unittest
1063     {
1064         assert(DesktopFile.splitValues("").empty);
1065         assert(DesktopFile.splitValues(";").empty);
1066         assert(DesktopFile.splitValues(";;;").empty);
1067         assert(equal(DesktopFile.splitValues("Application;Utility;FileManager;"), ["Application", "Utility", "FileManager"]));
1068         assert(equal(DesktopFile.splitValues("I\\;Me;\\;You\\;We\\;"), ["I;Me", ";You;We;"]));
1069 
1070         auto values = DesktopFile.splitValues("Application;Utility;FileManager;");
1071         assert(values.front == "Application");
1072         values.popFront();
1073         assert(equal(values, ["Utility", "FileManager"]));
1074         auto saved = values.save;
1075         values.popFront();
1076         assert(equal(values, ["FileManager"]));
1077         assert(equal(saved, ["Utility", "FileManager"]));
1078     }
1079 
1080     /**
1081      * Join range of multiple values into a string using semicolon as separator. Adds trailing semicolon.
1082      * Returns: Values of range joined into one string with ';' after each value or empty string if range is empty.
1083      * Note: If some value of range contains ';' character it's automatically escaped.
1084      * See_Also: $(D splitValues)
1085      */
1086     static string joinValues(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
1087         auto result = values.filter!( s => !s.empty ).map!( s => s.replace(";", "\\;")).joiner(";");
1088         if (result.empty) {
1089             return null;
1090         } else {
1091             return text(result) ~ ";";
1092         }
1093     }
1094 
1095     ///
1096     unittest
1097     {
1098         assert(DesktopFile.joinValues([""]).empty);
1099         assert(equal(DesktopFile.joinValues(["Application", "Utility", "FileManager"]), "Application;Utility;FileManager;"));
1100         assert(equal(DesktopFile.joinValues(["I;Me", ";You;We;"]), "I\\;Me;\\;You\\;We\\;;"));
1101     }
1102 
1103     /**
1104      * Get $(LINK2 https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s11.html, additional application action) by name.
1105      * Returns: $(D DesktopAction) with given action name or null if not found or found section does not have a name.
1106      * See_Also: $(D actions), $(D byAction)
1107      */
1108     @trusted inout(DesktopAction) action(string actionName) inout {
1109         if (actions().canFind(actionName)) {
1110             auto desktopAction = cast(typeof(return))group("Desktop Action "~actionName);
1111             if (desktopAction !is null && desktopAction.displayName().length != 0) {
1112                 return desktopAction;
1113             }
1114         }
1115         return null;
1116     }
1117 
1118     /**
1119      * Iterating over existing actions.
1120      * Returns: Range of DesktopAction.
1121      * See_Also: $(D actions), $(D action)
1122      */
1123     @safe auto byAction() const {
1124         return actions().map!(actionName => action(actionName)).filter!(desktopAction => desktopAction !is null);
1125     }
1126 
1127     /**
1128      * Returns: instance of "Desktop Entry" group.
1129      * Note: Usually you don't need to call this function since you can rely on alias this.
1130      */
1131     @nogc @safe inout(DesktopEntry) desktopEntry() nothrow inout pure {
1132         return _desktopEntry;
1133     }
1134 
1135     /**
1136      * This alias allows to call functions related to "Desktop Entry" group without need to call desktopEntry explicitly.
1137      */
1138     alias desktopEntry this;
1139 
1140     unittest
1141     {
1142         // Making sure rebindable const is possible
1143         import std.typecons : rebindable;
1144         const d = new DesktopFile();
1145         auto r = d.rebindable;
1146     }
1147 
1148     /**
1149      * Expand "Exec" value into the array of command line arguments to use to start the program.
1150      * It applies unquoting and unescaping.
1151      * See_Also: $(D execValue), $(D desktopfile.utils.expandExecArgs), $(D startApplication)
1152      */
1153     @safe string[] expandExecValue(scope const(string)[] urls = null, string locale = null) const
1154     {
1155         return expandExecArgs(unquoteExec(execValue()), urls, localizedIconName(locale), localizedDisplayName(locale), fileName());
1156     }
1157 
1158     ///
1159     unittest
1160     {
1161         string contents =
1162 `[Desktop Entry]
1163 Name=Program
1164 Name[ru]=Программа
1165 Exec="quoted program" %i -w %c -f %k %U %D %u %f %F
1166 Icon=folder
1167 Icon[ru]=folder_ru`;
1168         auto df = new DesktopFile(iniLikeStringReader(contents), "/example.desktop");
1169         assert(df.expandExecValue(["one", "two"], "ru") ==
1170         ["quoted program", "--icon", "folder_ru", "-w", "Программа", "-f", "/example.desktop", "one", "two", "one", "one", "one", "two"]);
1171     }
1172 
1173     /**
1174      * Starts the application associated with this .desktop file using urls as command line params.
1175      *
1176      * If the program should be run in terminal it tries to find system defined terminal emulator to run in.
1177      * Params:
1178      *  urls = urls application will start with.
1179      *  locale = locale that may be needed to be placed in params if Exec value has %c code.
1180      *  terminalCommand = preferable terminal emulator command. If not set then terminal is determined via $(D desktopfile.utils.getTerminalCommand).
1181      * Note:
1182      *  This function does not check if the type of desktop file is Application. It relies only on "Exec" value.
1183      * Throws:
1184      *  $(B ProcessException) on failure to start the process.
1185      *  $(D desktopfile.utils.DesktopExecException) if exec string is invalid.
1186      * See_Also: $(D desktopfile.utils.spawnApplication), $(D desktopfile.utils.getTerminalCommand), $(D start), $(D expandExecValue)
1187      */
1188     @trusted void startApplication(scope const(string)[] urls = null, string locale = null, lazy const(string)[] terminalCommand = getTerminalCommand) const
1189     {
1190         auto unquotedArgs = unquoteExec(execValue());
1191 
1192         SpawnParams params;
1193         params.urls = urls;
1194         params.iconName = localizedIconName(locale);
1195         params.displayName = localizedDisplayName(locale);
1196         params.fileName = fileName;
1197         params.workingDirectory = workingDirectory();
1198 
1199         if (terminal()) {
1200             params.terminalCommand = terminalCommand();
1201         }
1202 
1203         return spawnApplication(unquotedArgs, params);
1204     }
1205 
1206     ///
1207     unittest
1208     {
1209         auto df = new DesktopFile();
1210         assertThrown(df.startApplication(string[].init));
1211 
1212         version(Posix) {
1213             static string[] emptyTerminalCommand() nothrow {
1214                 return null;
1215             }
1216 
1217             df = new DesktopFile(iniLikeStringReader("[Desktop Entry]\nTerminal=true\nType=Application\nExec=whoami"));
1218             try {
1219                 df.startApplication((string[]).init, null, emptyTerminalCommand);
1220             } catch(Exception e) {
1221 
1222             }
1223         }
1224     }
1225 
1226     ///Starts the application associated with this .desktop file using url as command line params.
1227     @trusted void startApplication(string url, string locale = null, lazy const(string)[] terminalCommand = getTerminalCommand) const {
1228         return startApplication([url], locale, terminalCommand);
1229     }
1230 
1231     /**
1232      * Opens url defined in .desktop file using $(LINK2 https://portland.freedesktop.org/doc/xdg-open.html, xdg-open).
1233      * Note:
1234      *  This function does not check if the type of desktop file is Link. It relies only on "URL" value.
1235      * Throws:
1236      *  $(B ProcessException) on failure to start the process.
1237      *  $(B Exception) if desktop file does not define URL or it's empty.
1238      * See_Also: $(D start)
1239      */
1240     @trusted void startLink() const {
1241         string myurl = url();
1242         enforce(myurl.length, "No URL to open");
1243         xdgOpen(myurl);
1244     }
1245 
1246     ///
1247     unittest
1248     {
1249         auto df = new DesktopFile();
1250         assertThrown(df.startLink());
1251     }
1252 
1253     /**
1254      * Starts application or open link depending on desktop entry type.
1255      * Throws:
1256      *  $(B ProcessException) on failure to start the process.
1257      *  $(D desktopfile.utils.DesktopExecException) if type is $(D DesktopEntry.Type.Application) and the exec string is invalid.
1258      *  $(B Exception) if type is $(D DesktopEntry.Type.Unknown) or $(D DesktopEntry.Type.Directory),
1259      *   or if type is $(D DesktopEntry.Type.Link), but no url provided.
1260      * See_Also: $(D startApplication), $(D startLink)
1261      */
1262     @trusted void start() const
1263     {
1264         final switch(type()) {
1265             case DesktopFile.Type.Application:
1266                 startApplication();
1267                 return;
1268             case DesktopFile.Type.Link:
1269                 startLink();
1270                 return;
1271             case DesktopFile.Type.Directory:
1272                 throw new Exception("Don't know how to start Directory");
1273             case DesktopFile.Type.Unknown:
1274                 throw new Exception("Unknown desktop entry type");
1275         }
1276     }
1277 
1278     ///
1279     unittest
1280     {
1281         string contents = "[Desktop Entry]\nType=Directory";
1282         auto df = new DesktopFile(iniLikeStringReader(contents));
1283         assertThrown(df.start());
1284 
1285         df = new DesktopFile();
1286         assertThrown(df.start());
1287     }
1288 
1289 private:
1290     DesktopEntry _desktopEntry;
1291     DesktopReadOptions _options;
1292 }
1293 
1294 ///
1295 unittest
1296 {
1297     import std.file;
1298     string desktopFileContents =
1299 `[Desktop Entry]
1300 # Comment
1301 Name=Double Commander
1302 Name[ru]=Двухпанельный коммандер
1303 GenericName=File manager
1304 GenericName[ru]=Файловый менеджер
1305 Comment=Double Commander is a cross platform open source file manager with two panels side by side.
1306 Comment[ru]=Double Commander - кроссплатформенный файловый менеджер.
1307 Terminal=false
1308 Icon=doublecmd
1309 Icon[ru]=doublecmd_ru
1310 Exec=doublecmd %f
1311 TryExec=doublecmd
1312 Type=Application
1313 Categories=Application;Utility;FileManager;
1314 Keywords=folder;manager;disk;filesystem;operations;
1315 Keywords[ru]=папка;директория;диск;файловый;менеджер;
1316 Actions=OpenDirectory;NotPresented;Settings;X-NoName;
1317 MimeType=inode/directory;application/x-directory;
1318 NoDisplay=false
1319 Hidden=false
1320 StartupNotify=true
1321 DBusActivatable=true
1322 Path=/opt/doublecmd
1323 OnlyShowIn=GNOME;XFCE;LXDE;
1324 NotShowIn=KDE;
1325 
1326 [Desktop Action OpenDirectory]
1327 Name=Open directory
1328 Name[ru]=Открыть папку
1329 Icon=open
1330 Exec=doublecmd %u
1331 
1332 [X-NoName]
1333 Icon=folder
1334 
1335 [Desktop Action Settings]
1336 Name=Settings
1337 Name[ru]=Настройки
1338 Icon=edit
1339 Exec=doublecmd settings
1340 
1341 [Desktop Action Notspecified]
1342 Name=Notspecified Action`;
1343 
1344     auto df = new DesktopFile(iniLikeStringReader(desktopFileContents), "doublecmd.desktop");
1345     assert(df.desktopEntry().groupName() == "Desktop Entry");
1346     assert(df.fileName() == "doublecmd.desktop");
1347     assert(df.displayName() == "Double Commander");
1348     assert(df.localizedDisplayName("ru_RU") == "Двухпанельный коммандер");
1349     assert(df.genericName() == "File manager");
1350     assert(df.localizedGenericName("ru_RU") == "Файловый менеджер");
1351     assert(df.comment() == "Double Commander is a cross platform open source file manager with two panels side by side.");
1352     assert(df.localizedComment("ru_RU") == "Double Commander - кроссплатформенный файловый менеджер.");
1353     assert(df.iconName() == "doublecmd");
1354     assert(df.localizedIconName("ru_RU") == "doublecmd_ru");
1355     assert(df.tryExecValue() == "doublecmd");
1356     assert(!df.terminal());
1357     assert(!df.noDisplay());
1358     assert(!df.hidden());
1359     assert(df.startupNotify());
1360     assert(df.dbusActivable());
1361     assert(df.workingDirectory() == "/opt/doublecmd");
1362     assert(df.type() == DesktopFile.Type.Application);
1363     assert(equal(df.keywords(), ["folder", "manager", "disk", "filesystem", "operations"]));
1364     assert(equal(df.localizedKeywords("ru_RU"), ["папка", "директория", "диск", "файловый", "менеджер"]));
1365     assert(equal(df.categories(), ["Application", "Utility", "FileManager"]));
1366     assert(equal(df.actions(), ["OpenDirectory", "NotPresented", "Settings", "X-NoName"]));
1367     assert(equal(df.mimeTypes(), ["inode/directory", "application/x-directory"]));
1368     assert(equal(df.onlyShowIn(), ["GNOME", "XFCE", "LXDE"]));
1369     assert(equal(df.notShowIn(), ["KDE"]));
1370     assert(df.group("X-NoName") !is null);
1371 
1372     assert(equal(df.byAction().map!(desktopAction =>
1373     tuple(desktopAction.displayName(), desktopAction.localizedDisplayName("ru"), desktopAction.iconName(), desktopAction.execValue())),
1374                  [tuple("Open directory", "Открыть папку", "open", "doublecmd %u"), tuple("Settings", "Настройки", "edit", "doublecmd settings")]));
1375 
1376     DesktopAction desktopAction = df.action("OpenDirectory");
1377     assert(desktopAction !is null);
1378     assert(desktopAction.expandExecValue(["path/to/file"]) == ["doublecmd", "path/to/file"]);
1379 
1380     assert(df.action("NotPresented") is null);
1381     assert(df.action("Notspecified") is null);
1382     assert(df.action("X-NoName") is null);
1383     assert(df.action("Settings") !is null);
1384 
1385     assert(df.saveToString() == desktopFileContents);
1386 
1387     assert(df.contains("Icon"));
1388     df.removeEntry("Icon");
1389     assert(!df.contains("Icon"));
1390     df.setEscapedValue("Icon", "files");
1391     assert(df.contains("Icon"));
1392 
1393     string contents =
1394 `# First comment
1395 [Desktop Entry]
1396 Key=Value
1397 # Comment in group`;
1398 
1399     df = new DesktopFile(iniLikeStringReader(contents), "test.desktop");
1400     assert(df.fileName() == "test.desktop");
1401     df.removeGroup("Desktop Entry");
1402     assert(df.group("Desktop Entry") !is null);
1403     assert(df.desktopEntry() !is null);
1404 
1405     contents =
1406 `[X-SomeGroup]
1407 Key=Value`;
1408 
1409     auto thrown = collectException!IniLikeReadException(new DesktopFile(iniLikeStringReader(contents)));
1410     assert(thrown !is null);
1411     assert(thrown.lineNumber == 0);
1412 
1413     df = new DesktopFile();
1414     df.desktopEntry().setUnescapedValue("$Invalid", "Valid value", IniLikeGroup.InvalidKeyPolicy.save);
1415     assert(df.desktopEntry().escapedValue("$Invalid") == "Valid value");
1416     df.desktopEntry().setUnescapedValue("Another$Invalid", "Valid value", IniLikeGroup.InvalidKeyPolicy.skip);
1417     assert(df.desktopEntry().escapedValue("Another$Invalid") is null);
1418     df.terminal = true;
1419     df.type = DesktopFile.Type.Application;
1420     df.categories = ["Development", "Compilers", "One;Two", "Three\\;Four", "New\nLine"];
1421 
1422     assert(df.terminal() == true);
1423     assert(df.type() == DesktopFile.Type.Application);
1424     assert(equal(df.categories(), ["Development", "Compilers", "One;Two", "Three\\;Four","New\nLine"]));
1425 
1426     df.displayName = "Program name";
1427     assert(df.displayName() == "Program name");
1428     df.genericName = "Program";
1429     assert(df.genericName() == "Program");
1430     df.comment = "Do\nthings";
1431     assert(df.comment() == "Do\nthings");
1432 
1433     df.execValue = "utilname";
1434     assert(df.execValue() == "utilname");
1435 
1436     df.noDisplay = true;
1437     assert(df.noDisplay());
1438     df.hidden = true;
1439     assert(df.hidden());
1440     df.dbusActivable = true;
1441     assert(df.dbusActivable());
1442     df.startupNotify = true;
1443     assert(df.startupNotify());
1444 
1445     df.url = "/some/url";
1446     assert(df.url == "/some/url");
1447 }