1 /** 2 * Class representation of desktop file. 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.file; 14 15 public import inilike.file; 16 public import desktopfile.utils; 17 18 private @trusted void validateDesktopKeyImpl(string groupName, string key, string value) { 19 if (!isValidDesktopFileKey(key)) { 20 throw new IniLikeEntryException("key is invalid", groupName, key, value); 21 } 22 } 23 24 /** 25 * Subclass of $(D inilike.file.IniLikeGroup) for easy access to desktop action. 26 */ 27 final class DesktopAction : IniLikeGroup 28 { 29 protected: 30 @trusted override void validateKey(string key, string value) const { 31 validateDesktopKeyImpl(groupName(), key, value); 32 } 33 public: 34 package @nogc @safe this(string groupName) nothrow { 35 super(groupName); 36 } 37 38 /** 39 * Label that will be shown to the user. 40 * Returns: The unescaped value associated with "Name" key. 41 */ 42 @safe string displayName() const nothrow pure { 43 return unescapedValue("Name"); 44 } 45 46 /** 47 * Label that will be shown to the user in given locale. 48 * Returns: The unescaped value associated with "Name" key and given locale. 49 * See_Also: $(D displayName) 50 */ 51 @safe string localizedDisplayName(string locale) const nothrow pure { 52 return unescapedValue("Name", locale); 53 } 54 55 /** 56 * Icon name of action. 57 * Returns: The unescaped value associated with "Icon" key. 58 */ 59 @safe string iconName() const nothrow pure { 60 return unescapedValue("Icon"); 61 } 62 63 /** 64 * Returns: Localized icon name. 65 * See_Also: $(D iconName) 66 */ 67 @safe string localizedIconName(string locale) const nothrow pure { 68 return unescapedValue("Icon", locale); 69 } 70 71 /** 72 * Returns: The unescaped value associated with "Exec" key. 73 */ 74 @safe string execValue() const nothrow pure { 75 return unescapedValue("Exec"); 76 } 77 78 /** 79 * Expand "Exec" value into the array of command line arguments to use to start the action. 80 * It applies unquoting and unescaping. 81 * See_Also: $(D execValue), $(D desktopfile.utils.expandExecArgs), $(D start) 82 */ 83 @safe string[] expandExecValue(scope const(string)[] urls = null, string locale = null) const 84 { 85 return expandExecArgs(unquoteExec(execValue()), urls, localizedIconName(locale), localizedDisplayName(locale)); 86 } 87 88 /** 89 * Start this action with provided urls. 90 * Throws: 91 * $(B ProcessException) on failure to start the process. 92 * $(D desktopfile.utils.DesktopExecException) if exec string is invalid. 93 * See_Also: $(D execValue), $(D desktopfile.utils.spawnApplication) 94 */ 95 @safe void start(scope const(string)[] urls, string locale = null) const 96 { 97 auto unquotedArgs = unquoteExec(execValue()); 98 99 SpawnParams params; 100 params.urls = urls; 101 params.iconName = localizedIconName(locale); 102 params.displayName = localizedDisplayName(locale); 103 104 return spawnApplication(unquotedArgs, params); 105 } 106 107 /// ditto, but using a single url 108 @safe void start(string url, string locale) const 109 { 110 return start([url], locale); 111 } 112 113 /// ditto, but without any urls. 114 @safe void start(string locale = null) const { 115 return start(string[].init, locale); 116 } 117 } 118 119 /** 120 * Subclass of $(D inilike.file.IniLikeGroup) for easy accessing of Desktop Entry properties. 121 */ 122 final class DesktopEntry : IniLikeGroup 123 { 124 ///Desktop entry type 125 enum Type 126 { 127 Unknown, ///Desktop entry is unknown type 128 Application, ///Desktop describes application 129 Link, ///Desktop describes URL 130 Directory ///Desktop entry describes directory settings 131 } 132 133 protected @nogc @safe this() nothrow { 134 super("Desktop Entry"); 135 } 136 137 /** 138 * Type of desktop entry. 139 * Returns: Type of desktop entry. 140 */ 141 @nogc @safe Type type() const nothrow pure { 142 string t = escapedValue("Type"); 143 if (t.length) { 144 if (t == "Application") { 145 return Type.Application; 146 } else if (t == "Link") { 147 return Type.Link; 148 } else if (t == "Directory") { 149 return Type.Directory; 150 } 151 } 152 return Type.Unknown; 153 } 154 155 /// 156 unittest 157 { 158 string contents = "[Desktop Entry]\nType=Application"; 159 auto desktopFile = new DesktopFile(iniLikeStringReader(contents)); 160 assert(desktopFile.type == Type.Application); 161 162 desktopFile.desktopEntry.setEscapedValue("Type", "Link"); 163 assert(desktopFile.type == Type.Link); 164 165 desktopFile.desktopEntry.setEscapedValue("Type", "Directory"); 166 assert(desktopFile.type == Type.Directory); 167 } 168 169 /** 170 * Sets "Type" field to type 171 * Note: Setting the $(D Type.Unknown) removes type field. 172 */ 173 @safe Type type(Type t) { 174 final switch(t) { 175 case Type.Application: 176 setEscapedValue("Type", "Application"); 177 break; 178 case Type.Link: 179 setEscapedValue("Type", "Link"); 180 break; 181 case Type.Directory: 182 setEscapedValue("Type", "Directory"); 183 break; 184 case Type.Unknown: 185 this.removeEntry("Type"); 186 break; 187 } 188 return t; 189 } 190 191 /// 192 unittest 193 { 194 auto desktopFile = new DesktopFile(); 195 desktopFile.type = Type.Application; 196 assert(desktopFile.desktopEntry.escapedValue("Type") == "Application"); 197 desktopFile.type = Type.Link; 198 assert(desktopFile.desktopEntry.escapedValue("Type") == "Link"); 199 desktopFile.type = Type.Directory; 200 assert(desktopFile.desktopEntry.escapedValue("Type") == "Directory"); 201 202 desktopFile.type = Type.Unknown; 203 assert(desktopFile.desktopEntry.escapedValue("Type").empty); 204 } 205 206 /** 207 * Specific name of the application, for example "Qupzilla". 208 * Returns: The unescaped value associated with "Name" key. 209 * See_Also: $(D localizedDisplayName) 210 */ 211 @safe string displayName() const nothrow pure { 212 return unescapedValue("Name"); 213 } 214 215 /** 216 * Set "Name" to name escaping the value if needed. 217 */ 218 @safe string displayName(string name) { 219 return setUnescapedValue("Name", name); 220 } 221 222 /** 223 * Returns: Localized name. 224 * See_Also: $(D displayName) 225 */ 226 @safe string localizedDisplayName(string locale) const nothrow pure { 227 return unescapedValue("Name", locale); 228 } 229 230 /** 231 * Generic name of the application, for example "Web Browser". 232 * Returns: The unescaped value associated with "GenericName" key. 233 * See_Also: $(D localizedGenericName) 234 */ 235 @safe string genericName() const nothrow pure { 236 return unescapedValue("GenericName"); 237 } 238 239 /** 240 * Set "GenericName" to name escaping the value if needed. 241 */ 242 @safe string genericName(string name) { 243 return setUnescapedValue("GenericName", name); 244 } 245 /** 246 * Returns: Localized generic name 247 * See_Also: $(D genericName) 248 */ 249 @safe string localizedGenericName(string locale) const nothrow pure { 250 return unescapedValue("GenericName", locale); 251 } 252 253 /** 254 * Tooltip for the entry, for example "View sites on the Internet". 255 * Returns: The unescaped value associated with "Comment" key. 256 * See_Also: $(D localizedComment) 257 */ 258 @safe string comment() const nothrow pure { 259 return unescapedValue("Comment"); 260 } 261 262 /** 263 * Set "Comment" to commentary escaping the value if needed. 264 */ 265 @safe string comment(string commentary) { 266 return setUnescapedValue("Comment", commentary); 267 } 268 269 /** 270 * Returns: Localized comment 271 * See_Also: $(D comment) 272 */ 273 @safe string localizedComment(string locale) const nothrow pure { 274 return unescapedValue("Comment", locale); 275 } 276 277 /** 278 * Exec value of desktop file. 279 * Returns: The unescaped value associated with "Exec" key. 280 * See_Also: $(D expandExecValue), $(D startApplication), $(D tryExecValue) 281 */ 282 @safe string execValue() const nothrow pure { 283 return unescapedValue("Exec"); 284 } 285 286 /** 287 * Set "Exec" to exec escaping the value if needed. 288 * See_Also: $(D desktopfile.utils.ExecBuilder). 289 */ 290 @safe string execValue(string exec) { 291 return setUnescapedValue("Exec", exec); 292 } 293 294 /** 295 * URL to access. 296 * Returns: The unescaped value associated with "URL" key. 297 */ 298 @safe string url() const nothrow pure { 299 return unescapedValue("URL"); 300 } 301 302 /** 303 * Set "URL" to link escaping the value if needed. 304 */ 305 @safe string url(string link) { 306 return setUnescapedValue("URL", link); 307 } 308 309 /// 310 unittest 311 { 312 auto df = new DesktopFile(iniLikeStringReader("[Desktop Entry]\nType=Link\nURL=https://github.com/")); 313 assert(df.url() == "https://github.com/"); 314 } 315 316 /** 317 * Value used to determine if the program is actually installed. 318 * 319 * 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). 320 * Returns: The unescaped value associated with "TryExec" key. 321 * See_Also: $(D execValue) 322 */ 323 @safe string tryExecValue() const nothrow pure { 324 return unescapedValue("TryExec"); 325 } 326 327 /** 328 * Set TryExec value escaping it if needed. 329 * Throws: 330 * $(B IniLikeEntryException) if tryExec is not abolute path nor base name. 331 */ 332 @safe string tryExecValue(string tryExec) { 333 if (!tryExec.isAbsolute && tryExec.baseName != tryExec) { 334 throw new IniLikeEntryException("TryExec must be absolute path or base name", groupName(), "TryExec", tryExec); 335 } 336 return setUnescapedValue("TryExec", tryExec); 337 } 338 339 /// 340 unittest 341 { 342 auto df = new DesktopFile(); 343 assertNotThrown(df.tryExecValue = "base"); 344 version(Posix) { 345 assertNotThrown(df.tryExecValue = "/absolute/path"); 346 } 347 assertThrown(df.tryExecValue = "not/absolute"); 348 assertThrown(df.tryExecValue = "./relative"); 349 } 350 351 /** 352 * Icon to display in file manager, menus, etc. 353 * Returns: The unescaped value associated with "Icon" key. 354 * Note: This function returns Icon as it's defined in .desktop file. 355 * It does not provide any lookup of actual icon file on the system if the name if not an absolute path. 356 * 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/FreeSlave/icontheme, icontheme library). 357 */ 358 @safe string iconName() const nothrow pure { 359 return unescapedValue("Icon"); 360 } 361 362 /** 363 * Set Icon value. 364 * Throws: 365 * $(B IniLikeEntryException) if icon is not abolute path nor base name. 366 */ 367 @safe string iconName(string icon) { 368 if (!icon.isAbsolute && icon.baseName != icon) { 369 throw new IniLikeEntryException("Icon must be absolute path or base name", groupName(), "Icon", icon); 370 } 371 return setUnescapedValue("Icon", icon); 372 } 373 374 /// 375 unittest 376 { 377 auto df = new DesktopFile(); 378 assertNotThrown(df.iconName = "base"); 379 version(Posix) { 380 assertNotThrown(df.iconName = "/absolute/path"); 381 } 382 assertThrown(df.iconName = "not/absolute"); 383 assertThrown(df.iconName = "./relative"); 384 } 385 386 /** 387 * Returns: Localized icon name 388 * See_Also: $(D iconName) 389 */ 390 @safe string localizedIconName(string locale) const nothrow pure { 391 return unescapedValue("Icon", locale); 392 } 393 394 /** 395 * NoDisplay means "this application exists, but don't display it in the menus". 396 * Returns: The value associated with "NoDisplay" key converted to bool using $(D inilike.common.isTrue). 397 */ 398 @nogc @safe bool noDisplay() const nothrow pure { 399 return isTrue(escapedValue("NoDisplay")); 400 } 401 402 ///setter 403 @safe bool noDisplay(bool notDisplay) { 404 setEscapedValue("NoDisplay", boolToString(notDisplay)); 405 return notDisplay; 406 } 407 408 /** 409 * Hidden means the user deleted (at his level) something that was present (at an upper level, e.g. in the system dirs). 410 * It's strictly equivalent to the .desktop file not existing at all, as far as that user is concerned. 411 * Returns: The value associated with "Hidden" key converted to bool using $(D inilike.common.isTrue). 412 */ 413 @nogc @safe bool hidden() const nothrow pure { 414 return isTrue(escapedValue("Hidden")); 415 } 416 417 ///setter 418 @safe bool hidden(bool hide) { 419 setEscapedValue("Hidden", boolToString(hide)); 420 return hide; 421 } 422 423 /** 424 * A boolean value specifying if D-Bus activation is supported for this application. 425 * Returns: The value associated with "DBusActivable" key converted to bool using $(D inilike.common.isTrue). 426 */ 427 @nogc @safe bool dbusActivable() const nothrow pure { 428 return isTrue(escapedValue("DBusActivatable")); 429 } 430 431 ///setter 432 @safe bool dbusActivable(bool activable) { 433 setEscapedValue("DBusActivatable", boolToString(activable)); 434 return activable; 435 } 436 437 /** 438 * A boolean value specifying if an application uses Startup Notification Protocol. 439 * Returns: The value associated with "StartupNotify" key converted to bool using $(D inilike.common.isTrue). 440 */ 441 @nogc @safe bool startupNotify() const nothrow pure { 442 return isTrue(escapedValue("StartupNotify")); 443 } 444 445 ///setter 446 @safe bool startupNotify(bool notify) { 447 setEscapedValue("StartupNotify", boolToString(notify)); 448 return notify; 449 } 450 451 /** 452 * The working directory to run the program in. 453 * Returns: The unescaped value associated with "Path" key. 454 */ 455 @safe string workingDirectory() const nothrow pure { 456 return unescapedValue("Path"); 457 } 458 459 /** 460 * Set Path value. 461 * Throws: 462 * $(D IniLikeEntryException) if wd is not valid path or wd is not abolute path. 463 */ 464 @safe string workingDirectory(string wd) { 465 if (!wd.isValidPath) { 466 throw new IniLikeEntryException("Working directory must be valid path", groupName(), "Path", wd); 467 } 468 version(Posix) { 469 if (!wd.isAbsolute) { 470 throw new IniLikeEntryException("Working directory must be absolute path", groupName(), "Path", wd); 471 } 472 } 473 return setUnescapedValue("Path", wd); 474 } 475 476 /// 477 unittest 478 { 479 auto df = new DesktopFile(); 480 version(Posix) { 481 assertNotThrown(df.workingDirectory = "/valid"); 482 assertThrown(df.workingDirectory = "not absolute"); 483 } 484 assertThrown(df.workingDirectory = "/foo\0/bar"); 485 } 486 487 /** 488 * Whether the program runs in a terminal window. 489 * Returns: The value associated with "Terminal" key converted to bool using $(D inilike.common.isTrue). 490 */ 491 @nogc @safe bool terminal() const nothrow pure { 492 return isTrue(escapedValue("Terminal")); 493 } 494 ///setter 495 @safe bool terminal(bool t) { 496 setEscapedValue("Terminal", boolToString(t)); 497 return t; 498 } 499 500 /** 501 * Categories this program belongs to. 502 * Returns: The range of multiple values associated with "Categories" key. 503 */ 504 @safe auto categories() const nothrow pure { 505 return DesktopFile.splitValues(unescapedValue("Categories")); 506 } 507 508 /** 509 * Sets the list of values for the "Categories" list. 510 */ 511 string categories(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 512 return setUnescapedValue("Categories", DesktopFile.joinValues(values)); 513 } 514 515 /** 516 * A list of strings which may be used in addition to other metadata to describe this entry. 517 * Returns: The range of multiple values associated with "Keywords" key. 518 */ 519 @safe auto keywords() const nothrow pure { 520 return DesktopFile.splitValues(unescapedValue("Keywords")); 521 } 522 523 /** 524 * A list of localied strings which may be used in addition to other metadata to describe this entry. 525 * Returns: The range of multiple values associated with "Keywords" key in given locale. 526 */ 527 @safe auto localizedKeywords(string locale) const nothrow pure { 528 return DesktopFile.splitValues(unescapedValue("Keywords", locale)); 529 } 530 531 /** 532 * Sets the list of values for the "Keywords" list. 533 */ 534 string keywords(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 535 return setUnescapedValue("Keywords", DesktopFile.joinValues(values)); 536 } 537 538 /** 539 * The MIME type(s) supported by this application. 540 * Returns: The range of multiple values associated with "MimeType" key. 541 */ 542 @safe auto mimeTypes() nothrow const pure { 543 return DesktopFile.splitValues(unescapedValue("MimeType")); 544 } 545 546 /** 547 * Sets the list of values for the "MimeType" list. 548 */ 549 string mimeTypes(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 550 return setUnescapedValue("MimeType", DesktopFile.joinValues(values)); 551 } 552 553 /** 554 * Actions supported by application. 555 * Returns: Range of multiple values associated with "Actions" key. 556 * Note: This only depends on "Actions" value, not on actually presented sections in desktop file. 557 * See_Also: $(D byAction), $(D action) 558 */ 559 @safe auto actions() nothrow const pure { 560 return DesktopFile.splitValues(unescapedValue("Actions")); 561 } 562 563 /** 564 * Sets the list of values for "Actions" list. 565 */ 566 string actions(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 567 return setUnescapedValue("Actions", DesktopFile.joinValues(values)); 568 } 569 570 /** 571 * A list of strings identifying the desktop environments that should display a given desktop entry. 572 * Returns: The range of multiple values associated with "OnlyShowIn" key. 573 * See_Also: $(D notShowIn), $(D showIn) 574 */ 575 @safe auto onlyShowIn() nothrow const pure { 576 return DesktopFile.splitValues(unescapedValue("OnlyShowIn")); 577 } 578 579 ///setter 580 string onlyShowIn(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 581 return setUnescapedValue("OnlyShowIn", DesktopFile.joinValues(values)); 582 } 583 584 /** 585 * A list of strings identifying the desktop environments that should not display a given desktop entry. 586 * Returns: The range of multiple values associated with "NotShowIn" key. 587 * See_Also: $(D onlyShowIn), $(D showIn) 588 */ 589 @safe auto notShowIn() nothrow const pure { 590 return DesktopFile.splitValues(unescapedValue("NotShowIn")); 591 } 592 593 ///setter 594 string notShowIn(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 595 return setUnescapedValue("NotShowIn", DesktopFile.joinValues(values)); 596 } 597 598 /** 599 * Check if desktop file should be shown in menu of specific desktop environment. 600 * Params: 601 * desktopEnvironment = Name of desktop environment, usually detected by $(B XDG_CURRENT_DESKTOP) variable. 602 * See_Also: $(LINK2 https://specifications.freedesktop.org/menu-spec/latest/apb.html, Registered OnlyShowIn Environments), $(D notShowIn), $(D onlyShowIn) 603 */ 604 @trusted bool showIn(string desktopEnvironment) 605 { 606 if (notShowIn().canFind(desktopEnvironment)) { 607 return false; 608 } 609 auto onlyIn = onlyShowIn(); 610 return onlyIn.empty || onlyIn.canFind(desktopEnvironment); 611 } 612 613 /// 614 unittest 615 { 616 auto df = new DesktopFile(); 617 df.notShowIn = ["GNOME", "MATE"]; 618 assert(df.showIn("KDE")); 619 assert(df.showIn("awesome")); 620 assert(df.showIn("")); 621 assert(!df.showIn("GNOME")); 622 df.onlyShowIn = ["LXDE", "XFCE"]; 623 assert(df.showIn("LXDE")); 624 assert(df.showIn("XFCE")); 625 assert(!df.showIn("")); 626 assert(!df.showIn("awesome")); 627 assert(!df.showIn("KDE")); 628 assert(!df.showIn("MATE")); 629 } 630 631 protected: 632 @trusted override void validateKey(string key, string value) const { 633 validateDesktopKeyImpl(groupName(), key, value); 634 } 635 } 636 637 /** 638 * Represents .desktop file. 639 */ 640 final class DesktopFile : IniLikeFile 641 { 642 public: 643 /** 644 * Alias for backward compatibility. 645 */ 646 alias DesktopEntry.Type Type; 647 648 /** 649 * Policy about reading Desktop Action groups. 650 */ 651 enum ActionGroupPolicy : ubyte { 652 skip, ///Don't save Desktop Action groups. 653 preserve ///Save Desktop Action groups. 654 } 655 656 /** 657 * Policy about reading extension groups (those start with 'X-'). 658 */ 659 enum ExtensionGroupPolicy : ubyte { 660 skip, ///Don't save extension groups. 661 preserve ///Save extension groups. 662 } 663 664 /** 665 * Policy about reading groups with names which meaning is unknown, i.e. it's not extension nor Desktop Action. 666 */ 667 enum UnknownGroupPolicy : ubyte { 668 skip, ///Don't save unknown groups. 669 preserve, ///Save unknown groups. 670 throwError ///Throw error when unknown group is encountered. 671 } 672 673 ///Options to manage desktop file reading 674 static struct DesktopReadOptions 675 { 676 ///Base $(D inilike.file.IniLikeFile.ReadOptions). 677 IniLikeFile.ReadOptions baseOptions; 678 679 alias baseOptions this; 680 681 /** 682 * Set policy about unknown groups. By default they are skipped without errors. 683 * Note that all groups still need to be preserved if desktop file must be rewritten. 684 */ 685 UnknownGroupPolicy unknownGroupPolicy = UnknownGroupPolicy.skip; 686 687 /** 688 * Set policy about extension groups. By default they are all preserved. 689 * Set it to skip if you're not willing to support any extensions in your applications. 690 * Note that all groups still need to be preserved if desktop file must be rewritten. 691 */ 692 ExtensionGroupPolicy extensionGroupPolicy = ExtensionGroupPolicy.preserve; 693 694 /** 695 * Set policy about desktop action groups. By default they are all preserved. 696 * Note that all groups still need to be preserved if desktop file must be rewritten. 697 */ 698 ActionGroupPolicy actionGroupPolicy = ActionGroupPolicy.preserve; 699 700 ///Setting parameters in any order, leaving not mentioned ones in default state. 701 @nogc @safe this(Args...)(Args args) nothrow pure { 702 foreach(arg; args) { 703 alias Unqual!(typeof(arg)) ArgType; 704 static if (is(ArgType == IniLikeFile.ReadOptions)) { 705 baseOptions = arg; 706 } else static if (is(ArgType == UnknownGroupPolicy)) { 707 unknownGroupPolicy = arg; 708 } else static if (is(ArgType == ExtensionGroupPolicy)) { 709 extensionGroupPolicy = arg; 710 } else static if (is(ArgType == ActionGroupPolicy)) { 711 actionGroupPolicy = arg; 712 } else { 713 baseOptions.assign(arg); 714 } 715 } 716 } 717 718 /// 719 unittest 720 { 721 DesktopReadOptions options; 722 723 options = DesktopReadOptions( 724 ExtensionGroupPolicy.skip, 725 UnknownGroupPolicy.preserve, 726 ActionGroupPolicy.skip, 727 DuplicateKeyPolicy.skip, 728 DuplicateGroupPolicy.preserve, 729 No.preserveComments 730 ); 731 assert(options.unknownGroupPolicy == UnknownGroupPolicy.preserve); 732 assert(options.actionGroupPolicy == ActionGroupPolicy.skip); 733 assert(options.extensionGroupPolicy == ExtensionGroupPolicy.skip); 734 assert(options.duplicateGroupPolicy == DuplicateGroupPolicy.preserve); 735 assert(options.duplicateKeyPolicy == DuplicateKeyPolicy.skip); 736 assert(!options.preserveComments); 737 } 738 } 739 740 /// 741 unittest 742 { 743 string contents = 744 `[Desktop Entry] 745 Key=Value 746 Actions=Action1; 747 [Desktop Action Action1] 748 Name=Action1 Name 749 Key=Value`; 750 751 alias DesktopFile.DesktopReadOptions DesktopReadOptions; 752 753 auto df = new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(ActionGroupPolicy.skip)); 754 assert(df.action("Action1") is null); 755 756 contents = 757 `[Desktop Entry] 758 Key=Value 759 Actions=Action1; 760 [X-SomeGroup] 761 Key=Value`; 762 763 df = new DesktopFile(iniLikeStringReader(contents)); 764 assert(df.group("X-SomeGroup") !is null); 765 766 df = new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(ExtensionGroupPolicy.skip)); 767 assert(df.group("X-SomeGroup") is null); 768 769 contents = 770 `[Desktop Entry] 771 Valid=Key 772 $=Invalid`; 773 774 auto thrown = collectException!IniLikeReadException(new DesktopFile(iniLikeStringReader(contents))); 775 assert(thrown !is null); 776 assert(thrown.entryException !is null); 777 assert(thrown.entryException.key == "$"); 778 assert(thrown.entryException.value == "Invalid"); 779 780 assertNotThrown(new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(IniLikeGroup.InvalidKeyPolicy.skip))); 781 782 df = new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(IniLikeGroup.InvalidKeyPolicy.save)); 783 assert(df.desktopEntry.escapedValue("$") == "Invalid"); 784 785 contents = 786 `[Desktop Entry] 787 Name=Name 788 [Unknown] 789 Key=Value`; 790 791 assertThrown(new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(UnknownGroupPolicy.throwError))); 792 793 assertNotThrown(df = new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(UnknownGroupPolicy.preserve))); 794 assert(df.group("Unknown") !is null); 795 796 df = new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(UnknownGroupPolicy.skip)); 797 assert(df.group("Unknown") is null); 798 799 contents = 800 `[Desktop Entry] 801 Name=One 802 [Desktop Entry] 803 Name=Two`; 804 805 df = new DesktopFile(iniLikeStringReader(contents), DesktopReadOptions(DuplicateGroupPolicy.preserve)); 806 assert(df.displayName() == "One"); 807 assert(df.byGroup().map!(g => g.escapedValue("Name")).equal(["One", "Two"])); 808 } 809 810 protected: 811 ///Check if groupName is name of Desktop Action group. 812 @trusted static bool isActionName(string groupName) 813 { 814 return groupName.startsWith("Desktop Action "); 815 } 816 817 @trusted override IniLikeGroup createGroupByName(string groupName) { 818 if (groupName == "Desktop Entry") { 819 return new DesktopEntry(); 820 } else if (groupName.startsWith("X-")) { 821 if (_options.extensionGroupPolicy == ExtensionGroupPolicy.skip) { 822 return null; 823 } else { 824 return createEmptyGroup(groupName); 825 } 826 } else if (isActionName(groupName)) { 827 if (_options.actionGroupPolicy == ActionGroupPolicy.skip) { 828 return null; 829 } else { 830 return new DesktopAction(groupName); 831 } 832 } else { 833 final switch(_options.unknownGroupPolicy) { 834 case UnknownGroupPolicy.skip: 835 return null; 836 case UnknownGroupPolicy.preserve: 837 return createEmptyGroup(groupName); 838 case UnknownGroupPolicy.throwError: 839 throw new IniLikeException("Invalid group name: '" ~ groupName ~ "'. Must start with 'Desktop Action ' or 'X-'"); 840 } 841 } 842 } 843 844 public: 845 /** 846 * Reads desktop file from file. 847 * Throws: 848 * $(B ErrnoException) if file could not be opened. 849 * $(D inilike.file.IniLikeReadException) if error occured while reading the file or "Desktop Entry" group is missing. 850 */ 851 @trusted this(string fileName, DesktopReadOptions options = DesktopReadOptions.init) { 852 this(iniLikeFileReader(fileName), options, fileName); 853 } 854 855 /** 856 * Reads desktop file from IniLikeReader, e.g. acquired from iniLikeFileReader or iniLikeStringReader. 857 * Throws: 858 * $(D inilike.file.IniLikeReadException) if error occured while parsing or "Desktop Entry" group is missing. 859 */ 860 this(IniLikeReader)(IniLikeReader reader, DesktopReadOptions options = DesktopReadOptions.init, string fileName = null) 861 { 862 _options = options; 863 super(reader, fileName, options.baseOptions); 864 _desktopEntry = cast(DesktopEntry)group("Desktop Entry"); 865 enforce(_desktopEntry !is null, new IniLikeReadException("No \"Desktop Entry\" group", 0)); 866 } 867 868 /** 869 * Reads desktop file from IniLikeReader, e.g. acquired from iniLikeFileReader or iniLikeStringReader. 870 * Throws: 871 * $(D inilike.file.IniLikeReadException) if error occured while parsing or "Desktop Entry" group is missing. 872 */ 873 this(IniLikeReader)(IniLikeReader reader, string fileName, DesktopReadOptions options = DesktopReadOptions.init) 874 { 875 this(reader, options, fileName); 876 } 877 878 /** 879 * Constructs DesktopFile with "Desktop Entry" group and Version set to 1.1 880 */ 881 @safe this() { 882 super(); 883 _desktopEntry = new DesktopEntry(); 884 insertGroup(_desktopEntry); 885 _desktopEntry.setEscapedValue("Version", "1.1"); 886 } 887 888 /// 889 unittest 890 { 891 auto df = new DesktopFile(); 892 assert(df.desktopEntry()); 893 assert(df.desktopEntry().escapedValue("Version") == "1.1"); 894 assert(df.categories().empty); 895 assert(df.type() == DesktopFile.Type.Unknown); 896 } 897 898 /** 899 * Removes group by name. You can't remove "Desktop Entry" group with this function. 900 */ 901 @safe override bool removeGroup(string groupName) nothrow { 902 if (groupName == "Desktop Entry") { 903 return false; 904 } 905 return super.removeGroup(groupName); 906 } 907 908 /// 909 unittest 910 { 911 auto df = new DesktopFile(); 912 df.addGenericGroup("X-Action"); 913 assert(df.group("X-Action") !is null); 914 df.removeGroup("X-Action"); 915 assert(df.group("X-Action") is null); 916 df.removeGroup("Desktop Entry"); 917 assert(df.desktopEntry() !is null); 918 } 919 920 /** 921 * Type of desktop entry. 922 * Returns: Type of desktop entry. 923 * See_Also: $(D DesktopEntry.type) 924 */ 925 @nogc @safe Type type() const nothrow { 926 auto t = desktopEntry().type(); 927 if (t == Type.Unknown && fileName().endsWith(".directory")) { 928 return Type.Directory; 929 } 930 return t; 931 } 932 933 @safe Type type(Type t) { 934 return desktopEntry().type(t); 935 } 936 937 /// 938 unittest 939 { 940 auto desktopFile = new DesktopFile(iniLikeStringReader("[Desktop Entry]"), ".directory"); 941 assert(desktopFile.type == DesktopFile.Type.Directory); 942 } 943 944 static if (isFreedesktop) { 945 /** 946 * See $(LINK2 https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s02.html#desktop-file-id, Desktop File ID) 947 * Returns: Desktop file ID or empty string if file does not have an ID. 948 * Note: This function retrieves applications paths each time it's called and therefore can impact performance. To avoid this issue use overload with argument. 949 * See_Also: $(D desktopfile.paths.applicationsPaths), $(D desktopfile.utils.desktopId) 950 */ 951 @safe string id() const nothrow { 952 return desktopId(fileName); 953 } 954 955 /// 956 unittest 957 { 958 import desktopfile.paths; 959 960 string contents = "[Desktop Entry]\nType=Directory"; 961 auto df = new DesktopFile(iniLikeStringReader(contents), "/home/user/data/applications/test/example.desktop"); 962 auto dataHomeGuard = EnvGuard("XDG_DATA_HOME", "/home/user/data"); 963 assert(df.id() == "test-example.desktop"); 964 } 965 } 966 967 /** 968 * See $(LINK2 https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s02.html#desktop-file-id, Desktop File ID) 969 * Params: 970 * appPaths = range of base application paths to check if this file belongs to one of them. 971 * Returns: Desktop file ID or empty string if file does not have an ID. 972 * See_Also: $(D desktopfile.paths.applicationsPaths), $(D desktopfile.utils.desktopId) 973 */ 974 string id(Range)(Range appPaths) const nothrow if (isInputRange!Range && is(ElementType!Range : string)) 975 { 976 return desktopId(fileName, appPaths); 977 } 978 979 /// 980 unittest 981 { 982 string contents = 983 `[Desktop Entry] 984 Name=Program 985 Type=Directory`; 986 987 string[] appPaths; 988 string filePath, nestedFilePath, wrongFilePath; 989 990 version(Windows) { 991 appPaths = [`C:\ProgramData\KDE\share\applications`, `C:\Users\username\.kde\share\applications`]; 992 filePath = `C:\ProgramData\KDE\share\applications\example.desktop`; 993 nestedFilePath = `C:\ProgramData\KDE\share\applications\kde\example.desktop`; 994 wrongFilePath = `C:\ProgramData\desktop\example.desktop`; 995 } else { 996 appPaths = ["/usr/share/applications", "/usr/local/share/applications"]; 997 filePath = "/usr/share/applications/example.desktop"; 998 nestedFilePath = "/usr/share/applications/kde/example.desktop"; 999 wrongFilePath = "/etc/desktop/example.desktop"; 1000 } 1001 1002 auto df = new DesktopFile(iniLikeStringReader(contents), nestedFilePath); 1003 assert(df.id(appPaths) == "kde-example.desktop"); 1004 1005 df = new DesktopFile(iniLikeStringReader(contents), filePath); 1006 assert(df.id(appPaths) == "example.desktop"); 1007 1008 df = new DesktopFile(iniLikeStringReader(contents), wrongFilePath); 1009 assert(df.id(appPaths).empty); 1010 1011 df = new DesktopFile(iniLikeStringReader(contents)); 1012 assert(df.id(appPaths).empty); 1013 } 1014 1015 private static struct SplitValues 1016 { 1017 @trusted this(string value) nothrow pure { 1018 _value = value; 1019 next(); 1020 } 1021 @nogc @trusted string front() const nothrow pure { 1022 return _current; 1023 } 1024 @trusted void popFront() nothrow pure { 1025 next(); 1026 } 1027 @nogc @trusted bool empty() const nothrow pure { 1028 return _value.empty && _current.empty; 1029 } 1030 @nogc @trusted @property auto save() const nothrow pure { 1031 SplitValues values; 1032 values._value = _value; 1033 values._current = _current; 1034 return values; 1035 } 1036 private: 1037 void next() nothrow pure { 1038 size_t i=0; 1039 for (; i<_value.length && ( (_value[i] != ';') || (i && _value[i-1] == '\\' && _value[i] == ';')); ++i) { 1040 //pass 1041 } 1042 _current = _value[0..i].replace("\\;", ";"); 1043 _value = i == _value.length ? _value[_value.length..$] : _value[i+1..$]; 1044 } 1045 string _value; 1046 string _current; 1047 } 1048 1049 static assert(isForwardRange!SplitValues); 1050 1051 /** 1052 * Some keys can have multiple values, separated by semicolon. This function helps to parse such kind of strings into the range. 1053 * Returns: The range of multiple nonempty values. 1054 * Note: Returned range unescapes ';' character automatically. 1055 * See_Also: $(D joinValues) 1056 */ 1057 @trusted static auto splitValues(string values) nothrow pure { 1058 return SplitValues(values).filter!(s => !s.empty); 1059 } 1060 1061 /// 1062 unittest 1063 { 1064 assert(DesktopFile.splitValues("").empty); 1065 assert(DesktopFile.splitValues(";").empty); 1066 assert(DesktopFile.splitValues(";;;").empty); 1067 assert(equal(DesktopFile.splitValues("Application;Utility;FileManager;"), ["Application", "Utility", "FileManager"])); 1068 assert(equal(DesktopFile.splitValues("I\\;Me;\\;You\\;We\\;"), ["I;Me", ";You;We;"])); 1069 1070 auto values = DesktopFile.splitValues("Application;Utility;FileManager;"); 1071 assert(values.front == "Application"); 1072 values.popFront(); 1073 assert(equal(values, ["Utility", "FileManager"])); 1074 auto saved = values.save; 1075 values.popFront(); 1076 assert(equal(values, ["FileManager"])); 1077 assert(equal(saved, ["Utility", "FileManager"])); 1078 } 1079 1080 /** 1081 * Join range of multiple values into a string using semicolon as separator. Adds trailing semicolon. 1082 * Returns: Values of range joined into one string with ';' after each value or empty string if range is empty. 1083 * Note: If some value of range contains ';' character it's automatically escaped. 1084 * See_Also: $(D splitValues) 1085 */ 1086 static string joinValues(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 1087 auto result = values.filter!( s => !s.empty ).map!( s => s.replace(";", "\\;")).joiner(";"); 1088 if (result.empty) { 1089 return null; 1090 } else { 1091 return text(result) ~ ";"; 1092 } 1093 } 1094 1095 /// 1096 unittest 1097 { 1098 assert(DesktopFile.joinValues([""]).empty); 1099 assert(equal(DesktopFile.joinValues(["Application", "Utility", "FileManager"]), "Application;Utility;FileManager;")); 1100 assert(equal(DesktopFile.joinValues(["I;Me", ";You;We;"]), "I\\;Me;\\;You\\;We\\;;")); 1101 } 1102 1103 /** 1104 * Get $(LINK2 https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s11.html, additional application action) by name. 1105 * Returns: $(D DesktopAction) with given action name or null if not found or found section does not have a name. 1106 * See_Also: $(D actions), $(D byAction) 1107 */ 1108 @trusted inout(DesktopAction) action(string actionName) inout { 1109 if (actions().canFind(actionName)) { 1110 auto desktopAction = cast(typeof(return))group("Desktop Action "~actionName); 1111 if (desktopAction !is null && desktopAction.displayName().length != 0) { 1112 return desktopAction; 1113 } 1114 } 1115 return null; 1116 } 1117 1118 /** 1119 * Iterating over existing actions. 1120 * Returns: Range of DesktopAction. 1121 * See_Also: $(D actions), $(D action) 1122 */ 1123 @safe auto byAction() const { 1124 return actions().map!(actionName => action(actionName)).filter!(desktopAction => desktopAction !is null); 1125 } 1126 1127 /** 1128 * Returns: instance of "Desktop Entry" group. 1129 * Note: Usually you don't need to call this function since you can rely on alias this. 1130 */ 1131 @nogc @safe inout(DesktopEntry) desktopEntry() nothrow inout pure { 1132 return _desktopEntry; 1133 } 1134 1135 /** 1136 * This alias allows to call functions related to "Desktop Entry" group without need to call desktopEntry explicitly. 1137 */ 1138 alias desktopEntry this; 1139 1140 unittest 1141 { 1142 // Making sure rebindable const is possible 1143 import std.typecons : rebindable; 1144 const d = new DesktopFile(); 1145 auto r = d.rebindable; 1146 } 1147 1148 /** 1149 * Expand "Exec" value into the array of command line arguments to use to start the program. 1150 * It applies unquoting and unescaping. 1151 * See_Also: $(D execValue), $(D desktopfile.utils.expandExecArgs), $(D startApplication) 1152 */ 1153 @safe string[] expandExecValue(scope const(string)[] urls = null, string locale = null) const 1154 { 1155 return expandExecArgs(unquoteExec(execValue()), urls, localizedIconName(locale), localizedDisplayName(locale), fileName()); 1156 } 1157 1158 /// 1159 unittest 1160 { 1161 string contents = 1162 `[Desktop Entry] 1163 Name=Program 1164 Name[ru]=Программа 1165 Exec="quoted program" %i -w %c -f %k %U %D %u %f %F 1166 Icon=folder 1167 Icon[ru]=folder_ru`; 1168 auto df = new DesktopFile(iniLikeStringReader(contents), "/example.desktop"); 1169 assert(df.expandExecValue(["one", "two"], "ru") == 1170 ["quoted program", "--icon", "folder_ru", "-w", "Программа", "-f", "/example.desktop", "one", "two", "one", "one", "one", "two"]); 1171 } 1172 1173 /** 1174 * Starts the application associated with this .desktop file using urls as command line params. 1175 * 1176 * If the program should be run in terminal it tries to find system defined terminal emulator to run in. 1177 * Params: 1178 * urls = urls application will start with. 1179 * locale = locale that may be needed to be placed in params if Exec value has %c code. 1180 * terminalCommand = preferable terminal emulator command. If not set then terminal is determined via $(D desktopfile.utils.getTerminalCommand). 1181 * Note: 1182 * This function does not check if the type of desktop file is Application. It relies only on "Exec" value. 1183 * Throws: 1184 * $(B ProcessException) on failure to start the process. 1185 * $(D desktopfile.utils.DesktopExecException) if exec string is invalid. 1186 * See_Also: $(D desktopfile.utils.spawnApplication), $(D desktopfile.utils.getTerminalCommand), $(D start), $(D expandExecValue) 1187 */ 1188 @trusted void startApplication(scope const(string)[] urls = null, string locale = null, lazy const(string)[] terminalCommand = getTerminalCommand) const 1189 { 1190 auto unquotedArgs = unquoteExec(execValue()); 1191 1192 SpawnParams params; 1193 params.urls = urls; 1194 params.iconName = localizedIconName(locale); 1195 params.displayName = localizedDisplayName(locale); 1196 params.fileName = fileName; 1197 params.workingDirectory = workingDirectory(); 1198 1199 if (terminal()) { 1200 params.terminalCommand = terminalCommand(); 1201 } 1202 1203 return spawnApplication(unquotedArgs, params); 1204 } 1205 1206 /// 1207 unittest 1208 { 1209 auto df = new DesktopFile(); 1210 assertThrown(df.startApplication(string[].init)); 1211 1212 version(Posix) { 1213 static string[] emptyTerminalCommand() nothrow { 1214 return null; 1215 } 1216 1217 df = new DesktopFile(iniLikeStringReader("[Desktop Entry]\nTerminal=true\nType=Application\nExec=whoami")); 1218 try { 1219 df.startApplication((string[]).init, null, emptyTerminalCommand); 1220 } catch(Exception e) { 1221 1222 } 1223 } 1224 } 1225 1226 ///Starts the application associated with this .desktop file using url as command line params. 1227 @trusted void startApplication(string url, string locale = null, lazy const(string)[] terminalCommand = getTerminalCommand) const { 1228 return startApplication([url], locale, terminalCommand); 1229 } 1230 1231 /** 1232 * Opens url defined in .desktop file using $(LINK2 https://portland.freedesktop.org/doc/xdg-open.html, xdg-open). 1233 * Note: 1234 * This function does not check if the type of desktop file is Link. It relies only on "URL" value. 1235 * Throws: 1236 * $(B ProcessException) on failure to start the process. 1237 * $(B Exception) if desktop file does not define URL or it's empty. 1238 * See_Also: $(D start) 1239 */ 1240 @trusted void startLink() const { 1241 string myurl = url(); 1242 enforce(myurl.length, "No URL to open"); 1243 xdgOpen(myurl); 1244 } 1245 1246 /// 1247 unittest 1248 { 1249 auto df = new DesktopFile(); 1250 assertThrown(df.startLink()); 1251 } 1252 1253 /** 1254 * Starts application or open link depending on desktop entry type. 1255 * Throws: 1256 * $(B ProcessException) on failure to start the process. 1257 * $(D desktopfile.utils.DesktopExecException) if type is $(D DesktopEntry.Type.Application) and the exec string is invalid. 1258 * $(B Exception) if type is $(D DesktopEntry.Type.Unknown) or $(D DesktopEntry.Type.Directory), 1259 * or if type is $(D DesktopEntry.Type.Link), but no url provided. 1260 * See_Also: $(D startApplication), $(D startLink) 1261 */ 1262 @trusted void start() const 1263 { 1264 final switch(type()) { 1265 case DesktopFile.Type.Application: 1266 startApplication(); 1267 return; 1268 case DesktopFile.Type.Link: 1269 startLink(); 1270 return; 1271 case DesktopFile.Type.Directory: 1272 throw new Exception("Don't know how to start Directory"); 1273 case DesktopFile.Type.Unknown: 1274 throw new Exception("Unknown desktop entry type"); 1275 } 1276 } 1277 1278 /// 1279 unittest 1280 { 1281 string contents = "[Desktop Entry]\nType=Directory"; 1282 auto df = new DesktopFile(iniLikeStringReader(contents)); 1283 assertThrown(df.start()); 1284 1285 df = new DesktopFile(); 1286 assertThrown(df.start()); 1287 } 1288 1289 private: 1290 DesktopEntry _desktopEntry; 1291 DesktopReadOptions _options; 1292 } 1293 1294 /// 1295 unittest 1296 { 1297 import std.file; 1298 string desktopFileContents = 1299 `[Desktop Entry] 1300 # Comment 1301 Name=Double Commander 1302 Name[ru]=Двухпанельный коммандер 1303 GenericName=File manager 1304 GenericName[ru]=Файловый менеджер 1305 Comment=Double Commander is a cross platform open source file manager with two panels side by side. 1306 Comment[ru]=Double Commander - кроссплатформенный файловый менеджер. 1307 Terminal=false 1308 Icon=doublecmd 1309 Icon[ru]=doublecmd_ru 1310 Exec=doublecmd %f 1311 TryExec=doublecmd 1312 Type=Application 1313 Categories=Application;Utility;FileManager; 1314 Keywords=folder;manager;disk;filesystem;operations; 1315 Keywords[ru]=папка;директория;диск;файловый;менеджер; 1316 Actions=OpenDirectory;NotPresented;Settings;X-NoName; 1317 MimeType=inode/directory;application/x-directory; 1318 NoDisplay=false 1319 Hidden=false 1320 StartupNotify=true 1321 DBusActivatable=true 1322 Path=/opt/doublecmd 1323 OnlyShowIn=GNOME;XFCE;LXDE; 1324 NotShowIn=KDE; 1325 1326 [Desktop Action OpenDirectory] 1327 Name=Open directory 1328 Name[ru]=Открыть папку 1329 Icon=open 1330 Exec=doublecmd %u 1331 1332 [X-NoName] 1333 Icon=folder 1334 1335 [Desktop Action Settings] 1336 Name=Settings 1337 Name[ru]=Настройки 1338 Icon=edit 1339 Exec=doublecmd settings 1340 1341 [Desktop Action Notspecified] 1342 Name=Notspecified Action`; 1343 1344 auto df = new DesktopFile(iniLikeStringReader(desktopFileContents), "doublecmd.desktop"); 1345 assert(df.desktopEntry().groupName() == "Desktop Entry"); 1346 assert(df.fileName() == "doublecmd.desktop"); 1347 assert(df.displayName() == "Double Commander"); 1348 assert(df.localizedDisplayName("ru_RU") == "Двухпанельный коммандер"); 1349 assert(df.genericName() == "File manager"); 1350 assert(df.localizedGenericName("ru_RU") == "Файловый менеджер"); 1351 assert(df.comment() == "Double Commander is a cross platform open source file manager with two panels side by side."); 1352 assert(df.localizedComment("ru_RU") == "Double Commander - кроссплатформенный файловый менеджер."); 1353 assert(df.iconName() == "doublecmd"); 1354 assert(df.localizedIconName("ru_RU") == "doublecmd_ru"); 1355 assert(df.tryExecValue() == "doublecmd"); 1356 assert(!df.terminal()); 1357 assert(!df.noDisplay()); 1358 assert(!df.hidden()); 1359 assert(df.startupNotify()); 1360 assert(df.dbusActivable()); 1361 assert(df.workingDirectory() == "/opt/doublecmd"); 1362 assert(df.type() == DesktopFile.Type.Application); 1363 assert(equal(df.keywords(), ["folder", "manager", "disk", "filesystem", "operations"])); 1364 assert(equal(df.localizedKeywords("ru_RU"), ["папка", "директория", "диск", "файловый", "менеджер"])); 1365 assert(equal(df.categories(), ["Application", "Utility", "FileManager"])); 1366 assert(equal(df.actions(), ["OpenDirectory", "NotPresented", "Settings", "X-NoName"])); 1367 assert(equal(df.mimeTypes(), ["inode/directory", "application/x-directory"])); 1368 assert(equal(df.onlyShowIn(), ["GNOME", "XFCE", "LXDE"])); 1369 assert(equal(df.notShowIn(), ["KDE"])); 1370 assert(df.group("X-NoName") !is null); 1371 1372 assert(equal(df.byAction().map!(desktopAction => 1373 tuple(desktopAction.displayName(), desktopAction.localizedDisplayName("ru"), desktopAction.iconName(), desktopAction.execValue())), 1374 [tuple("Open directory", "Открыть папку", "open", "doublecmd %u"), tuple("Settings", "Настройки", "edit", "doublecmd settings")])); 1375 1376 DesktopAction desktopAction = df.action("OpenDirectory"); 1377 assert(desktopAction !is null); 1378 assert(desktopAction.expandExecValue(["path/to/file"]) == ["doublecmd", "path/to/file"]); 1379 1380 assert(df.action("NotPresented") is null); 1381 assert(df.action("Notspecified") is null); 1382 assert(df.action("X-NoName") is null); 1383 assert(df.action("Settings") !is null); 1384 1385 assert(df.saveToString() == desktopFileContents); 1386 1387 assert(df.contains("Icon")); 1388 df.removeEntry("Icon"); 1389 assert(!df.contains("Icon")); 1390 df.setEscapedValue("Icon", "files"); 1391 assert(df.contains("Icon")); 1392 1393 string contents = 1394 `# First comment 1395 [Desktop Entry] 1396 Key=Value 1397 # Comment in group`; 1398 1399 df = new DesktopFile(iniLikeStringReader(contents), "test.desktop"); 1400 assert(df.fileName() == "test.desktop"); 1401 df.removeGroup("Desktop Entry"); 1402 assert(df.group("Desktop Entry") !is null); 1403 assert(df.desktopEntry() !is null); 1404 1405 contents = 1406 `[X-SomeGroup] 1407 Key=Value`; 1408 1409 auto thrown = collectException!IniLikeReadException(new DesktopFile(iniLikeStringReader(contents))); 1410 assert(thrown !is null); 1411 assert(thrown.lineNumber == 0); 1412 1413 df = new DesktopFile(); 1414 df.desktopEntry().setUnescapedValue("$Invalid", "Valid value", IniLikeGroup.InvalidKeyPolicy.save); 1415 assert(df.desktopEntry().escapedValue("$Invalid") == "Valid value"); 1416 df.desktopEntry().setUnescapedValue("Another$Invalid", "Valid value", IniLikeGroup.InvalidKeyPolicy.skip); 1417 assert(df.desktopEntry().escapedValue("Another$Invalid") is null); 1418 df.terminal = true; 1419 df.type = DesktopFile.Type.Application; 1420 df.categories = ["Development", "Compilers", "One;Two", "Three\\;Four", "New\nLine"]; 1421 1422 assert(df.terminal() == true); 1423 assert(df.type() == DesktopFile.Type.Application); 1424 assert(equal(df.categories(), ["Development", "Compilers", "One;Two", "Three\\;Four","New\nLine"])); 1425 1426 df.displayName = "Program name"; 1427 assert(df.displayName() == "Program name"); 1428 df.genericName = "Program"; 1429 assert(df.genericName() == "Program"); 1430 df.comment = "Do\nthings"; 1431 assert(df.comment() == "Do\nthings"); 1432 1433 df.execValue = "utilname"; 1434 assert(df.execValue() == "utilname"); 1435 1436 df.noDisplay = true; 1437 assert(df.noDisplay()); 1438 df.hidden = true; 1439 assert(df.hidden()); 1440 df.dbusActivable = true; 1441 assert(df.dbusActivable()); 1442 df.startupNotify = true; 1443 assert(df.startupNotify()); 1444 1445 df.url = "/some/url"; 1446 assert(df.url == "/some/url"); 1447 }