1 /** 2 * Reading, writing and executing .desktop file 3 * Authors: 4 * $(LINK2 https://github.com/MyLittleRobo, Roman Chistokhodov). 5 * License: 6 * $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0). 7 * See_Also: 8 * $(LINK2 http://standards.freedesktop.org/desktop-entry-spec/latest/index.html, Desktop Entry Specification). 9 */ 10 11 module desktopfile; 12 13 import inilike; 14 15 private { 16 import std.algorithm; 17 import std.array; 18 import std.conv; 19 import std.exception; 20 import std.file; 21 import std.path; 22 import std.process; 23 import std.range; 24 import std.stdio; 25 import std.string; 26 import std.traits; 27 import std.typecons; 28 } 29 30 /** 31 * Exception thrown when error occures during the .desktop file read. 32 */ 33 alias IniLikeException DesktopFileException; 34 35 36 37 version(Posix) 38 { 39 private bool isExecutable(string filePath) @trusted nothrow { 40 import core.sys.posix.unistd; 41 return access(toStringz(filePath), X_OK) == 0; 42 } 43 /** 44 * Checks if the program exists and is executable. 45 * If the programPath is not an absolute path, the file is looked up in the $PATH environment variable. 46 * This function is defined only on Posix. 47 */ 48 bool checkTryExec(string programPath) @safe { 49 if (programPath.isAbsolute()) { 50 return isExecutable(programPath); 51 } 52 53 foreach(path; environment.get("PATH").splitter(':')) { 54 if (isExecutable(buildPath(path, programPath))) { 55 return true; 56 } 57 } 58 return false; 59 } 60 } 61 62 63 64 65 /** 66 * This class represents the group (section) in the desktop file. 67 * You can create and use instances of this class only in the context of $(B DesktopFile) instance. 68 */ 69 alias IniLikeGroup DesktopGroup; 70 71 72 73 74 /** 75 * Represents .desktop file. 76 * 77 */ 78 final class DesktopFile : IniLikeFile 79 { 80 public: 81 ///Desktop entry type 82 enum Type 83 { 84 Unknown, ///Desktop entry is unknown type 85 Application, ///Desktop describes application 86 Link, ///Desktop describes URL 87 Directory ///Desktop entry describes directory settings 88 } 89 90 alias IniLikeFile.ReadOptions ReadOptions; 91 92 /** 93 * Reads desktop file from file. 94 * Throws: 95 * $(B ErrnoException) if file could not be opened. 96 * $(B DesktopFileException) if error occured while reading the file. 97 */ 98 static DesktopFile loadFromFile(string fileName, ReadOptions options = ReadOptions.noOptions) @trusted { 99 return new DesktopFile(iniLikeFileReader(fileName), options, fileName); 100 } 101 102 /** 103 * Reads desktop file from string. 104 * Throws: 105 * $(B DesktopFileException) if error occured while parsing the contents. 106 */ 107 static DesktopFile loadFromString(string contents, ReadOptions options = ReadOptions.noOptions, string fileName = null) @trusted { 108 return new DesktopFile(iniLikeStringReader(contents), options, fileName); 109 } 110 111 this(Range)(Range byLine, ReadOptions options = ReadOptions.noOptions, string fileName = null) @trusted 112 { 113 super(byLine, options, fileName); 114 auto groups = byGroup(); 115 enforce(!groups.empty, "no groups"); 116 enforce(groups.front.name == "Desktop Entry", "the first group must be Desktop Entry"); 117 118 _desktopEntry = groups.front; 119 } 120 121 /** 122 * Constructs DesktopFile with "Desktop Entry" group and Version set to 1.0 123 */ 124 this() { 125 super(); 126 _desktopEntry = addGroup("Desktop Entry"); 127 this["Version"] = "1.0"; 128 } 129 130 /** 131 * Removes group by name. 132 */ 133 override void removeGroup(string groupName) @safe nothrow { 134 if (groupName != "Desktop Entry") { 135 super.removeGroup(groupName); 136 } 137 } 138 139 /** 140 * Returns: Type of desktop entry. 141 */ 142 Type type() const @safe @nogc nothrow { 143 string t = value("Type"); 144 if (t.length) { 145 if (t == "Application") { 146 return Type.Application; 147 } else if (t == "Link") { 148 return Type.Link; 149 } else if (t == "Directory") { 150 return Type.Directory; 151 } 152 } 153 if (_fileName.extension == ".directory") { 154 return Type.Directory; 155 } 156 157 return Type.Unknown; 158 } 159 /// Sets "Type" field to type 160 Type type(Type t) @safe { 161 final switch(t) { 162 case Type.Application: 163 this["Type"] = "Application"; 164 break; 165 case Type.Link: 166 this["Type"] = "Link"; 167 break; 168 case Type.Directory: 169 this["Type"] = "Directory"; 170 break; 171 case Type.Unknown: 172 break; 173 } 174 return t; 175 } 176 177 /** 178 * Specific name of the application, for example "Mozilla". 179 * Returns: the value associated with "Name" key. 180 */ 181 string name() const @safe @nogc nothrow { 182 return value("Name"); 183 } 184 ///ditto, but returns localized value. 185 string localizedName(string locale = null) const @safe nothrow { 186 return localizedValue("Name"); 187 } 188 189 /** 190 * Generic name of the application, for example "Web Browser". 191 * Returns: the value associated with "GenericName" key. 192 */ 193 string genericName() const @safe @nogc nothrow { 194 return value("GenericName"); 195 } 196 ///ditto, but returns localized value. 197 string localizedGenericName(string locale = null) const @safe nothrow { 198 return localizedValue("GenericName"); 199 } 200 201 /** 202 * Tooltip for the entry, for example "View sites on the Internet". 203 * Returns: the value associated with "Comment" key. 204 */ 205 string comment() const @safe @nogc nothrow { 206 return value("Comment"); 207 } 208 ///ditto, but returns localized value. 209 string localizedComment(string locale = null) const @safe nothrow { 210 return localizedValue("Comment"); 211 } 212 213 /** 214 * Returns: the value associated with "Exec" key. 215 * Note: don't use this to start the program. Consider using expandExecString or startApplication instead. 216 */ 217 string execString() const @safe @nogc nothrow { 218 return value("Exec"); 219 } 220 221 222 /** 223 * Returns: the value associated with "TryExec" key. 224 */ 225 string tryExecString() const @safe @nogc nothrow { 226 return value("TryExec"); 227 } 228 229 /** 230 * Returns: the value associated with "Icon" key. If not found it also tries "X-Window-Icon". 231 * 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. 232 */ 233 string iconName() const @safe @nogc nothrow { 234 string iconPath = value("Icon"); 235 if (iconPath is null) { 236 iconPath = value("X-Window-Icon"); 237 } 238 return iconPath; 239 } 240 241 /** 242 * Returns: the value associated with "NoDisplay" key converted to bool using isTrue. 243 */ 244 bool noDisplay() const @safe @nogc nothrow { 245 return isTrue(value("NoDisplay")); 246 } 247 248 /** 249 * Returns: the value associated with "Hidden" key converted to bool using isTrue. 250 */ 251 bool hidden() const @safe @nogc nothrow { 252 return isTrue(value("Hidden")); 253 } 254 255 /** 256 * The working directory to run the program in. 257 * Returns: the value associated with "Path" key. 258 */ 259 string workingDirectory() const @safe @nogc nothrow { 260 return value("Path"); 261 } 262 263 /** 264 * Whether the program runs in a terminal window. 265 * Returns: the value associated with "Hidden" key converted to bool using isTrue. 266 */ 267 bool terminal() const @safe @nogc nothrow { 268 return isTrue(value("Terminal")); 269 } 270 /// Sets "Terminal" field to true or false. 271 bool terminal(bool t) @safe { 272 this["Terminal"] = t ? "true" : "false"; 273 return t; 274 } 275 276 /** 277 * Some keys can have multiple values, separated by semicolon. This function helps to parse such kind of strings into the range. 278 * Returns: the range of multiple nonempty values. 279 */ 280 static auto splitValues(string values) @trusted { 281 return values.splitter(';').filter!(s => s.length != 0); 282 } 283 284 /** 285 * Join range of multiple values into a string using semicolon as separator. Adds trailing semicolon. 286 * If range is empty, then the empty string is returned. 287 */ 288 static @trusted string joinValues(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 289 auto result = values.filter!( s => s.length != 0 ).joiner(";"); 290 if (result.empty) { 291 return null; 292 } else { 293 return text(result) ~ ";"; 294 } 295 } 296 297 /** 298 * Categories this program belongs to. 299 * Returns: the range of multiple values associated with "Categories" key. 300 */ 301 auto categories() const @safe { 302 return splitValues(value("Categories")); 303 } 304 305 /** 306 * Sets the list of values for the "Categories" list. 307 */ 308 @safe void categories(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 309 this["Categories"] = joinValues(values); 310 } 311 312 /** 313 * A list of strings which may be used in addition to other metadata to describe this entry. 314 * Returns: the range of multiple values associated with "Keywords" key. 315 */ 316 auto keywords() const @safe { 317 return splitValues(value("Keywords")); 318 } 319 320 /** 321 * Sets the list of values for the "Keywords" list. 322 */ 323 @safe void keywords(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 324 this["Keywords"] = joinValues(values); 325 } 326 327 /** 328 * The MIME type(s) supported by this application. 329 * Returns: the range of multiple values associated with "MimeType" key. 330 */ 331 auto mimeTypes() const @safe { 332 return splitValues(value("MimeType")); 333 } 334 335 /** 336 * Sets the list of values for the "MimeType" list. 337 */ 338 @safe void mimeTypes(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 339 this["MimeType"] = joinValues(values); 340 } 341 342 /** 343 * A list of strings identifying the desktop environments that should display a given desktop entry. 344 * Returns: the range of multiple values associated with "OnlyShowIn" key. 345 */ 346 auto onlyShowIn() const @safe { 347 return splitValues(value("OnlyShowIn")); 348 } 349 350 /** 351 * A list of strings identifying the desktop environments that should not display a given desktop entry. 352 * Returns: the range of multiple values associated with "NotShowIn" key. 353 */ 354 auto notShowIn() const @safe { 355 return splitValues(value("NotShowIn")); 356 } 357 358 /** 359 * Returns: instance of "Desktop Entry" group. 360 * Note: usually you don't need to call this function since you can rely on alias this. 361 */ 362 inout(DesktopGroup) desktopEntry() @safe @nogc nothrow inout { 363 return _desktopEntry; 364 } 365 366 367 /** 368 * This alias allows to call functions related to "Desktop Entry" group without need to call desktopEntry explicitly. 369 */ 370 alias desktopEntry this; 371 372 /** 373 * Expands Exec string into the array of command line arguments to use to start the program. 374 */ 375 string[] expandExecString(in string[] urls = null) const @safe 376 { 377 string[] toReturn; 378 auto execStr = execString().unescapeExec(); //add unquoting 379 380 foreach(token; execStr.split) { 381 if (token == "%f") { 382 if (urls.length) { 383 toReturn ~= urls.front; 384 } 385 } else if (token == "%F") { 386 toReturn ~= urls; 387 } else if (token == "%u") { 388 if (urls.length) { 389 toReturn ~= urls.front; 390 } 391 } else if (token == "%U") { 392 toReturn ~= urls; 393 } else if (token == "%i") { 394 string iconStr = iconName(); 395 if (iconStr.length) { 396 toReturn ~= "--icon"; 397 toReturn ~= iconStr; 398 } 399 } else if (token == "%c") { 400 toReturn ~= localizedValue("Name"); 401 } else if (token == "%k") { 402 toReturn ~= fileName(); 403 } else if (token == "%d" || token == "%D" || token == "%n" || token == "%N" || token == "%m" || token == "%v") { 404 continue; 405 } else { 406 toReturn ~= token; 407 } 408 } 409 410 return toReturn; 411 } 412 413 /** 414 * Starts the program associated with this .desktop file using urls as command line params. 415 * Note: 416 * If the program should be run in terminal it tries to find system defined terminal emulator to run in. 417 * 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. 418 * $(I xterm) is used by default, if could not determine other terminal emulator. 419 * Note: 420 * This function does not check if the type of desktop file is Application. It relies only on "Exec" value. 421 * Returns: 422 * Pid of started process. 423 * Throws: 424 * ProcessException on failure to start the process. 425 * Exception if expanded exec string is empty. 426 */ 427 Pid startApplication(in string[] urls = null) const @trusted 428 { 429 auto args = expandExecString(urls); 430 enforce(args.length, "No command line params to run the program. Is Exec missing?"); 431 432 if (terminal()) { 433 string term = environment.get("TERM"); 434 435 version(linux) { 436 if (term.empty) { 437 string debianTerm = "/usr/bin/x-terminal-emulator"; 438 if (debianTerm.isExecutable()) { 439 term = debianTerm; 440 } 441 } 442 } 443 444 if (term.empty) { 445 term = "xterm"; 446 } 447 448 args = [term, "-e"] ~ args; 449 } 450 451 File newStdin; 452 version(Posix) { 453 newStdin = File("/dev/null", "rb"); 454 } else { 455 newStdin = std.stdio.stdin; 456 } 457 458 return spawnProcess(args, newStdin, std.stdio.stdout, std.stdio.stderr, null, Config.none, workingDirectory()); 459 } 460 461 ///ditto, but uses the only url. 462 Pid startApplication(in string url) const @trusted 463 { 464 return startApplication([url]); 465 } 466 467 Pid startLink() const @trusted 468 { 469 string url = value("URL"); 470 return spawnProcess(["xdg-open", url], null, Config.none); 471 } 472 473 private: 474 DesktopGroup _desktopEntry; 475 string _fileName; 476 477 size_t[string] _groupIndices; 478 DesktopGroup[] _groups; 479 480 string[] firstLines; 481 } 482 483 unittest 484 { 485 //Test split/join values 486 487 assert(equal(DesktopFile.splitValues("Application;Utility;FileManager;"), ["Application", "Utility", "FileManager"])); 488 assert(DesktopFile.splitValues(";").empty); 489 assert(equal(DesktopFile.joinValues(["Application", "Utility", "FileManager"]), "Application;Utility;FileManager;")); 490 assert(DesktopFile.joinValues([""]).empty); 491 492 //Test DesktopFile 493 string desktopFileContents = 494 `[Desktop Entry] 495 # Comment 496 Name=Double Commander 497 GenericName=File manager 498 GenericName[ru]=Файловый менеджер 499 Comment=Double Commander is a cross platform open source file manager with two panels side by side. 500 Terminal=false 501 Icon=doublecmd 502 Exec=doublecmd 503 Type=Application 504 Categories=Application;Utility;FileManager; 505 Keywords=folder;manager;explore;disk;filesystem;orthodox;copy;queue;queuing;operations;`; 506 507 auto df = DesktopFile.loadFromString(desktopFileContents, DesktopFile.ReadOptions.preserveComments); 508 assert(df.name() == "Double Commander"); 509 assert(df.genericName() == "File manager"); 510 assert(df.localizedValue("GenericName", "ru_RU") == "Файловый менеджер"); 511 assert(!df.terminal()); 512 assert(df.type() == DesktopFile.Type.Application); 513 assert(equal(df.categories(), ["Application", "Utility", "FileManager"])); 514 515 assert(df.saveToString() == desktopFileContents); 516 517 assert(df.contains("Icon")); 518 df.removeEntry("Icon"); 519 assert(!df.contains("Icon")); 520 df["Icon"] = "files"; 521 assert(df.contains("Icon")); 522 523 df = new DesktopFile(); 524 assert(df.desktopEntry()); 525 assert(df.value("Version") == "1.0"); 526 assert(df.categories().empty); 527 assert(df.type() == DesktopFile.Type.Unknown); 528 529 df.terminal = true; 530 df.type = DesktopFile.Type.Application; 531 df.categories = ["Development", "Compilers"]; 532 533 assert(df.terminal() == true); 534 assert(df.type() == DesktopFile.Type.Application); 535 assert(equal(df.categories(), ["Development", "Compilers"])); 536 }