mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
39
bin/cli/commands/autostart.mjs
Normal file
39
bin/cli/commands/autostart.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
36
bin/cli/commands/tray.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
147
bin/cli/tray/autostart.mjs
Normal 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
26
bin/cli/tray/index.mjs
Normal 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;
|
||||
}
|
||||
107
bin/cli/tray/traySystray.mjs
Normal file
107
bin/cli/tray/traySystray.mjs
Normal 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 {}
|
||||
}
|
||||
75
bin/cli/tray/trayWindows.mjs
Normal file
75
bin/cli/tray/trayWindows.mjs
Normal 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 {}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
75
tests/unit/cli-tray.test.ts
Normal file
75
tests/unit/cli-tray.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user