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