chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { loadSqliteRuntime } from "./sqliteRuntime.mjs";
|
||||
|
||||
let warmed = false;
|
||||
|
||||
/**
|
||||
* Pre-resolves native runtimes at startup so the first DB access is fast
|
||||
* and EBUSY-resilient on Windows.
|
||||
*
|
||||
* Tray runtime (systray2) is warmed lazily by initTray() in bin/cli/tray/.
|
||||
*/
|
||||
export async function warmUpRuntimes() {
|
||||
if (warmed) return;
|
||||
warmed = true;
|
||||
try {
|
||||
await loadSqliteRuntime();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export { loadSqliteRuntime };
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Validates that a file starts with a known native-binary magic number.
|
||||
* Returns the platform label ("elf" | "macho" | "macho-le" | "macho-fat" | "pe")
|
||||
* or null if unrecognized / missing / unreadable.
|
||||
*
|
||||
*/
|
||||
export function validateBinaryMagic(path) {
|
||||
if (!existsSync(path)) return null;
|
||||
let buf;
|
||||
try {
|
||||
buf = readFileSync(path, { encoding: null }).subarray(0, 16);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (buf.length < 4) return null;
|
||||
|
||||
// ELF: 7F 45 4C 46
|
||||
if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) return "elf";
|
||||
|
||||
// Mach-O big-endian: FE ED FA CF (64-bit) / FE ED FA CE (32-bit)
|
||||
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xcf) return "macho";
|
||||
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xce) return "macho";
|
||||
|
||||
// Mach-O little-endian: CF FA ED FE (64-bit) / CE FA ED FE (32-bit)
|
||||
if (buf[0] === 0xcf && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) return "macho-le";
|
||||
if (buf[0] === 0xce && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) return "macho-le";
|
||||
|
||||
// Mach-O fat (universal binary): CA FE BA BE
|
||||
if (buf[0] === 0xca && buf[1] === 0xfe && buf[2] === 0xba && buf[3] === 0xbe) return "macho-fat";
|
||||
|
||||
// PE (Windows .node — DLL): MZ at offset 0
|
||||
if (buf[0] === 0x4d && buf[1] === 0x5a) return "pe";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the expected magic label for the current platform.
|
||||
* Used to validate that a runtime-installed binary matches this OS.
|
||||
*/
|
||||
export function platformBinaryLabel() {
|
||||
if (process.platform === "win32") return "pe";
|
||||
if (process.platform === "darwin") return "macho";
|
||||
return "elf";
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
openSync,
|
||||
readSync,
|
||||
closeSync,
|
||||
mkdirSync,
|
||||
} from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
|
||||
const BETTER_SQLITE3_VERSION = "12.10.1";
|
||||
|
||||
function runtimeDir() {
|
||||
return join(resolveDataDir(), "runtime");
|
||||
}
|
||||
|
||||
function runtimeModules() {
|
||||
return join(runtimeDir(), "node_modules");
|
||||
}
|
||||
|
||||
export function ensureRuntimeDir() {
|
||||
const dir = runtimeDir();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const pkgPath = join(dir, "package.json");
|
||||
if (!existsSync(pkgPath)) {
|
||||
writeFileSync(
|
||||
pkgPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "omniroute-runtime",
|
||||
version: "1.0.0",
|
||||
private: true,
|
||||
description: "User-writable runtime deps for OmniRoute (native binaries)",
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function getRuntimeNodeModules() {
|
||||
return runtimeModules();
|
||||
}
|
||||
|
||||
export function hasModule(name) {
|
||||
return existsSync(join(runtimeModules(), name, "package.json"));
|
||||
}
|
||||
|
||||
export function isBetterSqliteBinaryValid() {
|
||||
const binary = join(
|
||||
runtimeModules(),
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
);
|
||||
if (!existsSync(binary)) return false;
|
||||
try {
|
||||
const fd = openSync(binary, "r");
|
||||
const buf = Buffer.alloc(4);
|
||||
readSync(fd, buf, 0, 4, 0);
|
||||
closeSync(fd);
|
||||
const magic = buf.toString("hex");
|
||||
const os = platform();
|
||||
if (os === "linux") return magic.startsWith("7f454c46"); // ELF
|
||||
if (os === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
|
||||
if (os === "win32") return magic.startsWith("4d5a"); // PE/MZ
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function npmInstallRuntime(pkgs, opts = {}) {
|
||||
const cwd = ensureRuntimeDir();
|
||||
// Persist to the runtime package.json (exact version) instead of --no-save so a later
|
||||
// install of a sibling runtime dep (e.g. systray2 from trayRuntime.ts, which writes to the
|
||||
// same runtime dir) does not prune this package as "extraneous" — that pruning otherwise
|
||||
// reproduces "No SQLite driver available" after a tray install removes better-sqlite3.
|
||||
const npmArgs = [
|
||||
"install",
|
||||
...pkgs,
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--prefer-online",
|
||||
"--save-exact",
|
||||
];
|
||||
// On Windows .cmd files cannot be executed without a shell; use cmd.exe /c explicitly
|
||||
// so we never set shell:true (which would propagate env and enable injection).
|
||||
const isWin = platform() === "win32";
|
||||
const [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
|
||||
if (!opts.silent) {
|
||||
process.stdout.write(`[omniroute][runtime] npm ${npmArgs.join(" ")}\n`);
|
||||
}
|
||||
const res = spawnSync(exe, args, {
|
||||
cwd,
|
||||
stdio: opts.silent ? "ignore" : "inherit",
|
||||
timeout: opts.timeout ?? 180_000,
|
||||
shell: false,
|
||||
env: { ...process.env },
|
||||
});
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure better-sqlite3 is installed and valid in the runtime dir.
|
||||
* Returns { betterSqlite: boolean }.
|
||||
*/
|
||||
export function ensureBetterSqliteRuntime({ silent = false, force = false } = {}) {
|
||||
ensureRuntimeDir();
|
||||
const valid = hasModule("better-sqlite3") && isBetterSqliteBinaryValid();
|
||||
if (valid && !force) {
|
||||
if (!silent) process.stdout.write("[omniroute][runtime] better-sqlite3 OK\n");
|
||||
return { betterSqlite: true };
|
||||
}
|
||||
const ok = npmInstallRuntime([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { silent });
|
||||
if (!ok && !silent) {
|
||||
process.stderr.write("[omniroute][runtime] better-sqlite3 install failed\n");
|
||||
}
|
||||
return { betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an env object with NODE_PATH extended to include the runtime node_modules.
|
||||
*/
|
||||
export function buildEnvWithRuntime(baseEnv = process.env) {
|
||||
const runtimeNm = runtimeModules();
|
||||
const existing = baseEnv.NODE_PATH || "";
|
||||
const parts = [runtimeNm, existing].filter(Boolean);
|
||||
return { ...baseEnv, NODE_PATH: parts.join(sep === "\\" ? ";" : ":") };
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { dirname } from "node:path";
|
||||
import { writePidFile, cleanupPidFile, killAllSubprocesses } from "../utils/pid.mjs";
|
||||
import {
|
||||
RESTART_RESET_MS,
|
||||
DEFAULT_MAX_RESTARTS,
|
||||
shouldExitInsteadOfRestart,
|
||||
computeRestartDelayMs,
|
||||
waitUntilPortFree,
|
||||
} from "./supervisorPolicy.mjs";
|
||||
import { buildNodeHeapArgs } from "../../../scripts/build/runtime-env.mjs";
|
||||
|
||||
const CRASH_LOG_LINES = 50;
|
||||
|
||||
export class ServerSupervisor {
|
||||
constructor({ serverPath, env, maxRestarts = DEFAULT_MAX_RESTARTS, memoryLimit = 512, onCrashCallback }) {
|
||||
this.serverPath = serverPath;
|
||||
this.env = env;
|
||||
this.maxRestarts = maxRestarts;
|
||||
this.memoryLimit = memoryLimit;
|
||||
this.onCrashCallback = onCrashCallback;
|
||||
this.restartCount = 0;
|
||||
this.startedAt = 0;
|
||||
this.crashLog = [];
|
||||
this.child = null;
|
||||
this.isShuttingDown = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.startedAt = Date.now();
|
||||
this.crashLog = [];
|
||||
|
||||
const showLog = process.env.OMNIROUTE_SHOW_LOG === "1";
|
||||
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
|
||||
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value). The
|
||||
// calibrated heap is already carried by env.NODE_OPTIONS either way.
|
||||
const heapArgs = buildNodeHeapArgs(process.env, this.memoryLimit);
|
||||
this.child = spawn("node", [...heapArgs, this.serverPath], {
|
||||
cwd: dirname(this.serverPath),
|
||||
env: this.env,
|
||||
stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
|
||||
writePidFile("server", this.child.pid);
|
||||
|
||||
if (this.child.stderr) {
|
||||
this.child.stderr.on("data", (data) => {
|
||||
const lines = data.toString().split("\n").filter(Boolean);
|
||||
this.crashLog.push(...lines);
|
||||
if (this.crashLog.length > CRASH_LOG_LINES) {
|
||||
this.crashLog = this.crashLog.slice(-CRASH_LOG_LINES);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.child.on("error", (err) => this.handleExit(-1, err));
|
||||
this.child.on("exit", (code) => this.handleExit(code));
|
||||
|
||||
return this.child;
|
||||
}
|
||||
|
||||
handleExit(code) {
|
||||
// Node.js v24+ requires process.exit() to receive a number. Spawn-error events
|
||||
// deliver err.code (a string like 'ENOENT') via the 'error' listener; normalise here.
|
||||
const exitCode = typeof code === "number" ? code : null;
|
||||
cleanupPidFile("server");
|
||||
|
||||
// #4425: only exit on an intentional shutdown. A spontaneous code-0 exit (e.g. a
|
||||
// systemd MemoryMax cgroup kill, which reports the process exited cleanly) is anomalous
|
||||
// and must be restarted, not treated as a graceful stop that leaves the gateway dead.
|
||||
if (shouldExitInsteadOfRestart(this.isShuttingDown)) {
|
||||
process.exit(exitCode ?? 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const aliveMs = Date.now() - this.startedAt;
|
||||
if (aliveMs >= RESTART_RESET_MS) this.restartCount = 0;
|
||||
|
||||
if (this.restartCount >= this.maxRestarts) {
|
||||
console.error(`\n⚠ Server crashed ${this.maxRestarts} times in <30s.`);
|
||||
if (this.onCrashCallback) {
|
||||
const action = this.onCrashCallback(this.crashLog);
|
||||
if (action === "disable-mitm-and-retry") {
|
||||
console.error("⚠ Disabling MITM and retrying...\n");
|
||||
this.restartCount = 0;
|
||||
this.start();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.dumpCrashLog();
|
||||
process.exit(exitCode ?? 1);
|
||||
return;
|
||||
}
|
||||
|
||||
this.restartCount++;
|
||||
const delay = computeRestartDelayMs(this.restartCount);
|
||||
console.error(
|
||||
`\n⚠ Server exited (code=${code ?? "?"}). Restarting in ${delay / 1000}s... (${this.restartCount}/${this.maxRestarts})`
|
||||
);
|
||||
if (this.crashLog.length) this.dumpCrashLog();
|
||||
// #4425: after a crash the OS may not have released the listen socket yet — restarting
|
||||
// immediately produced the EADDRINUSE cascade that exhausted the restart budget. Wait
|
||||
// (bounded) for the port to free up before respawning.
|
||||
setTimeout(async () => {
|
||||
await waitUntilPortFree(process.env.PORT || 20128);
|
||||
this.start();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
dumpCrashLog() {
|
||||
console.error("\n--- Server crash log ---");
|
||||
this.crashLog.forEach((l) => console.error(l));
|
||||
console.error("--- End crash log ---\n");
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isShuttingDown = true;
|
||||
if (this.child?.pid) {
|
||||
try {
|
||||
process.kill(this.child.pid, "SIGTERM");
|
||||
} catch {}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
process.kill(this.child.pid, "SIGKILL");
|
||||
} catch {}
|
||||
}, 5000);
|
||||
}
|
||||
killAllSubprocesses();
|
||||
}
|
||||
}
|
||||
|
||||
export function detectMitmCrash(crashLog) {
|
||||
const text = crashLog.join("\n").toLowerCase();
|
||||
const signals = ["mitm", "tls socket", "certificate", "hosts", "eaccess"];
|
||||
return signals.filter((s) => text.includes(s)).length >= 2;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { validateBinaryMagic, platformBinaryLabel } from "./magicBytes.mjs";
|
||||
|
||||
const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime");
|
||||
const BETTER_SQLITE3_VERSION = "better-sqlite3@^12.10.1";
|
||||
|
||||
let resolvedCached = null;
|
||||
|
||||
/**
|
||||
* Resolves a SQLite driver through a 5-step fallback chain:
|
||||
* 1. Bundled better-sqlite3 (optionalDependency)
|
||||
* 2. Runtime-installed better-sqlite3 in ~/.omniroute/runtime/
|
||||
* 3. Lazy npm install into runtime dir
|
||||
* 4. node:sqlite (Node ≥22.5 stdlib)
|
||||
* 5. sql.js (bundled WASM, always available)
|
||||
*
|
||||
* Returns { driver, source } where source is one of:
|
||||
* "bundled" | "runtime" | "runtime-installed-now" | "node-sqlite" | "sql-js"
|
||||
*/
|
||||
export async function loadSqliteRuntime() {
|
||||
if (resolvedCached) return resolvedCached;
|
||||
|
||||
const bundled = await tryLoadBundled();
|
||||
if (bundled) {
|
||||
resolvedCached = { driver: bundled, source: "bundled" };
|
||||
return resolvedCached;
|
||||
}
|
||||
|
||||
const runtimeInstalled = await tryLoadRuntimeInstalled();
|
||||
if (runtimeInstalled) {
|
||||
resolvedCached = { driver: runtimeInstalled, source: "runtime" };
|
||||
return resolvedCached;
|
||||
}
|
||||
|
||||
try {
|
||||
await installRuntime();
|
||||
const after = await tryLoadRuntimeInstalled();
|
||||
if (after) {
|
||||
resolvedCached = { driver: after, source: "runtime-installed-now" };
|
||||
return resolvedCached;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[omniroute] runtime install failed: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const nodeSqlite = await import("node:sqlite");
|
||||
resolvedCached = {
|
||||
driver: { kind: "node-sqlite", DatabaseSync: nodeSqlite.DatabaseSync },
|
||||
source: "node-sqlite",
|
||||
};
|
||||
return resolvedCached;
|
||||
} catch {}
|
||||
|
||||
const sqljs = await import("sql.js");
|
||||
resolvedCached = {
|
||||
driver: { kind: "sql-js", initSqlJs: sqljs.default ?? sqljs.initSqlJs },
|
||||
source: "sql-js",
|
||||
};
|
||||
return resolvedCached;
|
||||
}
|
||||
|
||||
async function tryLoadBundled() {
|
||||
try {
|
||||
const mod = await import("better-sqlite3");
|
||||
return { kind: "better-sqlite3", Database: mod.default ?? mod };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryLoadRuntimeInstalled() {
|
||||
const runtimeNodeModules = resolve(RUNTIME_DIR, "node_modules");
|
||||
const pkgRoot = resolve(runtimeNodeModules, "better-sqlite3");
|
||||
if (!pkgRoot.startsWith(`${runtimeNodeModules}/`)) return null;
|
||||
if (!existsSync(join(pkgRoot, "package.json"))) return null;
|
||||
|
||||
const buildDir = join(pkgRoot, "build", "Release");
|
||||
if (existsSync(buildDir)) {
|
||||
const nodeFile = readdirSync(buildDir).find((f) => f.endsWith(".node"));
|
||||
if (nodeFile) {
|
||||
const magic = validateBinaryMagic(join(buildDir, nodeFile));
|
||||
const expected = platformBinaryLabel();
|
||||
if (!magic || (magic !== expected && magic !== "macho-le" && magic !== "macho-fat")) {
|
||||
console.warn(
|
||||
`[omniroute] runtime sqlite binary magic mismatch (${magic} ≠ ${expected}) — skipping`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = await import(/* webpackIgnore: true */ pathToFileURL(pkgRoot).href);
|
||||
return { kind: "better-sqlite3", Database: mod.default ?? mod };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureRuntimeDir() {
|
||||
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, type: "commonjs" }),
|
||||
"utf-8"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function installRuntime() {
|
||||
ensureRuntimeDir();
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
execSync(
|
||||
`${npm} install --prefix "${RUNTIME_DIR}" ${BETTER_SQLITE3_VERSION} --no-audit --no-fund --silent`,
|
||||
{ stdio: ["ignore", "ignore", "pipe"], timeout: 180_000 }
|
||||
);
|
||||
}
|
||||
|
||||
/** Clears the cached resolved driver (for testing). */
|
||||
export function clearRuntimeCache() {
|
||||
resolvedCached = null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import net from "node:net";
|
||||
|
||||
// #4425: bumped from 30s — the old window reset the crash counter too quickly, so during
|
||||
// an EADDRINUSE cascade the supervisor kept "recovering" then crashing within the window
|
||||
// and exhausted its restart budget. A longer window keeps the counter meaningful.
|
||||
export const RESTART_RESET_MS = 60_000;
|
||||
|
||||
// #4425: bumped from 2 — more recovery headroom before the supervisor gives up.
|
||||
export const DEFAULT_MAX_RESTARTS = 3;
|
||||
|
||||
/**
|
||||
* #4425: a clean child exit (code 0) is only intentional when the supervisor itself is
|
||||
* shutting down. A spontaneous code-0 exit is anomalous — e.g. a systemd `MemoryMax`
|
||||
* cgroup kill reports the process exited with code 0 — and MUST be restarted, not treated
|
||||
* as a graceful stop (which left the gateway dead with `Restart=on-failure`).
|
||||
*/
|
||||
export function shouldExitInsteadOfRestart(isShuttingDown) {
|
||||
return isShuttingDown === true;
|
||||
}
|
||||
|
||||
/** Exponential backoff (1s, 2s, 4s, …) capped at 10s, matching the prior inline formula. */
|
||||
export function computeRestartDelayMs(restartCount) {
|
||||
return Math.min(1000 * 2 ** (Math.max(1, restartCount) - 1), 10_000);
|
||||
}
|
||||
|
||||
/** Resolve true when nothing is listening on `port` (so a restart won't hit EADDRINUSE). */
|
||||
export function isPortFree(port, host = "127.0.0.1") {
|
||||
return new Promise((resolve) => {
|
||||
const tester = net.createServer();
|
||||
tester.once("error", (err) => {
|
||||
// EADDRINUSE = something is bound → not free. Any other error → treat as free.
|
||||
resolve(!(err && err.code === "EADDRINUSE"));
|
||||
});
|
||||
tester.once("listening", () => {
|
||||
tester.close(() => resolve(true));
|
||||
});
|
||||
tester.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* #4425: wait until `port` is free before respawning. After a crash the OS may not have
|
||||
* released the listen socket yet; restarting immediately produced the EADDRINUSE cascade
|
||||
* that exhausted the restart budget. Polls up to `timeoutMs`, then proceeds anyway so a
|
||||
* stuck port never blocks recovery forever.
|
||||
*/
|
||||
export async function waitUntilPortFree(port, timeoutMs = 10_000, intervalMs = 250) {
|
||||
const p = Number(port);
|
||||
if (!Number.isFinite(p) || p <= 0) return true;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
if (await isPortFree(p)) return true;
|
||||
if (Date.now() >= deadline) return false;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user