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