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