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 }