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 <enable|disable>` CLI command.

DISPLAY added to env-sync allowlist as an OS/X11 variable, not an
OmniRoute config variable.
This commit is contained in:
diegosouzapw
2026-05-14 22:42:01 -03:00
parent e007fc11aa
commit 77a4429bf4
11 changed files with 663 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ Usage:
omniroute config get <tool> Show current config for a tool
omniroute config set <tool> [options] Write config for a tool
omniroute config validate <tool> Validate config format without writing
omniroute config tray <enable|disable> Enable/disable tray autostart on login
Options:
--base-url <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 <enable|disable>");
return 1;
}
printError(`Unknown subcommand: ${subcommand}`);
printConfigHelp();
return 1;

View File

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

164
bin/cli/tray/autostart.ts Normal file
View File

@@ -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<boolean> {
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<boolean> {
switch (platform()) {
case "darwin":
return disableMacOS();
case "linux":
return disableLinux();
case "win32":
return disableWindows();
default:
return false;
}
}
export async function isAutoStartEnabled(): Promise<boolean> {
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 = `<?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>/usr/bin/env</string><string>node</string><string>${cliPath}</string><string>--tray</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><false/>
</dict></plist>`;
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;
}
}

BIN
bin/cli/tray/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 B

87
bin/cli/tray/tray.ps1 Normal file
View File

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

176
bin/cli/tray/tray.ts Normal file
View File

@@ -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<TrayInstance | null> {
if (!isTraySupported()) return null;
if (process.platform === "win32") return initWindowsTrayInstance(options);
return initUnixTray(options);
}
async function initWindowsTrayInstance(options: TrayOptions): Promise<TrayInstance | null> {
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<TrayInstance | null> {
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(),
};
}

91
bin/cli/tray/trayWin.ts Normal file
View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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