import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { execSync } from "node:child_process"; const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime"); // systray2 is a maintained fork with prebuilt binaries — installed lazily at runtime, // not in dependencies, to avoid npm install overhead for users who don't use --tray. // // Pin: stay on the 2.x line. The original `systray@1.0.5` package bundles a 2017 // x86_64 Go binary whose Mach-O headers modern dyld (macOS 14+) rejects, so the // tray silently fails to register on Apple Silicon. systray2 2.x ships newer // getlantern/systray-portable binaries that work under Rosetta. Inherited from // upstream decolua/9router#1080. export const SYSTRAY_PACKAGE = "systray2"; export const SYSTRAY_VERSION = "2.1.4"; const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`; export function resolveSystrayBinName(platform: NodeJS.Platform): string | null { if (platform === "win32") return null; if (platform === "darwin") return "tray_darwin_release"; return "tray_linux_release"; } export interface ChmodResult { changed: boolean; reason?: "win32-skip" | "missing" | "chmod-failed"; } // systray2's npm tarball sometimes ships the bundled Go binary without the // executable bit set on macOS/Linux, causing spawn() to fail with EACCES. // Set +x best-effort so the tray actually starts. Inherited from // upstream decolua/9router#1080. export function chmodSystrayBinAt(runtimeRoot: string, platform: NodeJS.Platform): ChmodResult { const binName = resolveSystrayBinName(platform); if (!binName) return { changed: false, reason: "win32-skip" }; const binPath = join(runtimeRoot, "node_modules", SYSTRAY_PACKAGE, "traybin", binName); if (!existsSync(binPath)) return { changed: false, reason: "missing" }; try { chmodSync(binPath, 0o755); return { changed: true }; } catch { return { changed: false, reason: "chmod-failed" }; } } export async function loadSystray(): Promise<(new (...args: unknown[]) => unknown) | null> { if (process.platform === "win32") return null; // Windows uses tray.ps1 instead ensureRuntimeDir(); if (!isInstalled()) { try { installSystray(); } catch (err) { // Surface failures to stderr instead of staying silent — anyone hitting // a tray problem otherwise has zero diagnostic. (PR #1080) console.warn(`[omniroute] tray runtime install failed: ${(err as Error).message}`); return null; } } // Best-effort: ensure the bundled Go binary is executable. Some npm tarballs // 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); 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}`); return null; } } function ensureRuntimeDir(): void { if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true }); const pkg = join(RUNTIME_DIR, "package.json"); if (!existsSync(pkg)) { writeFileSync(pkg, JSON.stringify({ name: "omniroute-runtime", private: true }), "utf-8"); } } function isInstalled(): boolean { return existsSync(join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE, "package.json")); } function installSystray(): void { // --save-exact persists systray2 to the runtime package.json so installing it does not // prune a sibling runtime dep (e.g. better-sqlite3 from nativeDeps.mjs, which writes to the // same runtime dir) as "extraneous", and so the tray dep survives a later sibling install. // Without it, a sibling install reproduces "No SQLite driver available". execSync( `npm install --prefix "${RUNTIME_DIR}" ${SYSTRAY_SPEC} --no-audit --no-fund --save-exact --silent`, { stdio: ["ignore", "ignore", "pipe"], timeout: 120_000 } ); }