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:
Tuan Dinh
2026-08-23 19:48:16 +07:00
committed by GitHub
parent fb421bc580
commit 8d59ab363b
11 changed files with 504 additions and 19 deletions

View File

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

View File

@@ -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)"

View File

@@ -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 {}

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

View File

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

View 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.

View File

@@ -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:

View File

@@ -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,

View File

@@ -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();

View File

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

View 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" }]);
});