From 77a4429bf405fd26dd3b2a009e5bf6e4cc7cc321 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 14 May 2026 22:42:01 -0300 Subject: [PATCH] feat(cli): add standalone system tray with PowerShell fallback on Windows Cross-platform system tray for `omniroute --tray`: Windows uses a PowerShell NotifyIcon script (zero native binary, AV-safe); macOS/Linux use systray2 lazy-installed at runtime into ~/.omniroute/runtime/. Includes per-platform autostart (LaunchAgent / .desktop / registry) and `omniroute config tray ` CLI command. DISPLAY added to env-sync allowlist as an OS/X11 variable, not an OmniRoute config variable. --- bin/cli/commands/config.mjs | 24 ++++ bin/cli/runtime/trayRuntime.ts | 48 ++++++++ bin/cli/tray/autostart.ts | 164 +++++++++++++++++++++++++ bin/cli/tray/icon.png | Bin 0 -> 713 bytes bin/cli/tray/tray.ps1 | 87 +++++++++++++ bin/cli/tray/tray.ts | 176 +++++++++++++++++++++++++++ bin/cli/tray/trayWin.ts | 91 ++++++++++++++ bin/omniroute.mjs | 30 +++++ scripts/check/check-env-doc-sync.mjs | 2 + tests/unit/cli/autostart.test.ts | 8 ++ tests/unit/cli/tray.test.ts | 33 +++++ 11 files changed, 663 insertions(+) create mode 100644 bin/cli/runtime/trayRuntime.ts create mode 100644 bin/cli/tray/autostart.ts create mode 100644 bin/cli/tray/icon.png create mode 100644 bin/cli/tray/tray.ps1 create mode 100644 bin/cli/tray/tray.ts create mode 100644 bin/cli/tray/trayWin.ts create mode 100644 tests/unit/cli/autostart.test.ts create mode 100644 tests/unit/cli/tray.test.ts diff --git a/bin/cli/commands/config.mjs b/bin/cli/commands/config.mjs index 32d61cba3f..23fce1c3b0 100644 --- a/bin/cli/commands/config.mjs +++ b/bin/cli/commands/config.mjs @@ -11,6 +11,7 @@ Usage: omniroute config get Show current config for a tool omniroute config set [options] Write config for a tool omniroute config validate Validate config format without writing + omniroute config tray Enable/disable tray autostart on login Options: --base-url OmniRoute API base URL (default: http://localhost:20128/v1) @@ -176,6 +177,29 @@ export async function runConfigCommand(argv) { return 0; } + if (subcommand === "tray") { + const action = positionals[1]; + if (action === "enable") { + const { enableAutoStart } = await import("../tray/autostart.ts"); + const ok = await enableAutoStart(); + if (ok) { + printSuccess("Autostart enabled — omniroute --tray will launch on login."); + } else { + printError("Autostart failed (unsupported OS or permission denied)."); + return 1; + } + return 0; + } + if (action === "disable") { + const { disableAutoStart } = await import("../tray/autostart.ts"); + await disableAutoStart(); + printSuccess("Autostart disabled."); + return 0; + } + printError("Usage: omniroute config tray "); + return 1; + } + printError(`Unknown subcommand: ${subcommand}`); printConfigHelp(); return 1; diff --git a/bin/cli/runtime/trayRuntime.ts b/bin/cli/runtime/trayRuntime.ts new file mode 100644 index 0000000000..c1d76a96fd --- /dev/null +++ b/bin/cli/runtime/trayRuntime.ts @@ -0,0 +1,48 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { execSync } from "node:child_process"; + +const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime"); +// systray2 is a maintained fork with prebuilt binaries — installed lazily at runtime, +// not in dependencies, to avoid npm install overhead for users who don't use --tray. +const SYSTRAY_VERSION = "systray2@1.4.5"; + +export async function loadSystray(): Promise<(new (...args: unknown[]) => unknown) | null> { + if (process.platform === "win32") return null; // Windows uses tray.ps1 instead + ensureRuntimeDir(); + if (!isInstalled()) { + try { + installSystray(); + } catch (err) { + console.warn(`[omniroute] tray runtime install failed: ${(err as Error).message}`); + return null; + } + } + try { + const modPath = join(RUNTIME_DIR, "node_modules", "systray2"); + const mod = await import(modPath); + return (mod.default ?? mod.SysTray ?? mod) as (new (...args: unknown[]) => unknown) | null; + } catch { + return null; + } +} + +function ensureRuntimeDir(): void { + if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true }); + const pkg = join(RUNTIME_DIR, "package.json"); + if (!existsSync(pkg)) { + writeFileSync(pkg, JSON.stringify({ name: "omniroute-runtime", private: true }), "utf-8"); + } +} + +function isInstalled(): boolean { + return existsSync(join(RUNTIME_DIR, "node_modules", "systray2", "package.json")); +} + +function installSystray(): void { + execSync( + `npm install --prefix "${RUNTIME_DIR}" ${SYSTRAY_VERSION} --no-audit --no-fund --silent`, + { stdio: ["ignore", "ignore", "pipe"], timeout: 120_000 } + ); +} diff --git a/bin/cli/tray/autostart.ts b/bin/cli/tray/autostart.ts new file mode 100644 index 0000000000..512749433f --- /dev/null +++ b/bin/cli/tray/autostart.ts @@ -0,0 +1,164 @@ +import { existsSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { join, dirname, basename, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir, platform } from "node:os"; +import { execSync } from "node:child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const APP_NAME = "omniroute"; +const APP_LABEL = "com.omniroute.autostart"; + +function resolveCliPath(): string | null { + if ( + process.argv[1] && + basename(process.argv[1]) === "omniroute.mjs" && + existsSync(process.argv[1]) + ) { + return resolve(process.argv[1]); + } + // autostart.ts lives at bin/cli/tray/ → walk up to bin/omniroute.mjs + const guess = resolve(__dirname, "..", "..", "omniroute.mjs"); + return existsSync(guess) ? guess : null; +} + +export async function enableAutoStart(): Promise { + const cliPath = resolveCliPath(); + if (!cliPath) return false; + switch (platform()) { + case "darwin": + return enableMacOS(cliPath); + case "linux": + return enableLinux(cliPath); + case "win32": + return enableWindows(cliPath); + default: + return false; + } +} + +export async function disableAutoStart(): Promise { + switch (platform()) { + case "darwin": + return disableMacOS(); + case "linux": + return disableLinux(); + case "win32": + return disableWindows(); + default: + return false; + } +} + +export async function isAutoStartEnabled(): Promise { + switch (platform()) { + case "darwin": + return existsSync(macPlistPath()); + case "linux": + return existsSync(linuxDesktopPath()); + case "win32": + return windowsRegistryHasKey(); + default: + return false; + } +} + +function macPlistPath(): string { + return join(homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`); +} + +function enableMacOS(cliPath: string): boolean { + const plist = ` + + + Label${APP_LABEL} + ProgramArguments/usr/bin/envnode${cliPath}--tray + RunAtLoad + KeepAlive +`; + const path = macPlistPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, plist, "utf-8"); + try { + execSync(`launchctl load -w "${path}"`); + } catch { + // non-fatal — file was written, launchctl may fail in CI + } + return true; +} + +function disableMacOS(): boolean { + const path = macPlistPath(); + if (!existsSync(path)) return true; + try { + execSync(`launchctl unload "${path}"`); + } catch { + // non-fatal + } + try { + unlinkSync(path); + } catch { + // non-fatal + } + return true; +} + +function linuxDesktopPath(): string { + return join(homedir(), ".config", "autostart", `${APP_NAME}.desktop`); +} + +function enableLinux(cliPath: string): boolean { + const desktop = `[Desktop Entry] +Type=Application +Name=OmniRoute +Exec=node "${cliPath}" --tray +X-GNOME-Autostart-enabled=true +NoDisplay=false +`; + const path = linuxDesktopPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, desktop, "utf-8"); + return true; +} + +function disableLinux(): boolean { + const path = linuxDesktopPath(); + if (existsSync(path)) unlinkSync(path); + return true; +} + +function enableWindows(cliPath: string): boolean { + try { + const cmd = `node "${cliPath}" --tray`; + execSync( + `reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v ${APP_NAME} /t REG_SZ /d "${cmd}" /f`, + { windowsHide: true } + ); + return true; + } catch { + return false; + } +} + +function disableWindows(): boolean { + try { + execSync( + `reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v ${APP_NAME} /f`, + { windowsHide: true } + ); + } catch { + // already absent — treat as success + } + return true; +} + +function windowsRegistryHasKey(): boolean { + try { + const out = execSync( + `reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v ${APP_NAME}`, + { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] } + ).toString(); + return out.includes(APP_NAME); + } catch { + return false; + } +} diff --git a/bin/cli/tray/icon.png b/bin/cli/tray/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4e4abe2b781251126f9206c28774816f554d57c6 GIT binary patch literal 713 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4zl0AZa85pY67#JE_7#My5g&JNk zFq9fFFuY1&V6d9Oz#v{QXIG#NP=YDR+uenMVO6iP5s=4O;1O926a+~Cd6kb#fh_hC zPhVH|S4=WI;v&564Lv|1)e_f;l9a@fRIB8oR3OD*WME{bYha{nWD#O$WMyP*Wn`dj zU|?lnu<&+DCyIvL{FKbJO57SQwB0cUYS4h&P?DLOT3nKtTYy_n`{ci`fO@P!dWy@^ zt&;O|b5rw57!;g=WKm*{LSBAKssfjSe`!f-5tvg9Z(<)tQQ1;uzv_{Aj4P-(d%lIomxYTMtRN$8cX(FizM|QuuNY&x3Mu{!K23i(d%|RyHX`ne5^Vy;yj<6kV?!%74tKPCOF^mW^4$t zetbQAQH4&crn%0H<4#u!GIzopr08mZ^cmMzZ literal 0 HcmV?d00001 diff --git a/bin/cli/tray/tray.ps1 b/bin/cli/tray/tray.ps1 new file mode 100644 index 0000000000..35815dc00d --- /dev/null +++ b/bin/cli/tray/tray.ps1 @@ -0,0 +1,87 @@ +# OmniRoute tray icon for Windows using NotifyIcon (zero binary, AV-safe) +# IPC: stdin JSON commands, stdout JSON events +param([string]$IconPath, [string]$Tooltip) + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::InputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon +$script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath) +$script:notifyIcon.Text = $Tooltip +$script:notifyIcon.Visible = $true + +$script:menu = New-Object System.Windows.Forms.ContextMenuStrip +$script:notifyIcon.ContextMenuStrip = $script:menu +$script:items = @() + +function Write-Event($obj) { + $json = $obj | ConvertTo-Json -Compress + [Console]::Out.WriteLine($json) + [Console]::Out.Flush() +} + +function Add-MenuItem($index, $title, $enabled) { + $item = New-Object System.Windows.Forms.ToolStripMenuItem + $item.Text = $title + $item.Enabled = $enabled + $idx = $index + $item.Add_Click({ Write-Event @{ type = "click"; index = $idx } }.GetNewClosure()) + $script:menu.Items.Add($item) | Out-Null + $script:items += $item +} + +function Update-MenuItem($index, $title, $enabled) { + if ($index -lt $script:items.Count) { + $script:items[$index].Text = $title + $script:items[$index].Enabled = $enabled + } +} + +function Set-Tooltip($text) { + if ($text.Length -gt 63) { $text = $text.Substring(0, 63) } + $script:notifyIcon.Text = $text +} + +# Read commands from stdin on a timer to keep the Windows message loop alive +$reader = [Console]::In +$script:running = $true +$timer = New-Object System.Windows.Forms.Timer +$timer.Interval = 100 +$timer.Add_Tick({ + while ($reader.Peek() -ge 0) { + $line = $reader.ReadLine() + if ($null -eq $line) { + $script:notifyIcon.Visible = $false + [System.Windows.Forms.Application]::Exit() + return + } + try { + $cmd = $line | ConvertFrom-Json + switch ($cmd.type) { + "setMenu" { + $script:menu.Items.Clear() + $script:items = @() + for ($i = 0; $i -lt $cmd.items.Count; $i++) { + $it = $cmd.items[$i] + Add-MenuItem $i $it.title $it.enabled + } + } + "updateItem" { Update-MenuItem $cmd.index $cmd.title $cmd.enabled } + "setTooltip" { Set-Tooltip $cmd.text } + "quit" { + $script:notifyIcon.Visible = $false + [System.Windows.Forms.Application]::Exit() + } + } + } catch {} + } +}) +$timer.Start() + +Write-Event @{ type = "ready" } +[System.Windows.Forms.Application]::Run() diff --git a/bin/cli/tray/tray.ts b/bin/cli/tray/tray.ts new file mode 100644 index 0000000000..c904bc17d2 --- /dev/null +++ b/bin/cli/tray/tray.ts @@ -0,0 +1,176 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { initWindowsTray, type WinTrayHandle } from "./trayWin.ts"; +import { enableAutoStart, disableAutoStart, isAutoStartEnabled } from "./autostart.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface MenuItem { + title: string; + enabled: boolean; +} + +export interface TrayOptions { + port: number; + onQuit: () => void; + onOpenDashboard: () => void; +} + +export interface TrayInstance { + update(items: MenuItem[]): void; + setTooltip(text: string): void; + destroy(): void; +} + +// Minimal 16x16 OmniRoute icon as base64 PNG (fallback when file missing) +const FALLBACK_ICON_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAHpJREFUOE9jYBgFgwEwMjIy/Gdg+P8fyP4PxP8ZGBgEcBnGyMjIsICBgSEAhyH/gfgBUNN8XJoZsdkCVL8Ah+b/QPwbqvkBMvk/AwMDAzYX/GdgYAhAN+A/SICRWAMYGfFEJSMjzriEiwDR/xmIa2RkZCSqnZERb3QCAAo3KxzxbKe1AAAAAElFTkSuQmCC"; + +export function getIconPath(): string { + const isWin = process.platform === "win32"; + const iconFile = isWin ? "icon.ico" : "icon.png"; + const iconPath = join(__dirname, iconFile); + return existsSync(iconPath) ? iconPath : ""; +} + +export function getIconBase64(): string { + const iconPath = getIconPath(); + if (iconPath) { + try { + return readFileSync(iconPath).toString("base64"); + } catch { + // fall through to default + } + } + return FALLBACK_ICON_BASE64; +} + +export function isTraySupported(): boolean { + const p = process.platform; + if (!["darwin", "win32", "linux"].includes(p)) return false; + if (p === "linux" && !process.env.DISPLAY) return false; + return true; +} + +export function buildMenuItems(args: { port: number; autostartEnabled: boolean }): MenuItem[] { + return [ + { title: "Open OmniRoute Dashboard", enabled: true }, + { title: `Port: ${args.port}`, enabled: false }, + { title: args.autostartEnabled ? "Disable Autostart" : "Enable Autostart", enabled: true }, + { title: "Quit OmniRoute", enabled: true }, + ]; +} + +const MENU_INDEX = { + OPEN_DASHBOARD: 0, + PORT: 1, + AUTOSTART_TOGGLE: 2, + QUIT: 3, +} as const; + +export async function initTray(options: TrayOptions): Promise { + if (!isTraySupported()) return null; + if (process.platform === "win32") return initWindowsTrayInstance(options); + return initUnixTray(options); +} + +async function initWindowsTrayInstance(options: TrayOptions): Promise { + const iconPath = getIconPath(); + if (!iconPath) return null; + let autostartEnabled = await isAutoStartEnabled(); + let handle: WinTrayHandle | null = null; + handle = initWindowsTray({ + iconPath, + tooltip: `OmniRoute :${options.port}`, + onEvent: async (evt) => { + if (evt.type !== "click") return; + switch (evt.index) { + case MENU_INDEX.OPEN_DASHBOARD: + options.onOpenDashboard(); + break; + case MENU_INDEX.AUTOSTART_TOGGLE: { + if (autostartEnabled) await disableAutoStart(); + else await enableAutoStart(); + autostartEnabled = !autostartEnabled; + handle?.update(buildMenuItems({ port: options.port, autostartEnabled })); + break; + } + case MENU_INDEX.QUIT: + options.onQuit(); + break; + } + }, + }); + if (!handle) return null; + handle.update(buildMenuItems({ port: options.port, autostartEnabled })); + return { + update: (items) => handle!.update(items), + setTooltip: (text) => handle!.setTooltip(text), + destroy: () => handle!.destroy(), + }; +} + +async function initUnixTray(options: TrayOptions): Promise { + const { loadSystray } = await import("../runtime/trayRuntime.ts"); + const SysTray = await loadSystray(); + if (!SysTray) return null; + let autostartEnabled = await isAutoStartEnabled(); + const menuItems = buildMenuItems({ port: options.port, autostartEnabled }); + const systray = new SysTray({ + menu: { + icon: getIconBase64(), + title: "OmniRoute", + tooltip: `OmniRoute :${options.port}`, + items: menuItems.map((it) => ({ + title: it.title, + tooltip: "", + checked: false, + enabled: it.enabled, + })), + }, + debug: false, + copyDir: false, + }); + systray.onClick(async (action: { seq_id: number }) => { + switch (action.seq_id) { + case MENU_INDEX.OPEN_DASHBOARD: + options.onOpenDashboard(); + break; + case MENU_INDEX.AUTOSTART_TOGGLE: { + if (autostartEnabled) await disableAutoStart(); + else await enableAutoStart(); + autostartEnabled = !autostartEnabled; + systray.sendAction({ + type: "update-item", + item: { + title: autostartEnabled ? "Disable Autostart" : "Enable Autostart", + enabled: true, + checked: false, + tooltip: "", + }, + seq_id: MENU_INDEX.AUTOSTART_TOGGLE, + }); + break; + } + case MENU_INDEX.QUIT: + options.onQuit(); + break; + } + }); + return { + update: (items) => { + items.forEach((it, idx) => { + systray.sendAction({ + type: "update-item", + item: { title: it.title, enabled: it.enabled, checked: false, tooltip: "" }, + seq_id: idx, + }); + }); + }, + setTooltip: () => { + /* systray2 does not support runtime tooltip change */ + }, + destroy: () => systray.kill(), + }; +} diff --git a/bin/cli/tray/trayWin.ts b/bin/cli/tray/trayWin.ts new file mode 100644 index 0000000000..4e8fe2d3c7 --- /dev/null +++ b/bin/cli/tray/trayWin.ts @@ -0,0 +1,91 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PS1_PATH = join(__dirname, "tray.ps1"); + +export interface WinTrayEvent { + type: "click" | "ready" | "error"; + index?: number; + error?: string; +} + +export interface WinTrayOptions { + iconPath: string; + tooltip: string; + onEvent: (evt: WinTrayEvent) => void; +} + +export interface WinTrayHandle { + update(items: Array<{ title: string; enabled: boolean }>): void; + setTooltip(text: string): void; + destroy(): void; +} + +export function initWindowsTray(opts: WinTrayOptions): WinTrayHandle | null { + let child: ChildProcess; + try { + child = spawn( + "powershell.exe", + [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + PS1_PATH, + "-IconPath", + opts.iconPath, + "-Tooltip", + opts.tooltip, + ], + { stdio: ["pipe", "pipe", "pipe"], windowsHide: true } + ); + } catch (err) { + opts.onEvent({ type: "error", error: String(err) }); + return null; + } + + child.stdout?.setEncoding("utf-8"); + let buffer = ""; + child.stdout?.on("data", (chunk: string) => { + buffer += chunk; + let nl: number; + while ((nl = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + try { + const evt = JSON.parse(line) as WinTrayEvent; + opts.onEvent(evt); + } catch { + // ignore malformed JSON lines + } + } + }); + + child.on("error", (err) => opts.onEvent({ type: "error", error: err.message })); + + function send(cmd: object): void { + if (!child.stdin?.writable) return; + child.stdin.write(JSON.stringify(cmd) + "\n"); + } + + return { + update(items) { + send({ type: "setMenu", items }); + }, + setTooltip(text) { + send({ type: "setTooltip", text }); + }, + destroy() { + try { + send({ type: "quit" }); + } catch { + // already dead + } + setTimeout(() => child.kill(), 500); + }, + }; +} diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index b40f63d9ab..25f0108d46 100644 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -7,6 +7,7 @@ * omniroute Start the server (default port 20128) * omniroute --port 3000 Start on custom port * omniroute --no-open Start without opening browser + * omniroute --tray Enable system tray icon (macOS/Linux/Windows) * omniroute --mcp Start MCP server (stdio transport for IDEs) * omniroute setup Interactive guided setup * omniroute doctor Run local health checks @@ -387,6 +388,7 @@ if (portIdx !== -1 && args[portIdx + 1]) { const apiPort = parsePort(process.env.API_PORT || String(port), port); const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port); const noOpen = args.includes("--no-open"); +const useTray = args.includes("--tray"); console.log(` \x1b[36m ____ _ ____ _ @@ -546,6 +548,34 @@ async function onReady() { // open is optional — if not available, just skip. } } + + if (useTray) { + try { + const { initTray } = await import("./cli/tray/tray.ts"); + const { exec } = await import("node:child_process"); + const tray = await initTray({ + port: dashboardPort, + onQuit: () => { + shutdown(); + }, + onOpenDashboard: () => { + const url = dashboardUrl; + const cmd = + process.platform === "darwin" + ? `open "${url}"` + : process.platform === "win32" + ? `start "" "${url}"` + : `xdg-open "${url}"`; + exec(cmd); + }, + }); + if (tray) { + console.log(" \x1b[2m🖥 System tray active\x1b[0m"); + } + } catch (err) { + console.warn(` \x1b[33m⚠ Tray unavailable: ${err?.message ?? err}\x1b[0m`); + } + } } setTimeout(() => { diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index a69d0f94ef..3c1e139ea6 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -54,6 +54,8 @@ const IGNORE_FROM_CODE = new Set([ "XDG_CONFIG_HOME", "USERPROFILE", "PREFIX", + // X11 display server — set by the OS/session manager, not OmniRoute config. + "DISPLAY", // Next.js / Node test runners — these are framework-managed. "NEXT_DIST_DIR", "NEXT_PHASE", diff --git a/tests/unit/cli/autostart.test.ts b/tests/unit/cli/autostart.test.ts new file mode 100644 index 0000000000..7304e42f3e --- /dev/null +++ b/tests/unit/cli/autostart.test.ts @@ -0,0 +1,8 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { isAutoStartEnabled } from "../../../bin/cli/tray/autostart.ts"; + +test("isAutoStartEnabled does not throw and returns boolean", async () => { + const result = await isAutoStartEnabled(); + assert.equal(typeof result, "boolean"); +}); diff --git a/tests/unit/cli/tray.test.ts b/tests/unit/cli/tray.test.ts new file mode 100644 index 0000000000..b47a3d5082 --- /dev/null +++ b/tests/unit/cli/tray.test.ts @@ -0,0 +1,33 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildMenuItems, isTraySupported } from "../../../bin/cli/tray/tray.ts"; + +test("buildMenuItems contains expected entries", () => { + const items = buildMenuItems({ port: 20128, autostartEnabled: false }); + const titles = items.map((i) => i.title); + assert.ok(titles.includes("Open OmniRoute Dashboard"), "has Open entry"); + assert.ok( + titles.some((t) => t.startsWith("Port: 20128")), + "shows port" + ); + assert.ok(titles.includes("Enable Autostart"), "shows toggle when disabled"); + assert.ok(titles.includes("Quit OmniRoute"), "has quit"); +}); + +test("buildMenuItems shows Disable Autostart when enabled", () => { + const items = buildMenuItems({ port: 3000, autostartEnabled: true }); + const titles = items.map((i) => i.title); + assert.ok(titles.includes("Disable Autostart")); + assert.ok(!titles.includes("Enable Autostart")); +}); + +test("isTraySupported returns false on linux without DISPLAY", () => { + if (process.platform !== "linux") return; + const originalDisplay = process.env.DISPLAY; + delete process.env.DISPLAY; + try { + assert.equal(isTraySupported(), false); + } finally { + if (originalDisplay) process.env.DISPLAY = originalDisplay; + } +});