/** * @file native-dialog.js * @description Opens the operating system's own folder/file picker ON THE * SERVER'S DESKTOP and returns the absolute path the user chose. A browser's * native picker never hands a page an absolute path, and the dashboard is * local-first, so the server asks the desktop itself: zenity or kdialog on * Linux (with the graphical session's variables from `desktop-env.js`, which a * systemd user service lacks), `osascript` on macOS, PowerShell's WinForms * dialogs on Windows. Runs with `execFile` (never a shell, never sync — a * dialog waits on a human for minutes), one dialog at a time. Nothing from the * request reaches argv except the starting folder, resolved to an absolute * path first. macOS and Windows are code-only, untested on those systems. * @author Nguyễn Ngọc Trí Vĩ */ const { execFile } = require("node:child_process"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { desktopEnv } = require("./desktop-env"); const TITLES = { dir: "Select a folder", file: "Select a file" }; const TIMEOUT_MS = 10 * 60 * 1000; const defaultExec = (cmd, args, env, signal) => new Promise((resolve) => { execFile( cmd, args, { env, timeout: TIMEOUT_MS, windowsHide: true, encoding: "utf8", signal }, (err, stdout, stderr) => resolve({ missing: err?.code === "ENOENT", code: err ? (typeof err.code === "number" ? err.code : -1) : 0, stdout: String(stdout || ""), stderr: String(stderr || ""), }) ); }); let execImpl = defaultExec; /** Test seam — same pattern as desktop-env.js; `null` restores the real one. */ function __setExecImpl(fn) { execImpl = fn || defaultExec; } /** An existing folder to open the dialog in: `start` (or its parent when it is a file), else home. */ function startDir(start) { if (typeof start === "string" && start.trim()) { const raw = start.trim().replace(/^~(?=$|[\\/])/, os.homedir()); const abs = path.resolve(raw); for (const p of [abs, path.dirname(abs)]) { try { if (fs.statSync(p).isDirectory()) return p; } catch { // missing — try the parent, then home } } } return os.homedir(); } // user32 calls PowerShell has no cmdlet for: `Front` makes a window the // foreground one from a background process (see dialogCommands). const WIN_FRONT = [ '[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n);', '[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);', '[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);', '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();', '[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, IntPtr p);', '[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool on);', '[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();', "public static void Front(IntPtr h) {", " uint fg = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero);", " uint me = GetCurrentThreadId();", " bool joined = fg != 0 && fg != me && AttachThreadInput(me, fg, true);", " BringWindowToTop(h); SetForegroundWindow(h);", " if (joined) AttachThreadInput(me, fg, false);", "}", ].join("\n"); /** The candidate dialog commands for a platform, in order of preference. */ function dialogCommands(kind, dir, platform = process.platform) { const title = TITLES[kind]; if (platform === "darwin") { const what = kind === "dir" ? "folder" : "file"; return [ [ "osascript", [ "-e", "on run argv", "-e", `POSIX path of (choose ${what} with prompt (item 1 of argv) default location (POSIX file (item 2 of argv)))`, "-e", "end run", title, dir, ], ], ]; } if (platform === "win32") { const dialog = kind === "dir" ? "$d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.SelectedPath = $env:AGENTHUB_PICK_START; $d.Description = $env:AGENTHUB_PICK_TITLE" : "$d = New-Object System.Windows.Forms.OpenFileDialog; $d.InitialDirectory = $env:AGENTHUB_PICK_START; $d.Title = $env:AGENTHUB_PICK_TITLE"; const result = kind === "dir" ? "$d.SelectedPath" : "$d.FileName"; // The server is a background process, and Windows keeps one from bringing // a window forward: an ownerless dialog opened at the BOTTOM of the // z-order, behind the browser. So the dialog gets a TopMost owner, made // the foreground window by joining the current foreground thread's input // (AttachThreadInput — the lock exempts it). The first ShowWindow of a // process started with windowsHide is turned into a hide; a throwaway call // spends it, or the owner stays hidden and takes the dialog down with it. const script = [ "$ProgressPreference = 'SilentlyContinue'", "[Console]::OutputEncoding = [Text.Encoding]::UTF8", "Add-Type -AssemblyName System.Windows.Forms", `Add-Type -Namespace AgentHub -Name Win -MemberDefinition '${WIN_FRONT}'`, "$o = New-Object System.Windows.Forms.Form", "$o.TopMost = $true; $o.ShowInTaskbar = $false; $o.FormBorderStyle = 'None'; $o.Opacity = 0; $o.Width = 1; $o.Height = 1; $o.StartPosition = 'CenterScreen'", "[void][AgentHub.Win]::ShowWindow($o.Handle, 1)", "$o.Show()", "[AgentHub.Win]::Front($o.Handle)", dialog, `if ($d.ShowDialog($o) -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`, ].join("\n"); // -EncodedCommand: the C# above carries double quotes, which Windows // PowerShell's own command-line parsing mangles inside -Command. return [ [ "powershell.exe", [ "-NoProfile", "-STA", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64"), ], ], ]; } const sep = dir.endsWith("/") ? "" : "/"; return [ [ "zenity", [ "--file-selection", `--title=${title}`, `--filename=${dir}${sep}`, ...(kind === "dir" ? ["--directory"] : []), ], ], [ "kdialog", [kind === "dir" ? "--getexistingdirectory" : "--getopenfilename", dir, "--title", title], ], ]; } // A dialog that could not reach the desktop exits 1 too — a stale DISPLAY // handed over by `systemctl --user show-environment` after a logout, say. const NO_DISPLAY_RE = /cannot open display|could not connect to display|failed to open display|cannot connect to x server|unable to init server/i; /** * Whether an exit status of 1 means the user pressed Cancel. zenity/kdialog * may print GTK/Qt warnings on a normal Cancel, so only a display failure * counts against it; osascript says Cancel as error -128 and nothing else. */ function isCancel(cmd, stderr = "") { if (cmd === "osascript") return /-128/.test(stderr); if (cmd === "powershell.exe") return !stderr.trim(); return !NO_DISPLAY_RE.test(stderr); } function fail(code, message) { return Object.assign(new Error(message), { code }); } let busy = false; /** * Show the native picker and wait for the user. * @param {"dir"|"file"} kind * @param {string} [start] Folder (or file) to open the dialog at. * @param {AbortSignal} [signal] Closes the dialog — the browser went away. * @returns {Promise} The chosen absolute path, or null when cancelled. * @throws `ENODIALOG` (no desktop or no dialog program), `EBUSY` (one is already open), `EDIALOG`. */ async function pickPath(kind, start, signal) { if (busy) throw fail("EBUSY", "a picker is already open on the desktop"); const env = { ...process.env, ...desktopEnv() }; if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) { throw fail("ENODIALOG", "no graphical desktop session to show a picker on"); } const dir = startDir(start); env.AGENTHUB_PICK_START = dir; env.AGENTHUB_PICK_TITLE = TITLES[kind]; busy = true; try { for (const [cmd, args] of dialogCommands(kind, dir)) { const r = await execImpl(cmd, args, env, signal); if (r.missing) continue; if (r.code === 1) { if (isCancel(cmd, r.stderr)) return null; // every one of these exits 1 on Cancel throw fail("ENODIALOG", `${cmd} could not open a picker on the desktop`); } const out = r.stdout.replace(/[\r\n]+$/, ""); if (r.code !== 0 || !out) throw fail("EDIALOG", `${cmd} exited with ${r.code}`); // osascript ends a folder with "/"; "/" itself stays. return out.replace(/\/+$/, "") || "/"; } throw fail("ENODIALOG", "no native picker found (install zenity or kdialog)"); } finally { busy = false; } } module.exports = { pickPath, dialogCommands, startDir, isCancel, __setExecImpl };