1 /** 2 * Reading, writing and executing .desktop file 3 * 4 * License: $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0). 5 * See_Also: $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/index.html, Desktop Entry Specification) 6 */ 7 8 module desktopfile; 9 10 private { 11 import std.algorithm; 12 import std.array; 13 import std.conv; 14 import std.exception; 15 import std.file; 16 import std.path; 17 import std.process; 18 import std.range; 19 import std.stdio; 20 import std.string; 21 import std.traits; 22 import std.typecons; 23 } 24 25 /** 26 * Exception thrown when error occures during the .desktop file read. 27 */ 28 class DesktopFileException : Exception 29 { 30 this(string msg, size_t lineNumber, string file = __FILE__, size_t line = __LINE__, Throwable next = null) pure nothrow @safe { 31 super(msg, file, line, next); 32 _lineNumber = lineNumber; 33 } 34 35 ///Number of line in desktop file where the exception occured, starting from 1. Don't be confused with $(B line) property of $(B Throwable). 36 size_t lineNumber() const { 37 return _lineNumber; 38 } 39 40 private: 41 size_t _lineNumber; 42 } 43 44 private alias LocaleTuple = Tuple!(string, "lang", string, "country", string, "encoding", string, "modifier"); 45 private alias KeyValueTuple = Tuple!(string, "key", string, "value"); 46 47 /** Retrieves current locale probing environment variables LC_TYPE, LC_ALL and LANG (in this order) 48 * Returns: locale in posix form or empty string if could not determine locale 49 */ 50 string currentLocale() @safe nothrow 51 { 52 static string cache; 53 if (cache is null) { 54 try { 55 cache = environment.get("LC_CTYPE", environment.get("LC_ALL", environment.get("LANG"))); 56 } 57 catch(Exception e) { 58 59 } 60 if (cache is null) { 61 cache = ""; 62 } 63 } 64 return cache; 65 } 66 67 /** 68 * Returns: locale name in form lang_COUNTRY.ENCODING@MODIFIER 69 */ 70 string makeLocaleName(string lang, string country = null, string encoding = null, string modifier = null) pure nothrow @safe 71 { 72 return lang ~ (country.length ? "_"~country : "") ~ (encoding.length ? "."~encoding : "") ~ (modifier.length ? "@"~modifier : ""); 73 } 74 75 /** 76 * Parses locale name into the tuple of 4 values corresponding to language, country, encoding and modifier 77 * Returns: Tuple!(string, "lang", string, "country", string, "encoding", string, "modifier") 78 */ 79 auto parseLocaleName(string locale) pure nothrow @nogc @trusted 80 { 81 auto modifiderSplit = findSplit(locale, "@"); 82 auto modifier = modifiderSplit[2]; 83 84 auto encodongSplit = findSplit(modifiderSplit[0], "."); 85 auto encoding = encodongSplit[2]; 86 87 auto countrySplit = findSplit(encodongSplit[0], "_"); 88 auto country = countrySplit[2]; 89 90 auto lang = countrySplit[0]; 91 92 return LocaleTuple(lang, country, encoding, modifier); 93 } 94 95 /** 96 * Returns: localized key in form key[locale]. Automatically omits locale encoding if present. 97 */ 98 string localizedKey(string key, string locale) pure nothrow @safe 99 { 100 auto t = parseLocaleName(locale); 101 if (!t.encoding.empty) { 102 locale = makeLocaleName(t.lang, t.country, null, t.modifier); 103 } 104 return key ~ "[" ~ locale ~ "]"; 105 } 106 107 /** 108 * Ditto, but constructs locale name from arguments. 109 */ 110 string localizedKey(string key, string lang, string country, string modifier = null) pure nothrow @safe 111 { 112 return key ~ "[" ~ makeLocaleName(lang, country, null, modifier) ~ "]"; 113 } 114 115 /** 116 * Separates key name into non-localized key and locale name. 117 * If key is not localized returns original key and empty string. 118 * Returns: tuple of key and locale name; 119 */ 120 Tuple!(string, string) separateFromLocale(string key) nothrow @nogc @trusted { 121 if (key.endsWith("]")) { 122 auto t = key.findSplit("["); 123 if (t[1].length) { 124 return tuple(t[0], t[2][0..$-1]); 125 } 126 } 127 return tuple(key, string.init); 128 } 129 130 /** 131 * Tells whether the character is valid for desktop entry key. 132 * Note: This does not include characters presented in locale names. 133 */ 134 bool isValidKeyChar(char c) pure nothrow @nogc @safe 135 { 136 return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-'; 137 } 138 139 140 /** 141 * Tells whethe the string is valid dekstop entry key. 142 * Note: This does not include characters presented in locale names. Use $(B separateFromLocale) to get non-localized key to pass it to this function 143 */ 144 bool isValidKey(string key) pure nothrow @nogc @safe 145 { 146 if (key.empty) { 147 return false; 148 } 149 for (size_t i = 0; i<key.length; ++i) { 150 if (!key[i].isValidKeyChar()) { 151 return false; 152 } 153 } 154 return true; 155 } 156 157 /** 158 * Tells whether the dekstop entry value presents true 159 */ 160 bool isTrue(string value) pure nothrow @nogc @safe { 161 return (value == "true" || value == "1"); 162 } 163 164 /** 165 * Tells whether the desktop entry value presents false 166 */ 167 bool isFalse(string value) pure nothrow @nogc @safe { 168 return (value == "false" || value == "0"); 169 } 170 171 /** 172 * Check if the desktop entry value can be interpreted as boolean value. 173 */ 174 bool isBoolean(string value) pure nothrow @nogc @safe { 175 return isTrue(value) || isFalse(value); 176 } 177 178 string escapeValue(string value) @trusted nothrow pure { 179 return value.replace("\\", `\\`).replace("\n", `\n`).replace("\r", `\r`).replace("\t", `\t`); 180 } 181 182 string doUnescape(string value, in Tuple!(char, char)[] pairs) @trusted nothrow pure { 183 auto toReturn = appender!string(); 184 185 for (size_t i = 0; i < value.length; i++) { 186 if (value[i] == '\\') { 187 if (i < value.length - 1) { 188 char c = value[i+1]; 189 auto t = pairs.find!"a[0] == b[0]"(tuple(c,c)); 190 if (!t.empty) { 191 toReturn.put(t.front[1]); 192 i++; 193 continue; 194 } 195 } 196 } 197 toReturn.put(value[i]); 198 } 199 return toReturn.data; 200 } 201 202 string unescapeValue(string value) @trusted nothrow pure 203 { 204 static immutable Tuple!(char, char)[] pairs = [ 205 tuple('s', ' '), 206 tuple('n', '\n'), 207 tuple('r', '\r'), 208 tuple('t', '\t'), 209 tuple('\\', '\\') 210 ]; 211 return doUnescape(value, pairs); 212 } 213 214 string unescapeExec(string str) @trusted nothrow pure 215 { 216 static immutable Tuple!(char, char)[] pairs = [ 217 tuple('"', '"'), 218 tuple('\'', '\''), 219 tuple('\\', '\\'), 220 tuple('>', '>'), 221 tuple('<', '<'), 222 tuple('~', '~'), 223 tuple('|', '|'), 224 tuple('&', '&'), 225 tuple(';', ';'), 226 tuple('$', '$'), 227 tuple('*', '*'), 228 tuple('?', '?'), 229 tuple('#', '#'), 230 tuple('(', '('), 231 tuple(')', ')'), 232 ]; 233 return doUnescape(str, pairs); 234 } 235 236 /** 237 * Checks if the program exists and is executable. 238 * If the programPath is not an absolute path, the file is looked up in the $PATH environment variable. 239 * This function is defined only on Posix. 240 */ 241 version(Posix) 242 { 243 bool checkTryExec(string programPath) @safe { 244 bool isExecutable(string filePath) @trusted nothrow { 245 import core.sys.posix.unistd; 246 return access(toStringz(filePath), X_OK) == 0; 247 } 248 249 if (programPath.isAbsolute()) { 250 return isExecutable(programPath); 251 } 252 253 foreach(path; environment.get("PATH").splitter(':')) { 254 if (isExecutable(buildPath(path, programPath))) { 255 return true; 256 } 257 } 258 return false; 259 } 260 } 261 262 263 /** 264 * This class represents the group in the desktop file. 265 * You can create and use instances of this class only in the context of $(B DesktopFile) instance. 266 */ 267 final class DesktopGroup 268 { 269 private: 270 static struct Line 271 { 272 enum Type 273 { 274 None, 275 Comment, 276 KeyValue 277 } 278 279 this(string comment) @safe { 280 _first = comment; 281 _type = Type.Comment; 282 } 283 284 this(string key, string value) @safe { 285 _first = key; 286 _second = value; 287 _type = Type.KeyValue; 288 } 289 290 string comment() @safe @nogc nothrow const { 291 return _first; 292 } 293 294 string key() @safe @nogc nothrow const { 295 return _first; 296 } 297 298 string value() @safe @nogc nothrow const { 299 return _second; 300 } 301 302 Type type() @safe @nogc nothrow const { 303 return _type; 304 } 305 306 void makeNone() @safe @nogc nothrow { 307 _type = Type.None; 308 } 309 310 private: 311 Type _type = Type.None; 312 string _first; 313 string _second; 314 } 315 316 this(string name) @safe @nogc nothrow { 317 _name = name; 318 } 319 320 public: 321 322 /** 323 * Returns: the value associated with the key 324 * Note: it's an error to access nonexistent value 325 */ 326 string opIndex(string key) const @safe @nogc nothrow { 327 auto i = key in _indices; 328 assert(_values[*i].type == Line.Type.KeyValue); 329 assert(_values[*i].key == key); 330 return _values[*i].value; 331 } 332 333 /** 334 * Inserts new value or replaces the old one if value associated with key already exists. 335 * Returns: inserted/updated value 336 * Throws: $(B Exception) if key is not valid 337 * See_Also: isValidKey 338 */ 339 string opIndexAssign(string value, string key) @safe { 340 enforce(separateFromLocale(key)[0].isValidKey(), "key is invalid"); 341 auto pick = key in _indices; 342 if (pick) { 343 return (_values[*pick] = Line(key, value)).value; 344 } else { 345 _indices[key] = _values.length; 346 _values ~= Line(key, value); 347 return value; 348 } 349 } 350 /** 351 * Ditto, but also allows to specify the locale. 352 * See_Also: setLocalizedValue, localizedValue 353 */ 354 string opIndexAssign(string value, string key, string locale) @safe { 355 string keyName = localizedKey(key, locale); 356 return this[keyName] = value; 357 } 358 359 /** 360 * Tells if group contains value associated with the key. 361 */ 362 bool contains(string key) const @safe @nogc nothrow { 363 return value(key) !is null; 364 } 365 366 /** 367 * Returns: the value associated with the key, or defaultValue if group does not contain item with this key. 368 */ 369 string value(string key, string defaultValue = null) const @safe @nogc nothrow { 370 auto pick = key in _indices; 371 if (pick) { 372 if(_values[*pick].type == Line.Type.KeyValue) { 373 assert(_values[*pick].key == key); 374 return _values[*pick].value; 375 } 376 } 377 return defaultValue; 378 } 379 380 /** 381 * Performs locale matching lookup as described in $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s04.html, Localized values for keys). 382 * If locale is null it calls currentLocale to get the locale. 383 * Returns: the localized value associated with key and locale, or defaultValue if group does not contain item with this key. 384 */ 385 string localizedValue(string key, string locale = null, string defaultValue = null) const @safe nothrow { 386 if (locale is null) { 387 locale = currentLocale(); 388 } 389 390 //Any ideas how to get rid of this boilerplate and make less allocations? 391 auto t = parseLocaleName(locale); 392 auto lang = t.lang; 393 auto country = t.country; 394 auto modifier = t.modifier; 395 396 if (lang.length) { 397 string pick; 398 399 if (country.length && modifier.length) { 400 pick = value(localizedKey(key, locale)); 401 if (pick !is null) { 402 return pick; 403 } 404 } 405 406 if (country.length) { 407 pick = value(localizedKey(key, lang, country)); 408 if (pick !is null) { 409 return pick; 410 } 411 } 412 413 if (modifier.length) { 414 pick = value(localizedKey(key, lang, null, modifier)); 415 if (pick !is null) { 416 return pick; 417 } 418 } 419 420 pick = value(localizedKey(key, lang, null)); 421 if (pick !is null) { 422 return pick; 423 } 424 } 425 426 return value(key, defaultValue); 427 } 428 429 /** 430 * Same as localized version of opIndexAssign, but uses function syntax. 431 */ 432 void setLocalizedValue(string key, string locale, string value) @safe { 433 this[key, locale] = value; 434 } 435 436 /** 437 * Removes entry by key. To remove localized values use localizedKey. 438 */ 439 void removeEntry(string key) @safe nothrow { 440 auto pick = key in _indices; 441 if (pick) { 442 _values[*pick].makeNone(); 443 } 444 } 445 446 /** 447 * Returns: range of Tuple!(string, "key", string, "value") 448 */ 449 auto byKeyValue() const @safe @nogc nothrow { 450 return _values.filter!(v => v.type == Line.Type.KeyValue).map!(v => KeyValueTuple(v.key, v.value)); 451 } 452 453 /** 454 * Returns: the name of group 455 */ 456 string name() const @safe @nogc nothrow { 457 return _name; 458 } 459 460 private: 461 void addComment(string comment) { 462 _values ~= Line(comment); 463 } 464 465 size_t[string] _indices; 466 Line[] _values; 467 string _name; 468 } 469 470 /** 471 * Represents .desktop file. 472 * 473 */ 474 final class DesktopFile 475 { 476 public: 477 enum Type 478 { 479 Unknown, ///Desktop entry is unknown type 480 Application, ///Desktop describes application 481 Link, ///Desktop describes URL 482 Directory ///Desktop entry describes directory settings 483 } 484 485 enum ReadOptions 486 { 487 noOptions = 0, /// Read all groups and skip comments and empty lines 488 desktopEntryOnly = 1, /// Ignore other groups than Desktop Entry 489 preserveComments = 2 /// Preserve comments and empty lines 490 } 491 492 /** 493 * Reads desktop file from file. 494 * Throws: 495 * $(B ErrnoException) if file could not be opened. 496 * $(B DesktopFileException) if error occured while reading the file. 497 */ 498 static DesktopFile loadFromFile(string fileName, ReadOptions options = ReadOptions.noOptions) @trusted { 499 auto f = File(fileName, "r"); 500 return new DesktopFile(f.byLine().map!(s => s.idup), options, fileName); 501 } 502 503 /** 504 * Reads desktop file from string. 505 * Throws: 506 * $(B DesktopFileException) if error occured while parsing the contents. 507 */ 508 static DesktopFile loadFromString(string contents, ReadOptions options = ReadOptions.noOptions, string fileName = null) @trusted { 509 return new DesktopFile(contents.splitLines(), options, fileName); 510 } 511 512 private this(Range)(Range byLine, ReadOptions options, string fileName) @trusted 513 { 514 size_t lineNumber = 0; 515 string currentGroup; 516 517 try { 518 foreach(line; byLine) { 519 lineNumber++; 520 line = strip(line); 521 522 if (line.empty || line.startsWith("#")) { 523 if (options & ReadOptions.preserveComments) { 524 if (currentGroup is null) { 525 firstLines ~= line; 526 } else { 527 group(currentGroup).addComment(line); 528 } 529 } 530 531 continue; 532 } 533 534 if (line.startsWith("[") && line.endsWith("]")) { 535 string groupName = line[1..$-1]; 536 enforce(groupName.length, "empty group name"); 537 enforce(group(groupName) is null, "group is defined more than once"); 538 539 if (currentGroup is null) { 540 enforce(groupName == "Desktop Entry", "the first group must be Desktop Entry"); 541 } else if (options & ReadOptions.desktopEntryOnly) { 542 break; 543 } 544 545 addGroup(groupName); 546 currentGroup = groupName; 547 } else { 548 auto t = line.findSplit("="); 549 t[0] = t[0].stripRight(); 550 t[2] = t[2].stripLeft(); 551 552 enforce(t[1].length, "not key-value pair, nor group start nor comment"); 553 enforce(currentGroup.length, "met key-value pair before any group"); 554 assert(group(currentGroup) !is null, "logic error: currentGroup is not in _groups"); 555 556 group(currentGroup)[t[0]] = t[2]; 557 } 558 } 559 560 _desktopEntry = group("Desktop Entry"); 561 enforce(_desktopEntry !is null, "Desktop Entry group is missing"); 562 _fileName = fileName; 563 } 564 catch (Exception e) { 565 throw new DesktopFileException(e.msg, lineNumber, e.file, e.line, e.next); 566 } 567 } 568 569 /** 570 * Constructs DesktopFile with "Desktop Entry" group and Version set to 1.0 571 */ 572 this() { 573 _desktopEntry = addGroup("Desktop Entry"); 574 desktopEntry()["Version"] = "1.0"; 575 } 576 577 /** 578 * Returns: file name as was specified on the object creating 579 */ 580 string fileName() @safe @nogc nothrow const { 581 return _fileName; 582 } 583 584 /** 585 * Saves object to file using Desktop File format. 586 * Throws: ErrnoException if the file could not be opened or an error writing to the file occured. 587 */ 588 void saveToFile(string fileName) const { 589 auto f = File(fileName, "w"); 590 void dg(string line) { 591 f.writeln(line); 592 } 593 save(&dg); 594 } 595 596 /** 597 * Saves object to string using Desktop File format. 598 */ 599 string saveToString() const { 600 auto a = appender!(string[])(); 601 void dg(string line) { 602 a.put(line); 603 } 604 save(&dg); 605 return a.data.join("\n"); 606 } 607 608 private alias SaveDelegate = void delegate(string); 609 610 private void save(SaveDelegate sink) const { 611 foreach(line; firstLines) { 612 sink(line); 613 } 614 615 foreach(group; byGroup()) { 616 sink("[" ~ group.name ~ "]"); 617 foreach(line; group._values) { 618 if (line.type == DesktopGroup.Line.Type.Comment) { 619 sink(line.comment); 620 } else if (line.type == DesktopGroup.Line.Type.KeyValue) { 621 sink(line.key ~ "=" ~ line.value); 622 } 623 } 624 } 625 } 626 627 /** 628 * Returns: DesktopGroup instance associated with groupName or $(B null) if not found. 629 */ 630 inout(DesktopGroup) group(string groupName) @safe @nogc nothrow inout { 631 auto pick = groupName in _groupIndices; 632 if (pick) { 633 return _groups[*pick]; 634 } 635 return null; 636 } 637 638 /** 639 * Creates new group usin groupName. 640 * Returns: newly created instance of DesktopGroup. 641 * Throws: Exception if group with such name already exists or groupName is empty. 642 */ 643 DesktopGroup addGroup(string groupName) @safe { 644 enforce(groupName.length, "group name is empty"); 645 646 auto desktopGroup = new DesktopGroup(groupName); 647 enforce(group(groupName) is null, "group already exists"); 648 _groupIndices[groupName] = _groups.length; 649 _groups ~= desktopGroup; 650 651 return desktopGroup; 652 } 653 654 /** 655 * Range of groups in order how they are defined in .desktop file. The first group is always $(B Desktop Entry). 656 */ 657 auto byGroup() const { 658 return _groups[]; 659 } 660 661 /** 662 * Returns: Type of desktop entry. 663 */ 664 Type type() const @safe @nogc nothrow { 665 string t = value("Type"); 666 if (t.length) { 667 if (t == "Application") { 668 return Type.Application; 669 } else if (t == "Link") { 670 return Type.Link; 671 } else if (t == "Directory") { 672 return Type.Directory; 673 } 674 } 675 if (_fileName.extension == ".directory") { 676 return Type.Directory; 677 } 678 679 return Type.Unknown; 680 } 681 /// Sets "Type" field to type 682 Type type(Type t) @safe { 683 final switch(t) { 684 case Type.Application: 685 this["Type"] = "Application"; 686 break; 687 case Type.Link: 688 this["Type"] = "Link"; 689 break; 690 case Type.Directory: 691 this["Type"] = "Directory"; 692 break; 693 case Type.Unknown: 694 break; 695 } 696 return t; 697 } 698 699 /** 700 * Specific name of the application, for example "Mozilla". 701 * Returns: the value associated with "Name" key. 702 */ 703 string name() const @safe @nogc nothrow { 704 return value("Name"); 705 } 706 ///ditto, but returns localized value. 707 string localizedName(string locale = null) const @safe nothrow { 708 return localizedValue("Name"); 709 } 710 711 /** 712 * Generic name of the application, for example "Web Browser". 713 * Returns: the value associated with "GenericName" key. 714 */ 715 string genericName() const @safe @nogc nothrow { 716 return value("GenericName"); 717 } 718 ///ditto, but returns localized value. 719 string localizedGenericName(string locale = null) const @safe nothrow { 720 return localizedValue("GenericName"); 721 } 722 723 /** 724 * Tooltip for the entry, for example "View sites on the Internet". 725 * Returns: the value associated with "Comment" key. 726 */ 727 string comment() const @safe @nogc nothrow { 728 return value("Comment"); 729 } 730 ///ditto, but returns localized value. 731 string localizedComment(string locale = null) const @safe nothrow { 732 return localizedValue("Comment"); 733 } 734 735 /** 736 * Returns: the value associated with "Exec" key. 737 * Note: don't use this to start the program. Consider using expandExecString or startApplication instead. 738 */ 739 string execString() const @safe @nogc nothrow { 740 return value("Exec"); 741 } 742 743 744 /** 745 * Returns: the value associated with "TryExec" key. 746 */ 747 string tryExecString() const @safe @nogc nothrow { 748 return value("TryExec"); 749 } 750 751 /** 752 * Returns: the value associated with "Icon" key. If not found it also tries "X-Window-Icon". 753 * Note: this function returns Icon as it's defined in .desktop file. It does not provides any lookup of actual icon file on the system. 754 */ 755 string iconName() const @safe @nogc nothrow { 756 string iconPath = value("Icon"); 757 if (iconPath is null) { 758 iconPath = value("X-Window-Icon"); 759 } 760 return iconPath; 761 } 762 763 /** 764 * Returns: the value associated with "NoDisplay" key converted to bool using isTrue. 765 */ 766 bool noDisplay() const @safe @nogc nothrow { 767 return isTrue(value("NoDisplay")); 768 } 769 770 /** 771 * Returns: the value associated with "Hidden" key converted to bool using isTrue. 772 */ 773 bool hidden() const @safe @nogc nothrow { 774 return isTrue(value("Hidden")); 775 } 776 777 /** 778 * The working directory to run the program in. 779 * Returns: the value associated with "Path" key. 780 */ 781 string workingDirectory() const @safe @nogc nothrow { 782 return value("Path"); 783 } 784 785 /** 786 * Whether the program runs in a terminal window. 787 * Returns: the value associated with "Hidden" key converted to bool using isTrue. 788 */ 789 bool terminal() const @safe @nogc nothrow { 790 return isTrue(value("Terminal")); 791 } 792 /// Sets "Terminal" field to true or false. 793 bool terminal(bool t) @safe { 794 this["Terminal"] = t ? "true" : "false"; 795 return t; 796 } 797 798 /** 799 * Some keys can have multiple values, separated by semicolon. This function helps to parse such kind of strings to the range. 800 * Returns: the range of multiple values. 801 */ 802 static auto splitValues(string values) @trusted { 803 return values.splitter(';').filter!(s => s.length != 0); 804 } 805 806 /** 807 * Join range of multiple values into a string using semicolon as separator. Adds trailing semicolon. 808 * If range is empty, empty string is returned. 809 */ 810 static @trusted string joinValues(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 811 auto result = values.filter!( s => s.length != 0 ).joiner(";"); 812 if (result.empty) { 813 return null; 814 } else { 815 return text(result) ~ ";"; 816 } 817 } 818 819 /** 820 * Categories this program belongs to. 821 * Returns: the range of multiple values associated with "Categories" key. 822 */ 823 auto categories() const @safe { 824 return splitValues(value("Categories")); 825 } 826 827 /** 828 * Sets the list of values for the "Categories" list. 829 */ 830 @safe void categories(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 831 this["Categories"] = joinValues(values); 832 } 833 834 /** 835 * A list of strings which may be used in addition to other metadata to describe this entry. 836 * Returns: the range of multiple values associated with "Keywords" key. 837 */ 838 auto keywords() const @safe { 839 return splitValues(value("Keywords")); 840 } 841 842 /** 843 * Sets the list of values for the "Keywords" list. 844 */ 845 @safe void keywords(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 846 this["Keywords"] = joinValues(values); 847 } 848 849 /** 850 * The MIME type(s) supported by this application. 851 * Returns: the range of multiple values associated with "MimeType" key. 852 */ 853 auto mimeTypes() const @safe { 854 return splitValues(value("MimeType")); 855 } 856 857 /** 858 * Sets the list of values for the "MimeType" list. 859 */ 860 @safe void mimeTypes(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 861 this["MimeType"] = joinValues(values); 862 } 863 864 /** 865 * A list of strings identifying the desktop environments that should display a given desktop entry. 866 * Returns: the range of multiple values associated with "OnlyShowIn" key. 867 */ 868 auto onlyShowIn() const @safe { 869 return splitValues(value("OnlyShowIn")); 870 } 871 872 /** 873 * A list of strings identifying the desktop environments that should not display a given desktop entry. 874 * Returns: the range of multiple values associated with "NotShowIn" key. 875 */ 876 auto notShowIn() const @safe { 877 return splitValues(value("NotShowIn")); 878 } 879 880 /** 881 * Returns: instance of "Desktop Entry" group. 882 * Note: usually you don't need to call this function since you can rely on alias this. 883 */ 884 inout(DesktopGroup) desktopEntry() @safe @nogc nothrow inout { 885 return _desktopEntry; 886 } 887 888 889 /** 890 * This alias allows to call functions related to "Desktop Entry" group without need to call desktopEntry explicitly. 891 */ 892 alias desktopEntry this; 893 894 /** 895 * Expands Exec string into the array of command line arguments to use to start the program. 896 */ 897 string[] expandExecString(in string[] urls = null) const @safe 898 { 899 string[] toReturn; 900 auto execStr = execString().unescapeExec(); //add unquoting 901 902 foreach(token; execStr.split) { 903 if (token == "%f") { 904 if (urls.length) { 905 toReturn ~= urls.front; 906 } 907 } else if (token == "%F") { 908 toReturn ~= urls; 909 } else if (token == "%u") { 910 if (urls.length) { 911 toReturn ~= urls.front; 912 } 913 } else if (token == "%U") { 914 toReturn ~= urls; 915 } else if (token == "%i") { 916 string iconStr = iconName(); 917 if (iconStr.length) { 918 toReturn ~= "--icon"; 919 toReturn ~= iconStr; 920 } 921 } else if (token == "%c") { 922 toReturn ~= localizedValue("Name"); 923 } else if (token == "%k") { 924 toReturn ~= fileName(); 925 } else if (token == "%d" || token == "%D" || token == "%n" || token == "%N" || token == "%m" || token == "%v") { 926 continue; 927 } else { 928 toReturn ~= token; 929 } 930 } 931 932 return toReturn; 933 } 934 935 /** 936 * Starts the program associated with this .desktop file using urls as command line params. 937 * Note: 938 * If the program should be run in terminal it tries to find system defined terminal emulator to run in. 939 * First, it probes $(B TERM) environment variable. If not found, checks if /usr/bin/x-terminal-emulator exists on Linux and use it on success. 940 * Defaulted to xterm, if could not determine other terminal emulator. 941 * Note: 942 * This function does check if the type of desktop file is Application. It relies only on "Exec" value. 943 * Returns: 944 * Pid of started process. 945 * Throws: 946 * ProcessException on failure to start the process. 947 * Exception if expanded exec string is empty. 948 */ 949 Pid startApplication(string[] urls = null) const @trusted 950 { 951 auto args = expandExecString(urls); 952 enforce(args.length, "No command line params to run the program. Is Exec missing?"); 953 954 if (terminal()) { 955 string term = environment.get("TERM"); 956 957 version(linux) { 958 if (term is null) { 959 string debianTerm = "/usr/bin/x-terminal-emulator"; 960 if (debianTerm.exists) { 961 term = debianTerm; 962 } 963 } 964 } 965 966 if (term is null) { 967 term = "xterm"; 968 } 969 970 args = [term, "-e"] ~ args; 971 } 972 973 return spawnProcess(args, null, Config.none, workingDirectory()); 974 } 975 976 ///ditto, but uses the only url. 977 Pid startApplication(in string url) const @trusted 978 { 979 return startApplication([url]); 980 } 981 982 Pid startLink() const @trusted 983 { 984 string url = value("URL"); 985 return spawnProcess(["xdg-open", url], null, Config.none); 986 } 987 988 private: 989 DesktopGroup _desktopEntry; 990 string _fileName; 991 992 size_t[string] _groupIndices; 993 DesktopGroup[] _groups; 994 995 string[] firstLines; 996 } 997 998 unittest 999 { 1000 //Test locale-related functions 1001 assert(makeLocaleName("ru", "RU") == "ru_RU"); 1002 assert(makeLocaleName("ru", "RU", "UTF-8") == "ru_RU.UTF-8"); 1003 assert(makeLocaleName("ru", "RU", "UTF-8", "mod") == "ru_RU.UTF-8@mod"); 1004 assert(makeLocaleName("ru", null, null, "mod") == "ru@mod"); 1005 1006 assert(parseLocaleName("ru_RU.UTF-8@mod") == tuple("ru", "RU", "UTF-8", "mod")); 1007 assert(parseLocaleName("ru@mod") == tuple("ru", string.init, string.init, "mod")); 1008 assert(parseLocaleName("ru_RU") == tuple("ru", "RU", string.init, string.init)); 1009 1010 assert(localizedKey("Name", "ru_RU") == "Name[ru_RU]"); 1011 assert(localizedKey("Name", "ru_RU.UTF-8") == "Name[ru_RU]"); 1012 assert(localizedKey("Name", "ru", "RU") == "Name[ru_RU]"); 1013 1014 assert(separateFromLocale("Name[ru_RU]") == tuple("Name", "ru_RU")); 1015 assert(separateFromLocale("Name") == tuple("Name", string.init)); 1016 1017 //Test locale matching lookup 1018 auto group = new DesktopGroup("Desktop Entry"); 1019 group["Name"] = "Programmer"; 1020 group["Name[ru_RU]"] = "Разработчик"; 1021 group["Name[ru@jargon]"] = "Кодер"; 1022 group["Name[ru]"] = "Программист"; 1023 group["GenericName"] = "Program"; 1024 group["GenericName[ru]"] = "Программа"; 1025 assert(group["Name"] == "Programmer"); 1026 assert(group.localizedValue("Name", "ru@jargon") == "Кодер"); 1027 assert(group.localizedValue("Name", "ru_RU@jargon") == "Разработчик"); 1028 assert(group.localizedValue("Name", "ru") == "Программист"); 1029 assert(group.localizedValue("Name", "nonexistent locale") == "Programmer"); 1030 assert(group.localizedValue("GenericName", "ru_RU") == "Программа"); 1031 1032 //Test escaping and unescaping 1033 assert("\\next\nline".escapeValue() == `\\next\nline`); 1034 assert(`\\next\nline`.unescapeValue() == "\\next\nline"); 1035 1036 //Test split/join values 1037 1038 assert(equal(DesktopFile.splitValues("Application;Utility;FileManager;"), ["Application", "Utility", "FileManager"])); 1039 assert(DesktopFile.splitValues(";").empty); 1040 assert(equal(DesktopFile.joinValues(["Application", "Utility", "FileManager"]), "Application;Utility;FileManager;")); 1041 assert(DesktopFile.joinValues([""]).empty); 1042 1043 //Test DesktopFile 1044 string desktopFileContents = 1045 `[Desktop Entry] 1046 # Comment 1047 Name=Double Commander 1048 GenericName=File manager 1049 GenericName[ru]=Файловый менеджер 1050 Comment=Double Commander is a cross platform open source file manager with two panels side by side. 1051 Terminal=false 1052 Icon=doublecmd 1053 Exec=doublecmd 1054 Type=Application 1055 Categories=Application;Utility;FileManager; 1056 Keywords=folder;manager;explore;disk;filesystem;orthodox;copy;queue;queuing;operations;`; 1057 1058 auto df = DesktopFile.loadFromString(desktopFileContents, DesktopFile.ReadOptions.preserveComments); 1059 assert(df.name() == "Double Commander"); 1060 assert(df.genericName() == "File manager"); 1061 assert(df.localizedValue("GenericName", "ru_RU") == "Файловый менеджер"); 1062 assert(!df.terminal()); 1063 assert(df.type() == DesktopFile.Type.Application); 1064 assert(equal(df.categories(), ["Application", "Utility", "FileManager"])); 1065 1066 assert(df.saveToString() == desktopFileContents); 1067 1068 df = new DesktopFile(); 1069 assert(df.desktopEntry()); 1070 assert(df.value("Version") == "1.0"); 1071 assert(df.categories().empty); 1072 assert(df.type() == DesktopFile.Type.Unknown); 1073 1074 df.terminal = true; 1075 df.type = DesktopFile.Type.Application; 1076 df.categories = ["Development", "Compilers"]; 1077 1078 assert(df.terminal() == true); 1079 assert(df.type() == DesktopFile.Type.Application); 1080 assert(equal(df.categories(), ["Development", "Compilers"])); 1081 }