Files
2026-07-13 13:39:12 +08:00

95 lines
4.0 KiB
TypeScript

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