fix(cli): wire --tray Unix path through the runtime systray2 loader (#4605) (#5276)

The wired tray path (serve.mjs -> tray/index.mjs -> traySystray.mjs) loaded
systray2 via an inline loader calling require("module") inside an ESM .mjs file
(package type:module), which throws ReferenceError: require is not defined and
was silently swallowed -> no tray, no diagnostic (regressed in v3.8.34). Even
when fixed, systray2 is not in node_modules; it is lazily installed into
~/.omniroute/runtime by trayRuntime.ts.

initSystrayUnix now delegates loading to trayRuntime.ts::loadSystray (async),
fixes the icon path (icon.png, not icons/icon.png), sets isTemplateIcon:false
(full-color icon rendered as a white square under macOS template mode), and
serve.mjs surfaces tray-start failures to stderr instead of swallowing them.

TDD: tray-systray-loader-4605.test.ts drives the loader seam via an injected
ctor (native binary can't run in unit tests) and asserts the menu/icon/template
invariants; fail-before confirmed by restoring the broken require() loader.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-28 21:39:05 -03:00
committed by GitHub
parent 99b17f13b6
commit ba38e31ff4
5 changed files with 114 additions and 23 deletions

View File

@@ -15,6 +15,7 @@ _In development — bullets added per PR; finalized at release._
### 🔧 Bug Fixes
- **cli (tray):** fix `omniroute server --tray` showing no tray on macOS/Linux with no error printed. The wired Unix tray path loaded `systray2` through an inline loader that called `require("module")` inside an ESM `.mjs` file (`"type":"module"`) → `ReferenceError: require is not defined`, silently swallowed (regressed in v3.8.34); even if it had loaded, `systray2` isn't in `node_modules` (it's lazily installed into `~/.omniroute/runtime`). The loader now delegates to the runtime loader, the icon path (`icon.png`) is corrected, `isTemplateIcon` is `false` (the full-color icon rendered as a white square under macOS template mode), and tray start failures are surfaced to stderr instead of being swallowed (#4605 — thanks @ProgMEM-CC)
- **agent-bridge (antigravity):** unwrap the cloudcode-pa `.request` envelope when converting Antigravity IDE requests. The real IDE sends `cloudcode-pa.googleapis.com/v1internal:generateContent` with the Gemini request nested under `.request` (`{ project, model, request: { contents, systemInstruction, generationConfig } }`), but the bridge read those fields at the top level — yielding an empty conversation, so prompts hung mid-execution. The legacy `/v1beta/models/<model>:generateContent` top-level shape still works (#4294 — thanks @shabeer)
- **dashboard:** add a GitHub releases fallback to the "Update Available" lookup. After the v3.8.28 fix added an npm-registry HTTP fallback, the banner could still stay hidden on networks that reach GitHub (where the news feed already loads) but not `registry.npmjs.org`. `resolveLatestVersion()` now tries npm CLI → npm registry → GitHub releases (`/repos/diegosouzapw/OmniRoute/releases/latest`) before giving up, and logs a warning only when all three fail (#4100)
- **command-code:** omit `max_tokens` when the client omits it so the upstream applies the model's native default, fixing `400 "expected <=200000"` on `/alpha/generate` for high-cap models; an explicit oversized client value is clamped to the 200k endpoint ceiling (#5221 — thanks @adivekar-utexas)

View File

@@ -313,7 +313,7 @@ async function maybeStartTray(port, apiPort, supervisor) {
if (!isTraySupported()) return;
const { default: open } = await import("open").catch(() => ({ default: null }));
const dashboardUrl = `http://localhost:${port}`;
const tray = initTray({
const tray = await initTray({
port,
onQuit: () => {
killTrayIfActive();
@@ -329,8 +329,12 @@ async function maybeStartTray(port, apiPort, supervisor) {
const { killTray } = await import("../tray/index.mjs");
_killTray = killTray;
}
} catch {
// tray is optional — do not fail the server
} catch (err) {
// tray is optional — do not fail the server, but surface why it failed so
// "--tray shows nothing" is diagnosable instead of silent (#4605).
process.stderr.write(
`[omniroute][tray] failed to start: ${err?.message ?? String(err)}\n`
);
}
}

View File

@@ -5,10 +5,12 @@ let active = null;
export { isTraySupported };
export function initTray({ port, onQuit, onOpenDashboard, onShowLogs }) {
export async function initTray({ port, onQuit, onOpenDashboard, onShowLogs }) {
if (!isTraySupported()) return null;
const ctx = { port, onQuit, onOpenDashboard, onShowLogs };
active = process.platform === "win32" ? initWinTray(ctx) : initSystrayUnix(ctx);
// initSystrayUnix is async: it lazily installs/loads systray2 from the runtime
// dir (trayRuntime.ts) rather than from node_modules. (#4605)
active = process.platform === "win32" ? initWinTray(ctx) : await initSystrayUnix(ctx);
return active;
}

View File

@@ -14,30 +14,31 @@ export function isTraySupported() {
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;
// systray2 is NOT a static dependency — it is lazily installed into
// ~/.omniroute/runtime by trayRuntime.ts (loadSystray). The previous inline
// loader called `require("module")`, which throws `ReferenceError: require is
// not defined` in this ESM file (package "type":"module"); the throw was
// silently swallowed, so the tray never appeared on macOS/Linux with no error
// printed (#4605, regressed in v3.8.34). Delegate to the runtime loader, which
// resolves systray2 from the runtime dir and surfaces install/import failures.
async function loadSystray2() {
const { loadSystray } = await import("../runtime/trayRuntime.ts");
return loadSystray();
}
function getIconBase64() {
const iconPath = join(__dirname, "icons", "icon.png");
// Icon ships at bin/cli/tray/icon.png — the previous "icons/icon.png" path
// never existed, so the tray was created with an empty icon (#4605).
const iconPath = join(__dirname, "icon.png");
if (existsSync(iconPath)) return readFileSync(iconPath).toString("base64");
return "";
}
export function initSystrayUnix({ port, onQuit, onOpenDashboard, onShowLogs }) {
const SysTray = loadSystray2();
export async function initSystrayUnix(
{ port, onQuit, onOpenDashboard, onShowLogs },
loadCtor = loadSystray2
) {
const SysTray = await loadCtor();
if (!SysTray) return null;
const autostartEnabled = isAutostartEnabled();
@@ -57,7 +58,10 @@ export function initSystrayUnix({ port, onQuit, onOpenDashboard, onShowLogs }) {
tray = new SysTray({
menu: {
icon: getIconBase64(),
isTemplateIcon: process.platform === "darwin",
// isTemplateIcon must be false: icon.png is a full-color RGBA logo, and
// macOS template mode uses only the alpha channel → a solid white square
// (the icon looked "missing" even when the tray loaded). (PR #1080)
isTemplateIcon: false,
title: "",
tooltip: `OmniRoute — port ${port}`,
items,

View File

@@ -0,0 +1,80 @@
// Regression coverage for #4605 — `omniroute server --tray` showed no tray on
// macOS/Linux with no error printed.
//
// Root cause (regressed in v3.8.34): the wired Unix tray path
// (serve.mjs → tray/index.mjs → traySystray.mjs) loaded systray2 via an inline
// loader that called `require("module")` inside an ESM `.mjs` file (package
// "type":"module"). `require` is undefined in ESM, so it threw
// `ReferenceError: require is not defined`, which a bare `catch {}` swallowed →
// `loadSystray2()` returned null → no tray, no diagnostic. Even had it loaded,
// systray2 is not in node_modules (it is lazily installed into
// ~/.omniroute/runtime by trayRuntime.ts), the icon was read from a
// non-existent "icons/icon.png" path, and `isTemplateIcon` was true on darwin
// (full-color icon → white square).
//
// The fix makes `initSystrayUnix` async and delegates loading to the runtime
// loader. We can't spawn the real native Go binary in a unit test, so — like
// cli-tray-systray2.test.ts — we drive the seam with an injected ctor loader
// and assert the menu/icon/template invariants.
import test from "node:test";
import assert from "node:assert/strict";
import { initSystrayUnix } from "../../bin/cli/tray/traySystray.mjs";
class FakeSysTray {
static lastOpts: unknown = null;
onClickFn: unknown = null;
constructor(opts: unknown) {
FakeSysTray.lastOpts = opts;
}
onClick(fn: unknown) {
this.onClickFn = fn;
}
ready() {
return Promise.resolve();
}
sendAction() {}
kill() {}
}
const opts = {
port: 20128,
onQuit: () => {},
onOpenDashboard: () => {},
onShowLogs: () => {},
};
test("initSystrayUnix loads the injected SysTray ctor and builds the menu (#4605)", async () => {
FakeSysTray.lastOpts = null;
// Async loader seam — proves initSystrayUnix awaits the runtime loader instead
// of the old broken inline `require("module")` path.
const tray = await initSystrayUnix(opts, async () => FakeSysTray as unknown as new () => unknown);
assert.ok(tray, "a tray instance must be created when a SysTray ctor is available");
const menu = (FakeSysTray.lastOpts as { menu: Record<string, unknown> }).menu;
// White-square fix: must not be a macOS template icon.
assert.equal(menu.isTemplateIcon, false, "isTemplateIcon must be false (full-color icon)");
// Icon-path fix: the bundled icon.png must resolve to a non-empty base64 blob.
assert.ok(
typeof menu.icon === "string" && (menu.icon as string).length > 0,
"menu.icon must be a non-empty base64 string (icon.png path fix)"
);
const items = menu.items as Array<{ title: string }>;
const titles = items.map((i) => i.title);
assert.equal(items.length, 5, "menu should keep its 5 items");
assert.ok(
titles.includes("Show Logs"),
`the "Show Logs" item must be preserved, got: ${titles.join(", ")}`
);
assert.ok(
titles.includes("Quit OmniRoute"),
`the Quit item must be present, got: ${titles.join(", ")}`
);
});
test("initSystrayUnix returns null without throwing when the loader yields null (#4605)", async () => {
const tray = await initSystrayUnix(opts, async () => null);
assert.equal(tray, null, "must degrade to null when systray2 cannot be loaded");
});