1 module dlangui.core.filemanager;
2 import dlangui.core.logger;
3 import isfreedesktop;
4 
5 /**
6  * Show and select directory or file in OS file manager.
7  *
8  * On Windows this shows file in File Exporer.
9  *
10  * On macOS it reveals file in Finder.
11  *
12  * On Freedesktop systems this function finds user preferred program that used to open directories.
13  *  If found file manager is known to this function, it uses file manager specific way to select file.
14  *  Otherwise it fallbacks to opening $(D pathName) if it's directory or parent directory of $(D pathName) if it's file.
15  */
16 @trusted bool showInFileManager(string pathName) {
17     import std.process;
18     import std.path;
19     import std.file;
20     Log.i("showInFileManager(", pathName, ")");
21     string normalized = buildNormalizedPath(pathName);
22     if (!normalized.exists) {
23         Log.e("showInFileManager failed - file or directory does not exist");
24         return false;
25     }
26     import std.string;
27     try {
28         version (Windows) {
29             import core.sys.windows.windows;
30             import dlangui.core.files;
31             import std.utf : toUTF16z;
32 
33             string explorerPath = findExecutablePath("explorer.exe");
34             if (!explorerPath.length) {
35                 Log.e("showInFileManager failed - cannot find explorer.exe");
36                 return false;
37             }
38             string arg = "/select,\"" ~ normalized ~ "\"";
39             STARTUPINFO si;
40             si.cb = si.sizeof;
41             PROCESS_INFORMATION pi;
42             Log.d("showInFileManager: ", explorerPath, " ", arg);
43             arg = "\"" ~ explorerPath ~ "\" " ~ arg;
44             auto res = CreateProcessW(null, //explorerPath.toUTF16z,
45                                         cast(wchar*)arg.toUTF16z,
46                                         null, null, false, DETACHED_PROCESS,
47                                         null, null, &si, &pi);
48             if (!res) {
49                 Log.e("showInFileManager failed to run explorer.exe");
50                 return false;
51             }
52             return true;
53         } else version (OSX) {
54             string exe = "/usr/bin/osascript";
55             string[] args;
56             args ~= exe;
57             args ~= "-e";
58             args ~= "tell application \"Finder\" to reveal (POSIX file \"" ~ normalized ~ "\")";
59             Log.d("Executing command: ", args);
60             auto pid = spawnProcess(args);
61             wait(pid);
62             args[2] = "tell application \"Finder\" to activate";
63             Log.d("Executing command: ", args);
64             pid = spawnProcess(args);
65             wait(pid);
66             return true;
67         } else version(Android) {
68             Log.w("showInFileManager is not implemented for current platform");
69         } else static if (isFreedesktop) {
70             import std.stdio : File;
71             import std.algorithm : map, filter, splitter, canFind, equal, findSplit;
72             import std.exception : collectException, assumeUnique;
73             import std.path : buildPath, absolutePath, isAbsolute, dirName, baseName;
74             import std.range;
75             import std.string : toStringz;
76             import std.typecons : Tuple, tuple;
77             static import std.stdio;
78             import inilike.common;
79             import inilike.range;
80             import xdgpaths;
81 
82             string toOpen = pathName;
83 
84             static string unescapeQuotedArgument(string value) nothrow pure
85             {
86                 static immutable Tuple!(char, char)[] pairs = [
87                     tuple('`', '`'),
88                     tuple('$', '$'),
89                     tuple('"', '"'),
90                     tuple('\\', '\\')
91                 ];
92                 return doUnescape(value, pairs);
93             }
94 
95             static auto unquoteExec(string unescapedValue) pure
96             {
97                 auto value = unescapedValue;
98                 string[] result;
99                 size_t i;
100 
101                 static string parseQuotedPart(ref size_t i, char delimeter, string value)
102                 {
103                     size_t start = ++i;
104                     bool inQuotes = true;
105 
106                     while(i < value.length && inQuotes) {
107                         if (value[i] == '\\' && value.length > i+1 && value[i+1] == '\\') {
108                             i+=2;
109                             continue;
110                         }
111 
112                         inQuotes = !(value[i] == delimeter && (value[i-1] != '\\' || (i>=2 && value[i-1] == '\\' && value[i-2] == '\\') ));
113                         if (inQuotes) {
114                             i++;
115                         }
116                     }
117                     if (inQuotes) {
118                         throw new Exception("Missing pair quote");
119                     }
120                     return unescapeQuotedArgument(value[start..i]);
121                 }
122 
123                 char[] append;
124                 bool wasInQuotes;
125                 while(i < value.length) {
126                     if (value[i] == ' ' || value[i] == '\t') {
127                         if (!wasInQuotes && append.length >= 1 && append[$-1] == '\\') {
128                             append[$-1] = value[i];
129                         } else {
130                             if (append !is null) {
131                                 result ~= append.assumeUnique;
132                                 append = null;
133                             }
134                         }
135                         wasInQuotes = false;
136                     } else if (value[i] == '"' || value[i] == '\'') {
137                         append ~= parseQuotedPart(i, value[i], value);
138                         wasInQuotes = true;
139                     } else {
140                         append ~= value[i];
141                         wasInQuotes = false;
142                     }
143                     i++;
144                 }
145 
146                 if (append !is null) {
147                     result ~= append.assumeUnique;
148                 }
149 
150                 return result;
151             }
152 
153             static string urlToFilePath(string url) nothrow pure
154             {
155                 enum protocol = "file://";
156                 if (url.length > protocol.length && url[0..protocol.length] == protocol) {
157                     return url[protocol.length..$];
158                 } else {
159                     return url;
160                 }
161             }
162 
163             static string[] expandExecArgs(in string[] unquotedArgs, in string[] urls = null, string iconName = null, string displayName = null, string fileName = null) pure
164             {
165                 string[] toReturn;
166                 foreach(token; unquotedArgs) {
167                     if (token == "%F") {
168                         toReturn ~= urls.map!(url => urlToFilePath(url)).array;
169                     } else if (token == "%U") {
170                         toReturn ~= urls;
171                     } else if (token == "%i") {
172                         if (iconName.length) {
173                             toReturn ~= "--icon";
174                             toReturn ~= iconName;
175                         }
176                     } else {
177                         static void expand(string token, ref string expanded, ref size_t restPos, ref size_t i, string insert)
178                         {
179                             if (token.length == 2) {
180                                 expanded = insert;
181                             } else {
182                                 expanded ~= token[restPos..i] ~ insert;
183                             }
184                             restPos = i+2;
185                             i++;
186                         }
187 
188                         string expanded;
189                         size_t restPos = 0;
190                         bool ignore;
191                         loop: for(size_t i=0; i<token.length; ++i) {
192                             if (token[i] == '%' && i<token.length-1) {
193                                 switch(token[i+1]) {
194                                     case 'f': case 'u':
195                                     {
196                                         if (urls.length) {
197                                             string arg = urls.front;
198                                             if (token[i+1] == 'f') {
199                                                 arg = urlToFilePath(arg);
200                                             }
201                                             expand(token, expanded, restPos, i, arg);
202                                         } else {
203                                             ignore = true;
204                                             break loop;
205                                         }
206                                     }
207                                     break;
208                                     case 'c':
209                                     {
210                                         expand(token, expanded, restPos, i, displayName);
211                                     }
212                                     break;
213                                     case 'k':
214                                     {
215                                         expand(token, expanded, restPos, i, fileName);
216                                     }
217                                     break;
218                                     case 'd': case 'D': case 'n': case 'N': case 'm': case 'v':
219                                     {
220                                         ignore = true;
221                                         break loop;
222                                     }
223                                     case '%':
224                                     {
225                                         expand(token, expanded, restPos, i, "%");
226                                     }
227                                     break;
228                                     default:
229                                     {
230                                         throw new Exception("Unknown or misplaced field code: " ~ token);
231                                     }
232                                 }
233                             }
234                         }
235 
236                         if (!ignore) {
237                             toReturn ~= expanded ~ token[restPos..$];
238                         }
239                     }
240                 }
241 
242                 return toReturn;
243             }
244 
245             static bool isExecutable(string program) nothrow
246             {
247                 import core.sys.posix.unistd;
248                 return access(program.toStringz, X_OK) == 0;
249             }
250 
251             static string findExecutable(string program, const(string)[] binPaths) nothrow
252             {
253                 if (program.isAbsolute && isExecutable(program)) {
254                     return program;
255                 } else if (program.baseName == program) {
256                     foreach(path; binPaths) {
257                         auto candidate = buildPath(path, program);
258                         if (isExecutable(candidate)) {
259                             return candidate;
260                         }
261                     }
262                 }
263                 return null;
264             }
265 
266             static void parseConfigFile(string fileName, string wantedGroup, bool delegate (string, string) onKeyValue)
267             {
268                 auto r = iniLikeFileReader(fileName);
269                 foreach(group; r.byGroup())
270                 {
271                     if (group.groupName == wantedGroup)
272                     {
273                         foreach(entry; group.byEntry())
274                         {
275                             if (entry.length && !isComment(entry)) {
276                                 auto pair = parseKeyValue(entry);
277                                 if (!isValidKey(pair.key)) {
278                                     return;
279                                 }
280                                 if (!onKeyValue(pair.key, pair.value.unescapeValue)) {
281                                     return;
282                                 }
283                             }
284                         }
285                         return;
286                     }
287                 }
288             }
289 
290             static string[] findFileManagerCommand(string app, const(string)[] appDirs, const(string)[] binPaths) nothrow
291             {
292                 foreach(appDir; appDirs) {
293                     bool fileExists;
294                     auto appPath = buildPath(appDir, app);
295                     collectException(appPath.isFile, fileExists);
296                     if (!fileExists) {
297                         //check if file in subdirectory exist. E.g. kde4-dolphin.desktop refers to kde4/dolphin.desktop
298                         auto appSplitted = findSplit(app, "-");
299                         if (appSplitted[1].length && appSplitted[2].length) {
300                             appPath = buildPath(appDir, appSplitted[0], appSplitted[2]);
301                             collectException(appPath.isFile, fileExists);
302                         }
303                     }
304 
305                     if (fileExists) {
306                         try {
307                             bool canOpenDirectory; //not used for now. Some file managers does not have MimeType in their .desktop file.
308                             string exec, tryExec, icon, displayName;
309 
310                             parseConfigFile(appPath, "Desktop Entry", delegate bool(string key, string value) {
311                                 if (key.equal("MimeType")) {
312                                     canOpenDirectory = value.splitter(';').canFind("inode/directory");
313                                 } else if (key.equal("Exec")) {
314                                     exec = value;
315                                 } else if (key.equal("TryExec")) {
316                                     tryExec = value;
317                                 } else if (key.equal("Icon")) {
318                                     icon = value;
319                                 } else if (key.equal("Name")) {
320                                     displayName = value;
321                                 }
322                                 return true;
323                             });
324 
325                             if (exec.length) {
326                                 if (tryExec.length) {
327                                     auto program = findExecutable(tryExec, binPaths);
328                                     if (!program.length) {
329                                         continue;
330                                     }
331                                 }
332                                 return expandExecArgs(unquoteExec(exec), null, icon, displayName, appPath);
333                             }
334 
335                         } catch(Exception e) {
336 
337                         }
338                     }
339                 }
340 
341                 return null;
342             }
343 
344             static void execShowInFileManager(string[] fileManagerArgs, string toOpen)
345             {
346                 toOpen = toOpen.absolutePath();
347                 switch(fileManagerArgs[0].baseName) {
348                     //nautilus and nemo select item if it's a file
349                     case "nautilus":
350                     case "nemo":
351                         fileManagerArgs ~= toOpen;
352                         break;
353                     //dolphin needs --select option
354                     case "dolphin":
355                     case "konqueror":
356                         fileManagerArgs ~= ["--select", toOpen];
357                         break;
358                     default:
359                     {
360                         bool pathIsDir;
361                         collectException(toOpen.isDir, pathIsDir);
362                         if (!pathIsDir) {
363                             fileManagerArgs ~= toOpen.dirName;
364                         } else {
365                             fileManagerArgs ~= toOpen;
366                         }
367                     }
368                         break;
369                 }
370 
371                 File inFile, outFile, errFile;
372                 try {
373                     inFile = File("/dev/null", "rb");
374                 } catch(Exception) {
375                     inFile = std.stdio.stdin;
376                 }
377                 try {
378                     auto nullFile = File("/dev/null", "wb");
379                     outFile = nullFile;
380                     errFile = nullFile;
381                 } catch(Exception) {
382                     outFile = std.stdio.stdout;
383                     errFile = std.stdio.stderr;
384                 }
385 
386                 auto processConfig = Config.none;
387                 static if (is(typeof(Config.detached)))
388                 {
389                     processConfig |= Config.detached;
390                 }
391                 spawnProcess(fileManagerArgs, inFile, outFile, errFile, null, processConfig);
392             }
393 
394             string configHome = xdgConfigHome();
395             string appHome = xdgDataHome("applications");
396 
397             auto configDirs = xdgConfigDirs();
398             auto appDirs = xdgDataDirs("applications");
399 
400             auto allAppDirs = xdgAllDataDirs("applications");
401             auto binPaths = environment.get("PATH").splitter(':').filter!(p => p.length > 0).array;
402 
403             string[] fileManagerArgs;
404             foreach(mimeappsList; chain(only(configHome), only(appHome), configDirs, appDirs).map!(p => buildPath(p, "mimeapps.list"))) {
405                 try {
406                     parseConfigFile(mimeappsList, "Default Applications", delegate bool(string key, string value) {
407                         if (key.equal("inode/directory") && value.length) {
408                             auto app = value;
409                             fileManagerArgs = findFileManagerCommand(app, allAppDirs, binPaths);
410                             return false;
411                         }
412                         return true;
413                     });
414                 } catch(Exception e) {
415 
416                 }
417 
418                 if (fileManagerArgs.length) {
419                     execShowInFileManager(fileManagerArgs, toOpen);
420                     return true;
421                 }
422             }
423 
424             foreach(mimeinfoCache; allAppDirs.map!(p => buildPath(p, "mimeinfo.cache"))) {
425                 try {
426                     parseConfigFile(mimeinfoCache, "MIME Cache", delegate bool(string key, string value) {
427                         if (key > "inode/directory") { //no need to proceed, since MIME types are sorted in alphabetical order.
428                             return false;
429                         }
430                         if (key.equal("inode/directory") && value.length) {
431                             auto alternatives = value.splitter(';').filter!(p => p.length > 0);
432                             foreach(alternative; alternatives) {
433                                 fileManagerArgs = findFileManagerCommand(alternative, allAppDirs, binPaths);
434                                 if (fileManagerArgs.length) {
435                                     break;
436                                 }
437                             }
438                             return false;
439                         }
440                         return true;
441                     });
442                 } catch(Exception e) {
443 
444                 }
445 
446                 if (fileManagerArgs.length) {
447                     execShowInFileManager(fileManagerArgs, toOpen);
448                     return true;
449                 }
450             }
451 
452             Log.e("showInFileManager -- could not find application to open directory");
453             return false;
454         } else {
455             Log.w("showInFileManager is not implemented for current platform");
456         }
457     } catch (Exception e) {
458         Log.e("showInFileManager -- exception while trying to open file browser");
459     }
460     return false;
461 }