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 }