mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 23:52:18 +03:00
feat(cli): detach tray mode from terminal (#11230)
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!
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
buildNodeHeapArgs,
|
||||
} from "../../../scripts/build/runtime-env.mjs";
|
||||
import { resolveTlsOptions } from "../../../scripts/dev/tls-options.mjs";
|
||||
import { startDetachedTray, validateTrayOptions } from "../tray/detachedTray.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "package.json"), "utf8"));
|
||||
@@ -42,7 +43,7 @@ function parsePort(value, fallback) {
|
||||
}
|
||||
|
||||
export function registerServe(program) {
|
||||
program
|
||||
const command = program
|
||||
.command("serve", { isDefault: true })
|
||||
.description(t("serve.description"))
|
||||
.option("--port <port>", t("serve.port"))
|
||||
@@ -51,7 +52,7 @@ export function registerServe(program) {
|
||||
.option("--log", t("serve.log"))
|
||||
.option("--no-recovery", t("serve.no_recovery"))
|
||||
.option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2)
|
||||
.option("--tray", t("serve.tray") || "Show system tray icon (desktop only)")
|
||||
.option("--tray", t("serve.tray") || "Start in the system tray (desktop only)")
|
||||
.option("--no-tray", t("serve.no_tray") || "Disable system tray icon")
|
||||
.option(
|
||||
"--tls-cert <path>",
|
||||
@@ -66,6 +67,9 @@ export function registerServe(program) {
|
||||
.action(async (opts) => {
|
||||
await runServe(opts);
|
||||
});
|
||||
command.addOption(command.createOption("--tray-worker").hideHelp());
|
||||
command.addOption(command.createOption("--tray-ready-port <port>").hideHelp());
|
||||
command.addOption(command.createOption("--tray-ready-token <token>").hideHelp());
|
||||
}
|
||||
|
||||
/** Once-per-process guard so the Android/Termux cache hint is not spammed. */
|
||||
@@ -95,6 +99,32 @@ export function resetInstrumentationFailureHintForTests() {
|
||||
export async function runServe(opts = {}) {
|
||||
const startedAt = performance.now();
|
||||
|
||||
const trayOptionError = validateTrayOptions(opts);
|
||||
if (trayOptionError) throw new Error(trayOptionError);
|
||||
|
||||
if (opts.tray === true && opts.trayWorker !== true) {
|
||||
const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128);
|
||||
const tlsCert = opts.tlsCert ?? process.env.OMNIROUTE_TLS_CERT;
|
||||
const tlsKey = opts.tlsKey ?? process.env.OMNIROUTE_TLS_KEY;
|
||||
urlScheme = resolveTlsOptions({
|
||||
...process.env,
|
||||
...(tlsCert ? { OMNIROUTE_TLS_CERT: tlsCert } : {}),
|
||||
...(tlsKey ? { OMNIROUTE_TLS_KEY: tlsKey } : {}),
|
||||
})
|
||||
? "https"
|
||||
: "http";
|
||||
const result = await startDetachedTray({
|
||||
cliPath: join(ROOT, "bin", "omniroute.mjs"),
|
||||
port,
|
||||
maxRestarts: opts.maxRestarts ?? 2,
|
||||
tlsCert,
|
||||
tlsKey,
|
||||
});
|
||||
console.log(`\x1b[32m✔ OmniRoute tray started in background\x1b[0m`);
|
||||
console.log(` \x1b[1mDashboard:\x1b[0m ${urlScheme}://localhost:${port}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Same prep as bin/omniroute.mjs — keep it here so a direct `runServe()` call
|
||||
// (tests / programmatic) still gets a writable Next.js cache dir before spawn.
|
||||
ensureAndroidCacheDir({ env: process.env });
|
||||
@@ -255,7 +285,8 @@ export async function runServe(opts = {}) {
|
||||
opts.log === true,
|
||||
opts.maxRestarts ?? 2,
|
||||
startedAt,
|
||||
useTray
|
||||
useTray,
|
||||
{ trayReadyPort: opts.trayReadyPort, trayReadyToken: opts.trayReadyToken }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,9 +399,11 @@ async function runWithSupervisor(
|
||||
showLog,
|
||||
maxRestarts,
|
||||
startedAt,
|
||||
useTray = false
|
||||
useTray = false,
|
||||
{ trayReadyPort, trayReadyToken } = {}
|
||||
) {
|
||||
if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1";
|
||||
writePidFile("supervisor", process.pid);
|
||||
|
||||
const supervisor = new ServerSupervisor({
|
||||
serverPath: serverJs,
|
||||
@@ -394,17 +427,38 @@ async function runWithSupervisor(
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
});
|
||||
|
||||
if (!showLog) {
|
||||
waitForServer(dashboardPort, 60000).then(async (up) => {
|
||||
if (up) {
|
||||
if (useTray) await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
if (useTray) {
|
||||
const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
if (!trayReady) {
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (trayReadyPort && trayReadyToken) {
|
||||
const { notifyTrayReady } = await import("../tray/detachedTray.mjs");
|
||||
try {
|
||||
await notifyTrayReady(parsePort(trayReadyPort, 0), trayReadyToken);
|
||||
} catch {
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
||||
} else {
|
||||
reportReadinessTimeout(dashboardPort, supervisor);
|
||||
@@ -451,29 +505,30 @@ function killTrayIfActive() {
|
||||
async function maybeStartTray(port, apiPort, supervisor) {
|
||||
try {
|
||||
const { initTray, isTraySupported } = await import("../tray/index.mjs");
|
||||
if (!isTraySupported()) return;
|
||||
if (!isTraySupported()) return false;
|
||||
const { default: open } = await import("open").catch(() => ({ default: null }));
|
||||
const dashboardUrl = `${urlScheme}://localhost:${port}`;
|
||||
const tray = await initTray({
|
||||
port,
|
||||
onQuit: () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
},
|
||||
onOpenDashboard: () => open?.(dashboardUrl),
|
||||
onShowLogs: () => {
|
||||
// In-place: open logs stream (best-effort)
|
||||
process.stdout.write(`[omniroute][tray] Logs at: ${dashboardUrl}/logs\n`);
|
||||
},
|
||||
onShowLogs: () => open?.(`${dashboardUrl}/dashboard/logs`),
|
||||
});
|
||||
if (tray) {
|
||||
const { killTray } = await import("../tray/index.mjs");
|
||||
_killTray = killTray;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
// tray is optional — do not fail the server, but surface why it failed so
|
||||
// "--tray shows nothing" is diagnosable instead of silent (#4605).
|
||||
process.stderr.write(`[omniroute][tray] failed to start: ${err?.message ?? String(err)}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@
|
||||
"log": "Show server logs inline",
|
||||
"no_recovery": "Disable auto-restart on crash (debugging mode)",
|
||||
"max_restarts": "Max crash restarts within 30s before giving up (default: 2)",
|
||||
"tray": "Show system tray icon (desktop only, opt-in)",
|
||||
"tray": "Start in the system tray (desktop only, opt-in)",
|
||||
"no_tray": "Disable system tray icon",
|
||||
"tls_cert": "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
|
||||
|
||||
@@ -276,6 +276,10 @@ function isAgentSelfMac() {
|
||||
}
|
||||
}
|
||||
|
||||
function isDetachedTrayWorker() {
|
||||
return process.argv.includes("--tray-worker");
|
||||
}
|
||||
|
||||
function enableMac() {
|
||||
const plistDir = join(homedir(), "Library", "LaunchAgents");
|
||||
mkdirSync(plistDir, { recursive: true });
|
||||
@@ -300,7 +304,7 @@ function enableMac() {
|
||||
// If we're already the running agent, launchctl load/unload would SIGTERM us.
|
||||
// The plist is updated on disk and launchd already has us loaded under our own
|
||||
// PID — nothing more to do for the current session.
|
||||
if (isAgentSelfMac()) return existsSync(plistPath);
|
||||
if (isAgentSelfMac() || isDetachedTrayWorker()) return existsSync(plistPath);
|
||||
try {
|
||||
execSync("launchctl load -w " + JSON.stringify(plistPath), { stdio: "ignore" });
|
||||
} catch {}
|
||||
@@ -313,7 +317,7 @@ function disableMac() {
|
||||
// `launchctl unload` sends SIGTERM and a user clicking "Disable Autostart"
|
||||
// from the tray would lose the tray icon instead of just flipping the label.
|
||||
// Removing the plist file is enough to stop the agent at the next login.
|
||||
if (!isAgentSelfMac()) {
|
||||
if (!isAgentSelfMac() && !isDetachedTrayWorker()) {
|
||||
try {
|
||||
execSync("launchctl unload -w " + JSON.stringify(plistPath), { stdio: "ignore" });
|
||||
} catch {}
|
||||
|
||||
176
bin/cli/tray/detachedTray.mjs
Normal file
176
bin/cli/tray/detachedTray.mjs
Normal file
@@ -0,0 +1,176 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -97,9 +97,7 @@ export async function initSystrayUnix(
|
||||
}
|
||||
});
|
||||
|
||||
tray.ready().catch((err) => {
|
||||
process.stderr.write(`[omniroute][tray] systray2 failed: ${err?.message ?? String(err)}\n`);
|
||||
});
|
||||
await tray.ready();
|
||||
|
||||
return tray;
|
||||
}
|
||||
|
||||
1
changelog.d/features/unreleased-detached-cli-tray.md
Normal file
1
changelog.d/features/unreleased-detached-cli-tray.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(cli):** run `omniroute serve --tray` as a detached desktop process after server and tray readiness, with graphical login auto-start support.
|
||||
@@ -357,6 +357,49 @@ omniroute --port 3000
|
||||
|
||||
The CLI automatically loads `.env` from `~/.omniroute/.env` or `./.env`.
|
||||
|
||||
### Tray mode
|
||||
|
||||
Start OmniRoute in the system tray:
|
||||
|
||||
```bash
|
||||
omniroute serve --tray
|
||||
```
|
||||
|
||||
The command returns after the server and tray are ready.
|
||||
|
||||
The server continues without the terminal.
|
||||
|
||||
Tray mode supports macOS, Windows, and graphical Linux sessions. Tray mode does not open the dashboard automatically.
|
||||
|
||||
Use the tray menu for these actions:
|
||||
|
||||
- Open the dashboard.
|
||||
- Open `/dashboard/logs`.
|
||||
- Change auto-start.
|
||||
- Stop OmniRoute.
|
||||
|
||||
Do not combine `--tray` with these options:
|
||||
|
||||
- `--daemon`
|
||||
- `--log`
|
||||
- `--no-recovery`
|
||||
|
||||
These modes require different process ownership.
|
||||
|
||||
Enable startup at the next machine login:
|
||||
|
||||
```bash
|
||||
omniroute autostart enable
|
||||
```
|
||||
|
||||
Auto-start uses tray mode on macOS, Windows, and graphical Linux sessions. Headless Linux uses the existing systemd user service.
|
||||
|
||||
Disable startup at login:
|
||||
|
||||
```bash
|
||||
omniroute autostart disable
|
||||
```
|
||||
|
||||
### Uninstalling
|
||||
|
||||
When you no longer need OmniRoute, we provide two quick scripts for a clean removal:
|
||||
|
||||
@@ -30,14 +30,14 @@ test("serve daemon mode does not accept startedAt", () => {
|
||||
|
||||
test("serve runWithSupervisor uses startedAt before defaulted useTray", () => {
|
||||
const signatureRegex =
|
||||
/async\s+function\s+runWithSupervisor\s*\([\s\S]*?startedAt\s*,\s*useTray\s*=\s*false\s*\)/;
|
||||
/async\s+function\s+runWithSupervisor\s*\([\s\S]*?startedAt\s*,\s*useTray\s*=\s*false\s*,/;
|
||||
assert.match(
|
||||
serveSource,
|
||||
signatureRegex,
|
||||
"runWithSupervisor should declare startedAt before the defaulted useTray parameter"
|
||||
);
|
||||
|
||||
const callRegex = /runWithSupervisor\s*\([\s\S]*?startedAt\s*,\s*useTray\s*\)/;
|
||||
const callRegex = /runWithSupervisor\s*\([\s\S]*?startedAt\s*,\s*useTray\s*,/;
|
||||
assert.match(
|
||||
serveSource,
|
||||
callRegex,
|
||||
|
||||
@@ -75,7 +75,8 @@ test("resolveCliPath finds omniroute.mjs from argv", async () => {
|
||||
|
||||
if (existsSync(desktopPath)) {
|
||||
const desktop = readFileSync(desktopPath, "utf8");
|
||||
assert.match(desktop, /Exec=.*serve --no-open/);
|
||||
assert.match(desktop, /Exec=.*serve --no-open --tray/);
|
||||
assert.match(desktop, /Terminal=false/);
|
||||
}
|
||||
|
||||
disable();
|
||||
|
||||
@@ -88,4 +88,17 @@ test("enable/disable macOS skip launchctl when the current process is the agent"
|
||||
const source = readFileSync(join(process.cwd(), "bin/cli/tray/autostart.mjs"), "utf8");
|
||||
assert.match(source, /isAgentSelfMac/);
|
||||
assert.match(source, /parseAgentSelfFromLaunchctl/);
|
||||
assert.match(source, /isDetachedTrayWorker/);
|
||||
assert.match(source, /process\.argv\.includes\("--tray-worker"\)/);
|
||||
});
|
||||
|
||||
test("macOS autostart starts detached tray mode without a dashboard window", () => {
|
||||
const source = readFileSync(join(process.cwd(), "bin/cli/tray/autostart.mjs"), "utf8");
|
||||
const programArguments = source.match(/<key>ProgramArguments<\/key><array>([\s\S]*?)<\/array>/);
|
||||
|
||||
assert.ok(programArguments);
|
||||
assert.match(programArguments[1], /<string>serve<\/string>/);
|
||||
assert.match(programArguments[1], /<string>--tray<\/string>/);
|
||||
assert.match(programArguments[1], /<string>--no-open<\/string>/);
|
||||
assert.doesNotMatch(programArguments[1], /--tray-worker/);
|
||||
});
|
||||
|
||||
194
tests/unit/cli/tray-detached.test.ts
Normal file
194
tests/unit/cli/tray-detached.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildTrayLaunch,
|
||||
buildTrayWorkerArgs,
|
||||
createTrayReadinessServer,
|
||||
notifyTrayReady,
|
||||
startDetachedTray,
|
||||
validateTrayOptions,
|
||||
} from "../../../bin/cli/tray/detachedTray.mjs";
|
||||
|
||||
test("buildTrayWorkerArgs creates a non-recursive hidden tray worker command", () => {
|
||||
const args = buildTrayWorkerArgs({
|
||||
port: 20128,
|
||||
maxRestarts: 3,
|
||||
readyPort: 43123,
|
||||
readyToken: "secret-token",
|
||||
tlsCert: "/tmp/cert.pem",
|
||||
tlsKey: "/tmp/key.pem",
|
||||
});
|
||||
|
||||
assert.deepEqual(args, [
|
||||
"serve",
|
||||
"--tray",
|
||||
"--tray-worker",
|
||||
"--no-open",
|
||||
"--port",
|
||||
"20128",
|
||||
"--max-restarts",
|
||||
"3",
|
||||
"--tray-ready-port",
|
||||
"43123",
|
||||
"--tray-ready-token",
|
||||
"secret-token",
|
||||
"--tls-cert",
|
||||
"/tmp/cert.pem",
|
||||
"--tls-key",
|
||||
"/tmp/key.pem",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildTrayLaunch detaches Windows and Linux workers from the terminal", () => {
|
||||
for (const platform of ["linux", "win32"]) {
|
||||
const launch = buildTrayLaunch({
|
||||
platform,
|
||||
execPath: "/usr/bin/node",
|
||||
cliPath: "/opt/omniroute/bin/omniroute.mjs",
|
||||
workerArgs: ["serve", "--tray-worker"],
|
||||
label: "com.omniroute.tray.123",
|
||||
});
|
||||
|
||||
assert.equal(launch.command, "/usr/bin/node");
|
||||
assert.deepEqual(launch.args, ["/opt/omniroute/bin/omniroute.mjs", "serve", "--tray-worker"]);
|
||||
assert.deepEqual(launch.options, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("buildTrayLaunch submits a macOS launchd job", () => {
|
||||
const launch = buildTrayLaunch({
|
||||
platform: "darwin",
|
||||
execPath: "/usr/bin/node",
|
||||
cliPath: "/opt/omniroute/bin/omniroute.mjs",
|
||||
workerArgs: ["serve", "--tray-worker"],
|
||||
label: "com.omniroute.tray.123",
|
||||
});
|
||||
|
||||
assert.equal(launch.command, "launchctl");
|
||||
assert.deepEqual(launch.args, [
|
||||
"submit",
|
||||
"-l",
|
||||
"com.omniroute.tray.123",
|
||||
"--",
|
||||
"/usr/bin/node",
|
||||
"/opt/omniroute/bin/omniroute.mjs",
|
||||
"serve",
|
||||
"--tray-worker",
|
||||
]);
|
||||
assert.deepEqual(launch.options, { stdio: "ignore" });
|
||||
});
|
||||
|
||||
test("validateTrayOptions rejects modes that cannot detach safely", () => {
|
||||
assert.equal(validateTrayOptions({ tray: true, daemon: true }), "--tray cannot use --daemon");
|
||||
assert.equal(validateTrayOptions({ tray: true, log: true }), "--tray cannot use --log");
|
||||
assert.equal(
|
||||
validateTrayOptions({ tray: true, noRecovery: true }),
|
||||
"--tray cannot use --no-recovery"
|
||||
);
|
||||
assert.equal(
|
||||
validateTrayOptions({ tray: true, recovery: false }),
|
||||
"--tray cannot use --no-recovery"
|
||||
);
|
||||
assert.equal(validateTrayOptions({ tray: true }), null);
|
||||
assert.equal(
|
||||
validateTrayOptions({ tray: true, trayWorker: true }),
|
||||
"tray worker requires readiness credentials"
|
||||
);
|
||||
assert.equal(
|
||||
validateTrayOptions({
|
||||
tray: true,
|
||||
trayWorker: true,
|
||||
trayReadyPort: "43123",
|
||||
trayReadyToken: "token",
|
||||
}),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("tray worker readiness requires the parent token", async () => {
|
||||
const readiness = await createTrayReadinessServer("expected-token");
|
||||
try {
|
||||
await assert.rejects(notifyTrayReady(readiness.port, "wrong-token"));
|
||||
const ready = readiness.wait(1000);
|
||||
await notifyTrayReady(readiness.port, "expected-token");
|
||||
await ready;
|
||||
} finally {
|
||||
readiness.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("startDetachedTray waits for worker readiness and detaches it", async () => {
|
||||
let workerArgs: string[] = [];
|
||||
let unrefCalled = false;
|
||||
const result = await startDetachedTray(
|
||||
{
|
||||
cliPath: "/tmp/omniroute.mjs",
|
||||
port: 20128,
|
||||
maxRestarts: 2,
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
{
|
||||
platform: "linux",
|
||||
spawnProcess: (_command, args, options) => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
pid: number;
|
||||
unref: () => void;
|
||||
};
|
||||
child.pid = 45678;
|
||||
child.unref = () => {
|
||||
unrefCalled = true;
|
||||
};
|
||||
workerArgs = args;
|
||||
const port = Number(args[args.indexOf("--tray-ready-port") + 1]);
|
||||
const token = args[args.indexOf("--tray-ready-token") + 1];
|
||||
void notifyTrayReady(port, token);
|
||||
assert.deepEqual(options, { detached: true, stdio: "ignore", windowsHide: true });
|
||||
return child;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.platform, "linux");
|
||||
assert.equal(result.pid, 45678);
|
||||
assert.equal(workerArgs.includes("--tray-worker"), true);
|
||||
assert.equal(workerArgs.includes("--no-open"), true);
|
||||
assert.equal(unrefCalled, true);
|
||||
});
|
||||
|
||||
test("startDetachedTray stops a worker that never becomes ready", async () => {
|
||||
const originalKill = process.kill;
|
||||
const signals: Array<{ pid: number; signal: NodeJS.Signals | number }> = [];
|
||||
const child = new EventEmitter() as EventEmitter & { pid: number; unref: () => void };
|
||||
child.pid = 56789;
|
||||
child.unref = () => {};
|
||||
process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
|
||||
signals.push({ pid, signal: signal ?? 0 });
|
||||
return true;
|
||||
}) as typeof process.kill;
|
||||
try {
|
||||
await assert.rejects(
|
||||
startDetachedTray(
|
||||
{
|
||||
cliPath: "/tmp/omniroute.mjs",
|
||||
port: 20128,
|
||||
maxRestarts: 2,
|
||||
timeoutMs: 20,
|
||||
},
|
||||
{
|
||||
platform: "linux",
|
||||
spawnProcess: () => child,
|
||||
}
|
||||
),
|
||||
/did not become ready/
|
||||
);
|
||||
} finally {
|
||||
process.kill = originalKill;
|
||||
}
|
||||
assert.deepEqual(signals, [{ pid: 56789, signal: "SIGTERM" }]);
|
||||
});
|
||||
Reference in New Issue
Block a user