mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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;
|
||||
|
||||
48
bin/cli/runtime/trayRuntime.ts
Normal file
48
bin/cli/runtime/trayRuntime.ts
Normal 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
164
bin/cli/tray/autostart.ts
Normal 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
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
87
bin/cli/tray/tray.ps1
Normal 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
176
bin/cli/tray/tray.ts
Normal 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
91
bin/cli/tray/trayWin.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
8
tests/unit/cli/autostart.test.ts
Normal file
8
tests/unit/cli/autostart.test.ts
Normal 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");
|
||||
});
|
||||
33
tests/unit/cli/tray.test.ts
Normal file
33
tests/unit/cli/tray.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user