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 }