1 /** 2 * Reading, writing and executing .desktop file 3 * Authors: 4 * $(LINK2 https://github.com/MyLittleRobo, Roman Chistokhodov). 5 * Copyright: 6 * Roman Chistokhodov, 2015 7 * License: 8 * $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0). 9 * See_Also: 10 * $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/index.html, Desktop Entry Specification). 11 */ 12 13 module desktopfile; 14 15 import standardpaths; 16 17 public import inilike; 18 19 private { 20 import std.algorithm; 21 import std.array; 22 import std.conv; 23 import std.exception; 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 import std.uni; 32 33 static if( __VERSION__ < 2066 ) enum nogc = 1; 34 } 35 36 /** 37 * Exception thrown when "Exec" value of DesktopFile or DesktopAction is invalid. 38 */ 39 class DesktopExecException : Exception 40 { 41 this(string msg, string file = __FILE__, size_t line = __LINE__, Throwable next = null) pure nothrow @safe { 42 super(msg, file, line, next); 43 } 44 } 45 46 /** 47 * Applications paths based on data paths. 48 * This function is available on all platforms, but requires dataPaths argument (e.g. C:\ProgramData\KDE\share on Windows) 49 * Returns: Array of paths, based on dataPaths with "applications" directory appended. 50 */ 51 @trusted string[] applicationsPaths(in string[] dataPaths) nothrow { 52 return dataPaths.map!(p => buildPath(p, "applications")).array; 53 } 54 55 /// 56 unittest 57 { 58 assert(equal(applicationsPaths(["share", buildPath("local", "share")]), [buildPath("share", "applications"), buildPath("local", "share", "applications")])); 59 } 60 61 version(OSX) {} 62 else version(Posix) 63 { 64 /** 65 * ditto, but returns paths based on known data paths. It's practically the same as standardPaths(StandardPath.applications). 66 * This function is defined only on freedesktop systems to avoid confusion with other systems that have data paths not compatible with Desktop Entry Spec. 67 */ 68 @trusted string[] applicationsPaths() nothrow { 69 return standardPaths(StandardPath.applications); 70 } 71 72 /** 73 * Path where .desktop files can be stored without requiring of root privileges. 74 * It's practically the same as writablePath(StandardPath.applications). 75 * This function is defined only on freedesktop systems to avoid confusion with other systems that have data paths not compatible with Desktop Entry Spec. 76 * Note: it does not check if returned path exists and appears to be directory. 77 */ 78 @trusted string writableApplicationsPath() nothrow { 79 return writablePath(StandardPath.applications); 80 } 81 } 82 83 /** 84 * Unescape Exec argument as described in [specification](http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s06.html). 85 * Returns: Unescaped string. 86 */ 87 @trusted string unescapeExecArgument(string arg) nothrow pure 88 { 89 static immutable Tuple!(char, char)[] pairs = [ 90 tuple('s', ' '), 91 tuple('n', '\n'), 92 tuple('r', '\r'), 93 tuple('t', '\t'), 94 tuple('"', '"'), 95 tuple('\'', '\''), 96 tuple('\\', '\\'), 97 tuple('>', '>'), 98 tuple('<', '<'), 99 tuple('~', '~'), 100 tuple('|', '|'), 101 tuple('&', '&'), 102 tuple(';', ';'), 103 tuple('$', '$'), 104 tuple('*', '*'), 105 tuple('?', '?'), 106 tuple('#', '#'), 107 tuple('(', '('), 108 tuple(')', ')'), 109 tuple('`', '`'), 110 ]; 111 return doUnescape(arg, pairs); 112 } 113 114 /// 115 unittest 116 { 117 assert(unescapeExecArgument("simple") == "simple"); 118 assert(unescapeExecArgument(`with\&\"escaped\"\?symbols\$`) == `with&"escaped"?symbols$`); 119 } 120 121 private @trusted string unescapeQuotedArgument(string value) nothrow pure 122 { 123 static immutable Tuple!(char, char)[] pairs = [ 124 tuple('`', '`'), 125 tuple('$', '$'), 126 tuple('"', '"'), 127 tuple('\\', '\\') 128 ]; 129 return doUnescape(value, pairs); 130 } 131 132 /** 133 * Unquote Exec value into an array of escaped arguments. 134 * If an argument was quoted then unescaping of quoted arguments is applied automatically. Note that unescaping of quoted argument is not the same as unquoting argument in general. Read more in [specification](http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s06.html). 135 * Throws: 136 * DesktopExecException if string can't be unquoted (e.g. no pair quote). 137 * Note: 138 * Although Desktop Entry Specification says that arguments must be quoted by double quote, for compatibility reasons this implementation also recognizes single quotes. 139 */ 140 @trusted auto unquoteExecString(string value) pure 141 { 142 string[] result; 143 size_t i; 144 145 while(i < value.length) { 146 if (isWhite(value[i])) { 147 i++; 148 } else if (value[i] == '"' || value[i] == '\'') { 149 char delimeter = value[i]; 150 size_t start = ++i; 151 bool inQuotes = true; 152 bool wasSlash; 153 154 while(i < value.length) { 155 if (value[i] == '\\' && value.length > i+1 && value[i+1] == '\\') { 156 i+=2; 157 wasSlash = true; 158 continue; 159 } 160 161 if (value[i] == delimeter && (value[i-1] != '\\' || (value[i-1] == '\\' && wasSlash) )) { 162 inQuotes = false; 163 break; 164 } 165 wasSlash = false; 166 i++; 167 } 168 if (inQuotes) { 169 throw new DesktopExecException("Missing pair quote"); 170 } 171 result ~= value[start..i].unescapeQuotedArgument(); 172 i++; 173 174 } else { 175 size_t start = i; 176 while(i < value.length && !isWhite(value[i])) { 177 i++; 178 } 179 result ~= value[start..i]; 180 } 181 } 182 183 return result; 184 } 185 186 /// 187 unittest 188 { 189 assert(equal(unquoteExecString(``), string[].init)); 190 assert(equal(unquoteExecString(` `), string[].init)); 191 assert(equal(unquoteExecString(`"" " "`), [``, ` `])); 192 193 assert(equal(unquoteExecString(`cmd arg1 arg2 arg3 `), [`cmd`, `arg1`, `arg2`, `arg3`])); 194 assert(equal(unquoteExecString(`"cmd" arg1 arg2 `), [`cmd`, `arg1`, `arg2`])); 195 196 assert(equal(unquoteExecString(`"quoted cmd" arg1 "quoted arg" `), [`quoted cmd`, `arg1`, `quoted arg`])); 197 assert(equal(unquoteExecString(`"quoted \"cmd\"" arg1 "quoted \"arg\""`), [`quoted "cmd"`, `arg1`, `quoted "arg"`])); 198 199 assert(equal(unquoteExecString(`"\\\$" `), [`\$`])); 200 assert(equal(unquoteExecString(`"\\$" `), [`\$`])); 201 assert(equal(unquoteExecString(`"\$" `), [`$`])); 202 assert(equal(unquoteExecString(`"$"`), [`$`])); 203 204 assert(equal(unquoteExecString(`"\\" `), [`\`])); 205 assert(equal(unquoteExecString(`"\\\\" `), [`\\`])); 206 207 assert(equal(unquoteExecString(`'quoted cmd' arg`), [`quoted cmd`, `arg`])); 208 209 assertThrown!DesktopExecException(unquoteExecString(`cmd "quoted arg`)); 210 } 211 212 213 /** 214 * Convenient function used to unquote and unescape Exec value into an array of arguments. 215 * Note: 216 * Parsed arguments still may contain field codes that should be appropriately expanded before passing to spawnProcess. 217 * Throws: 218 * DesktopExecException if string can't be unquoted. 219 * See_Also: 220 * unquoteExecString, unescapeExecArgument 221 */ 222 @trusted string[] parseExecString(string execString) pure 223 { 224 return execString.unquoteExecString().map!(unescapeExecArgument).array; 225 } 226 227 /// 228 unittest 229 { 230 assert(equal(parseExecString(`"quoted cmd" new\nline "quoted\\\\arg" slash\\arg`), ["quoted cmd", "new\nline", `quoted\arg`, `slash\arg`])); 231 } 232 233 /** 234 * Expand Exec arguments (usually returned by parseExecString) replacing field codes with given values, making the array suitable for passing to spawnProcess. Deprecated field codes are ignored. 235 * Note: 236 * Returned array may be empty and should be checked before passing to spawnProcess. 237 * Params: 238 * execArgs = array of unquoted and unescaped arguments. 239 * urls = array of urls or file names that inserted in the place of %f, %F, %u or %U field codes. For %f and %u only the first element of array is used. 240 * iconName = icon name used to substitute %i field code by --icon iconName. 241 * name = name of application used that inserted in the place of %c field code. 242 * fileName = name of desktop file that inserted in the place of %k field code. 243 * Throws: 244 * DesktopExecException if command line contains unknown field code. 245 * See_Also: 246 * parseExecString 247 */ 248 @trusted string[] expandExecArgs(in string[] execArgs, in string[] urls = null, string iconName = null, string name = null, string fileName = null) pure 249 { 250 string[] toReturn; 251 foreach(token; execArgs) { 252 if (token == "%f") { 253 if (urls.length) { 254 toReturn ~= urls.front; 255 } 256 } else if (token == "%F") { 257 toReturn ~= urls; 258 } else if (token == "%u") { 259 if (urls.length) { 260 toReturn ~= urls.front; 261 } 262 } else if (token == "%U") { 263 toReturn ~= urls; 264 } else if (token == "%i") { 265 if (iconName.length) { 266 toReturn ~= "--icon"; 267 toReturn ~= iconName; 268 } 269 } else if (token == "%c") { 270 toReturn ~= name; 271 } else if (token == "%k") { 272 toReturn ~= fileName; 273 } else if (token == "%d" || token == "%D" || token == "%n" || token == "%N" || token == "%m" || token == "%v") { 274 continue; 275 } else { 276 if (token.length >= 2 && token[0] == '%') { 277 if (token[1] == '%') { 278 toReturn ~= token[1..$]; 279 } else { 280 throw new DesktopExecException("Unknown field code: " ~ token); 281 } 282 } else { 283 toReturn ~= token; 284 } 285 } 286 } 287 288 return toReturn; 289 } 290 291 /// 292 unittest 293 { 294 assert(expandExecArgs(["program name", "%%f", "%f", "%i"], ["one", "two"], "folder") == ["program name", "%f", "one", "--icon", "folder"]); 295 assertThrown!DesktopExecException(expandExecArgs(["program name", "%y"])); 296 } 297 298 /** 299 * Unquote, unescape Exec string and expand field codes substituting them with appropriate values. 300 * Throws: 301 * DesktopExecException if string can't be unquoted, unquoted command line is empty or it has unknown field code. 302 * See_Also: 303 * expandExecArgs, parseExecString 304 */ 305 @trusted string[] expandExecString(string execString, in string[] urls = null, string iconName = null, string name = null, string fileName = null) pure 306 { 307 auto execArgs = parseExecString(execString); 308 if (execArgs.empty) { 309 throw new DesktopExecException("No arguments. Missing or empty Exec value"); 310 } 311 return expandExecArgs(execArgs, urls, iconName, name, fileName); 312 } 313 314 /// 315 unittest 316 { 317 assert(expandExecString(`"quoted program" %i -w %c -f %k %U %D %u %f %F`, ["one", "two"], "folder", "Программа", "/example.desktop") == ["quoted program", "--icon", "folder", "-w", "Программа", "-f", "/example.desktop", "one", "two", "one", "one", "one", "two"]); 318 319 assertThrown!DesktopExecException(expandExecString(`program %f %y`)); //%y is unknown field code. 320 assertThrown!DesktopExecException(expandExecString(``)); 321 } 322 323 /** 324 * Detect command which will run program in terminal emulator. 325 * On Freedesktop it looks for x-terminal-emulator first. If found ["/path/to/x-terminal-emulator", "-e"] is returned. 326 * Otherwise it looks for xdg-terminal. If found ["/path/to/xdg-terminal"] is returned. 327 * If all guesses failed, it uses ["xterm", "-e"] as fallback. 328 * Note: This function always returns empty array on non-freedesktop systems. 329 */ 330 string[] getTerminalCommand() nothrow @trusted 331 { 332 version(OSX) { 333 return null; 334 } else version(Posix) { 335 string term = findExecutable("x-terminal-emulator"); 336 if (!term.empty) { 337 return [term, "-e"]; 338 } 339 term = findExecutable("xdg-terminal"); 340 if (!term.empty) { 341 return [term]; 342 } 343 return ["xterm", "-e"]; 344 } else { 345 return null; 346 } 347 } 348 349 private @trusted File getNullStdin() 350 { 351 version(Posix) { 352 return File("/dev/null", "rb"); 353 } else { 354 return std.stdio.stdin; 355 } 356 } 357 358 private @trusted File getNullStdout() 359 { 360 version(Posix) { 361 return File("/dev/null", "wb"); 362 } else { 363 return std.stdio.stdout; 364 } 365 } 366 367 private @trusted File getNullStderr() 368 { 369 version(Posix) { 370 return File("/dev/null", "wb"); 371 } else { 372 return std.stdio.stderr; 373 } 374 } 375 376 private @trusted Pid execProcess(string[] args, string workingDirectory = null) 377 { 378 static if( __VERSION__ < 2066 ) { 379 return spawnProcess(args, getNullStdin(), getNullStdout(), getNullStderr(), null, Config.none); 380 } else { 381 return spawnProcess(args, getNullStdin(), getNullStdout(), getNullStderr(), null, Config.none, workingDirectory); 382 } 383 } 384 385 /** 386 * Adapter of IniLikeGroup for easy access to desktop action. 387 */ 388 struct DesktopAction 389 { 390 @nogc @safe this(const(IniLikeGroup) group) nothrow { 391 _group = group; 392 } 393 394 /** 395 * Label that will be shown to the user. 396 * Returns: The value associated with "Name" key. 397 * Note: Don't confuse this with name of section. To access name of section use group().name. 398 */ 399 @nogc @safe string name() const nothrow { 400 return value("Name"); 401 } 402 403 /** 404 * Label that will be shown to the user in given locale. 405 * Returns: The value associated with "Name" key and given locale. 406 */ 407 @safe string localizedName(string locale) const nothrow { 408 return localizedValue("Name", locale); 409 } 410 411 /** 412 * Icon name of action. 413 * Returns: The value associated with "Icon" key. 414 */ 415 @nogc @safe string iconName() const nothrow { 416 return value("Icon"); 417 } 418 419 /** 420 * Returns: Localized icon name 421 * See_Also: iconName 422 */ 423 @safe string localizedIconName(string locale) const nothrow { 424 return localizedValue("Icon", locale); 425 } 426 427 /** 428 * Returns: The value associated with "Exec" key and given locale. 429 */ 430 @nogc @safe string execString() const nothrow { 431 return value("Exec"); 432 } 433 434 /** 435 * Start this action. 436 * Returns: 437 * Pid of started process. 438 * Throws: 439 * ProcessException on failure to start the process. 440 * DesktopExecException if exec string is invalid. 441 * See_Also: execString 442 */ 443 @safe Pid start(string locale = null) const { 444 return execProcess(expandExecString(execString, null, localizedIconName(locale), localizedName(locale))); 445 } 446 447 /** 448 * Underlying IniLikeGroup instance. 449 * Returns: IniLikeGroup this object was constrcucted from. 450 */ 451 @nogc @safe const(IniLikeGroup) group() const nothrow { 452 return _group; 453 } 454 455 /** 456 * This alias allows to call functions of underlying IniLikeGroup instance. 457 */ 458 alias group this; 459 private: 460 const(IniLikeGroup) _group; 461 } 462 463 /** 464 * Represents .desktop file. 465 * 466 */ 467 final class DesktopFile : IniLikeFile 468 { 469 public: 470 ///Desktop entry type 471 enum Type 472 { 473 Unknown, ///Desktop entry is unknown type 474 Application, ///Desktop describes application 475 Link, ///Desktop describes URL 476 Directory ///Desktop entry describes directory settings 477 } 478 479 alias IniLikeFile.ReadOptions ReadOptions; 480 481 /** 482 * Reads desktop file from file. 483 * Throws: 484 * $(B ErrnoException) if file could not be opened. 485 * $(B IniLikeException) if error occured while reading the file. 486 */ 487 @safe this(string fileName, ReadOptions options = ReadOptions.noOptions) { 488 this(iniLikeFileReader(fileName), options, fileName); 489 } 490 491 /** 492 * Reads desktop file from range of $(B IniLikeLine)s. 493 * Throws: 494 * $(B IniLikeException) if error occured while parsing. 495 */ 496 @trusted this(Range)(Range byLine, ReadOptions options = ReadOptions.noOptions, string fileName = null) if(is(ElementType!Range : IniLikeLine)) 497 { 498 super(byLine, options, fileName); 499 _desktopEntry = group("Desktop Entry"); 500 enforce(_desktopEntry, new IniLikeException("No \"Desktop Entry\" group", 0)); 501 } 502 503 /** 504 * Constructs DesktopFile with "Desktop Entry" group and Version set to 1.0 505 */ 506 @safe this() { 507 super(); 508 _desktopEntry = super.addGroup("Desktop Entry"); 509 this["Version"] = "1.0"; 510 } 511 512 /// 513 unittest 514 { 515 auto df = new DesktopFile(); 516 assert(df.desktopEntry()); 517 assert(df.value("Version") == "1.0"); 518 assert(df.categories().empty); 519 assert(df.type() == DesktopFile.Type.Unknown); 520 } 521 522 @safe override IniLikeGroup addGroup(string groupName) { 523 auto entry = super.addGroup(groupName); 524 if (groupName == "Desktop Entry") { 525 _desktopEntry = entry; 526 } 527 return entry; 528 } 529 530 /** 531 * Removes group by name. You can't remove "Desktop Entry" group with this function. 532 */ 533 @safe override void removeGroup(string groupName) nothrow { 534 if (groupName != "Desktop Entry") { 535 super.removeGroup(groupName); 536 } 537 } 538 539 /// 540 unittest 541 { 542 auto df = new DesktopFile(); 543 df.addGroup("Action"); 544 assert(df.group("Action") !is null); 545 df.removeGroup("Action"); 546 assert(df.group("Action") is null); 547 df.removeGroup("Desktop Entry"); 548 assert(df.desktopEntry() !is null); 549 } 550 551 /** 552 * Type of desktop entry. 553 * Returns: Type of desktop entry. 554 */ 555 @nogc @safe Type type() const nothrow { 556 string t = value("Type"); 557 if (t.length) { 558 if (t == "Application") { 559 return Type.Application; 560 } else if (t == "Link") { 561 return Type.Link; 562 } else if (t == "Directory") { 563 return Type.Directory; 564 } 565 } 566 if (fileName().endsWith(".directory")) { 567 return Type.Directory; 568 } 569 570 return Type.Unknown; 571 } 572 573 /// 574 unittest 575 { 576 string contents = "[Desktop Entry]\nType=Application"; 577 auto desktopFile = new DesktopFile(iniLikeStringReader(contents)); 578 assert(desktopFile.type == DesktopFile.Type.Application); 579 580 desktopFile.desktopEntry["Type"] = "Link"; 581 assert(desktopFile.type == DesktopFile.Type.Link); 582 583 desktopFile.desktopEntry["Type"] = "Directory"; 584 assert(desktopFile.type == DesktopFile.Type.Directory); 585 586 desktopFile = new DesktopFile(iniLikeStringReader("[Desktop Entry]"), ReadOptions.noOptions, ".directory"); 587 assert(desktopFile.type == DesktopFile.Type.Directory); 588 } 589 590 /** 591 * Sets "Type" field to type 592 * Note: Setting the Unknown type removes type field. 593 */ 594 @safe Type type(Type t) { 595 final switch(t) { 596 case Type.Application: 597 this["Type"] = "Application"; 598 break; 599 case Type.Link: 600 this["Type"] = "Link"; 601 break; 602 case Type.Directory: 603 this["Type"] = "Directory"; 604 break; 605 case Type.Unknown: 606 this.removeEntry("Type"); 607 break; 608 } 609 return t; 610 } 611 612 /// 613 unittest 614 { 615 auto desktopFile = new DesktopFile(); 616 desktopFile.type = DesktopFile.Type.Application; 617 assert(desktopFile.desktopEntry["Type"] == "Application"); 618 desktopFile.type = DesktopFile.Type.Link; 619 assert(desktopFile.desktopEntry["Type"] == "Link"); 620 desktopFile.type = DesktopFile.Type.Directory; 621 assert(desktopFile.desktopEntry["Type"] == "Directory"); 622 623 desktopFile.type = DesktopFile.Type.Unknown; 624 assert(desktopFile.desktopEntry.value("Type").empty); 625 } 626 627 /** 628 * Specific name of the application, for example "Mozilla". 629 * Returns: The value associated with "Name" key. 630 * See_Also: localizedName 631 */ 632 @nogc @safe string name() const nothrow { 633 return value("Name"); 634 } 635 /** 636 * Returns: Localized name. 637 * See_Also: name 638 */ 639 @safe string localizedName(string locale) const nothrow { 640 return localizedValue("Name", locale); 641 } 642 643 version(OSX) {} version(Posix) { 644 /** 645 * See $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ape.html, Desktop File ID) 646 * Returns: Desktop file ID or empty string if file does not have an ID. 647 * Note: This function retrieves applications paths each time it's called and therefore can impact performance. To avoid this issue use overload with argument. 648 * See_Also: applicationsPaths 649 */ 650 @trusted string id() const nothrow { 651 return id(applicationsPaths()); 652 } 653 } 654 655 /** 656 * See $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ape.html, Desktop File ID) 657 * Params: 658 * appPaths = range of base application paths to check if this file belongs to one of them. 659 * Returns: Desktop file ID or empty string if file does not have an ID. 660 * See_Also: applicationsPaths 661 */ 662 @trusted string id(Range)(Range appPaths) const nothrow if (isInputRange!Range && is(ElementType!Range : string)) 663 { 664 try { 665 string absolute = fileName.absolutePath; 666 foreach (path; appPaths) { 667 auto pathSplit = pathSplitter(path); 668 auto fileSplit = pathSplitter(absolute); 669 670 while (!pathSplit.empty && !fileSplit.empty && pathSplit.front == fileSplit.front) { 671 pathSplit.popFront(); 672 fileSplit.popFront(); 673 } 674 675 if (pathSplit.empty) { 676 return to!string(fileSplit.join("-")); 677 } 678 } 679 } catch(Exception e) { 680 681 } 682 return null; 683 } 684 685 /// 686 unittest 687 { 688 string contents = 689 `[Desktop Entry] 690 Name=Program 691 Type=Directory`; 692 693 string[] appPaths; 694 string filePath, nestedFilePath, wrongFilePath; 695 696 version(Windows) { 697 appPaths = [`C:\ProgramData\KDE\share\applications`, `C:\Users\username\.kde\share\applications`]; 698 filePath = `C:\ProgramData\KDE\share\applications\example.desktop`; 699 nestedFilePath = `C:\ProgramData\KDE\share\applications\kde\example.desktop`; 700 wrongFilePath = `C:\ProgramData\desktop\example.desktop`; 701 } else { 702 appPaths = ["/usr/share/applications", "/usr/local/share/applications"]; 703 filePath = "/usr/share/applications/example.desktop"; 704 nestedFilePath = "/usr/share/applications/kde/example.desktop"; 705 wrongFilePath = "/etc/desktop/example.desktop"; 706 } 707 708 auto df = new DesktopFile(iniLikeStringReader(contents), DesktopFile.ReadOptions.noOptions, nestedFilePath); 709 assert(df.id(appPaths) == "kde-example.desktop"); 710 711 df = new DesktopFile(iniLikeStringReader(contents), DesktopFile.ReadOptions.noOptions, filePath); 712 assert(df.id(appPaths) == "example.desktop"); 713 714 df = new DesktopFile(iniLikeStringReader(contents), DesktopFile.ReadOptions.noOptions, wrongFilePath); 715 assert(df.id(appPaths).empty); 716 717 df = new DesktopFile(iniLikeStringReader(contents), DesktopFile.ReadOptions.noOptions); 718 assert(df.id(appPaths).empty); 719 } 720 721 /** 722 * Generic name of the application, for example "Web Browser". 723 * Returns: The value associated with "GenericName" key. 724 * See_Also: localizedGenericName 725 */ 726 @nogc @safe string genericName() const nothrow { 727 return value("GenericName"); 728 } 729 /** 730 * Returns: Localized generic name 731 * See_Also: genericName 732 */ 733 @safe string localizedGenericName(string locale) const nothrow { 734 return localizedValue("GenericName", locale); 735 } 736 737 /** 738 * Tooltip for the entry, for example "View sites on the Internet". 739 * Returns: The value associated with "Comment" key. 740 * See_Also: localizedComment 741 */ 742 @nogc @safe string comment() const nothrow { 743 return value("Comment"); 744 } 745 /** 746 * Returns: Localized comment 747 * See_Also: comment 748 */ 749 @safe string localizedComment(string locale) const nothrow { 750 return localizedValue("Comment", locale); 751 } 752 753 /** 754 * Returns: the value associated with "Exec" key. 755 * Note: To get arguments from exec string use expandExecString. 756 * See_Also: expandExecString, startApplication 757 */ 758 @nogc @safe string execString() const nothrow { 759 return value("Exec"); 760 } 761 762 /** 763 * URL to access. 764 * Returns: The value associated with "URL" key. 765 */ 766 @nogc @safe string url() const nothrow { 767 return value("URL"); 768 } 769 770 /// 771 unittest 772 { 773 auto df = new DesktopFile(iniLikeStringReader("[Desktop Entry]\nType=Link\nURL=https://github.com/")); 774 assert(df.url() == "https://github.com/"); 775 } 776 777 /** 778 * Value used to determine if the program is actually installed. If the path is not an absolute path, the file should be looked up in the $(B PATH) environment variable. If the file is not present or if it is not executable, the entry may be ignored (not be used in menus, for example). 779 * Returns: The value associated with "TryExec" key. 780 */ 781 @nogc @safe string tryExecString() const nothrow { 782 return value("TryExec"); 783 } 784 785 /** 786 * Icon to display in file manager, menus, etc. 787 * Returns: The value associated with "Icon" key. 788 * Note: This function returns Icon as it's defined in .desktop file. 789 * It does not provide any lookup of actual icon file on the system if the name if not an absolute path. 790 * To find the path to icon file refer to $(LINK2 http://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html, Icon Theme Specification) or consider using $(LINK2 https://github.com/MyLittleRobo/icontheme, icontheme library). 791 */ 792 @nogc @safe string iconName() const nothrow { 793 return value("Icon"); 794 } 795 796 /** 797 * Returns: Localized icon name 798 * See_Also: iconName 799 */ 800 @safe string localizedIconName(string locale) const nothrow { 801 return localizedValue("Icon", locale); 802 } 803 804 /** 805 * NoDisplay means "this application exists, but don't display it in the menus". 806 * Returns: The value associated with "NoDisplay" key converted to bool using isTrue. 807 */ 808 @nogc @safe bool noDisplay() const nothrow { 809 return isTrue(value("NoDisplay")); 810 } 811 812 /** 813 * Hidden means the user deleted (at his level) something that was present (at an upper level, e.g. in the system dirs). 814 * It's strictly equivalent to the .desktop file not existing at all, as far as that user is concerned. 815 * Returns: The value associated with "Hidden" key converted to bool using isTrue. 816 */ 817 @nogc @safe bool hidden() const nothrow { 818 return isTrue(value("Hidden")); 819 } 820 821 /** 822 * A boolean value specifying if D-Bus activation is supported for this application. 823 * Returns: The value associated with "dbusActivable" key converted to bool using isTrue. 824 */ 825 @nogc @safe bool dbusActivable() const nothrow { 826 return isTrue(value("DBusActivatable")); 827 } 828 829 /** 830 * Returns: The value associated with "startupNotify" key converted to bool using isTrue. 831 */ 832 @nogc @safe bool startupNotify() const nothrow { 833 return isTrue(value("StartupNotify")); 834 } 835 836 /** 837 * The working directory to run the program in. 838 * Returns: The value associated with "Path" key. 839 */ 840 @nogc @safe string workingDirectory() const nothrow { 841 return value("Path"); 842 } 843 844 /** 845 * Whether the program runs in a terminal window. 846 * Returns: The value associated with "Terminal" key converted to bool using isTrue. 847 */ 848 @nogc @safe bool terminal() const nothrow { 849 return isTrue(value("Terminal")); 850 } 851 /// Sets "Terminal" field to true or false. 852 @safe bool terminal(bool t) { 853 this["Terminal"] = t ? "true" : "false"; 854 return t; 855 } 856 857 private static struct SplitValues 858 { 859 @trusted this(string value) { 860 _value = value; 861 next(); 862 } 863 @trusted string front() { 864 return _current; 865 } 866 @trusted void popFront() { 867 next(); 868 } 869 @trusted bool empty() { 870 return _value.empty && _current.empty; 871 } 872 @trusted @property auto save() { 873 return this; 874 } 875 private: 876 void next() { 877 size_t i=0; 878 for (; i<_value.length && ( (_value[i] != ';') || (i && _value[i-1] == '\\' && _value[i] == ';')); ++i) { 879 //pass 880 } 881 _current = _value[0..i].replace("\\;", ";"); 882 _value = i == _value.length ? _value[_value.length..$] : _value[i+1..$]; 883 } 884 string _value; 885 string _current; 886 } 887 888 static assert(isForwardRange!SplitValues); 889 890 /** 891 * Some keys can have multiple values, separated by semicolon. This function helps to parse such kind of strings into the range. 892 * Returns: The range of multiple nonempty values. 893 * Note: Returned range unescapes ';' character automatically. 894 */ 895 @trusted static auto splitValues(string values) { 896 return SplitValues(values).filter!(s => !s.empty); 897 } 898 899 /// 900 unittest 901 { 902 assert(DesktopFile.splitValues("").empty); 903 assert(DesktopFile.splitValues(";").empty); 904 assert(DesktopFile.splitValues(";;;").empty); 905 assert(equal(DesktopFile.splitValues("Application;Utility;FileManager;"), ["Application", "Utility", "FileManager"])); 906 assert(equal(DesktopFile.splitValues("I\\;Me;\\;You\\;We\\;"), ["I;Me", ";You;We;"])); 907 } 908 909 /** 910 * Join range of multiple values into a string using semicolon as separator. Adds trailing semicolon. 911 * Returns: Values of range joined into one string with ';' after each value or empty string if range is empty. 912 * Note: If some value of range contains ';' character it's automatically escaped. 913 */ 914 @trusted static string joinValues(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 915 auto result = values.filter!( s => !s.empty ).map!( s => s.replace(";", "\\;")).joiner(";"); 916 if (result.empty) { 917 return null; 918 } else { 919 return text(result) ~ ";"; 920 } 921 } 922 923 /// 924 unittest 925 { 926 assert(DesktopFile.joinValues([""]).empty); 927 assert(equal(DesktopFile.joinValues(["Application", "Utility", "FileManager"]), "Application;Utility;FileManager;")); 928 assert(equal(DesktopFile.joinValues(["I;Me", ";You;We;"]), "I\\;Me;\\;You\\;We\\;;")); 929 } 930 931 /** 932 * Categories this program belongs to. 933 * Returns: The range of multiple values associated with "Categories" key. 934 */ 935 @safe auto categories() const { 936 return splitValues(value("Categories")); 937 } 938 939 /** 940 * Sets the list of values for the "Categories" list. 941 */ 942 @safe void categories(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 943 this["Categories"] = joinValues(values); 944 } 945 946 /** 947 * A list of strings which may be used in addition to other metadata to describe this entry. 948 * Returns: The range of multiple values associated with "Keywords" key. 949 */ 950 @safe auto keywords() const { 951 return splitValues(value("Keywords")); 952 } 953 954 /** 955 * Sets the list of values for the "Keywords" list. 956 */ 957 @safe void keywords(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 958 this["Keywords"] = joinValues(values); 959 } 960 961 /** 962 * The MIME type(s) supported by this application. 963 * Returns: The range of multiple values associated with "MimeType" key. 964 */ 965 @safe auto mimeTypes() const { 966 return splitValues(value("MimeType")); 967 } 968 969 /** 970 * Sets the list of values for the "MimeType" list. 971 */ 972 @safe void mimeTypes(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 973 this["MimeType"] = joinValues(values); 974 } 975 976 /** 977 * Actions supported by application. 978 * Returns: Range of multiple values associated with "Actions" key. 979 * Note: This only depends on "Actions" value, not on actually presented sections in desktop file. 980 * See_Also: byAction, action 981 */ 982 @safe auto actions() const { 983 return splitValues(value("Actions")); 984 } 985 986 /** 987 * Sets the list of values for "Actions" list. 988 */ 989 @safe void actions(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 990 this["Actions"] = joinValues(values); 991 } 992 993 /** 994 * Get $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s10.html, additional application action) by name. 995 * Returns: DesktopAction with given action name or DesktopAction with null group if not found or found section does not have a name. 996 * See_Also: actions, byAction 997 */ 998 @safe const(DesktopAction) action(string actionName) const { 999 if (actions().canFind(actionName)) { 1000 auto desktopAction = DesktopAction(group("Desktop Action "~actionName)); 1001 if (desktopAction.group() !is null && desktopAction.name().length != 0) { 1002 return desktopAction; 1003 } 1004 } 1005 1006 return DesktopAction(null); 1007 } 1008 1009 /** 1010 * Iterating over existing actions. 1011 * Returns: Range of DesktopAction. 1012 * See_Also: actions, action 1013 */ 1014 @safe auto byAction() const { 1015 return actions().map!(actionName => DesktopAction(group("Desktop Action "~actionName))).filter!(delegate(desktopAction) { 1016 return desktopAction.group !is null && desktopAction.name.length != 0; 1017 }); 1018 } 1019 1020 /** 1021 * A list of strings identifying the desktop environments that should display a given desktop entry. 1022 * Returns: The range of multiple values associated with "OnlyShowIn" key. 1023 */ 1024 @safe auto onlyShowIn() const { 1025 return splitValues(value("OnlyShowIn")); 1026 } 1027 1028 /** 1029 * A list of strings identifying the desktop environments that should not display a given desktop entry. 1030 * Returns: The range of multiple values associated with "NotShowIn" key. 1031 */ 1032 @safe auto notShowIn() const { 1033 return splitValues(value("NotShowIn")); 1034 } 1035 1036 /** 1037 * Returns: instance of "Desktop Entry" group. 1038 * Note: Usually you don't need to call this function since you can rely on alias this. 1039 */ 1040 @nogc @safe inout(IniLikeGroup) desktopEntry() nothrow inout { 1041 return _desktopEntry; 1042 } 1043 1044 /** 1045 * This alias allows to call functions related to "Desktop Entry" group without need to call desktopEntry explicitly. 1046 */ 1047 alias desktopEntry this; 1048 1049 /** 1050 * Expand "Exec" value into the array of command line arguments to use to start the program. 1051 * It applies unquoting and unescaping. 1052 * See_Also: execString, unquoteExecString, unescapeExecArgument, startApplication 1053 */ 1054 @safe string[] expandExecString(in string[] urls = null, string locale = null) const 1055 { 1056 return .expandExecString(execString(), urls, localizedIconName(locale), localizedName(locale), fileName()); 1057 } 1058 1059 /// 1060 unittest 1061 { 1062 string contents = 1063 `[Desktop Entry] 1064 Name=Program 1065 Name[ru]=Программа 1066 Exec="quoted program" %i -w %c -f %k %U %D %u %f %F 1067 Icon=folder 1068 Icon[ru]=folder_ru`; 1069 auto df = new DesktopFile(iniLikeStringReader(contents), DesktopFile.ReadOptions.noOptions, "/example.desktop"); 1070 assert(df.expandExecString(["one", "two"], "ru") == 1071 ["quoted program", "--icon", "folder_ru", "-w", "Программа", "-f", "/example.desktop", "one", "two", "one", "one", "one", "two"]); 1072 } 1073 1074 /** 1075 * Starts the application associated with this .desktop file using urls as command line params. 1076 * If the program should be run in terminal it tries to find system defined terminal emulator to run in. 1077 * Params: 1078 * urls = urls application will start with. 1079 * locale = locale that may be needed to be placed in urls if Exec value has %c code. 1080 * terminalCommand = preferable terminal emulator command. If not set then terminal is determined via getTerminalCommand. 1081 * Note: 1082 * This function does not check if the type of desktop file is Application. It relies only on "Exec" value. 1083 * Returns: 1084 * Pid of started process. 1085 * Throws: 1086 * ProcessException on failure to start the process. 1087 * DesktopExecException if exec string is invalid. 1088 * See_Also: getTerminalCommand, start, expandExecString 1089 */ 1090 @trusted Pid startApplication(in string[] urls = null, string locale = null, lazy const(string)[] terminalCommand = getTerminalCommand) const 1091 { 1092 auto args = expandExecString(urls, locale); 1093 if (terminal()) { 1094 auto termCmd = terminalCommand(); 1095 args = termCmd ~ args; 1096 } 1097 return execProcess(args, workingDirectory()); 1098 } 1099 1100 /// 1101 unittest 1102 { 1103 auto df = new DesktopFile(); 1104 assertThrown(df.startApplication(string[].init)); 1105 } 1106 1107 ///ditto, but uses the only url. 1108 @trusted Pid startApplication(string url, string locale = null, lazy const(string)[] terminalCommand = getTerminalCommand) const { 1109 return startApplication([url], locale, terminalCommand); 1110 } 1111 1112 /** 1113 * Opens url defined in .desktop file using $(LINK2 http://portland.freedesktop.org/xdg-utils-1.0/xdg-open.html, xdg-open). 1114 * Note: 1115 * This function does not check if the type of desktop file is Link. It relies only on "URL" value. 1116 * Returns: 1117 * Pid of started process. 1118 * Throws: 1119 * ProcessException on failure to start the process. 1120 * Exception if desktop file does not define URL or it's empty. 1121 * See_Also: start 1122 */ 1123 @trusted Pid startLink() const { 1124 string myurl = url(); 1125 enforce(myurl.length, "No URL to open"); 1126 return spawnProcess(["xdg-open", myurl], null, Config.none); 1127 } 1128 1129 /// 1130 unittest 1131 { 1132 auto df = new DesktopFile(); 1133 assertThrown(df.startLink()); 1134 } 1135 1136 /** 1137 * Starts application or open link depending on desktop entry type. 1138 * Returns: 1139 * Pid of started process. 1140 * Throws: 1141 * ProcessException on failure to start the process. 1142 * Exception if type is Unknown or Directory. 1143 * See_Also: startApplication, startLink 1144 */ 1145 @trusted Pid start() const 1146 { 1147 final switch(type()) { 1148 case DesktopFile.Type.Application: 1149 return startApplication(); 1150 case DesktopFile.Type.Link: 1151 return startLink(); 1152 case DesktopFile.Type.Directory: 1153 throw new Exception("Don't know how to start Directory"); 1154 case DesktopFile.Type.Unknown: 1155 throw new Exception("Unknown desktop entry type"); 1156 } 1157 } 1158 1159 /// 1160 unittest 1161 { 1162 string contents = "[Desktop Entry]\nType=Directory"; 1163 auto df = new DesktopFile(iniLikeStringReader(contents)); 1164 assertThrown(df.start()); 1165 1166 df = new DesktopFile(); 1167 assertThrown(df.start()); 1168 } 1169 1170 private: 1171 IniLikeGroup _desktopEntry; 1172 } 1173 1174 /// 1175 unittest 1176 { 1177 import std.file; 1178 //Test DesktopFile 1179 string desktopFileContents = 1180 `[Desktop Entry] 1181 # Comment 1182 Name=Double Commander 1183 Name[ru]=Двухпанельный коммандер 1184 GenericName=File manager 1185 GenericName[ru]=Файловый менеджер 1186 Comment=Double Commander is a cross platform open source file manager with two panels side by side. 1187 Comment[ru]=Double Commander - кроссплатформенный файловый менеджер. 1188 Terminal=false 1189 Icon=doublecmd 1190 Icon[ru]=doublecmd_ru 1191 Exec=doublecmd %f 1192 TryExec=doublecmd 1193 Type=Application 1194 Categories=Application;Utility;FileManager; 1195 Keywords=folder;manager;disk;filesystem;operations; 1196 Actions=OpenDirectory;NotPresented;Settings;NoName; 1197 MimeType=inode/directory;application/x-directory; 1198 NoDisplay=false 1199 Hidden=false 1200 StartupNotify=true 1201 DBusActivatable=true 1202 Path=/opt/doublecmd 1203 OnlyShowIn=GNOME;XFCE;LXDE; 1204 NotShowIn=KDE; 1205 1206 [Desktop Action OpenDirectory] 1207 Name=Open directory 1208 Name[ru]=Открыть папку 1209 Icon=open 1210 Exec=doublecmd %u 1211 1212 [NoName] 1213 Icon=folder 1214 1215 [Desktop Action Settings] 1216 Name=Settings 1217 Name[ru]=Настройки 1218 Icon=edit 1219 Exec=doublecmd settings 1220 1221 [Desktop Action Notspecified] 1222 Name=Notspecified Action`; 1223 1224 auto df = new DesktopFile(iniLikeStringReader(desktopFileContents), DesktopFile.ReadOptions.preserveComments); 1225 assert(df.name() == "Double Commander"); 1226 assert(df.localizedName("ru_RU") == "Двухпанельный коммандер"); 1227 assert(df.genericName() == "File manager"); 1228 assert(df.localizedGenericName("ru_RU") == "Файловый менеджер"); 1229 assert(df.comment() == "Double Commander is a cross platform open source file manager with two panels side by side."); 1230 assert(df.localizedComment("ru_RU") == "Double Commander - кроссплатформенный файловый менеджер."); 1231 assert(df.iconName() == "doublecmd"); 1232 assert(df.localizedIconName("ru_RU") == "doublecmd_ru"); 1233 assert(df.tryExecString() == "doublecmd"); 1234 assert(!df.terminal()); 1235 assert(!df.noDisplay()); 1236 assert(!df.hidden()); 1237 assert(df.startupNotify()); 1238 assert(df.dbusActivable()); 1239 assert(df.workingDirectory() == "/opt/doublecmd"); 1240 assert(df.type() == DesktopFile.Type.Application); 1241 assert(equal(df.keywords(), ["folder", "manager", "disk", "filesystem", "operations"])); 1242 assert(equal(df.categories(), ["Application", "Utility", "FileManager"])); 1243 assert(equal(df.actions(), ["OpenDirectory", "NotPresented", "Settings", "NoName"])); 1244 assert(equal(df.mimeTypes(), ["inode/directory", "application/x-directory"])); 1245 assert(equal(df.onlyShowIn(), ["GNOME", "XFCE", "LXDE"])); 1246 assert(equal(df.notShowIn(), ["KDE"])); 1247 1248 assert(equal(df.byAction().map!(desktopAction => 1249 tuple(desktopAction.name(), desktopAction.localizedName("ru"), desktopAction.iconName(), desktopAction.execString())), 1250 [tuple("Open directory", "Открыть папку", "open", "doublecmd %u"), tuple("Settings", "Настройки", "edit", "doublecmd settings")])); 1251 1252 assert(df.action("NotPresented").group() is null); 1253 assert(df.action("Notspecified").group() is null); 1254 assert(df.action("NoName").group() is null); 1255 assert(df.action("Settings").group() !is null); 1256 1257 assert(df.saveToString() == desktopFileContents); 1258 1259 assert(df.contains("Icon")); 1260 df.removeEntry("Icon"); 1261 assert(!df.contains("Icon")); 1262 df["Icon"] = "files"; 1263 assert(df.contains("Icon")); 1264 1265 df = new DesktopFile(); 1266 df.terminal = true; 1267 df.type = DesktopFile.Type.Application; 1268 df.categories = ["Development", "Compilers"]; 1269 1270 assert(df.terminal() == true); 1271 assert(df.type() == DesktopFile.Type.Application); 1272 assert(equal(df.categories(), ["Development", "Compilers"])); 1273 1274 string contents = 1275 `[Not desktop entry] 1276 Key=Value`; 1277 assertThrown(new DesktopFile(iniLikeStringReader(contents))); 1278 }