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");
+ });
+ });
+});