diff --git a/bin/cli/runtime/trayRuntime.ts b/bin/cli/runtime/trayRuntime.ts index 98a3abfccc..ef8894dc04 100644 --- a/bin/cli/runtime/trayRuntime.ts +++ b/bin/cli/runtime/trayRuntime.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { execSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime"); // systray2 is a maintained fork with prebuilt binaries — installed lazily at runtime, @@ -16,6 +17,16 @@ export const SYSTRAY_PACKAGE = "systray2"; export const SYSTRAY_VERSION = "2.1.4"; const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`; +// Dynamic `import()` resolves its specifier as a URL, not a filesystem path. +// On Windows the lazily-installed systray2 lives at an absolute path whose +// leading drive letter the ESM loader parses as an unsupported URL scheme +// (e.g. `c:`) and rejects. Build a file:// URL so the tray import works on +// Windows too. Same defect fixed for the CLI db-fallback imports in #11238, +// missed at this call site. +export function systrayModuleSpecifier(runtimeDir: string): string { + return pathToFileURL(join(runtimeDir, "node_modules", SYSTRAY_PACKAGE)).href; +} + export function resolveSystrayBinName(platform: NodeJS.Platform): string | null { if (platform === "win32") return "tray_windows_release.exe"; if (platform === "darwin") return "tray_darwin_release"; @@ -60,8 +71,7 @@ export async function loadSystray(): Promise<(new (...args: unknown[]) => unknow // drop the +x bit on extraction (observed on macOS). chmodSystrayBinAt(RUNTIME_DIR, process.platform); try { - const modPath = join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE); - const mod = await import(modPath); + const mod = await import(systrayModuleSpecifier(RUNTIME_DIR)); return (mod.default ?? mod.SysTray ?? mod) as (new (...args: unknown[]) => unknown) | null; } catch (err) { console.warn(`[omniroute] tray runtime import failed: ${(err as Error).message}`); diff --git a/tests/unit/cli/tray-runtime-windows-esm-import.test.ts b/tests/unit/cli/tray-runtime-windows-esm-import.test.ts new file mode 100644 index 0000000000..7e1d0fb33b --- /dev/null +++ b/tests/unit/cli/tray-runtime-windows-esm-import.test.ts @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import os from "node:os"; +import { pathToFileURL } from "node:url"; +import { + systrayModuleSpecifier, + SYSTRAY_PACKAGE, +} from "../../../bin/cli/runtime/trayRuntime.ts"; + +// Regression guard for the Windows-only ESM loader failure at the lazy tray +// import in bin/cli/runtime/trayRuntime.ts (loadSystray): +// +// Error: Only URLs with a scheme in: file, data, and node are supported by +// the default ESM loader. On Windows, absolute paths must be valid file:// +// URLs. Received protocol 'c:' +// +// `import()` resolves its specifier as a URL. A POSIX absolute path like +// /home/x/.omniroute/runtime/node_modules/systray2 doubles as a valid relative +// URL, so passing it works by accident on Linux/macOS (and CI stays green). A +// Windows absolute path is C:\Users\x\.omniroute\runtime\node_modules\systray2, +// whose leading drive letter the loader parses as the URL scheme `c:` and +// rejects — so `omniroute server --tray` never loads the tray on Windows. +// This is the same defect as #11238 (CLI db-fallback imports), which missed +// this call site. The specifier must be a file:// URL. + +test("systrayModuleSpecifier returns a file:// URL, not a raw absolute path", () => { + const runtimeDir = path.join(os.homedir(), ".omniroute", "runtime"); + const spec = systrayModuleSpecifier(runtimeDir); + + assert.match( + spec, + /^file:\/\//, + "dynamic import() of a raw absolute path fails on Windows (drive letter " + + "parsed as a URL scheme); wrap the path in pathToFileURL(...).href", + ); + assert.ok(spec.includes(SYSTRAY_PACKAGE), "specifier must target the systray2 package"); + // A file:// URL is a loader-acceptable specifier on every platform. + assert.doesNotThrow(() => new URL(spec)); +}); + +test("systrayModuleSpecifier matches pathToFileURL of the module directory", () => { + const runtimeDir = path.join(os.tmpdir(), "omniroute-tray-spec-test"); + const expected = pathToFileURL( + path.join(runtimeDir, "node_modules", SYSTRAY_PACKAGE), + ).href; + assert.equal(systrayModuleSpecifier(runtimeDir), expected); +});