/** * @file file-manager.js * @description The files THIS machine's file manager (Explorer, Finder, * Nautilus/Dolphin) is handing the browser, by their own absolute paths — * what a native terminal pastes or types for them. A browser gives a page a * pasted or dropped file as bytes and a name only (Chrome: never its path), * so the terminal and the prompt box would upload a copy, capped in size. * Two sources, each answered only to a loopback browser of a local-mode * dashboard; the client uses a path only when its name and size match the * file the browser holds, so an SSH tunnel's browser still uploads: * - `clipboardFiles` (`POST /api/run/clipboard-files`, a paste): what a Copy * put on the clipboard — Windows `powershell -Sta` + * `Clipboard.GetFileDropList()` (Claude Code reads its images the same * way), macOS JXA over `NSPasteboard` file URLs, Linux `wl-paste`/`xclip` * for `text/uri-list` or `x-special/gnome-copied-files`; * - `selectedFiles` (`POST /api/run/selected-files`, a drop): a drag from * a file manager carries the items selected in it, so its selection — * Windows `Shell.Application` (every Explorer window, and the desktop), * macOS Finder's `selection` (macOS asks once to let AgentHub control * Finder). Linux file managers publish no selection: a drop there takes * the `file://` list the browser already hands over, else uploads. * Nothing is searched for on disk. Every reader is a fixed argv, nothing from * the request; a missing reader or an empty answer is an empty list. * @author Nguyễn Ngọc Trí Vĩ */ const { execFile } = require("node:child_process"); const fs = require("node:fs"); const path = require("node:path"); const MAX_FILES = 100; const TIMEOUT_MS = 4000; let execImpl = (cmd, args, env) => new Promise((resolve) => { execFile( cmd, args, { encoding: "utf8", timeout: TIMEOUT_MS, maxBuffer: 1024 * 1024, env, windowsHide: true }, (err, stdout) => resolve(err ? "" : String(stdout || "")) ); }); /** Test seam: `(cmd, args, env) => Promise`. */ function __setExecImpl(fn) { execImpl = fn; } const WIN_SCRIPT = "[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; " + "[System.Windows.Forms.Clipboard]::GetFileDropList() | ForEach-Object { $_ }"; const MAC_SCRIPT = [ 'ObjC.import("AppKit");', "const pb = $.NSPasteboard.generalPasteboard;", "const urls = pb.readObjectsForClassesOptions($([$.NSURL]), $({ NSPasteboardURLReadingFileURLsOnlyKey: true }));", "const out = [];", "if (urls) for (let i = 0; i < urls.count; i++) out.push(urls.objectAtIndex(i).path.js);", 'out.join("\\n");', ].join("\n"); /** `file://` URIs (and a gnome list's leading `copy`/`cut`) as local paths. */ function uriListPaths(text) { const out = []; for (const raw of String(text || "").split(/\r?\n/)) { const line = raw.trim(); if (!line || line.startsWith("#") || line === "copy" || line === "cut") continue; try { const u = new URL(line); if (u.protocol !== "file:" || (u.host && u.host !== "localhost")) continue; out.push(decodeURIComponent(u.pathname)); } catch { /* not a URI */ } } return out; } /** The raw paths on the clipboard, per platform; [] when it holds none. */ async function clipboardPaths(platform = process.platform) { if (platform === "win32") { const out = await execImpl( "powershell", ["-NoProfile", "-NonInteractive", "-Sta", "-Command", WIN_SCRIPT], process.env ); return out .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); } if (platform === "darwin") { const out = await execImpl("osascript", ["-l", "JavaScript", "-e", MAC_SCRIPT], process.env); return out .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); } // Linux: the desktop session's variables, which a user service lacks. const env = { ...process.env, ...require("./desktop-env").desktopEnv() }; const tries = []; if (env.WAYLAND_DISPLAY) { tries.push(["wl-paste", ["--no-newline", "--type", "text/uri-list"]]); tries.push(["wl-paste", ["--no-newline", "--type", "x-special/gnome-copied-files"]]); } if (env.DISPLAY) { tries.push(["xclip", ["-selection", "clipboard", "-t", "text/uri-list", "-o"]]); tries.push(["xclip", ["-selection", "clipboard", "-t", "x-special/gnome-copied-files", "-o"]]); } for (const [cmd, args] of tries) { const paths = uriListPaths(await execImpl(cmd, args, env)); if (paths.length) return paths; } return []; } const WIN_SELECTION = "[Console]::OutputEncoding = [Text.Encoding]::UTF8; $sh = New-Object -ComObject Shell.Application; " + "foreach ($w in @($sh.Windows())) { try { foreach ($i in $w.Document.SelectedItems()) { $i.Path } } catch {} }; " + // The desktop is no Explorer window: ShellWindows.FindWindowSW(SWC_DESKTOP). "try { $d = $sh.Windows().FindWindowSW(0, $null, 8, [ref]0, 1); foreach ($i in $d.Document.SelectedItems()) { $i.Path } } catch {}"; const MAC_SELECTION = [ 'tell application "Finder"', 'set out to ""', "repeat with a in (selection as alias list)", "set out to out & POSIX path of a & linefeed", "end repeat", "return out", "end tell", ]; /** The paths a file manager has selected, per platform; [] when unknown. */ async function selectionPaths(platform = process.platform) { let out = ""; if (platform === "win32") { out = await execImpl( "powershell", ["-NoProfile", "-NonInteractive", "-Sta", "-Command", WIN_SELECTION], process.env ); } else if (platform === "darwin") { out = await execImpl( "osascript", MAC_SELECTION.flatMap((l) => ["-e", l]), process.env ); } return out .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); } /** `[{ path, size, dir }]` for each absolute path that exists here (`size` * null for a folder), at most 100, without repeats. */ function existing(paths) { const out = []; const seen = new Set(); for (const p of paths.slice(0, MAX_FILES)) { if (!path.isAbsolute(p) && !/^[A-Za-z]:[\\/]/.test(p)) continue; if (seen.has(p)) continue; seen.add(p); try { const st = fs.statSync(p); if (st.isFile()) out.push({ path: p, size: st.size, dir: false }); else if (st.isDirectory()) out.push({ path: p, size: null, dir: true }); } catch { /* gone since the copy */ } } return out; } /** The clipboard's files (a file manager's Copy); [] when it holds none. */ async function clipboardFiles(platform = process.platform) { try { return existing(await clipboardPaths(platform)); } catch { return []; } } /** The files selected in the file manager — what a drag from it carries; * [] when none, or on a platform whose file manager publishes none. */ async function selectedFiles(platform = process.platform) { try { return existing(await selectionPaths(platform)); } catch { return []; } } module.exports = { clipboardFiles, selectedFiles, uriListPaths, __setExecImpl };