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