mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix(desktop): route shell:open-path through explorer.exe on WSL (#1581)
On WSL Electron, clicking "Continue in CLI" handed the validated project
directory to shell.openPath, which delegates to xdg-open on Linux.
xdg-open on common WSL installs routes inode/directory MIME through the
default browser via wslu, so the click landed on a Chrome tab showing a
file:// directory listing instead of a native file manager.
Detect WSL via os.release().includes("microsoft"), translate the resolved
POSIX path with `wslpath -w`, and hand the Windows path to explorer.exe so
the host's Explorer opens the folder. Fall back to shell.openPath for
non-WSL Linux, macOS, native Windows, and WSL setups where wslpath or
explorer.exe are missing.
Pull the routing decision into a pure helper (open-path.ts) so the WSL
branch is unit-testable with mocked deps, mirroring the dependency-
injection pattern createDesktopUpdater already uses. Path validation
(validateExistingDirectory, isOpenPathAllowedForProject) stays in front
of the new branch — no change to the allowlist gate.
This commit is contained in:
parent
0c4b7e50be
commit
60ac2db79d
3 changed files with 257 additions and 1 deletions
70
apps/desktop/src/main/open-path.ts
Normal file
70
apps/desktop/src/main/open-path.ts
Normal file
|
|
@ -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 <dir>` + `explorer.exe <windows-path>` 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<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -1198,7 +1204,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);
|
||||
}
|
||||
|
|
|
|||
173
apps/desktop/tests/main/open-path.test.ts
Normal file
173
apps/desktop/tests/main/open-path.test.ts
Normal file
|
|
@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue