feat(cli): fase 8.8 — system tray + autostart (omniroute serve --tray)

- bin/cli/tray/index.mjs: initTray/killTray/isTrayActive/isTraySupported
- bin/cli/tray/traySystray.mjs: systray2 para macOS/Linux (graceful fallback)
- bin/cli/tray/trayWindows.mjs: PowerShell NotifyIcon (sem binário extra)
- bin/cli/tray/autostart.mjs: launchd (macOS), reg (Windows), .desktop (Linux)
- bin/cli/commands/tray.mjs: subcomandos show/hide/quit
- bin/cli/commands/autostart.mjs: subcomandos enable/disable/status
- serve.mjs: flags --tray/--no-tray, integração após servidor iniciar
- i18n: chaves tray.*, autostart.*, serve.tray/no_tray em en.json e pt-BR.json
- check-env-doc-sync: DISPLAY e WAYLAND_DISPLAY adicionados ao allowlist (sinais do host OS)
- 8 testes unitários cobrindo autostart por plataforma e importação dos módulos
This commit is contained in:
diegosouzapw
2026-05-15 04:07:20 -03:00
parent 27f7e5c4fe
commit b3cfac3c14
12 changed files with 599 additions and 8 deletions

View File

@@ -0,0 +1,39 @@
import { t } from "../i18n.mjs";
import { emit } from "../output.mjs";
export function registerAutostart(program) {
const cmd = program
.command("autostart")
.description(t("autostart.description") || "Manage OmniRoute autostart at login");
cmd
.command("enable")
.description(t("autostart.enable") || "Enable autostart at login")
.action(async (opts, c) => {
const globalOpts = c.optsWithGlobals();
const { enable } = await import("../tray/autostart.mjs");
const ok = enable();
emit({ enabled: ok }, globalOpts);
if (!ok) process.exit(1);
});
cmd
.command("disable")
.description(t("autostart.disable") || "Disable autostart at login")
.action(async (opts, c) => {
const globalOpts = c.optsWithGlobals();
const { disable } = await import("../tray/autostart.mjs");
const ok = disable();
emit({ disabled: ok }, globalOpts);
if (!ok) process.exit(1);
});
cmd
.command("status")
.description(t("autostart.status") || "Show autostart status")
.action(async (opts, c) => {
const globalOpts = c.optsWithGlobals();
const { isAutostartEnabled } = await import("../tray/autostart.mjs");
emit({ enabled: isAutostartEnabled() }, globalOpts);
});
}

View File

@@ -52,6 +52,8 @@ import { registerEnv } from "./env.mjs";
import { registerTestProvider } from "./test-provider.mjs";
import { registerCompletion } from "./completion.mjs";
import { registerRuntime } from "./runtime.mjs";
import { registerTray } from "./tray.mjs";
import { registerAutostart } from "./autostart.mjs";
export function registerCommands(program) {
registerMemory(program);
@@ -109,4 +111,6 @@ export function registerCommands(program) {
registerTestProvider(program);
registerCompletion(program);
registerRuntime(program);
registerTray(program);
registerAutostart(program);
}

View File

@@ -26,6 +26,8 @@ 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("--no-tray", t("serve.no_tray") || "Disable system tray icon")
.action(async (opts) => {
await runServe(opts);
});
@@ -128,6 +130,7 @@ export async function runServe(opts = {}) {
};
const isDaemon = opts.daemon === true;
const useTray = opts.tray === true;
if (isDaemon) {
return runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort);
@@ -145,7 +148,8 @@ export async function runServe(opts = {}) {
apiPort,
noOpen,
opts.log === true,
opts.maxRestarts ?? 2
opts.maxRestarts ?? 2,
useTray
);
}
@@ -229,7 +233,8 @@ async function runWithSupervisor(
apiPort,
noOpen,
showLog,
maxRestarts
maxRestarts,
useTray = false
) {
if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1";
@@ -253,16 +258,62 @@ async function runWithSupervisor(
supervisor.start();
process.on("SIGINT", () => supervisor.stop());
process.on("SIGTERM", () => supervisor.stop());
process.on("SIGINT", () => {
killTrayIfActive();
supervisor.stop();
});
process.on("SIGTERM", () => {
killTrayIfActive();
supervisor.stop();
});
if (!showLog) {
waitForServer(dashboardPort, 20000).then((up) => {
if (up) onReady(dashboardPort, apiPort, noOpen);
waitForServer(dashboardPort, 20000).then(async (up) => {
if (up) {
if (useTray) await maybeStartTray(dashboardPort, apiPort, supervisor);
onReady(dashboardPort, apiPort, noOpen);
}
});
}
}
let _killTray = null;
function killTrayIfActive() {
if (_killTray) {
try {
_killTray();
} catch {}
_killTray = null;
}
}
async function maybeStartTray(port, apiPort, supervisor) {
try {
const { initTray, isTraySupported } = await import("../tray/index.mjs");
if (!isTraySupported()) return;
const { default: open } = await import("open").catch(() => ({ default: null }));
const dashboardUrl = `http://localhost:${port}`;
const tray = initTray({
port,
onQuit: () => {
killTrayIfActive();
supervisor.stop();
},
onOpenDashboard: () => open?.(dashboardUrl),
onShowLogs: () => {
// In-place: open logs stream (best-effort)
process.stdout.write(`[omniroute][tray] Logs at: ${dashboardUrl}/logs\n`);
},
});
if (tray) {
const { killTray } = await import("../tray/index.mjs");
_killTray = killTray;
}
} catch {
// tray is optional — do not fail the server
}
}
async function onReady(dashboardPort, apiPort, noOpen) {
const dashboardUrl = `http://localhost:${dashboardPort}`;
const apiUrl = `http://localhost:${apiPort}`;

36
bin/cli/commands/tray.mjs Normal file
View File

@@ -0,0 +1,36 @@
import { t } from "../i18n.mjs";
export function registerTray(program) {
const cmd = program
.command("tray")
.description(t("tray.description") || "Control the system tray icon");
cmd
.command("show")
.description(t("tray.show") || "Show the tray icon (if server is running with --tray)")
.action(() => {
process.stderr.write(
"The tray is managed by `omniroute serve --tray`. Start the server with --tray to enable it.\n"
);
});
cmd
.command("hide")
.description(t("tray.hide") || "Hide the tray icon")
.action(() => {
process.stderr.write(
"Send SIGUSR1 to the serve process to toggle the tray, or restart without --tray.\n"
);
});
cmd
.command("quit")
.description(t("tray.quit") || "Quit OmniRoute via tray")
.action(async () => {
const { default: pidUtils } = await import("../utils/pid.mjs").catch(() => ({
default: null,
}));
process.stderr.write("Use `omniroute stop` to stop the server.\n");
process.exit(0);
});
}

View File

@@ -174,7 +174,9 @@
"daemon": "Run server as a background daemon",
"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)"
"max_restarts": "Max crash restarts within 30s before giving up (default: 2)",
"tray": "Show system tray icon (desktop only, opt-in)",
"no_tray": "Disable system tray icon"
},
"backup": {
"title": "Backup",
@@ -1083,6 +1085,18 @@
"durationMax": "Max request duration in ms",
"export": "Save logs to file (json/jsonl/csv)"
},
"tray": {
"description": "Control the system tray icon",
"show": "Show the tray icon (if server is running with --tray)",
"hide": "Hide the tray icon",
"quit": "Quit OmniRoute via tray"
},
"autostart": {
"description": "Manage OmniRoute autostart at login",
"enable": "Enable autostart at login",
"disable": "Disable autostart at login",
"status": "Show autostart status"
},
"runtime": {
"description": "Manage native runtime dependencies",
"check": "Check status of native deps in runtime directory",

View File

@@ -174,7 +174,9 @@
"daemon": "Executar servidor em segundo plano",
"log": "Exibir logs do servidor inline",
"no_recovery": "Desabilitar reinício automático em crash (modo debug)",
"max_restarts": "Máximo de reinícios em 30s antes de desistir (padrão: 2)"
"max_restarts": "Máximo de reinícios em 30s antes de desistir (padrão: 2)",
"tray": "Mostrar ícone na bandeja do sistema (apenas desktop, opt-in)",
"no_tray": "Desabilitar ícone na bandeja do sistema"
},
"backup": {
"title": "Backup",
@@ -1083,6 +1085,18 @@
"durationMax": "Duração máxima do request em ms",
"export": "Salvar logs em arquivo (json/jsonl/csv)"
},
"tray": {
"description": "Controlar o ícone na bandeja do sistema",
"show": "Mostrar o ícone na bandeja (se o servidor estiver rodando com --tray)",
"hide": "Ocultar o ícone na bandeja",
"quit": "Encerrar OmniRoute via bandeja"
},
"autostart": {
"description": "Gerenciar inicialização automática do OmniRoute no login",
"enable": "Habilitar inicialização automática no login",
"disable": "Desabilitar inicialização automática no login",
"status": "Mostrar status de inicialização automática"
},
"runtime": {
"description": "Gerenciar dependências nativas do runtime",
"check": "Verificar status das deps nativas no diretório de runtime",

147
bin/cli/tray/autostart.mjs Normal file
View File

@@ -0,0 +1,147 @@
import { existsSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
import { execSync } from "node:child_process";
import { homedir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const APP_LABEL = "com.omniroute.autostart";
const WIN_REG_VALUE = "OmniRoute";
function resolveCliPath() {
return (
process.argv[1] || join(dirname(fileURLToPath(import.meta.url)), "..", "..", "omniroute.mjs")
);
}
export function enable() {
if (process.platform === "darwin") return enableMac();
if (process.platform === "win32") return enableWin();
if (process.platform === "linux") return enableLinux();
return false;
}
export function disable() {
if (process.platform === "darwin") return disableMac();
if (process.platform === "win32") return disableWin();
if (process.platform === "linux") return disableLinux();
return false;
}
export function isAutostartEnabled() {
if (process.platform === "darwin") return isEnabledMac();
if (process.platform === "win32") return isEnabledWin();
if (process.platform === "linux") return isEnabledLinux();
return false;
}
function enableMac() {
const plistDir = join(homedir(), "Library", "LaunchAgents");
mkdirSync(plistDir, { recursive: true });
const plistPath = join(plistDir, `${APP_LABEL}.plist`);
const cliPath = resolveCliPath();
const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>${APP_LABEL}</string>
<key>ProgramArguments</key><array>
<string>${process.execPath}</string>
<string>${cliPath}</string>
<string>serve</string>
<string>--tray</string>
<string>--no-open</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><false/>
</dict></plist>`;
writeFileSync(plistPath, plist, { mode: 0o644 });
try {
execSync("launchctl load -w " + JSON.stringify(plistPath), { stdio: "ignore" });
} catch {}
return existsSync(plistPath);
}
function disableMac() {
const plistPath = join(homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
try {
execSync("launchctl unload -w " + JSON.stringify(plistPath), { stdio: "ignore" });
} catch {}
try {
unlinkSync(plistPath);
} catch {}
return !existsSync(plistPath);
}
function isEnabledMac() {
return existsSync(join(homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`));
}
function enableWin() {
const cliPath = resolveCliPath();
const value = `"${process.execPath}" "${cliPath}" serve --tray --no-open`;
try {
execSync(
`reg add HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE} /t REG_SZ /d "${value}" /f`,
{ stdio: "ignore", windowsHide: true }
);
return true;
} catch {
return false;
}
}
function disableWin() {
try {
execSync(
`reg delete HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE} /f`,
{ stdio: "ignore", windowsHide: true }
);
return true;
} catch {
return false;
}
}
function isEnabledWin() {
try {
const out = execSync(
`reg query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE}`,
{ stdio: "pipe", windowsHide: true, encoding: "utf8" }
);
return out.includes(WIN_REG_VALUE);
} catch {
return false;
}
}
function enableLinux() {
const dir = join(homedir(), ".config", "autostart");
mkdirSync(dir, { recursive: true });
const cliPath = resolveCliPath();
const desktop =
[
"[Desktop Entry]",
"Type=Application",
"Name=OmniRoute",
"Comment=AI proxy router with auto fallback",
`Exec=${process.execPath} ${cliPath} serve --tray --no-open`,
"Terminal=false",
"Hidden=false",
"X-GNOME-Autostart-enabled=true",
].join("\n") + "\n";
writeFileSync(join(dir, "omniroute.desktop"), desktop, { mode: 0o644 });
return true;
}
function disableLinux() {
const desktopPath = join(homedir(), ".config", "autostart", "omniroute.desktop");
try {
unlinkSync(desktopPath);
return true;
} catch {
return false;
}
}
function isEnabledLinux() {
return existsSync(join(homedir(), ".config", "autostart", "omniroute.desktop"));
}

26
bin/cli/tray/index.mjs Normal file
View File

@@ -0,0 +1,26 @@
import { isTraySupported, initSystrayUnix, killSystrayUnix } from "./traySystray.mjs";
import { initWinTray, killWinTray } from "./trayWindows.mjs";
let active = null;
export { isTraySupported };
export function initTray({ port, onQuit, onOpenDashboard, onShowLogs }) {
if (!isTraySupported()) return null;
const ctx = { port, onQuit, onOpenDashboard, onShowLogs };
active = process.platform === "win32" ? initWinTray(ctx) : initSystrayUnix(ctx);
return active;
}
export function killTray() {
if (!active) return;
try {
if (process.platform === "win32") killWinTray(active);
else killSystrayUnix(active);
} catch {}
active = null;
}
export function isTrayActive() {
return active !== null;
}

View File

@@ -0,0 +1,107 @@
import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { isAutostartEnabled } from "./autostart.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const MENU_INDEX = { STATUS: 0, DASHBOARD: 1, LOGS: 2, AUTOSTART: 3, QUIT: 4 };
export function isTraySupported() {
const p = process.platform;
if (!["darwin", "linux", "win32"].includes(p)) return false;
if (p === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
return true;
}
function loadSystray2() {
const candidates = [
() => {
const { createRequire } = require("module");
const req = createRequire(import.meta.url);
return req("systray2").default;
},
];
for (const attempt of candidates) {
try {
return attempt();
} catch {}
}
return null;
}
function getIconBase64() {
const iconPath = join(__dirname, "icons", "icon.png");
if (existsSync(iconPath)) return readFileSync(iconPath).toString("base64");
return "";
}
export function initSystrayUnix({ port, onQuit, onOpenDashboard, onShowLogs }) {
const SysTray = loadSystray2();
if (!SysTray) return null;
const autostartEnabled = isAutostartEnabled();
const items = [
{ title: `OmniRoute • port ${port}`, tooltip: "Server running", enabled: false },
{ title: "Open Dashboard", enabled: true },
{ title: "Show Logs", enabled: true },
{
title: autostartEnabled ? "✓ Auto-start (click to disable)" : "Enable Auto-start",
enabled: true,
},
{ title: "Quit OmniRoute", enabled: true },
];
let tray;
try {
tray = new SysTray({
menu: {
icon: getIconBase64(),
isTemplateIcon: process.platform === "darwin",
title: "",
tooltip: `OmniRoute — port ${port}`,
items,
},
debug: false,
copyDir: false,
});
} catch {
return null;
}
tray.onClick(async (action) => {
if (action.seq_id === MENU_INDEX.DASHBOARD) {
onOpenDashboard?.();
} else if (action.seq_id === MENU_INDEX.LOGS) {
onShowLogs?.();
} else if (action.seq_id === MENU_INDEX.AUTOSTART) {
const { enable, disable, isAutostartEnabled: isEnabled } = await import("./autostart.mjs");
const wasOn = isEnabled();
if (wasOn) disable();
else enable();
const nowOn = !wasOn;
tray.sendAction({
type: "update-item",
item: {
title: nowOn ? "✓ Auto-start (click to disable)" : "Enable Auto-start",
enabled: true,
},
seq_id: MENU_INDEX.AUTOSTART,
});
} else if (action.seq_id === MENU_INDEX.QUIT) {
onQuit?.();
}
});
tray.ready().catch((err) => {
process.stderr.write(`[omniroute][tray] systray2 failed: ${err?.message ?? String(err)}\n`);
});
return tray;
}
export function killSystrayUnix(tray) {
try {
tray.kill(false);
} catch {}
}

View File

@@ -0,0 +1,75 @@
import { spawn } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { writeFileSync, unlinkSync, existsSync } from "node:fs";
const OMNIROUTE_IPC_PORT_BASE = 29128;
export function initWinTray({ port, onQuit, onOpenDashboard, onShowLogs }) {
if (process.platform !== "win32") return null;
const ipcPort = OMNIROUTE_IPC_PORT_BASE + (port % 1000);
const scriptPath = join(tmpdir(), `omniroute-tray-${process.pid}.ps1`);
const ps1 = `
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$tray = New-Object System.Windows.Forms.NotifyIcon
$tray.Text = "OmniRoute - Port ${port}"
$tray.Icon = [System.Drawing.SystemIcons]::Application
$tray.Visible = $true
$menu = New-Object System.Windows.Forms.ContextMenuStrip
$mDash = $menu.Items.Add("Open Dashboard")
$mDash.add_Click({ [System.Net.Sockets.TcpClient]::new("127.0.0.1", ${ipcPort}).Close(); Write-Host "DASHBOARD" })
$mLogs = $menu.Items.Add("Show Logs")
$mLogs.add_Click({ Write-Host "LOGS" })
$mAutostart = $menu.Items.Add("Enable Auto-start")
$mAutostart.add_Click({ Write-Host "AUTOSTART" })
$mQuit = $menu.Items.Add("Quit OmniRoute")
$mQuit.add_Click({ Write-Host "QUIT"; [System.Windows.Forms.Application]::Exit() })
$tray.ContextMenuStrip = $menu
[System.Windows.Forms.Application]::Run()
$tray.Dispose()
`.trim();
writeFileSync(scriptPath, ps1, "utf8");
const proc = spawn("powershell.exe", ["-NonInteractive", "-File", scriptPath], {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
detached: false,
shell: false,
});
proc.stdout.on("data", async (data) => {
const line = data.toString().trim();
if (line === "DASHBOARD") onOpenDashboard?.();
else if (line === "LOGS") onShowLogs?.();
else if (line === "AUTOSTART") {
const { enable, disable, isAutostartEnabled } = await import("./autostart.mjs");
if (isAutostartEnabled()) disable();
else enable();
} else if (line === "QUIT") onQuit?.();
});
proc.on("exit", () => {
try {
if (existsSync(scriptPath)) unlinkSync(scriptPath);
} catch {}
});
return proc;
}
export function killWinTray(proc) {
try {
proc.kill("SIGTERM");
} catch {}
}

View File

@@ -77,6 +77,9 @@ const IGNORE_FROM_CODE = new Set([
"REPL_SLUG",
"WSL_DISTRO_NAME",
"WSL_INTEROP",
// X11/Wayland display server vars used by tray heuristic (isTraySupported).
"DISPLAY",
"WAYLAND_DISPLAY",
// Aliases for documented vars handled via fallback ordering.
"API_KEY",
"APP_URL",

View File

@@ -0,0 +1,75 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
let tmpDir: string;
let origHome: string | undefined;
test.before(() => {
tmpDir = mkdtempSync(join(tmpdir(), "omniroute-tray-test-"));
origHome = process.env.HOME;
// Redirecionar HOME para tmpDir para isolar testes de autostart
process.env.HOME = tmpDir;
});
test.after(() => {
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {}
});
test("tray/index.mjs pode ser importado sem erro", async () => {
const mod = await import("../../bin/cli/tray/index.mjs");
assert.equal(typeof mod.initTray, "function");
assert.equal(typeof mod.killTray, "function");
assert.equal(typeof mod.isTrayActive, "function");
assert.equal(typeof mod.isTraySupported, "function");
});
test("isTraySupported retorna boolean", async () => {
const { isTraySupported } = await import("../../bin/cli/tray/index.mjs");
assert.equal(typeof isTraySupported(), "boolean");
});
test("isTrayActive retorna false antes de iniciar", async () => {
const { isTrayActive } = await import("../../bin/cli/tray/index.mjs");
assert.equal(isTrayActive(), false);
});
test("tray/autostart.mjs pode ser importado sem erro", async () => {
const mod = await import("../../bin/cli/tray/autostart.mjs");
assert.equal(typeof mod.enable, "function");
assert.equal(typeof mod.disable, "function");
assert.equal(typeof mod.isAutostartEnabled, "function");
});
test("autostart.isAutostartEnabled retorna boolean", async () => {
const { isAutostartEnabled } = await import("../../bin/cli/tray/autostart.mjs");
const result = isAutostartEnabled();
assert.equal(typeof result, "boolean");
assert.equal(result, false, "autostart não deve estar habilitado em tmpDir isolado");
});
test("autostart.enable cria arquivo de configuração no Linux", async () => {
if (process.platform !== "linux") return;
const { enable, isAutostartEnabled, disable } = await import("../../bin/cli/tray/autostart.mjs");
const ok = enable();
assert.equal(ok, true, "enable deve retornar true");
assert.equal(isAutostartEnabled(), true, "isAutostartEnabled deve ser true após enable");
disable();
assert.equal(isAutostartEnabled(), false, "isAutostartEnabled deve ser false após disable");
});
test("commands/tray.mjs pode ser importado sem erro", async () => {
const mod = await import("../../bin/cli/commands/tray.mjs");
assert.equal(typeof mod.registerTray, "function");
});
test("commands/autostart.mjs pode ser importado sem erro", async () => {
const mod = await import("../../bin/cli/commands/autostart.mjs");
assert.equal(typeof mod.registerAutostart, "function");
});