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 }