diff --git a/apps/desktop/src/main/open-path.ts b/apps/desktop/src/main/open-path.ts new file mode 100644 index 000000000..d85a9a212 --- /dev/null +++ b/apps/desktop/src/main/open-path.ts @@ -0,0 +1,70 @@ +/** + * Open a validated absolute directory in the host platform's native file + * manager. Pulled out of the `shell:open-path` IPC handler so the routing + * decision (Electron's default opener vs. WSL → Explorer pivot) can be unit + * tested without booting Electron. + * + * **WSL routing rationale (#1581).** On WSL Electron, `shell.openPath` + * delegates to xdg-open on Linux, and `xdg-open` typically routes + * `inode/directory` MIME through the default browser (Chrome on common WSL + * setups via `wslu`) rather than Explorer or a native Linux file manager. + * Route through `wslpath -w ` + `explorer.exe ` so the + * Windows host's Explorer opens the resolved folder, matching what the + * "Continue in CLI" flow promised users. + * + * Non-WSL Linux installs with proper `xdg-open` MIME associations, plus + * macOS and native Windows, are untouched — they still hit `shell.openPath`. + * If the WSL helpers fail (missing `wslpath`, missing `explorer.exe`, + * non-standard WSL setup), the routing falls back to `shell.openPath` + * rather than surfacing a WSL-specific error. + */ +export interface OpenPathDeps { + release: () => string; + execFile: (command: string, args: readonly string[]) => Promise<{ stdout: string }>; + openPath: (path: string) => Promise; +} + +/** + * Returns `""` on success (matching Electron's `shell.openPath` contract so + * the IPC return value is unchanged across platforms), or an error message + * string on failure. + */ +export async function openValidatedDirectory( + resolvedPath: string, + deps: OpenPathDeps, +): Promise { + if (deps.release().toLowerCase().includes("microsoft")) { + let windowsPath: string; + try { + const { stdout } = await deps.execFile("wslpath", ["-w", resolvedPath]); + windowsPath = stdout.trim(); + } catch { + return await deps.openPath(resolvedPath); + } + if (windowsPath.length > 0) { + try { + await deps.execFile("explorer.exe", [windowsPath]); + } catch (err) { + // explorer.exe routinely exits non-zero (typically 1) even after + // opening the folder successfully, so a rejected execFile here + // does NOT mean Explorer failed to launch — it just means the + // process exited non-zero. Only fall back to shell.openPath when + // explorer.exe never spawned at all (ENOENT/EACCES); for every + // other error code, treat Explorer as having opened the folder + // and short-circuit the success path. Without this distinction, + // the WSL happy path would still surface the Chrome file:// + // listing that #1581 is about, because the post-launch exit-1 + // would look identical to a missing binary. + const code = + err && typeof err === "object" && "code" in err + ? (err as { code?: unknown }).code + : undefined; + if (code === "ENOENT" || code === "EACCES") { + return await deps.openPath(resolvedPath); + } + } + return ""; + } + } + return await deps.openPath(resolvedPath); +} diff --git a/apps/desktop/src/main/runtime.ts b/apps/desktop/src/main/runtime.ts index 474e4cbe0..6051e7da4 100644 --- a/apps/desktop/src/main/runtime.ts +++ b/apps/desktop/src/main/runtime.ts @@ -1,8 +1,11 @@ +import { execFile } from "node:child_process"; import { createHmac, randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; import { appendFile, mkdir, realpath, stat, writeFile } from "node:fs/promises"; +import { release } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { BrowserWindow, app, dialog, ipcMain, nativeImage, screen, shell } from "electron"; import { @@ -15,10 +18,13 @@ import { } from "@open-design/sidecar-proto"; import type { OpenDesignHostActionResult, OpenDesignHostUpdaterActionOptions } from "@open-design/host"; +import { openValidatedDirectory } from "./open-path.js"; import { createElectronPdfTarget, exportPdfFromHtml, savePrintReadyDocumentAsPdf } from "./pdf-export.js"; import type { PrintReadyPdfOptions } from "./pdf-export.js"; import type { DesktopUpdater } from "./updater.js"; +const execFileAsync = promisify(execFile); + /** * Result of validating a candidate path before exposing it to a * privileged shell operation. @@ -1204,7 +1210,14 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom const validated = await validateExistingDirectory(resolved.context.resolvedDir); if (!validated.ok) return `open-path: ${validated.reason}`; try { - return await shell.openPath(validated.resolved); + return await openValidatedDirectory(validated.resolved, { + release, + execFile: async (cmd, args) => { + const { stdout } = await execFileAsync(cmd, [...args]); + return { stdout }; + }, + openPath: (p) => shell.openPath(p), + }); } catch (err) { return err instanceof Error ? err.message : String(err); } diff --git a/apps/desktop/tests/main/open-path.test.ts b/apps/desktop/tests/main/open-path.test.ts new file mode 100644 index 000000000..46c0a6b70 --- /dev/null +++ b/apps/desktop/tests/main/open-path.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from "vitest"; + +import { openValidatedDirectory, type OpenPathDeps } from "../../src/main/open-path.js"; + +function makeDeps(overrides: Partial = {}): OpenPathDeps { + return { + release: () => "5.15.0-100-generic", + execFile: vi.fn(async () => ({ stdout: "" })), + openPath: vi.fn(async () => ""), + ...overrides, + }; +} + +describe("openValidatedDirectory", () => { + describe("non-WSL Linux", () => { + it("hands the path to shell.openPath and never invokes wslpath", async () => { + const execFile = vi.fn(async () => ({ stdout: "" })); + const openPath = vi.fn(async () => ""); + const result = await openValidatedDirectory("/home/u/project", makeDeps({ + release: () => "5.15.0-100-generic", + execFile, + openPath, + })); + + expect(result).toBe(""); + expect(openPath).toHaveBeenCalledWith("/home/u/project"); + expect(execFile).not.toHaveBeenCalled(); + }); + + it("propagates an error string from shell.openPath", async () => { + const result = await openValidatedDirectory("/home/u/project", makeDeps({ + release: () => "5.15.0-100-generic", + openPath: vi.fn(async () => "shell.openPath: permission denied"), + })); + + expect(result).toBe("shell.openPath: permission denied"); + }); + }); + + describe("WSL", () => { + it("routes through wslpath -w + explorer.exe and returns empty on success", async () => { + const execFile = vi.fn(async (cmd: string, args: readonly string[]) => { + if (cmd === "wslpath" && args[0] === "-w") { + return { stdout: "C:\\Users\\u\\project\n" }; + } + if (cmd === "explorer.exe") { + return { stdout: "" }; + } + throw new Error(`unexpected execFile call: ${cmd}`); + }); + const openPath = vi.fn(async () => ""); + const result = await openValidatedDirectory("/mnt/c/Users/u/project", makeDeps({ + release: () => "5.15.167.4-microsoft-standard-WSL2", + execFile, + openPath, + })); + + expect(result).toBe(""); + expect(execFile).toHaveBeenNthCalledWith(1, "wslpath", ["-w", "/mnt/c/Users/u/project"]); + expect(execFile).toHaveBeenNthCalledWith(2, "explorer.exe", ["C:\\Users\\u\\project"]); + expect(openPath).not.toHaveBeenCalled(); + }); + + it("detects WSL release strings regardless of casing", async () => { + const execFile = vi.fn(async (cmd: string, args: readonly string[]) => { + if (cmd === "wslpath") return { stdout: "C:\\Users\\u\\project\n" }; + return { stdout: "" }; + }); + await openValidatedDirectory("/mnt/c/Users/u/project", makeDeps({ + release: () => "4.19.128-Microsoft-Standard", + execFile, + })); + expect(execFile).toHaveBeenCalledWith("wslpath", ["-w", "/mnt/c/Users/u/project"]); + }); + + it("falls back to shell.openPath when wslpath fails", async () => { + const execFile = vi.fn(async (cmd: string) => { + if (cmd === "wslpath") throw new Error("wslpath: command not found"); + return { stdout: "" }; + }); + const openPath = vi.fn(async () => ""); + const result = await openValidatedDirectory("/home/u/project", makeDeps({ + release: () => "5.15.0-microsoft-standard-WSL2", + execFile, + openPath, + })); + + expect(result).toBe(""); + expect(openPath).toHaveBeenCalledWith("/home/u/project"); + }); + + it("falls back to shell.openPath when wslpath returns an empty string", async () => { + const execFile = vi.fn(async () => ({ stdout: " \n" })); + const openPath = vi.fn(async () => ""); + const result = await openValidatedDirectory("/home/u/project", makeDeps({ + release: () => "5.15.0-microsoft-standard-WSL2", + execFile, + openPath, + })); + + expect(result).toBe(""); + expect(execFile).toHaveBeenCalledTimes(1); + expect(execFile).toHaveBeenCalledWith("wslpath", ["-w", "/home/u/project"]); + expect(openPath).toHaveBeenCalledWith("/home/u/project"); + }); + + it("falls back to shell.openPath when explorer.exe cannot be spawned (ENOENT)", async () => { + const execFile = vi.fn(async (cmd: string) => { + if (cmd === "wslpath") return { stdout: "C:\\Users\\u\\project\n" }; + if (cmd === "explorer.exe") { + throw Object.assign(new Error("spawn explorer.exe ENOENT"), { code: "ENOENT" }); + } + return { stdout: "" }; + }); + const openPath = vi.fn(async () => ""); + const result = await openValidatedDirectory("/mnt/c/Users/u/project", makeDeps({ + release: () => "5.15.0-microsoft-standard-WSL2", + execFile, + openPath, + })); + + expect(result).toBe(""); + expect(openPath).toHaveBeenCalledWith("/mnt/c/Users/u/project"); + }); + + it("treats a non-zero explorer.exe exit after a successful spawn as success", async () => { + // explorer.exe routinely exits 1 even after opening the folder, so + // a rejected execFile without an ENOENT-style code must not fall + // back to shell.openPath; otherwise the user would see Explorer + // open AND a Chrome file:// tab — the original #1581 symptom. + const execFile = vi.fn(async (cmd: string) => { + if (cmd === "wslpath") return { stdout: "C:\\Users\\u\\project\n" }; + if (cmd === "explorer.exe") { + throw Object.assign(new Error("Command failed with exit code 1"), { + code: 1, + stdout: "", + stderr: "", + }); + } + return { stdout: "" }; + }); + const openPath = vi.fn(async () => ""); + const result = await openValidatedDirectory("/mnt/c/Users/u/project", makeDeps({ + release: () => "5.15.0-microsoft-standard-WSL2", + execFile, + openPath, + })); + + expect(result).toBe(""); + expect(execFile).toHaveBeenCalledWith("explorer.exe", ["C:\\Users\\u\\project"]); + expect(openPath).not.toHaveBeenCalled(); + }); + + it("falls back to shell.openPath when explorer.exe is blocked (EACCES)", async () => { + const execFile = vi.fn(async (cmd: string) => { + if (cmd === "wslpath") return { stdout: "C:\\Users\\u\\project\n" }; + if (cmd === "explorer.exe") { + throw Object.assign(new Error("spawn explorer.exe EACCES"), { code: "EACCES" }); + } + return { stdout: "" }; + }); + const openPath = vi.fn(async () => ""); + const result = await openValidatedDirectory("/mnt/c/Users/u/project", makeDeps({ + release: () => "5.15.0-microsoft-standard-WSL2", + execFile, + openPath, + })); + + expect(result).toBe(""); + expect(openPath).toHaveBeenCalledWith("/mnt/c/Users/u/project"); + }); + }); +});