1 /**
2  * Reading, writing and executing .desktop file
3  * Authors: 
4  *  $(LINK2 https://github.com/MyLittleRobo, Roman Chistokhodov).
5  * Copyright:
6  *  Roman Chistokhodov, 2015
7  * License: 
8  *  $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0).
9  * See_Also: 
10  *  $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/index.html, Desktop Entry Specification).
11  */
12 
13 module desktopfile;
14 
15 import standardpaths;
16 
17 public import inilike;
18 
19 private {
20     import std.algorithm;
21     import std.array;
22     import std.conv;
23     import std.exception;
24     import std.path;
25     import std.process;
26     import std.range;
27     import std.stdio;
28     import std.string;
29     import std.traits;
30     import std.typecons;
31     import std.uni;
32     
33     static if( __VERSION__ < 2066 ) enum nogc = 1;
34 }
35 
36 /**
37  * Exception thrown when "Exec" value of DesktopFile or DesktopAction is invalid.
38  */
39 class DesktopExecException : Exception
40 {
41     this(string msg, string file = __FILE__, size_t line = __LINE__, Throwable next = null) pure nothrow @safe {
42         super(msg, file, line, next);
43     }
44 }
45 
46 /**
47  * Applications paths based on data paths. 
48  * This function is available on all platforms, but requires dataPaths argument (e.g. C:\ProgramData\KDE\share on Windows)
49  * Returns: Array of paths, based on dataPaths with "applications" directory appended.
50  */
51 @trusted string[] applicationsPaths(in string[] dataPaths) nothrow {
52     return dataPaths.map!(p => buildPath(p, "applications")).array;
53 }
54 
55 ///
56 unittest
57 {
58     assert(equal(applicationsPaths(["share", buildPath("local", "share")]), [buildPath("share", "applications"), buildPath("local", "share", "applications")]));
59 }
60 
61 version(OSX) {}
62 else version(Posix)
63 {
64     /**
65      * ditto, but returns paths based on known data paths. It's practically the same as standardPaths(StandardPath.applications).
66      * This function is defined only on freedesktop systems to avoid confusion with other systems that have data paths not compatible with Desktop Entry Spec.
67      */
68     @trusted string[] applicationsPaths() nothrow {
69         return standardPaths(StandardPath.applications);
70     }
71     
72     /**
73      * Path where .desktop files can be stored without requiring of root privileges.
74      * It's practically the same as writablePath(StandardPath.applications).
75      * This function is defined only on freedesktop systems to avoid confusion with other systems that have data paths not compatible with Desktop Entry Spec.
76      * Note: it does not check if returned path exists and appears to be directory.
77      */
78     @trusted string writableApplicationsPath() nothrow {
79         return writablePath(StandardPath.applications);
80     }
81 }
82 
83 /**
84  * Unescape Exec argument as described in [specification](http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s06.html).
85  * Returns: Unescaped string.
86  */
87 @trusted string unescapeExecArgument(string arg) nothrow pure
88 {
89     static immutable Tuple!(char, char)[] pairs = [
90        tuple('s', ' '),
91        tuple('n', '\n'),
92        tuple('r', '\r'),
93        tuple('t', '\t'),
94        tuple('"', '"'),
95        tuple('\'', '\''),
96        tuple('\\', '\\'),
97        tuple('>', '>'),
98        tuple('<', '<'),
99        tuple('~', '~'),
100        tuple('|', '|'),
101        tuple('&', '&'),
102        tuple(';', ';'),
103        tuple('$', '$'),
104        tuple('*', '*'),
105        tuple('?', '?'),
106        tuple('#', '#'),
107        tuple('(', '('),
108        tuple(')', ')'),
109        tuple('`', '`'),
110     ];
111     return doUnescape(arg, pairs);
112 }
113 
114 ///
115 unittest
116 {
117     assert(unescapeExecArgument("simple") == "simple");
118     assert(unescapeExecArgument(`with\&\"escaped\"\?symbols\$`) == `with&"escaped"?symbols$`);
119 }
120 
121 private @trusted string unescapeQuotedArgument(string value) nothrow pure
122 {
123     static immutable Tuple!(char, char)[] pairs = [
124        tuple('`', '`'),
125        tuple('$', '$'),
126        tuple('"', '"'),
127        tuple('\\', '\\')
128     ];
129     return doUnescape(value, pairs);
130 }
131 
132 /**
133  * Unquote Exec value into an array of escaped arguments. 
134  * If an argument was quoted then unescaping of quoted arguments is applied automatically. Note that unescaping of quoted argument is not the same as unquoting argument in general. Read more in [specification](http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s06.html).
135  * Throws:
136  *  DesktopExecException if string can't be unquoted (e.g. no pair quote).
137  * Note:
138  *  Although Desktop Entry Specification says that arguments must be quoted by double quote, for compatibility reasons this implementation also recognizes single quotes.
139  */
140 @trusted auto unquoteExecString(string value) pure
141 {
142     string[] result;
143     size_t i;
144     
145     while(i < value.length) {
146         if (isWhite(value[i])) {
147             i++;
148         } else if (value[i] == '"' || value[i] == '\'') {
149             char delimeter = value[i];
150             size_t start = ++i;
151             bool inQuotes = true;
152             bool wasSlash;
153             
154             while(i < value.length) {
155                 if (value[i] == '\\' && value.length > i+1 && value[i+1] == '\\') {
156                     i+=2;
157                     wasSlash = true;
158                     continue;
159                 }
160                 
161                 if (value[i] == delimeter && (value[i-1] != '\\' || (value[i-1] == '\\' && wasSlash) )) {
162                     inQuotes = false;
163                     break;
164                 }
165                 wasSlash = false;
166                 i++;
167             }
168             if (inQuotes) {
169                 throw new DesktopExecException("Missing pair quote");
170             }
171             result ~= value[start..i].unescapeQuotedArgument();
172             i++;
173             
174         } else {
175             size_t start = i;
176             while(i < value.length && !isWhite(value[i])) {
177                 i++;
178             }
179             result ~= value[start..i];
180         }
181     }
182     
183     return result;
184 }
185 
186 ///
187 unittest 
188 {
189     assert(equal(unquoteExecString(``), string[].init));
190     assert(equal(unquoteExecString(`   `), string[].init));
191     assert(equal(unquoteExecString(`"" "  "`), [``, `  `]));
192     
193     assert(equal(unquoteExecString(`cmd arg1  arg2   arg3   `), [`cmd`, `arg1`, `arg2`, `arg3`]));
194     assert(equal(unquoteExecString(`"cmd" arg1 arg2  `), [`cmd`, `arg1`, `arg2`]));
195     
196     assert(equal(unquoteExecString(`"quoted cmd"   arg1  "quoted arg"  `), [`quoted cmd`, `arg1`, `quoted arg`]));
197     assert(equal(unquoteExecString(`"quoted \"cmd\"" arg1 "quoted \"arg\""`), [`quoted "cmd"`, `arg1`, `quoted "arg"`]));
198     
199     assert(equal(unquoteExecString(`"\\\$" `), [`\$`]));
200     assert(equal(unquoteExecString(`"\\$" `), [`\$`]));
201     assert(equal(unquoteExecString(`"\$" `), [`$`]));
202     assert(equal(unquoteExecString(`"$"`), [`$`]));
203     
204     assert(equal(unquoteExecString(`"\\" `), [`\`]));
205     assert(equal(unquoteExecString(`"\\\\" `), [`\\`]));
206     
207     assert(equal(unquoteExecString(`'quoted cmd' arg`), [`quoted cmd`, `arg`]));
208     
209     assertThrown!DesktopExecException(unquoteExecString(`cmd "quoted arg`));
210 }
211 
212 
213 /**
214  * Convenient function used to unquote and unescape Exec value into an array of arguments.
215  * Note:
216  *  Parsed arguments still may contain field codes that should be appropriately expanded before passing to spawnProcess.
217  * Throws:
218  *  DesktopExecException if string can't be unquoted.
219  * See_Also:
220  *  unquoteExecString, unescapeExecArgument
221  */
222 @trusted string[] parseExecString(string execString) pure
223 {
224     return execString.unquoteExecString().map!(unescapeExecArgument).array;
225 }
226 
227 ///
228 unittest
229 {
230     assert(equal(parseExecString(`"quoted cmd" new\nline "quoted\\\\arg" slash\\arg`), ["quoted cmd", "new\nline", `quoted\arg`, `slash\arg`]));
231 }
232 
233 /**
234  * Expand Exec arguments (usually returned by parseExecString) replacing field codes with given values, making the array suitable for passing to spawnProcess. Deprecated field codes are ignored.
235  * Note:
236  *  Returned array may be empty and should be checked before passing to spawnProcess.
237  * Params:
238  * execArgs = array of unquoted and unescaped arguments.
239  *  urls = array of urls or file names that inserted in the place of %f, %F, %u or %U field codes. For %f and %u only the first element of array is used.
240  *  iconName = icon name used to substitute %i field code by --icon iconName.
241  *  name = name of application used that inserted in the place of %c field code.
242  *  fileName = name of desktop file that inserted in the place of %k field code.
243  * Throws:
244  *  DesktopExecException if command line contains unknown field code.
245  * See_Also:
246  *  parseExecString
247  */
248 @trusted string[] expandExecArgs(in string[] execArgs, in string[] urls = null, string iconName = null, string name = null, string fileName = null) pure
249 {
250     string[] toReturn;
251     foreach(token; execArgs) {
252         if (token == "%f") {
253             if (urls.length) {
254                 toReturn ~= urls.front;
255             }
256         } else if (token == "%F") {
257             toReturn ~= urls;
258         } else if (token == "%u") {
259             if (urls.length) {
260                 toReturn ~= urls.front;
261             }
262         } else if (token == "%U") {
263             toReturn ~= urls;
264         } else if (token == "%i") {
265             if (iconName.length) {
266                 toReturn ~= "--icon";
267                 toReturn ~= iconName;
268             }
269         } else if (token == "%c") {
270             toReturn ~= name;
271         } else if (token == "%k") {
272             toReturn ~= fileName;
273         } else if (token == "%d" || token == "%D" || token == "%n" || token == "%N" || token == "%m" || token == "%v") {
274             continue;
275         } else {
276             if (token.length >= 2 && token[0] == '%') {
277                 if (token[1] == '%') {
278                     toReturn ~= token[1..$];
279                 } else {
280                     throw new DesktopExecException("Unknown field code: " ~ token);
281                 }
282             } else {
283                 toReturn ~= token;
284             }
285         }
286     }
287     
288     return toReturn;
289 }
290 
291 ///
292 unittest
293 {
294     assert(expandExecArgs(["program name", "%%f", "%f", "%i"], ["one", "two"], "folder") == ["program name", "%f", "one", "--icon", "folder"]);
295     assertThrown!DesktopExecException(expandExecArgs(["program name", "%y"]));
296 }
297 
298 /**
299  * Unquote, unescape Exec string and expand field codes substituting them with appropriate values.
300  * Throws:
301  *  DesktopExecException if string can't be unquoted, unquoted command line is empty or it has unknown field code.
302  * See_Also:
303  *  expandExecArgs, parseExecString
304  */
305 @trusted string[] expandExecString(string execString, in string[] urls = null, string iconName = null, string name = null, string fileName = null) pure
306 {
307     auto execArgs = parseExecString(execString);
308     if (execArgs.empty) {
309         throw new DesktopExecException("No arguments. Missing or empty Exec value");
310     }
311     return expandExecArgs(execArgs, urls, iconName, name, fileName);
312 }
313 
314 ///
315 unittest
316 {
317     assert(expandExecString(`"quoted program" %i -w %c -f %k %U %D %u %f %F`, ["one", "two"], "folder", "Программа", "/example.desktop") == ["quoted program", "--icon", "folder", "-w", "Программа", "-f", "/example.desktop", "one", "two", "one", "one", "one", "two"]);
318     
319     assertThrown!DesktopExecException(expandExecString(`program %f %y`)); //%y is unknown field code.
320     assertThrown!DesktopExecException(expandExecString(``));
321 }
322 
323 /**
324  * Detect command which will run program in terminal emulator.
325  * On Freedesktop it looks for x-terminal-emulator first. If found ["/path/to/x-terminal-emulator", "-e"] is returned.
326  * Otherwise it looks for xdg-terminal. If found ["/path/to/xdg-terminal"] is returned.
327  * If all guesses failed, it uses ["xterm", "-e"] as fallback.
328  * Note: This function always returns empty array on non-freedesktop systems.
329  */
330 string[] getTerminalCommand() nothrow @trusted 
331 {
332     version(OSX) {
333         return null;
334     } else version(Posix) {
335         string term = findExecutable("x-terminal-emulator");
336         if (!term.empty) {
337             return [term, "-e"];
338         }
339         term = findExecutable("xdg-terminal");
340         if (!term.empty) {
341             return [term];
342         }
343         return ["xterm", "-e"];
344     } else {
345         return null;
346     }
347 }
348 
349 private @trusted File getNullStdin()
350 {
351     version(Posix) {
352         return File("/dev/null", "rb");
353     } else {
354         return std.stdio.stdin;
355     }
356 }
357 
358 private @trusted File getNullStdout()
359 {
360     version(Posix) {
361         return File("/dev/null", "wb");
362     } else {
363         return std.stdio.stdout;
364     }
365 }
366 
367 private @trusted File getNullStderr()
368 {
369     version(Posix) {
370         return File("/dev/null", "wb");
371     } else {
372         return std.stdio.stderr;
373     }
374 }
375 
376 private @trusted Pid execProcess(string[] args, string workingDirectory = null)
377 {
378     static if( __VERSION__ < 2066 ) {
379         return spawnProcess(args, getNullStdin(), getNullStdout(), getNullStderr(), null, Config.none);
380     } else {
381         return spawnProcess(args, getNullStdin(), getNullStdout(), getNullStderr(), null, Config.none, workingDirectory);
382     }
383 }
384 
385 /**
386  * Adapter of IniLikeGroup for easy access to desktop action.
387  */
388 struct DesktopAction
389 {
390     @nogc @safe this(const(IniLikeGroup) group) nothrow {
391         _group = group;
392     }
393     
394     /**
395      * Label that will be shown to the user.
396      * Returns: The value associated with "Name" key.
397      * Note: Don't confuse this with name of section. To access name of section use group().name.
398      */
399     @nogc @safe string name() const nothrow {
400         return value("Name");
401     }
402     
403     /**
404      * Label that will be shown to the user in given locale.
405      * Returns: The value associated with "Name" key and given locale.
406      */
407     @safe string localizedName(string locale) const nothrow {
408         return localizedValue("Name", locale);
409     }
410     
411     /**
412      * Icon name of action.
413      * Returns: The value associated with "Icon" key.
414      */
415     @nogc @safe string iconName() const nothrow {
416         return value("Icon");
417     }
418     
419     /**
420      * Returns: Localized icon name
421      * See_Also: iconName
422      */
423     @safe string localizedIconName(string locale) const nothrow {
424         return localizedValue("Icon", locale);
425     }
426     
427     /**
428      * Returns: The value associated with "Exec" key and given locale.
429      */
430     @nogc @safe string execString() const nothrow {
431         return value("Exec");
432     }
433     
434     /**
435      * Start this action.
436      * Returns:
437      *  Pid of started process.
438      * Throws:
439      *  ProcessException on failure to start the process.
440      *  DesktopExecException if exec string is invalid.
441      * See_Also: execString
442      */
443     @safe Pid start(string locale = null) const {
444         return execProcess(expandExecString(execString, null, localizedIconName(locale), localizedName(locale)));
445     }
446     
447     /**
448      * Underlying IniLikeGroup instance. 
449      * Returns: IniLikeGroup this object was constrcucted from.
450      */
451     @nogc @safe const(IniLikeGroup) group() const nothrow {
452         return _group;
453     }
454     
455     /**
456      * This alias allows to call functions of underlying IniLikeGroup instance.
457      */
458     alias group this;
459 private:
460     const(IniLikeGroup) _group;
461 }
462 
463 /**
464  * Represents .desktop file.
465  * 
466  */
467 final class DesktopFile : IniLikeFile
468 {
469 public:
470     ///Desktop entry type
471     enum Type
472     {
473         Unknown, ///Desktop entry is unknown type
474         Application, ///Desktop describes application
475         Link, ///Desktop describes URL
476         Directory ///Desktop entry describes directory settings
477     }
478     
479     alias IniLikeFile.ReadOptions ReadOptions;
480     
481     /**
482      * Reads desktop file from file.
483      * Throws:
484      *  $(B ErrnoException) if file could not be opened.
485      *  $(B IniLikeException) if error occured while reading the file.
486      */
487     @safe this(string fileName, ReadOptions options = ReadOptions.noOptions) {
488         this(iniLikeFileReader(fileName), options, fileName);
489     }
490     
491     /**
492      * Reads desktop file from range of $(B IniLikeLine)s.
493      * Throws:
494      *  $(B IniLikeException) if error occured while parsing.
495      */
496     @trusted this(Range)(Range byLine, ReadOptions options = ReadOptions.noOptions, string fileName = null) if(is(ElementType!Range : IniLikeLine))
497     {   
498         super(byLine, options, fileName);
499         _desktopEntry = group("Desktop Entry");
500         enforce(_desktopEntry, new IniLikeException("No \"Desktop Entry\" group", 0));
501     }
502     
503     /**
504      * Constructs DesktopFile with "Desktop Entry" group and Version set to 1.0
505      */
506     @safe this() {
507         super();
508         _desktopEntry = super.addGroup("Desktop Entry");
509         this["Version"] = "1.0";
510     }
511     
512     ///
513     unittest
514     {
515         auto df = new DesktopFile();
516         assert(df.desktopEntry());
517         assert(df.value("Version") == "1.0");
518         assert(df.categories().empty);
519         assert(df.type() == DesktopFile.Type.Unknown);
520     }
521     
522     @safe override IniLikeGroup addGroup(string groupName) {
523         auto entry = super.addGroup(groupName);
524         if (groupName == "Desktop Entry") {
525             _desktopEntry = entry;
526         }
527         return entry;
528     }
529     
530     /**
531      * Removes group by name. You can't remove "Desktop Entry" group with this function.
532      */
533     @safe override void removeGroup(string groupName) nothrow {
534         if (groupName != "Desktop Entry") {
535             super.removeGroup(groupName);
536         }
537     }
538     
539     ///
540     unittest
541     {
542         auto df = new DesktopFile();
543         df.addGroup("Action");
544         assert(df.group("Action") !is null);
545         df.removeGroup("Action");
546         assert(df.group("Action") is null);
547         df.removeGroup("Desktop Entry");
548         assert(df.desktopEntry() !is null);
549     }
550     
551     /**
552      * Type of desktop entry.
553      * Returns: Type of desktop entry.
554      */
555     @nogc @safe Type type() const nothrow {
556         string t = value("Type");
557         if (t.length) {
558             if (t == "Application") {
559                 return Type.Application;
560             } else if (t == "Link") {
561                 return Type.Link;
562             } else if (t == "Directory") {
563                 return Type.Directory;
564             }
565         }
566         if (fileName().endsWith(".directory")) {
567             return Type.Directory;
568         }
569         
570         return Type.Unknown;
571     }
572     
573     ///
574     unittest
575     {
576         string contents = "[Desktop Entry]\nType=Application";
577         auto desktopFile = new DesktopFile(iniLikeStringReader(contents));
578         assert(desktopFile.type == DesktopFile.Type.Application);
579         
580         desktopFile.desktopEntry["Type"] = "Link";
581         assert(desktopFile.type == DesktopFile.Type.Link);
582         
583         desktopFile.desktopEntry["Type"] = "Directory";
584         assert(desktopFile.type == DesktopFile.Type.Directory);
585         
586         desktopFile = new DesktopFile(iniLikeStringReader("[Desktop Entry]"), ReadOptions.noOptions, ".directory");
587         assert(desktopFile.type == DesktopFile.Type.Directory);
588     }
589     
590     /**
591      * Sets "Type" field to type
592      * Note: Setting the Unknown type removes type field.
593      */
594     @safe Type type(Type t) {
595         final switch(t) {
596             case Type.Application:
597                 this["Type"] = "Application";
598                 break;
599             case Type.Link:
600                 this["Type"] = "Link";
601                 break;
602             case Type.Directory:
603                 this["Type"] = "Directory";
604                 break;
605             case Type.Unknown:
606                 this.removeEntry("Type");
607                 break;
608         }
609         return t;
610     }
611     
612     ///
613     unittest
614     {
615         auto desktopFile = new DesktopFile();
616         desktopFile.type = DesktopFile.Type.Application;
617         assert(desktopFile.desktopEntry["Type"] == "Application");
618         desktopFile.type = DesktopFile.Type.Link;
619         assert(desktopFile.desktopEntry["Type"] == "Link");
620         desktopFile.type = DesktopFile.Type.Directory;
621         assert(desktopFile.desktopEntry["Type"] == "Directory");
622         
623         desktopFile.type = DesktopFile.Type.Unknown;
624         assert(desktopFile.desktopEntry.value("Type").empty);
625     }
626     
627     /**
628      * Specific name of the application, for example "Mozilla".
629      * Returns: The value associated with "Name" key.
630      * See_Also: localizedName
631      */
632     @nogc @safe string name() const nothrow {
633         return value("Name");
634     }
635     /**
636      * Returns: Localized name.
637      * See_Also: name
638      */
639     @safe string localizedName(string locale) const nothrow {
640         return localizedValue("Name", locale);
641     }
642     
643     version(OSX) {} version(Posix) {
644         /** 
645         * See $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ape.html, Desktop File ID)
646         * Returns: Desktop file ID or empty string if file does not have an ID.
647         * Note: This function retrieves applications paths each time it's called and therefore can impact performance. To avoid this issue use overload with argument.
648         * See_Also: applicationsPaths
649         */
650         @trusted string id() const nothrow {
651             return id(applicationsPaths());
652         }
653     }
654     
655     /**
656      * See $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ape.html, Desktop File ID)
657      * Params: 
658      *  appPaths = range of base application paths to check if this file belongs to one of them.
659      * Returns: Desktop file ID or empty string if file does not have an ID.
660      * See_Also: applicationsPaths
661      */
662     @trusted string id(Range)(Range appPaths) const nothrow if (isInputRange!Range && is(ElementType!Range : string)) 
663     {
664         try {
665             string absolute = fileName.absolutePath;
666             foreach (path; appPaths) {
667                 auto pathSplit = pathSplitter(path);
668                 auto fileSplit = pathSplitter(absolute);
669                 
670                 while (!pathSplit.empty && !fileSplit.empty && pathSplit.front == fileSplit.front) {
671                     pathSplit.popFront();
672                     fileSplit.popFront();
673                 }
674                 
675                 if (pathSplit.empty) {
676                     return to!string(fileSplit.join("-"));
677                 }
678             }
679         } catch(Exception e) {
680             
681         }
682         return null;
683     }
684     
685     ///
686     unittest 
687     {
688         string contents = 
689 `[Desktop Entry]
690 Name=Program
691 Type=Directory`;
692         
693         string[] appPaths;
694         string filePath, nestedFilePath, wrongFilePath;
695         
696         version(Windows) {
697             appPaths = [`C:\ProgramData\KDE\share\applications`, `C:\Users\username\.kde\share\applications`];
698             filePath = `C:\ProgramData\KDE\share\applications\example.desktop`;
699             nestedFilePath = `C:\ProgramData\KDE\share\applications\kde\example.desktop`;
700             wrongFilePath = `C:\ProgramData\desktop\example.desktop`;
701         } else {
702             appPaths = ["/usr/share/applications", "/usr/local/share/applications"];
703             filePath = "/usr/share/applications/example.desktop";
704             nestedFilePath = "/usr/share/applications/kde/example.desktop";
705             wrongFilePath = "/etc/desktop/example.desktop";
706         }
707          
708         auto df = new DesktopFile(iniLikeStringReader(contents), DesktopFile.ReadOptions.noOptions, nestedFilePath);
709         assert(df.id(appPaths) == "kde-example.desktop");
710         
711         df = new DesktopFile(iniLikeStringReader(contents), DesktopFile.ReadOptions.noOptions, filePath);
712         assert(df.id(appPaths) == "example.desktop");
713         
714         df = new DesktopFile(iniLikeStringReader(contents), DesktopFile.ReadOptions.noOptions, wrongFilePath);
715         assert(df.id(appPaths).empty);
716         
717         df = new DesktopFile(iniLikeStringReader(contents), DesktopFile.ReadOptions.noOptions);
718         assert(df.id(appPaths).empty);
719     }
720     
721     /**
722      * Generic name of the application, for example "Web Browser".
723      * Returns: The value associated with "GenericName" key.
724      * See_Also: localizedGenericName
725      */
726     @nogc @safe string genericName() const nothrow {
727         return value("GenericName");
728     }
729     /**
730      * Returns: Localized generic name
731      * See_Also: genericName
732      */
733     @safe string localizedGenericName(string locale) const nothrow {
734         return localizedValue("GenericName", locale);
735     }
736     
737     /**
738      * Tooltip for the entry, for example "View sites on the Internet".
739      * Returns: The value associated with "Comment" key.
740      * See_Also: localizedComment
741      */
742     @nogc @safe string comment() const nothrow {
743         return value("Comment");
744     }
745     /**
746      * Returns: Localized comment
747      * See_Also: comment
748      */
749     @safe string localizedComment(string locale) const nothrow {
750         return localizedValue("Comment", locale);
751     }
752     
753     /** 
754      * Returns: the value associated with "Exec" key.
755      * Note: To get arguments from exec string use expandExecString.
756      * See_Also: expandExecString, startApplication
757      */
758     @nogc @safe string execString() const nothrow {
759         return value("Exec");
760     }
761     
762     /**
763      * URL to access.
764      * Returns: The value associated with "URL" key.
765      */
766     @nogc @safe string url() const nothrow {
767         return value("URL");
768     }
769     
770     ///
771     unittest
772     {
773         auto df = new DesktopFile(iniLikeStringReader("[Desktop Entry]\nType=Link\nURL=https://github.com/"));
774         assert(df.url() == "https://github.com/");
775     }
776     
777     /**
778      * Value used to determine if the program is actually installed. 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).
779      * Returns: The value associated with "TryExec" key.
780      */
781     @nogc @safe string tryExecString() const nothrow {
782         return value("TryExec");
783     }
784     
785     /**
786      * Icon to display in file manager, menus, etc.
787      * Returns: The value associated with "Icon" key.
788      * Note: This function returns Icon as it's defined in .desktop file. 
789      *  It does not provide any lookup of actual icon file on the system if the name if not an absolute path.
790      *  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/MyLittleRobo/icontheme, icontheme library).
791      */
792     @nogc @safe string iconName() const nothrow {
793         return value("Icon");
794     }
795     
796     /**
797      * Returns: Localized icon name
798      * See_Also: iconName
799      */
800     @safe string localizedIconName(string locale) const nothrow {
801         return localizedValue("Icon", locale);
802     }
803     
804     /**
805      * NoDisplay means "this application exists, but don't display it in the menus".
806      * Returns: The value associated with "NoDisplay" key converted to bool using isTrue.
807      */
808     @nogc @safe bool noDisplay() const nothrow {
809         return isTrue(value("NoDisplay"));
810     }
811     
812     /**
813      * Hidden means the user deleted (at his level) something that was present (at an upper level, e.g. in the system dirs). 
814      * It's strictly equivalent to the .desktop file not existing at all, as far as that user is concerned. 
815      * Returns: The value associated with "Hidden" key converted to bool using isTrue.
816      */
817     @nogc @safe bool hidden() const nothrow {
818         return isTrue(value("Hidden"));
819     }
820     
821     /**
822      * A boolean value specifying if D-Bus activation is supported for this application.
823      * Returns: The value associated with "dbusActivable" key converted to bool using isTrue.
824      */
825     @nogc @safe bool dbusActivable() const nothrow {
826         return isTrue(value("DBusActivatable"));
827     }
828     
829     /**
830      * Returns: The value associated with "startupNotify" key converted to bool using isTrue.
831      */
832     @nogc @safe bool startupNotify() const nothrow {
833         return isTrue(value("StartupNotify"));
834     }
835     
836     /**
837      * The working directory to run the program in.
838      * Returns: The value associated with "Path" key.
839      */
840     @nogc @safe string workingDirectory() const nothrow {
841         return value("Path");
842     }
843     
844     /**
845      * Whether the program runs in a terminal window.
846      * Returns: The value associated with "Terminal" key converted to bool using isTrue.
847      */
848     @nogc @safe bool terminal() const nothrow {
849         return isTrue(value("Terminal"));
850     }
851     /// Sets "Terminal" field to true or false.
852     @safe bool terminal(bool t) {
853         this["Terminal"] = t ? "true" : "false";
854         return t;
855     }
856     
857     private static struct SplitValues
858     {
859         @trusted this(string value) {
860             _value = value;
861             next();
862         }
863         @trusted string front() {
864             return _current;
865         }
866         @trusted void popFront() {
867             next();
868         }
869         @trusted bool empty() {
870             return _value.empty && _current.empty;
871         }
872         @trusted @property auto save() {
873             return this;
874         }
875     private:
876         void next() {
877             size_t i=0;
878             for (; i<_value.length && ( (_value[i] != ';') || (i && _value[i-1] == '\\' && _value[i] == ';')); ++i) {
879                 //pass
880             }
881             _current = _value[0..i].replace("\\;", ";");
882             _value = i == _value.length ? _value[_value.length..$] : _value[i+1..$];
883         }
884         string _value;
885         string _current;
886     }
887 
888     static assert(isForwardRange!SplitValues);
889     
890     /**
891      * Some keys can have multiple values, separated by semicolon. This function helps to parse such kind of strings into the range.
892      * Returns: The range of multiple nonempty values.
893      * Note: Returned range unescapes ';' character automatically.
894      */
895     @trusted static auto splitValues(string values) {
896         return SplitValues(values).filter!(s => !s.empty);
897     }
898     
899     ///
900     unittest 
901     {
902         assert(DesktopFile.splitValues("").empty);
903         assert(DesktopFile.splitValues(";").empty);
904         assert(DesktopFile.splitValues(";;;").empty);
905         assert(equal(DesktopFile.splitValues("Application;Utility;FileManager;"), ["Application", "Utility", "FileManager"]));
906         assert(equal(DesktopFile.splitValues("I\\;Me;\\;You\\;We\\;"), ["I;Me", ";You;We;"]));
907     }
908     
909     /**
910      * Join range of multiple values into a string using semicolon as separator. Adds trailing semicolon.
911      * Returns: Values of range joined into one string with ';' after each value or empty string if range is empty.
912      * Note: If some value of range contains ';' character it's automatically escaped.
913      */
914     @trusted static string joinValues(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
915         auto result = values.filter!( s => !s.empty ).map!( s => s.replace(";", "\\;")).joiner(";");
916         if (result.empty) {
917             return null;
918         } else {
919             return text(result) ~ ";";
920         }
921     }
922     
923     ///
924     unittest
925     {
926         assert(DesktopFile.joinValues([""]).empty);
927         assert(equal(DesktopFile.joinValues(["Application", "Utility", "FileManager"]), "Application;Utility;FileManager;"));
928         assert(equal(DesktopFile.joinValues(["I;Me", ";You;We;"]), "I\\;Me;\\;You\\;We\\;;"));
929     }
930     
931     /**
932      * Categories this program belongs to.
933      * Returns: The range of multiple values associated with "Categories" key.
934      */
935     @safe auto categories() const {
936         return splitValues(value("Categories"));
937     }
938     
939     /**
940      * Sets the list of values for the "Categories" list.
941      */
942     @safe void categories(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
943         this["Categories"] = joinValues(values);
944     }
945     
946     /**
947      * A list of strings which may be used in addition to other metadata to describe this entry.
948      * Returns: The range of multiple values associated with "Keywords" key.
949      */
950     @safe auto keywords() const {
951         return splitValues(value("Keywords"));
952     }
953     
954     /**
955      * Sets the list of values for the "Keywords" list.
956      */
957     @safe void keywords(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
958         this["Keywords"] = joinValues(values);
959     }
960     
961     /**
962      * The MIME type(s) supported by this application.
963      * Returns: The range of multiple values associated with "MimeType" key.
964      */
965     @safe auto mimeTypes() const {
966         return splitValues(value("MimeType"));
967     }
968     
969     /**
970      * Sets the list of values for the "MimeType" list.
971      */
972     @safe void mimeTypes(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
973         this["MimeType"] = joinValues(values);
974     }
975     
976     /**
977      * Actions supported by application.
978      * Returns: Range of multiple values associated with "Actions" key.
979      * Note: This only depends on "Actions" value, not on actually presented sections in desktop file.
980      * See_Also: byAction, action
981      */
982     @safe auto actions() const {
983         return splitValues(value("Actions"));
984     }
985     
986     /**
987      * Sets the list of values for "Actions" list.
988      */
989     @safe void actions(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
990         this["Actions"] = joinValues(values);
991     }
992     
993     /**
994      * Get $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s10.html, additional application action) by name.
995      * Returns: DesktopAction with given action name or DesktopAction with null group if not found or found section does not have a name.
996      * See_Also: actions, byAction
997      */
998     @safe const(DesktopAction) action(string actionName) const {
999         if (actions().canFind(actionName)) {
1000             auto desktopAction = DesktopAction(group("Desktop Action "~actionName));
1001             if (desktopAction.group() !is null && desktopAction.name().length != 0) {
1002                 return desktopAction;
1003             }
1004         }
1005         
1006         return DesktopAction(null);
1007     }
1008     
1009     /**
1010      * Iterating over existing actions.
1011      * Returns: Range of DesktopAction.
1012      * See_Also: actions, action
1013      */
1014     @safe auto byAction() const {
1015         return actions().map!(actionName => DesktopAction(group("Desktop Action "~actionName))).filter!(delegate(desktopAction) {
1016             return desktopAction.group !is null && desktopAction.name.length != 0;
1017         });
1018     }
1019     
1020     /**
1021      * A list of strings identifying the desktop environments that should display a given desktop entry.
1022      * Returns: The range of multiple values associated with "OnlyShowIn" key.
1023      */
1024     @safe auto onlyShowIn() const {
1025         return splitValues(value("OnlyShowIn"));
1026     }
1027     
1028     /**
1029      * A list of strings identifying the desktop environments that should not display a given desktop entry.
1030      * Returns: The range of multiple values associated with "NotShowIn" key.
1031      */
1032     @safe auto notShowIn() const {
1033         return splitValues(value("NotShowIn"));
1034     }
1035     
1036     /**
1037      * Returns: instance of "Desktop Entry" group.
1038      * Note: Usually you don't need to call this function since you can rely on alias this.
1039      */
1040     @nogc @safe inout(IniLikeGroup) desktopEntry() nothrow inout {
1041         return _desktopEntry;
1042     }
1043     
1044     /**
1045      * This alias allows to call functions related to "Desktop Entry" group without need to call desktopEntry explicitly.
1046      */
1047     alias desktopEntry this;
1048     
1049     /**
1050      * Expand "Exec" value into the array of command line arguments to use to start the program.
1051      * It applies unquoting and unescaping.
1052      * See_Also: execString, unquoteExecString, unescapeExecArgument, startApplication
1053      */
1054     @safe string[] expandExecString(in string[] urls = null, string locale = null) const
1055     {   
1056         return .expandExecString(execString(), urls, localizedIconName(locale), localizedName(locale), fileName());
1057     }
1058     
1059     ///
1060     unittest 
1061     {
1062         string contents = 
1063 `[Desktop Entry]
1064 Name=Program
1065 Name[ru]=Программа
1066 Exec="quoted program" %i -w %c -f %k %U %D %u %f %F
1067 Icon=folder
1068 Icon[ru]=folder_ru`;
1069         auto df = new DesktopFile(iniLikeStringReader(contents), DesktopFile.ReadOptions.noOptions, "/example.desktop");
1070         assert(df.expandExecString(["one", "two"], "ru") == 
1071         ["quoted program", "--icon", "folder_ru", "-w", "Программа", "-f", "/example.desktop", "one", "two", "one", "one", "one", "two"]);
1072     }
1073     
1074     /**
1075      * Starts the application associated with this .desktop file using urls as command line params.
1076      * If the program should be run in terminal it tries to find system defined terminal emulator to run in.
1077      * Params:
1078      *  urls = urls application will start with.
1079      *  locale = locale that may be needed to be placed in urls if Exec value has %c code.
1080      *  terminalCommand = preferable terminal emulator command. If not set then terminal is determined via getTerminalCommand.
1081      * Note:
1082      *  This function does not check if the type of desktop file is Application. It relies only on "Exec" value.
1083      * Returns:
1084      *  Pid of started process.
1085      * Throws:
1086      *  ProcessException on failure to start the process.
1087      *  DesktopExecException if exec string is invalid.
1088      * See_Also: getTerminalCommand, start, expandExecString
1089      */
1090     @trusted Pid startApplication(in string[] urls = null, string locale = null, lazy const(string)[] terminalCommand = getTerminalCommand) const
1091     {
1092         auto args = expandExecString(urls, locale);
1093         if (terminal()) {
1094             auto termCmd = terminalCommand();
1095             args = termCmd ~ args;
1096         }
1097         return execProcess(args, workingDirectory());
1098     }
1099     
1100     ///
1101     unittest
1102     {
1103         auto df = new DesktopFile();
1104         assertThrown(df.startApplication(string[].init));
1105     }
1106     
1107     ///ditto, but uses the only url.
1108     @trusted Pid startApplication(string url, string locale = null, lazy const(string)[] terminalCommand = getTerminalCommand) const {
1109         return startApplication([url], locale, terminalCommand);
1110     }
1111     
1112     /**
1113      * Opens url defined in .desktop file using $(LINK2 http://portland.freedesktop.org/xdg-utils-1.0/xdg-open.html, xdg-open).
1114      * Note:
1115      *  This function does not check if the type of desktop file is Link. It relies only on "URL" value.
1116      * Returns:
1117      *  Pid of started process.
1118      * Throws:
1119      *  ProcessException on failure to start the process.
1120      *  Exception if desktop file does not define URL or it's empty.
1121      * See_Also: start
1122      */
1123     @trusted Pid startLink() const {
1124         string myurl = url();
1125         enforce(myurl.length, "No URL to open");
1126         return spawnProcess(["xdg-open", myurl], null, Config.none);
1127     }
1128     
1129     ///
1130     unittest
1131     {
1132         auto df = new DesktopFile();
1133         assertThrown(df.startLink());
1134     }
1135     
1136     /**
1137      * Starts application or open link depending on desktop entry type.
1138      * Returns: 
1139      *  Pid of started process.
1140      * Throws:
1141      *  ProcessException on failure to start the process.
1142      *  Exception if type is Unknown or Directory.
1143      * See_Also: startApplication, startLink
1144      */
1145     @trusted Pid start() const
1146     {
1147         final switch(type()) {
1148             case DesktopFile.Type.Application:
1149                 return startApplication();
1150             case DesktopFile.Type.Link:
1151                 return startLink();
1152             case DesktopFile.Type.Directory:
1153                 throw new Exception("Don't know how to start Directory");
1154             case DesktopFile.Type.Unknown:
1155                 throw new Exception("Unknown desktop entry type");
1156         }
1157     }
1158     
1159     ///
1160     unittest
1161     {
1162         string contents = "[Desktop Entry]\nType=Directory";
1163         auto df = new DesktopFile(iniLikeStringReader(contents));
1164         assertThrown(df.start());
1165         
1166         df = new DesktopFile();
1167         assertThrown(df.start());
1168     }
1169     
1170 private:
1171     IniLikeGroup _desktopEntry;
1172 }
1173 
1174 ///
1175 unittest 
1176 {
1177     import std.file;
1178     //Test DesktopFile
1179     string desktopFileContents = 
1180 `[Desktop Entry]
1181 # Comment
1182 Name=Double Commander
1183 Name[ru]=Двухпанельный коммандер
1184 GenericName=File manager
1185 GenericName[ru]=Файловый менеджер
1186 Comment=Double Commander is a cross platform open source file manager with two panels side by side.
1187 Comment[ru]=Double Commander - кроссплатформенный файловый менеджер.
1188 Terminal=false
1189 Icon=doublecmd
1190 Icon[ru]=doublecmd_ru
1191 Exec=doublecmd %f
1192 TryExec=doublecmd
1193 Type=Application
1194 Categories=Application;Utility;FileManager;
1195 Keywords=folder;manager;disk;filesystem;operations;
1196 Actions=OpenDirectory;NotPresented;Settings;NoName;
1197 MimeType=inode/directory;application/x-directory;
1198 NoDisplay=false
1199 Hidden=false
1200 StartupNotify=true
1201 DBusActivatable=true
1202 Path=/opt/doublecmd
1203 OnlyShowIn=GNOME;XFCE;LXDE;
1204 NotShowIn=KDE;
1205 
1206 [Desktop Action OpenDirectory]
1207 Name=Open directory
1208 Name[ru]=Открыть папку
1209 Icon=open
1210 Exec=doublecmd %u
1211 
1212 [NoName]
1213 Icon=folder
1214 
1215 [Desktop Action Settings]
1216 Name=Settings
1217 Name[ru]=Настройки
1218 Icon=edit
1219 Exec=doublecmd settings
1220 
1221 [Desktop Action Notspecified]
1222 Name=Notspecified Action`;
1223     
1224     auto df = new DesktopFile(iniLikeStringReader(desktopFileContents), DesktopFile.ReadOptions.preserveComments);
1225     assert(df.name() == "Double Commander");
1226     assert(df.localizedName("ru_RU") == "Двухпанельный коммандер");
1227     assert(df.genericName() == "File manager");
1228     assert(df.localizedGenericName("ru_RU") == "Файловый менеджер");
1229     assert(df.comment() == "Double Commander is a cross platform open source file manager with two panels side by side.");
1230     assert(df.localizedComment("ru_RU") == "Double Commander - кроссплатформенный файловый менеджер.");
1231     assert(df.iconName() == "doublecmd");
1232     assert(df.localizedIconName("ru_RU") == "doublecmd_ru");
1233     assert(df.tryExecString() == "doublecmd");
1234     assert(!df.terminal());
1235     assert(!df.noDisplay());
1236     assert(!df.hidden());
1237     assert(df.startupNotify());
1238     assert(df.dbusActivable());
1239     assert(df.workingDirectory() == "/opt/doublecmd");
1240     assert(df.type() == DesktopFile.Type.Application);
1241     assert(equal(df.keywords(), ["folder", "manager", "disk", "filesystem", "operations"]));
1242     assert(equal(df.categories(), ["Application", "Utility", "FileManager"]));
1243     assert(equal(df.actions(), ["OpenDirectory", "NotPresented", "Settings", "NoName"]));
1244     assert(equal(df.mimeTypes(), ["inode/directory", "application/x-directory"]));
1245     assert(equal(df.onlyShowIn(), ["GNOME", "XFCE", "LXDE"]));
1246     assert(equal(df.notShowIn(), ["KDE"]));
1247     
1248     assert(equal(df.byAction().map!(desktopAction => 
1249     tuple(desktopAction.name(), desktopAction.localizedName("ru"), desktopAction.iconName(), desktopAction.execString())), 
1250                  [tuple("Open directory", "Открыть папку", "open", "doublecmd %u"), tuple("Settings", "Настройки", "edit", "doublecmd settings")]));
1251     
1252     assert(df.action("NotPresented").group() is null);
1253     assert(df.action("Notspecified").group() is null);
1254     assert(df.action("NoName").group() is null);
1255     assert(df.action("Settings").group() !is null);
1256     
1257     assert(df.saveToString() == desktopFileContents);
1258     
1259     assert(df.contains("Icon"));
1260     df.removeEntry("Icon");
1261     assert(!df.contains("Icon"));
1262     df["Icon"] = "files";
1263     assert(df.contains("Icon"));
1264     
1265     df = new DesktopFile();
1266     df.terminal = true;
1267     df.type = DesktopFile.Type.Application;
1268     df.categories = ["Development", "Compilers"];
1269     
1270     assert(df.terminal() == true);
1271     assert(df.type() == DesktopFile.Type.Application);
1272     assert(equal(df.categories(), ["Development", "Compilers"]));
1273     
1274     string contents = 
1275 `[Not desktop entry]
1276 Key=Value`;
1277     assertThrown(new DesktopFile(iniLikeStringReader(contents)));
1278 }