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 }