1 /**
2  * Utility functions for reading and executing desktop files.
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.utils;
14 
15 public import inilike.common;
16 public import inilike.range;
17 
18 package {
19     import std.algorithm;
20     import std.array;
21     import std.conv;
22     import std.exception;
23     import std.file;
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 
32     import findexecutable;
33     import isfreedesktop;
34 }
35 
36 package @trusted File getNullStdin()
37 {
38     version(Posix) {
39         auto toReturn = std.stdio.stdin;
40         try {
41             toReturn = File("/dev/null", "rb");
42         } catch(Exception e) {
43 
44         }
45         return toReturn;
46     } else {
47         return std.stdio.stdin;
48     }
49 }
50 
51 package @trusted File getNullStdout()
52 {
53     version(Posix) {
54         auto toReturn = std.stdio.stdout;
55         try {
56             toReturn = File("/dev/null", "wb");
57         } catch(Exception e) {
58 
59         }
60         return toReturn;
61     } else {
62         return std.stdio.stdout;
63     }
64 }
65 
66 package @trusted File getNullStderr()
67 {
68     version(Posix) {
69         auto toReturn = std.stdio.stderr;
70         try {
71             toReturn = File("/dev/null", "wb");
72         } catch(Exception e) {
73 
74         }
75         return toReturn;
76     } else {
77         return std.stdio.stderr;
78     }
79 }
80 
81 /**
82  * Exception thrown when "Exec" value of DesktopFile or DesktopAction is invalid.
83  */
84 class DesktopExecException : Exception
85 {
86     ///
87     mixin basicExceptionCtors;
88 }
89 
90 /**
91  * Parameters for $(D spawnApplication).
92  */
93 struct SpawnParams
94 {
95     /// Urls or file paths to open
96     const(string)[] urls;
97 
98     /// Icon to use in place of %i field code.
99     string iconName;
100 
101     /// Name to use in place of %c field code.
102     string displayName;
103 
104     /// File name to use in place of %k field code.
105     string fileName;
106 
107     /// Working directory of starting process.
108     string workingDirectory;
109 
110     /// Terminal command to prepend to exec arguments.
111     const(string)[] terminalCommand;
112 
113     /// Allow starting multiple instances of application if needed.
114     bool allowMultipleInstances = true;
115 }
116 
117 private @trusted void execProcess(scope const(string)[] args, string workingDirectory = null)
118 {
119     spawnProcess(args, getNullStdin(), getNullStdout(), getNullStderr(), null, Config.detached, workingDirectory);
120 }
121 
122 /**
123  * Spawn application with given params.
124  * Params:
125  *  unquotedArgs = Unescaped unquoted arguments parsed from "Exec" value.
126  *  params = Field codes values and other properties to spawn application.
127  * Throws:
128  *  $(B ProcessException) if could not start process.
129  *  $(D DesktopExecException) if unquotedArgs is empty.
130  * See_Also: $(D SpawnParams)
131  */
132 @trusted void spawnApplication(const(string)[] unquotedArgs, const SpawnParams params)
133 {
134     if (!unquotedArgs.length) {
135         throw new DesktopExecException("No arguments. Missing or empty Exec value");
136     }
137 
138     if (params.terminalCommand) {
139         unquotedArgs = params.terminalCommand ~ unquotedArgs;
140     }
141 
142     if (params.urls.length && params.allowMultipleInstances && needMultipleInstances(unquotedArgs)) {
143         for(size_t i=0; i<params.urls.length; ++i) {
144             execProcess(expandExecArgs(unquotedArgs, params.urls[i..i+1], params.iconName, params.displayName, params.fileName), params.workingDirectory);
145         }
146     } else {
147         return execProcess(expandExecArgs(unquotedArgs, params.urls, params.iconName, params.displayName, params.fileName), params.workingDirectory);
148     }
149 }
150 
151 private @safe bool needQuoting(char c) nothrow pure
152 {
153     switch(c) {
154         case ' ':   case '\t':  case '\n':  case '\r':  case '"':
155         case '\\':  case '\'':  case '>':   case '<':   case '~':
156         case '|':   case '&':   case ';':   case '$':   case '*':
157         case '?':   case '#':   case '(':   case ')':   case '`':
158             return true;
159         default:
160             return false;
161     }
162 }
163 
164 private @safe bool needQuoting(scope string arg) nothrow pure
165 {
166     if (arg.length == 0) {
167         return true;
168     }
169 
170     for (size_t i=0; i<arg.length; ++i)
171     {
172         if (needQuoting(arg[i])) {
173             return true;
174         }
175     }
176     return false;
177 }
178 
179 unittest
180 {
181     assert(needQuoting(""));
182     assert(needQuoting("hello\tworld"));
183     assert(needQuoting("hello world"));
184     assert(needQuoting("world?"));
185     assert(needQuoting("sneaky_stdout_redirect>"));
186     assert(needQuoting("sneaky_pipe|"));
187     assert(!needQuoting("hello"));
188 }
189 
190 private @trusted string unescapeQuotedArgument(string value) nothrow pure
191 {
192     static immutable Tuple!(char, char)[] pairs = [
193        tuple('`', '`'),
194        tuple('$', '$'),
195        tuple('"', '"'),
196        tuple('\\', '\\')
197     ];
198     return doUnescape(value, pairs);
199 }
200 
201 private @trusted string escapeQuotedArgument(string value) pure {
202     return value.replace("`", "\\`").replace("\\", `\\`).replace("$", `\$`).replace("\"", `\"`);
203 }
204 
205 /**
206  * Apply unquoting to Exec value making it into an array of escaped arguments. It automatically performs quote-related unescaping.
207  * Params:
208  *  unescapedValue = value of Exec key. Must be unescaped by $(D unescapeValue) before passing (general escape rule is not the same as quote escape rule).
209  * Throws:
210  *  $(D DesktopExecException) if string can't be unquoted (e.g. no pair quote).
211  * Note:
212  *  Although Desktop Entry Specification says that arguments must be quoted by double quote, for compatibility reasons this implementation also recognizes single quotes.
213  * See_Also:
214  *  $(LINK2 https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s07.html, specification)
215  */
216 @trusted auto unquoteExec(string unescapedValue) pure
217 {
218     auto value = unescapedValue;
219     string[] result;
220     size_t i;
221 
222     static string parseQuotedPart(ref size_t i, char delimeter, string value)
223     {
224         const size_t start = ++i;
225 
226         while(i < value.length) {
227             if (value[i] == '\\' && value.length > i+1) {
228                 const char next = value[i+1];
229                 if (next == '\\' || next  == delimeter) {
230                     i+=2;
231                     continue;
232                 }
233             }
234 
235             if (value[i] == delimeter) {
236                 return value[start..i].unescapeQuotedArgument();
237             }
238             ++i;
239         }
240         throw new DesktopExecException("Missing pair quote");
241     }
242 
243     char[] append;
244     while(i < value.length) {
245         if (value[i] == '\\' && i+1 < value.length && needQuoting(value[i+1])) {
246             // this is actually does not adhere to the spec, but we need it to support some wine-generated .desktop files
247             append ~= value[i+1];
248             ++i;
249         } else if (value[i] == ' ' || value[i] == '\t') {
250             if (append !is null) {
251                 result ~= append.assumeUnique;
252                 append = null;
253             }
254         } else if (value[i] == '"' || value[i] == '\'') {
255             // some DEs can produce files with quoting by single quotes when there's a space in path
256             // it's not actually part of the spec, but we support it
257             append ~= parseQuotedPart(i, value[i], value);
258         } else {
259             append ~= value[i];
260         }
261         ++i;
262     }
263 
264     if (append !is null) {
265         result ~= append.assumeUnique;
266     }
267 
268     return result;
269 }
270 
271 ///
272 unittest
273 {
274     assert(equal(unquoteExec(``), string[].init));
275     assert(equal(unquoteExec(`   `), string[].init));
276     assert(equal(unquoteExec(`""`), [``]));
277     assert(equal(unquoteExec(`"" "  "`), [``, `  `]));
278 
279     assert(equal(unquoteExec(`cmd arg1  arg2   arg3   `), [`cmd`, `arg1`, `arg2`, `arg3`]));
280     assert(equal(unquoteExec(`"cmd" arg1 arg2  `), [`cmd`, `arg1`, `arg2`]));
281 
282     assert(equal(unquoteExec(`"quoted cmd"   arg1  "quoted arg"  `), [`quoted cmd`, `arg1`, `quoted arg`]));
283     assert(equal(unquoteExec(`"quoted \"cmd\"" arg1 "quoted \"arg\""`), [`quoted "cmd"`, `arg1`, `quoted "arg"`]));
284 
285     assert(equal(unquoteExec(`"\\\$" `), [`\$`]));
286     assert(equal(unquoteExec(`"\\$" `), [`\$`]));
287     assert(equal(unquoteExec(`"\$" `), [`$`]));
288     assert(equal(unquoteExec(`"$"`), [`$`]));
289 
290     assert(equal(unquoteExec(`"\\" `), [`\`]));
291     assert(equal(unquoteExec(`"\\\\" `), [`\\`]));
292 
293     assert(equal(unquoteExec(`'quoted cmd' arg`), [`quoted cmd`, `arg`]));
294 
295     assert(equal(unquoteExec(`test\ \ testing`), [`test  testing`]));
296     assert(equal(unquoteExec(`test\  testing`), [`test `, `testing`]));
297     assert(equal(unquoteExec(`test\ "one""two"\ more\ \ test `), [`test onetwo more  test`]));
298     assert(equal(unquoteExec(`"one"two"three"`), [`onetwothree`]));
299 
300     assert(equal(unquoteExec(`env WINEPREFIX="/home/freeslave/.wine" wine C:\\windows\\command\\start.exe /Unix /home/freeslave/.wine/dosdevices/c:/windows/profiles/freeslave/Start\ Menu/Programs/True\ Remembrance/True\ Remembrance.lnk`), [
301         "env", "WINEPREFIX=/home/freeslave/.wine", "wine", `C:\windows\command\start.exe`, "/Unix", "/home/freeslave/.wine/dosdevices/c:/windows/profiles/freeslave/Start Menu/Programs/True Remembrance/True Remembrance.lnk"
302     ]));
303     assert(equal(unquoteExec(`Sister\'s\ book\(TM\)`), [`Sister's book(TM)`]));
304 
305     assertThrown!DesktopExecException(unquoteExec(`cmd "quoted arg`));
306     assertThrown!DesktopExecException(unquoteExec(`"`));
307 }
308 
309 private @trusted string urlToFilePath(string url) nothrow pure
310 {
311     enum protocol = "file://";
312     if (url.length > protocol.length && url[0..protocol.length] == protocol) {
313         return url[protocol.length..$];
314     } else {
315         return url;
316     }
317 }
318 
319 /**
320  * Expand Exec arguments (usually returned by $(D unquoteExec)) replacing field codes with given values, making the array suitable for passing to spawnProcess.
321  * Deprecated field codes are ignored.
322  * Note:
323  *  Returned array may be empty and must be checked before passing to spawning the process.
324  * Params:
325  *  unquotedArgs = Array of unescaped and unquoted arguments.
326  *  urls = Array of urls or file names that inserted in the place of %f, %F, %u or %U field codes.
327  *      For %f and %u only the first element of array is used.
328  *      For %f and %F every url started with 'file://' will be replaced with normal path.
329  *  iconName = Icon name used to substitute %i field code by --icon iconName.
330  *  displayName = Name of application used that inserted in the place of %c field code.
331  *  fileName = Name of desktop file that inserted in the place of %k field code.
332  * Throws:
333  *  $(D DesktopExecException) if command line contains unknown field code.
334  * See_Also: $(D unquoteExec)
335  */
336 @trusted string[] expandExecArgs(scope const(string)[] unquotedArgs, scope const(string)[] urls = null, string iconName = null, string displayName = null, string fileName = null) pure
337 {
338     string[] toReturn;
339     foreach(token; unquotedArgs) {
340         if (token == "%F") {
341             toReturn ~= urls.map!(url => urlToFilePath(url)).array;
342         } else if (token == "%U") {
343             toReturn ~= urls;
344         } else if (token == "%i") {
345             if (iconName.length) {
346                 toReturn ~= "--icon";
347                 toReturn ~= iconName;
348             }
349         } else {
350             static void expand(string token, ref string expanded, ref size_t restPos, ref size_t i, string insert)
351             {
352                 if (token.length == 2) {
353                     expanded = insert;
354                 } else {
355                     expanded ~= token[restPos..i] ~ insert;
356                 }
357                 restPos = i+2;
358                 i++;
359             }
360 
361             string expanded;
362             size_t restPos = 0;
363             bool ignore;
364             loop: for(size_t i=0; i<token.length; ++i) {
365                 if (token[i] == '%' && i<token.length-1) {
366                     switch(token[i+1]) {
367                         case 'f': case 'u':
368                         {
369                             if (urls.length) {
370                                 string arg = urls.front;
371                                 if (token[i+1] == 'f') {
372                                     arg = urlToFilePath(arg);
373                                 }
374                                 expand(token, expanded, restPos, i, arg);
375                             } else {
376                                 ignore = true;
377                                 break loop;
378                             }
379                         }
380                         break;
381                         case 'c':
382                         {
383                             expand(token, expanded, restPos, i, displayName);
384                         }
385                         break;
386                         case 'k':
387                         {
388                             expand(token, expanded, restPos, i, fileName);
389                         }
390                         break;
391                         case 'd': case 'D': case 'n': case 'N': case 'm': case 'v':
392                         {
393                             ignore = true;
394                             break loop;
395                         }
396                         case '%':
397                         {
398                             expand(token, expanded, restPos, i, "%");
399                         }
400                         break;
401                         default:
402                         {
403                             throw new DesktopExecException("Unknown or misplaced field code: " ~ token);
404                         }
405                     }
406                 }
407             }
408 
409             if (!ignore) {
410                 toReturn ~= expanded ~ token[restPos..$];
411             }
412         }
413     }
414 
415     return toReturn;
416 }
417 
418 ///
419 unittest
420 {
421     assert(expandExecArgs(
422         ["program path", "%%f", "%%i", "%D", "--deprecated=%d", "%n", "%N", "%m", "%v", "--file=%f", "%i", "%F", "--myname=%c", "--mylocation=%k", "100%%"],
423         ["one"],
424         "folder", "program", "location"
425     ) == ["program path", "%f", "%i", "--file=one", "--icon", "folder", "one", "--myname=program", "--mylocation=location", "100%"]);
426 
427     assert(expandExecArgs(["program path", "many%%%%"]) == ["program path", "many%%"]);
428     assert(expandExecArgs(["program path", "%f"]) == ["program path"]);
429     assert(expandExecArgs(["program path", "%f%%%f"], ["file"]) == ["program path", "file%file"]);
430     assert(expandExecArgs(["program path", "%f"], ["file:///usr/share"]) == ["program path", "/usr/share"]);
431     assert(expandExecArgs(["program path", "%u"], ["file:///usr/share"]) == ["program path", "file:///usr/share"]);
432     assert(expandExecArgs(["program path"], ["one", "two"]) == ["program path"]);
433     assert(expandExecArgs(["program path", "%f"], ["one", "two"]) == ["program path", "one"]);
434     assert(expandExecArgs(["program path", "%F"], ["one", "two"]) == ["program path", "one", "two"]);
435     assert(expandExecArgs(["program path", "%F"], ["file://one", "file://two"]) == ["program path", "one", "two"]);
436     assert(expandExecArgs(["program path", "%U"], ["file://one", "file://two"]) == ["program path", "file://one", "file://two"]);
437 
438     assert(expandExecArgs(["program path", "--location=%k", "--myname=%c"]) == ["program path", "--location=", "--myname="]);
439     assert(expandExecArgs(["program path", "%k", "%c"]) == ["program path", "", ""]);
440     assertThrown!DesktopExecException(expandExecArgs(["program name", "%y"]));
441     assertThrown!DesktopExecException(expandExecArgs(["program name", "--file=%x"]));
442     assertThrown!DesktopExecException(expandExecArgs(["program name", "--files=%F"]));
443 }
444 
445 /**
446  * Flag set of parameter kinds supported by application.
447  * Having more than one flag means that Exec command is ambiguous.
448  * See_Also: $(D paramSupport)
449  */
450 enum ParamSupport
451 {
452     /**
453      * Application does not support parameters.
454      */
455     none = 0,
456 
457     /**
458      * Application can open single file at once.
459      */
460     file = 1,
461     /**
462      * Application can open multiple files at once.
463      */
464     files = 2,
465     /**
466      * Application understands URL syntax and can open single link at once.
467      */
468     url = 4,
469     /**
470      * Application supports URL syntax and can open multiple links at once.
471      */
472     urls = 8
473 }
474 
475 /**
476  * Evaluate ParamSupport flags for application Exec command.
477  * Params:
478  *  execArgs = Array of unescaped and unquoted arguments.
479  * See_Also: $(D unquoteExec), $(D needMultipleInstances)
480  */
481 @nogc @safe ParamSupport paramSupport(scope const(string)[] execArgs) pure nothrow
482 {
483     auto support = ParamSupport.none;
484     foreach(token; execArgs) {
485         if (token == "%F") {
486             support |= ParamSupport.files;
487         } else if (token == "%U") {
488             support |= ParamSupport.urls;
489         } else if (!(support & (ParamSupport.file | ParamSupport.url))) {
490             for(size_t i=0; i<token.length; ++i) {
491                 if (token[i] == '%' && i<token.length-1) {
492                     if (token[i+1] == '%') {
493                         i++;
494                     } else if (token[i+1] == 'f') {
495                         support |= ParamSupport.file;
496                         i++;
497                     } else if (token[i+1] == 'u') {
498                         support |= ParamSupport.url;
499                         i++;
500                     }
501                 }
502             }
503         }
504     }
505     return support;
506 }
507 
508 ///
509 unittest
510 {
511     assert(paramSupport(["program", "%f"]) == ParamSupport.file);
512     assert(paramSupport(["program", "%%f"]) == ParamSupport.none);
513     assert(paramSupport(["program", "%%%f"]) == ParamSupport.file);
514     assert(paramSupport(["program", "%u"]) == ParamSupport.url);
515     assert(paramSupport(["program", "%i"]) == ParamSupport.none);
516     assert(paramSupport(["program", "%u%f"]) == (ParamSupport.url | ParamSupport.file ));
517     assert(paramSupport(["program", "%F"]) == ParamSupport.files);
518     assert(paramSupport(["program", "%U"]) == ParamSupport.urls);
519     assert(paramSupport(["program", "%f", "%U"]) == (ParamSupport.file|ParamSupport.urls));
520     assert(paramSupport(["program", "%F", "%u"]) == (ParamSupport.files|ParamSupport.url));
521 }
522 
523 /**
524  * Check if application should be started multiple times to open multiple urls.
525  * Params:
526  *  execArgs = Array of unescaped and unquoted arguments.
527  * Returns: true if execArgs have only %f or %u and not %F or %U. Otherwise false is returned.
528  * See_Also: $(D unquoteExec), $(D paramSupport)
529  */
530 @nogc @safe bool needMultipleInstances(scope const(string)[] execArgs) pure nothrow
531 {
532     auto support = paramSupport(execArgs);
533     const bool noNeed = support == ParamSupport.none || (support & (ParamSupport.urls|ParamSupport.files)) != 0;
534     return !noNeed;
535 }
536 
537 ///
538 unittest
539 {
540     assert(needMultipleInstances(["program", "%f"]));
541     assert(needMultipleInstances(["program", "%u"]));
542     assert(!needMultipleInstances(["program", "%i"]));
543     assert(!needMultipleInstances(["program", "%F"]));
544     assert(!needMultipleInstances(["program", "%U"]));
545     assert(!needMultipleInstances(["program", "%f", "%U"]));
546     assert(!needMultipleInstances(["program", "%F", "%u"]));
547 }
548 
549 private @trusted string doublePercentSymbol(string value)
550 {
551     return value.replace("%", "%%");
552 }
553 
554 private struct ExecToken
555 {
556     string token;
557     bool needQuotes;
558 }
559 
560 /**
561  * Helper struct to build Exec string for desktop file.
562  * Note:
563  *  While Desktop Entry Specification says that field codes must not be inside quoted argument,
564  *  ExecBuilder does not consider it as error and may create quoted argument if field code is prepended by the string that needs quotation.
565  */
566 struct ExecBuilder
567 {
568     /**
569      * Construct ExecBuilder.
570      * Params:
571      *  executable = path to executable. Value will be escaped and quoted as needed.
572      * Throws:
573      *  Exception if executable is not absolute path nor base name.
574      */
575     @safe this(string executable) {
576         enforce(executable.isAbsolute || executable.baseName == executable, "Program part of Exec must be absolute path or base name");
577         execTokens ~= ExecToken(executable, executable.needQuoting());
578     }
579 
580     /**
581      * Add literal argument which is not field code.
582      * Params:
583      *  arg = Literal argument. Value will be escaped and quoted as needed.
584      *  forceQuoting = Whether to force argument quotation.
585      * Returns: this object for chained calls.
586      */
587     @safe ref ExecBuilder argument(string arg, Flag!"forceQuoting" forceQuoting = No.forceQuoting) return {
588         execTokens ~= ExecToken(arg.doublePercentSymbol(), arg.needQuoting() || forceQuoting);
589         return this;
590     }
591 
592     /**
593      * Add "%i" field code.
594      * Returns: this object for chained calls.
595      */
596     @safe ref ExecBuilder icon() return {
597         execTokens ~= ExecToken("%i", false);
598         return this;
599     }
600 
601 
602     /**
603      * Add "%f" field code.
604      * Returns: this object for chained calls.
605      */
606     @safe ref ExecBuilder file(string prepend = null) return {
607         return fieldCode(prepend, "%f");
608     }
609 
610     /**
611      * Add "%F" field code.
612      * Returns: this object for chained calls.
613      */
614     @safe ref ExecBuilder files() return {
615         execTokens ~= ExecToken("%F");
616         return this;
617     }
618 
619     /**
620      * Add "%u" field code.
621      * Returns: this object for chained calls.
622      */
623     @safe ref ExecBuilder url(string prepend = null) return {
624         return fieldCode(prepend, "%u");
625     }
626 
627     /**
628      * Add "%U" field code.
629      * Returns: this object for chained calls.
630      */
631     @safe ref ExecBuilder urls() return {
632         execTokens ~= ExecToken("%U");
633         return this;
634     }
635 
636     /**
637      * Add "%c" field code (name of application).
638      * Returns: this object for chained calls.
639      */
640     @safe ref ExecBuilder displayName(string prepend = null) return {
641         return fieldCode(prepend, "%c");
642     }
643 
644     /**
645      * Add "%k" field code (location of desktop file).
646      * Returns: this object for chained calls.
647      */
648     @safe ref ExecBuilder location(string prepend = null) return {
649         return fieldCode(prepend, "%k");
650     }
651 
652     /**
653      * Get resulting string that can be set to Exec field of Desktop Entry. The returned string is escaped.
654      */
655     @trusted string result() const {
656         return execTokens.map!(t => (t.needQuotes ? ('"' ~ t.token.escapeQuotedArgument() ~ '"') : t.token)).join(" ").escapeValue();
657     }
658 
659 private:
660     @safe ref ExecBuilder fieldCode(string prepend, string code) return
661     {
662         string token = prepend.doublePercentSymbol() ~ code;
663         execTokens ~= ExecToken(token, token.needQuoting());
664         return this;
665     }
666 
667     ExecToken[] execTokens;
668 }
669 
670 ///
671 unittest
672 {
673     assert(ExecBuilder("quoted program").icon()
674             .argument("-w").displayName()
675             .argument("$value")
676             .argument("slash\\")
677             .argument("100%")
678             .location("--location=")
679             .urls().url().file("--file=").files().result() == `"quoted program" %i -w %c "\\$value" "slash\\\\" 100%% --location=%k %U %u --file=%f %F`);
680 
681     assert(ExecBuilder("program").argument("").url("my url ").result() == `program "" "my url %u"`);
682 
683     assertThrown(ExecBuilder("./relative/path"));
684 }
685 
686 static if (isFreedesktop)
687 {
688     package string[] getDefaultTerminalCommand() nothrow
689     {
690         import std.utf : byCodeUnit;
691         string xdgCurrentDesktop;
692         collectException(environment.get("XDG_CURRENT_DESKTOP"), xdgCurrentDesktop);
693         foreach(desktop; xdgCurrentDesktop.byCodeUnit.splitter(':'))
694         {
695             switch(desktop.source) {
696                 case "GNOME":
697                 case "X-Cinnamon":
698                 case "Cinnamon":
699                     return ["gnome-terminal", "-x"];
700                 case "LXDE":
701                     return ["lxterminal", "-e"];
702                 case "XFCE":
703                     return ["xfce4-terminal", "-x"];
704                 case "MATE":
705                     return ["mate-terminal", "-x"];
706                 case "KDE":
707                     return ["konsole", "-e"];
708                 default:
709                     break;
710             }
711         }
712         return null;
713     }
714 
715     unittest
716     {
717         import desktopfile.paths : EnvGuard;
718         EnvGuard currentDesktopGuard = EnvGuard("XDG_CURRENT_DESKTOP", "KDE");
719         assert(getDefaultTerminalCommand()[0] == "konsole");
720 
721         environment["XDG_CURRENT_DESKTOP"] = "unity:GNOME";
722         assert(getDefaultTerminalCommand()[0] == "gnome-terminal");
723 
724         environment["XDG_CURRENT_DESKTOP"] = null;
725         assert(getDefaultTerminalCommand().empty);
726 
727         environment["XDG_CURRENT_DESKTOP"] = "Generic";
728         assert(getDefaultTerminalCommand().empty);
729     }
730 }
731 
732 /**
733  * Detect command which will run program in terminal emulator.
734  * It tries to detect your desktop environment and find default terminal emulator for it.
735  * If all guesses failed, it uses ["xterm", "-e"] as fallback.
736  * Note: This function always returns empty array on non-freedesktop systems.
737  */
738 string[] getTerminalCommand() nothrow @trusted
739 {
740     static if (isFreedesktop) {
741         string[] paths;
742         collectException(binPaths().array, paths);
743 
744         string[] termCommand = getDefaultTerminalCommand();
745         if (!termCommand.empty) {
746             termCommand[0] = findExecutable(termCommand[0], paths);
747             if (termCommand[0] != string.init)
748                 return termCommand;
749         }
750 
751         string term = findExecutable("rxvt", paths);
752         if (!term.empty)
753             return [term, "-e"];
754 
755         return ["xterm", "-e"];
756     } else {
757         return null;
758     }
759 }
760 
761 ///
762 unittest
763 {
764     if (isFreedesktop)
765     {
766         import desktopfile.paths : EnvGuard;
767         EnvGuard pathGuard = EnvGuard("PATH", ":");
768         assert(getTerminalCommand() == ["xterm", "-e"]);
769     }
770     else
771     {
772         assert(getTerminalCommand().empty);
773     }
774 }
775 
776 package void xdgOpen(scope string url)
777 {
778     execProcess(["xdg-open", url]);
779 }
780 
781 /**
782  * Options to pass to $(D fireDesktopFile).
783  */
784 struct FireOptions
785 {
786     /**
787      * Flags that changes behavior of fireDesktopFile.
788      */
789     enum
790     {
791         Exec = 1, /// $(D fireDesktopFile) can start applications.
792         Link = 2, /// $(D fireDesktopFile) can open links (urls or file names).
793         FollowLink = 4, /// If desktop file is link and url points to another desktop file fireDesktopFile will be called on this url with the same options.
794         All = Exec|Link|FollowLink /// All flags described above.
795     }
796 
797     /**
798      * Flags
799      * By default is set to use all flags.
800      */
801     auto flags = All;
802 
803     /**
804      * Urls to pass to the program is desktop file points to application.
805      * Empty by default.
806      */
807     const(string)[] urls;
808 
809     /**
810      * Locale of environment.
811      * Empty by default.
812      */
813     string locale;
814 
815     /**
816      * Delegate that will be used to open url if desktop file is link.
817      * To set static function use std.functional.toDelegate.
818      * If it's null fireDesktopFile will use xdg-open.
819      */
820     void delegate(string) opener = null;
821 
822     /**
823      * Delegate that will be used to get terminal command if desktop file is application and needs to ran in terminal.
824      * To set static function use std.functional.toDelegate.
825      * If it's null, fireDesktopFile will use getTerminalCommand.
826      * See_Also: $(D getTerminalCommand)
827      */
828     const(string)[] delegate() terminalDetector = null;
829 
830     /**
831      * Allow to run multiple instances of application if it does not support opening multiple urls in one instance.
832      */
833     bool allowMultipleInstances = true;
834 }
835 
836 package bool readDesktopEntryValues(IniLikeReader)(IniLikeReader reader, scope string locale, string fileName,
837                             out string iconName, out string name,
838                             out string execValue, out string url,
839                             out string workingDirectory, out bool terminal)
840 {
841     import inilike.read;
842     string bestLocale;
843     bool hasDesktopEntry;
844     auto onMyGroup = delegate ActionOnGroup(string groupName) {
845         if (groupName == "Desktop Entry") {
846             hasDesktopEntry = true;
847             return ActionOnGroup.stopAfter;
848         } else {
849             return ActionOnGroup.skip;
850         }
851     };
852     auto onMyKeyValue = delegate void(string key, string value, string groupName) {
853         if (groupName != "Desktop Entry") {
854             return;
855         }
856         switch(key) {
857             case "Exec": execValue = value.unescapeValue(); break;
858             case "URL": url = value.unescapeValue(); break;
859             case "Icon": iconName = value.unescapeValue(); break;
860             case "Path": workingDirectory = value.unescapeValue(); break;
861             case "Terminal": terminal = isTrue(value); break;
862             default: {
863                 auto kl = separateFromLocale(key);
864                 if (kl[0] == "Name") {
865                     auto lv = selectLocalizedValue(locale, kl[1], value, bestLocale, name);
866                     bestLocale = lv[0];
867                     name = lv[1].unescapeValue();
868                 }
869             }
870             break;
871         }
872     };
873 
874     readIniLike(reader, null, onMyGroup, onMyKeyValue, null, fileName);
875     return hasDesktopEntry;
876 }
877 
878 unittest
879 {
880     string contents = "[Desktop Entry]\nExec=whoami\nURL=http://example.org\nIcon=folder\nPath=/usr/bin\nTerminal=true\nName=Example\nName[ru]=Пример";
881     auto reader = iniLikeStringReader(contents);
882 
883     string iconName, name, execValue, url, workingDirectory;
884     bool terminal;
885     readDesktopEntryValues(reader, "ru_RU", null, iconName, name , execValue, url, workingDirectory, terminal);
886     assert(iconName == "folder");
887     assert(execValue == "whoami");
888     assert(url == "http://example.org");
889     assert(workingDirectory == "/usr/bin");
890     assert(terminal);
891     assert(name == "Пример");
892     readDesktopEntryValues(reader, string.init, null, iconName, name , execValue, url, workingDirectory, terminal);
893     assert(name == "Example");
894 }
895 
896 /**
897  * Read the desktop file and run application or open link depending on the type of the given desktop file.
898  * Params:
899  *  reader = $(D inilike.range.IniLikeReader) returned by $(D inilike.range.iniLikeRangeReader) or similar function.
900  *  fileName = file name of desktop file where data read from. Can be used in field code expanding, should be set to the file name from which contents $(D inilike.range.IniLikeReader) was constructed.
901  *  options = options that set behavior of the function.
902  * Use this function to execute desktop file fast, without creating of DesktopFile instance.
903  * Throws:
904  *  $(B ProcessException) on failure to start the process.
905  *  $(D DesktopExecException) if exec string is invalid.
906  *  $(B Exception) on other errors.
907  * See_Also: $(D FireOptions), $(D spawnApplication), $(D getTerminalCommand)
908  */
909 void fireDesktopFile(IniLikeReader)(IniLikeReader reader, string fileName = null, FireOptions options = FireOptions.init)
910 {
911     enforce(options.flags & (FireOptions.Exec|FireOptions.Link), "At least one of the options Exec or Link must be provided");
912 
913     string iconName, name, execValue, url, workingDirectory;
914     bool terminal;
915 
916     if (readDesktopEntryValues(reader, options.locale, fileName, iconName, name, execValue, url, workingDirectory, terminal)) {
917         import std.functional : toDelegate;
918 
919         if (execValue.length && (options.flags & FireOptions.Exec)) {
920             auto unquotedArgs = unquoteExec(execValue);
921 
922             SpawnParams params;
923             params.urls = options.urls;
924             params.iconName = iconName;
925             params.displayName = name;
926             params.fileName = fileName;
927             params.workingDirectory = workingDirectory;
928 
929             if (terminal) {
930                 if (options.terminalDetector == null) {
931                     options.terminalDetector = toDelegate(&getTerminalCommand);
932                 }
933                 params.terminalCommand = options.terminalDetector();
934             }
935             spawnApplication(unquotedArgs, params);
936         } else if (url.length && (options.flags & FireOptions.FollowLink) && url.extension == ".desktop" && url.exists) {
937             options.flags = options.flags & (~FireOptions.FollowLink); //avoid recursion
938             fireDesktopFile(url, options);
939         } else if (url.length && (options.flags & FireOptions.Link)) {
940             if (options.opener == null) {
941                 options.opener = toDelegate(&xdgOpen);
942             }
943             options.opener(url);
944         } else {
945             if (execValue.length) {
946                 throw new Exception("Desktop file is an application, but flags don't include FireOptions.Exec");
947             }
948             if (url.length) {
949                 throw new Exception("Desktop file is a link, but flags don't include FireOptions.Link");
950             }
951             throw new Exception("Desktop file is neither application nor link");
952         }
953     } else {
954         throw new Exception("File does not have Desktop Entry group");
955     }
956 }
957 
958 ///
959 unittest
960 {
961     string contents;
962     FireOptions options;
963 
964     contents = "[Desktop Entry]\nURL=testurl";
965     options.flags = FireOptions.FollowLink;
966     assertThrown(fireDesktopFile(iniLikeStringReader(contents), null, options));
967 
968     contents = "[Group]\nKey=Value";
969     options = FireOptions.init;
970     assertThrown(fireDesktopFile(iniLikeStringReader(contents), null, options));
971 
972     contents = "[Desktop Entry]\nURL=testurl";
973     options = FireOptions.init;
974     bool wasCalled;
975     options.opener = delegate void (string url) {
976         assert(url == "testurl");
977         wasCalled = true;
978     };
979 
980     fireDesktopFile(iniLikeStringReader(contents), null, options);
981     assert(wasCalled);
982 
983     contents = "[Desktop Entry]";
984     options = FireOptions.init;
985     assertThrown(fireDesktopFile(iniLikeStringReader(contents), null, options));
986 
987     contents = "[Desktop Entry]\nURL=testurl";
988     options.flags = FireOptions.Exec;
989     assertThrown(fireDesktopFile(iniLikeStringReader(contents), null, options));
990 
991     contents = "[Desktop Entry]\nExec=whoami";
992     options.flags = FireOptions.Link;
993     assertThrown(fireDesktopFile(iniLikeStringReader(contents), null, options));
994 
995     version(desktopfileFileTest) static if (isFreedesktop) {
996         try {
997             contents = "[Desktop Entry]\nExec=whoami\nTerminal=true";
998             options.flags = FireOptions.Exec;
999             wasCalled = false;
1000             options.terminalDetector = delegate string[] () {wasCalled = true; return null;};
1001             fireDesktopFile(iniLikeStringReader(contents), null, options);
1002             assert(wasCalled);
1003 
1004             string tempPath = buildPath(tempDir(), "desktopfile-unittest-tempdir");
1005             if (!tempPath.exists) {
1006                 mkdir(tempPath);
1007             }
1008             scope(exit) rmdir(tempPath);
1009 
1010             string tempDesktopFile = buildPath(tempPath, "followtest.desktop");
1011             auto f = File(tempDesktopFile, "w");
1012             scope(exit) remove(tempDesktopFile);
1013             f.rawWrite("[Desktop Entry]\nURL=testurl");
1014             f.flush();
1015 
1016             contents = "[Desktop Entry]\nURL=" ~ tempDesktopFile;
1017             options.flags = FireOptions.Link | FireOptions.FollowLink;
1018             options.opener = delegate void (string url) {
1019                 assert(url == "testurl");
1020                 wasCalled = true;
1021             };
1022 
1023             fireDesktopFile(iniLikeStringReader(contents), null, options);
1024             assert(wasCalled);
1025         } catch(Exception e) {
1026 
1027         }
1028     }
1029 }
1030 
1031 /// ditto, but automatically create IniLikeReader from the file.
1032 @trusted void fireDesktopFile(string fileName, FireOptions options = FireOptions.init)
1033 {
1034     fireDesktopFile(iniLikeFileReader(fileName), fileName, options);
1035 }
1036 
1037 /**
1038  * See $(LINK2 https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s02.html#desktop-file-id, Desktop File ID)
1039  * Params:
1040  *  fileName = Desktop file.
1041  *  appsPaths = Range of base application paths.
1042  * Returns: Desktop file ID or empty string if file does not have an ID.
1043  * See_Also: $(D desktopfile.paths.applicationsPaths)
1044  */
1045 string desktopId(Range)(string fileName, Range appsPaths) if (isInputRange!Range && is(ElementType!Range : string))
1046 {
1047     try {
1048         string absolute = fileName.absolutePath;
1049         foreach (path; appsPaths) {
1050             auto pathSplit = pathSplitter(path);
1051             auto fileSplit = pathSplitter(absolute);
1052 
1053             while (!pathSplit.empty && !fileSplit.empty && pathSplit.front == fileSplit.front) {
1054                 pathSplit.popFront();
1055                 fileSplit.popFront();
1056             }
1057 
1058             if (pathSplit.empty) {
1059                 return to!string(fileSplit.join("-"));
1060             }
1061         }
1062     } catch(Exception e) {
1063 
1064     }
1065     return null;
1066 }
1067 
1068 ///
1069 unittest
1070 {
1071     string[] appPaths;
1072     string filePath, nestedFilePath, wrongFilePath;
1073 
1074     version(Windows) {
1075         appPaths = [`C:\ProgramData\KDE\share\applications`, `C:\Users\username\.kde\share\applications`];
1076         filePath = `C:\ProgramData\KDE\share\applications\example.desktop`;
1077         nestedFilePath = `C:\ProgramData\KDE\share\applications\kde\example.desktop`;
1078         wrongFilePath = `C:\ProgramData\desktop\example.desktop`;
1079     } else {
1080         appPaths = ["/usr/share/applications", "/usr/local/share/applications"];
1081         filePath = "/usr/share/applications/example.desktop";
1082         nestedFilePath = "/usr/share/applications/kde/example.desktop";
1083         wrongFilePath = "/etc/desktop/example.desktop";
1084     }
1085 
1086     assert(desktopId(nestedFilePath, appPaths) == "kde-example.desktop");
1087     assert(desktopId(filePath, appPaths) == "example.desktop");
1088     assert(desktopId(wrongFilePath, appPaths).empty);
1089     assert(desktopId("", appPaths).empty);
1090 }
1091 
1092 static if (isFreedesktop)
1093 {
1094     /**
1095      * See $(LINK2 https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s02.html#desktop-file-id, Desktop File ID)
1096      * Returns: Desktop file ID or empty string if file does not have an ID.
1097      * Params:
1098      *  fileName = Desktop file.
1099      * Note: This function retrieves applications paths each time it's called and therefore can impact performance. To avoid this issue use the overload with argument.
1100      * See_Also: $(D desktopfile.paths.applicationsPaths)
1101      */
1102     @trusted string desktopId(string fileName) nothrow
1103     {
1104         import desktopfile.paths;
1105         return desktopId(fileName, applicationsPaths());
1106     }
1107 }
1108 
1109 /**
1110  * Find desktop file by Desktop File ID.
1111  * Desktop file ID can be ambiguous when it has hyphen symbol, so this function can try both variants.
1112  * Params:
1113  *  desktopId = Desktop file ID.
1114  *  appsPaths = Range of base application paths.
1115  * Returns: The first found existing desktop file, or null if could not find any.
1116  * Note: This does not ensure that file is valid .desktop file.
1117  * See_Also: $(D desktopfile.paths.applicationsPaths)
1118  */
1119 string findDesktopFile(Range)(string desktopId, Range appsPaths) if (isInputRange!Range && is(ElementType!Range : string))
1120 {
1121     if (desktopId != desktopId.baseName) {
1122         return null;
1123     }
1124 
1125     foreach(appsPath; appsPaths) {
1126         auto filePath = buildPath(appsPath, desktopId);
1127         bool fileExists = filePath.exists;
1128         if (!fileExists && filePath.canFind('-')) {
1129             filePath = buildPath(appsPath, desktopId.replace("-", "/"));
1130             fileExists = filePath.exists;
1131         }
1132         if (fileExists) {
1133             return filePath;
1134         }
1135     }
1136     return null;
1137 }
1138 
1139 ///
1140 unittest
1141 {
1142     assert(findDesktopFile("not base/path.desktop", ["/usr/share/applications"]) is null);
1143     assert(findDesktopFile("valid.desktop", (string[]).init) is null);
1144 }
1145 
1146 static if (isFreedesktop)
1147 {
1148     /**
1149      * ditto
1150      * Note: This function retrieves applications paths each time it's called and therefore can impact performance. To avoid this issue use the overload with argument.
1151      * See_Also: $(D desktopfile.paths.applicationsPaths)
1152      */
1153     @trusted string findDesktopFile(string desktopId) nothrow
1154     {
1155         import desktopfile.paths;
1156         try {
1157             return findDesktopFile(desktopId, applicationsPaths());
1158         } catch(Exception e) {
1159             return null;
1160         }
1161     }
1162 }
1163 
1164 /**
1165  * Check if .desktop file is trusted.
1166  *
1167  * This is not actually part of Desktop File Specification but many desktop envrionments have this concept.
1168  * The trusted .desktop file is a file the current user has executable access on or the owner of which is root.
1169  * This function should be applicable only to desktop files of $(D desktopfile.file.DesktopEntry.Type.Application) type.
1170  * Note: Always returns true on non-posix systems.
1171  */
1172 @trusted bool isTrusted(scope string appFileName) nothrow
1173 {
1174     version(Posix) {
1175         import core.sys.posix.sys.stat;
1176         import core.sys.posix.unistd;
1177 
1178         try { // try for outdated compilers
1179             auto namez = toStringz(appFileName);
1180             if (access(namez, X_OK) == 0) {
1181                 return true;
1182             }
1183 
1184             stat_t statbuf;
1185             auto result = stat(namez, &statbuf);
1186             return (result == 0 && statbuf.st_uid == 0);
1187         } catch(Exception e) {
1188             return false;
1189         }
1190     } else {
1191         return true;
1192     }
1193 }