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