mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 16:42:16 +03:00
Validated on the combined batch board over tip 0b41259f: static gates clean, typecheck:core clean, 430+ focused tests green across 5 groups.
--tray returns after readiness and survives terminal close, with tray-mode autostart on macOS/Windows/Linux while headless Linux keeps the systemd user service. tray-detached + autostart suites green. Closes #11229. Thank you @tuandinh0801!
177 lines
5.3 KiB
JavaScript
177 lines
5.3 KiB
JavaScript
import { execFileSync, spawn } from "node:child_process";
|
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
import { createServer, connect } from "node:net";
|
|
|
|
/** Builds arguments for the hidden process that owns the server and tray. */
|
|
export function buildTrayWorkerArgs({ port, maxRestarts, readyPort, readyToken, tlsCert, tlsKey }) {
|
|
const args = [
|
|
"serve",
|
|
"--tray",
|
|
"--tray-worker",
|
|
"--no-open",
|
|
"--port",
|
|
String(port),
|
|
"--max-restarts",
|
|
String(maxRestarts),
|
|
"--tray-ready-port",
|
|
String(readyPort),
|
|
"--tray-ready-token",
|
|
readyToken,
|
|
];
|
|
if (tlsCert) args.push("--tls-cert", tlsCert);
|
|
if (tlsKey) args.push("--tls-key", tlsKey);
|
|
return args;
|
|
}
|
|
|
|
/** Builds the platform command that starts the hidden tray worker. */
|
|
export function buildTrayLaunch({ platform, execPath, cliPath, workerArgs, label }) {
|
|
if (platform === "darwin") {
|
|
return {
|
|
command: "launchctl",
|
|
args: ["submit", "-l", label, "--", execPath, cliPath, ...workerArgs],
|
|
options: { stdio: "ignore" },
|
|
};
|
|
}
|
|
return {
|
|
command: execPath,
|
|
args: [cliPath, ...workerArgs],
|
|
options: { detached: true, stdio: "ignore", windowsHide: true },
|
|
};
|
|
}
|
|
|
|
/** Returns an error for command modes that conflict with detached tray mode. */
|
|
export function validateTrayOptions(opts) {
|
|
if (opts.trayWorker && (!opts.trayReadyPort || !opts.trayReadyToken)) {
|
|
return "tray worker requires readiness credentials";
|
|
}
|
|
if (!opts.tray || opts.trayWorker) return null;
|
|
if (opts.daemon) return "--tray cannot use --daemon";
|
|
if (opts.log) return "--tray cannot use --log";
|
|
if (opts.noRecovery || opts.recovery === false) return "--tray cannot use --no-recovery";
|
|
return null;
|
|
}
|
|
|
|
/** Creates a token-protected loopback server for tray worker readiness. */
|
|
export async function createTrayReadinessServer(token) {
|
|
let markReady;
|
|
const ready = new Promise((resolve) => {
|
|
markReady = resolve;
|
|
});
|
|
const expected = Buffer.from(token);
|
|
const server = createServer((socket) => {
|
|
let data = "";
|
|
socket.setEncoding("utf8");
|
|
socket.on("data", (chunk) => {
|
|
data += chunk;
|
|
if (data.length > 256) socket.destroy();
|
|
});
|
|
socket.on("end", () => {
|
|
const received = Buffer.from(data);
|
|
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
|
|
socket.end("ERROR");
|
|
return;
|
|
}
|
|
socket.end("READY");
|
|
markReady();
|
|
});
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const address = server.address();
|
|
return {
|
|
port: address.port,
|
|
wait(timeoutMs) {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(
|
|
() => reject(new Error("Tray worker did not become ready")),
|
|
timeoutMs
|
|
);
|
|
ready.then(() => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
});
|
|
},
|
|
close() {
|
|
server.close();
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Notifies the parent process that the server and tray are ready. */
|
|
export async function notifyTrayReady(port, token) {
|
|
await new Promise((resolve, reject) => {
|
|
const socket = connect({ host: "127.0.0.1", port }, () => socket.end(token));
|
|
let reply = "";
|
|
socket.setEncoding("utf8");
|
|
socket.on("data", (chunk) => {
|
|
reply += chunk;
|
|
});
|
|
socket.on("end", () => {
|
|
if (reply === "READY") resolve();
|
|
else reject(new Error("Tray readiness token was rejected"));
|
|
});
|
|
socket.on("error", reject);
|
|
});
|
|
}
|
|
|
|
/** Starts a detached tray worker and waits until its server and tray are ready. */
|
|
export async function startDetachedTray(
|
|
{ cliPath, port, maxRestarts, tlsCert, tlsKey, timeoutMs = 60000 },
|
|
{ platform = process.platform, spawnProcess = spawn } = {}
|
|
) {
|
|
const token = randomBytes(32).toString("hex");
|
|
const readiness = await createTrayReadinessServer(token);
|
|
const label = `com.omniroute.tray.${process.pid}.${Date.now()}`;
|
|
const workerArgs = buildTrayWorkerArgs({
|
|
port,
|
|
maxRestarts,
|
|
readyPort: readiness.port,
|
|
readyToken: token,
|
|
tlsCert,
|
|
tlsKey,
|
|
});
|
|
const launch = buildTrayLaunch({
|
|
platform,
|
|
execPath: process.execPath,
|
|
cliPath,
|
|
workerArgs,
|
|
label,
|
|
});
|
|
const child = spawnProcess(launch.command, launch.args, launch.options);
|
|
const spawnFailure = new Promise((_, reject) => {
|
|
child.once("error", reject);
|
|
child.once("exit", (code) => {
|
|
if (platform !== "darwin" || code !== 0) {
|
|
reject(new Error(`Tray worker exited before readiness with code ${code ?? "unknown"}`));
|
|
}
|
|
});
|
|
});
|
|
if (platform !== "darwin") child.unref?.();
|
|
try {
|
|
await Promise.race([readiness.wait(timeoutMs), spawnFailure]);
|
|
return { platform, pid: child.pid, label: platform === "darwin" ? label : null };
|
|
} catch (err) {
|
|
if (platform === "darwin") {
|
|
try {
|
|
execFileSync("launchctl", ["bootout", `gui/${process.getuid()}/${label}`], {
|
|
stdio: "ignore",
|
|
});
|
|
} catch {}
|
|
} else if (platform === "win32" && child.pid) {
|
|
try {
|
|
execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
} catch {}
|
|
} else if (child.pid) {
|
|
try {
|
|
process.kill(child.pid, "SIGTERM");
|
|
} catch {}
|
|
}
|
|
throw err;
|
|
} finally {
|
|
readiness.close();
|
|
}
|
|
}
|