diff --git a/cli/hooks/trayRuntime.js b/cli/hooks/trayRuntime.js index 323fc8fb..1548dd0e 100644 --- a/cli/hooks/trayRuntime.js +++ b/cli/hooks/trayRuntime.js @@ -1,27 +1,34 @@ -// Lazy install systray for macOS/Linux into USER_DATA_DIR/runtime/node_modules. +// Lazy install systray2 for macOS/Linux into USER_DATA_DIR/runtime/node_modules. // Windows uses PowerShell NotifyIcon (no binary) → no systray needed. // This keeps the published npm tarball free of unsigned Go binaries that // trigger antivirus false positives (e.g. Kaspersky flagging tray_windows.exe). +// +// We use the maintained `systray2` fork. The original `systray@1.0.5` package +// bundles a 2017 x86_64 Go binary whose Mach-O headers are rejected by modern +// dyld (macOS 14+), so the tray silently fails to register on Apple Silicon. const { spawnSync } = require("child_process"); const fs = require("fs"); const path = require("path"); const { getRuntimeDir, getRuntimeNodeModules } = require("./sqliteRuntime"); -const SYSTRAY_VERSION = "1.0.5"; +const SYSTRAY_PKG = "systray2"; +const SYSTRAY_VERSION = "2.1.4"; +const LEGACY_SYSTRAY_PKG = "systray"; function hasSystray() { - return fs.existsSync(path.join(getRuntimeNodeModules(), "systray", "package.json")); + return fs.existsSync(path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "package.json")); } -// Remove legacy systray from all known locations on Windows (AV false positive cleanup) -function cleanupWindowsSystray({ silent = false } = {}) { - if (process.platform !== "win32") return; - // 1) Runtime dir: %APPDATA%\9router\runtime\node_modules\systray - // 2) npm global nested: \node_modules\9router\node_modules\systray - // __dirname here = \node_modules\9router\hooks → up 1 = pkg root +// Remove the legacy `systray` package from all known locations. +// On Windows it was an AV false-positive risk; on macOS/Linux its bundled +// binary is broken on modern OS versions. +function cleanupLegacySystray({ silent = false } = {}) { + // 1) Runtime dir: ~/.9router/runtime/node_modules/systray (or %APPDATA% on Win) + // 2) npm global nested: /node_modules/9router/node_modules/systray + // __dirname here = /hooks → up 1 = pkg root const targets = [ - path.join(getRuntimeNodeModules(), "systray"), - path.join(__dirname, "..", "node_modules", "systray") + path.join(getRuntimeNodeModules(), LEGACY_SYSTRAY_PKG), + path.join(__dirname, "..", "node_modules", LEGACY_SYSTRAY_PKG) ]; for (const dir of targets) { if (fs.existsSync(dir)) { @@ -35,6 +42,21 @@ function cleanupWindowsSystray({ silent = false } = {}) { } } +// systray2's npm tarball sometimes ships the bundled Go binary without the +// executable bit set on macOS, causing spawn() to fail with EACCES. Set +x +// best-effort so the tray actually starts. +function chmodSystrayBin({ silent = false } = {}) { + if (process.platform === "win32") return; + const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release"; + const binPath = path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "traybin", binName); + if (!fs.existsSync(binPath)) return; + try { + fs.chmodSync(binPath, 0o755); + } catch (e) { + if (!silent) console.warn(`[9router][runtime] chmod tray bin failed: ${e.message}`); + } +} + function ensureRuntimeDir() { const dir = getRuntimeDir(); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); @@ -63,20 +85,25 @@ function npmInstall(pkgs, { silent = false } = {}) { return res.status === 0; } -// Public: ensure systray is installed on macOS/Linux only. +// Public: ensure systray2 is installed on macOS/Linux only. // Windows skips entirely (uses PowerShell tray). function ensureTrayRuntime({ silent = false } = {}) { + // Always evict the legacy `systray` package — its binary is broken on + // modern macOS and an AV false-positive on Windows. + cleanupLegacySystray({ silent }); + if (process.platform === "win32") { - cleanupWindowsSystray({ silent }); return { systray: false, skipped: true }; } if (hasSystray()) { - if (!silent) console.log("[9router][runtime] systray OK"); + chmodSystrayBin({ silent }); + if (!silent) console.log("[9router][runtime] systray2 OK"); return { systray: true }; } - const ok = npmInstall([`systray@${SYSTRAY_VERSION}`], { silent }); + const ok = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent }); + if (ok) chmodSystrayBin({ silent }); if (!ok && !silent) { - console.warn("[9router][runtime] systray install failed (tray will be disabled)"); + console.warn("[9router][runtime] systray2 install failed (tray will be disabled)"); } return { systray: ok && hasSystray() }; } diff --git a/cli/package.json b/cli/package.json index 217bdeb4..823e6ad3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -24,7 +24,7 @@ "react-dom": "19.2.1" }, "comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.", - "comment_systray": "systray is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping the unsigned Go binary tray_windows.exe that triggers antivirus false positives (Kaspersky).", + "comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails on modern macOS dyld.", "engines": { "node": ">=18.0.0" }, diff --git a/cli/src/cli/tray/tray.js b/cli/src/cli/tray/tray.js index cb55aa7c..1c8602ae 100644 --- a/cli/src/cli/tray/tray.js +++ b/cli/src/cli/tray/tray.js @@ -140,37 +140,75 @@ function initWindowsTray(options) { /** * macOS/Linux tray via systray binary + * + * Prefers `systray2` (active fork of `systray`, ships newer + * getlantern/systray-portable binaries that work on macOS 14+ and Apple + * Silicon under Rosetta). Falls back to legacy `systray@1.0.5` if systray2 + * is not available, though that binary's Mach-O headers are rejected by + * modern dyld and the icon will not appear. */ function resolveSystray() { - // Try local first (dev), then runtime dir (production lazy install) - try { - return require("systray").default; - } catch (e) {} + let runtimeDir = null; try { const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime"); - const systrayPath = path.join(getRuntimeNodeModules(), "systray"); - return require(systrayPath).default; - } catch (e) { - return null; + runtimeDir = getRuntimeNodeModules(); + } catch (e) {} + + // 1) systray2 in runtime dir (where ensureTrayRuntime installs it) + if (runtimeDir) { + try { return { mod: require(path.join(runtimeDir, "systray2")).default, isV2: true }; } catch (e) {} } + // 2) systray2 resolvable from the package's own node_modules / NODE_PATH + try { return { mod: require("systray2").default, isV2: true }; } catch (e) {} + // 3) Legacy systray fallback (unlikely to render on modern macOS) + try { return { mod: require("systray").default, isV2: false }; } catch (e) {} + if (runtimeDir) { + try { return { mod: require(path.join(runtimeDir, "systray")).default, isV2: false }; } catch (e) {} + } + return null; +} + +function chmodTrayBin(pkgName) { + // systray2's npm tarball occasionally lands without +x on the bundled Go + // binary (observed on macOS). spawn() then fails with EACCES. Best-effort + // chmod on every init avoids a hard-to-diagnose silent tray failure. + try { + const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime"); + const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release"; + const candidates = [ + path.join(getRuntimeNodeModules(), pkgName, "traybin", binName), + path.join(__dirname, "..", "..", "..", "node_modules", pkgName, "traybin", binName) + ]; + for (const p of candidates) { + if (fs.existsSync(p)) fs.chmodSync(p, 0o755); + } + } catch (e) {} } function initUnixTray(options) { const { port } = options; try { - const SysTray = resolveSystray(); - if (!SysTray) return null; + const resolved = resolveSystray(); + if (!resolved) return null; + const { mod: SysTray, isV2 } = resolved; + + chmodTrayBin(isV2 ? "systray2" : "systray"); + const autostartEnabled = getAutostartEnabled(); const items = buildMenuItems(port, autostartEnabled); const menu = { icon: getIconBase64(), + // The bundled icon.png is a full-color RGBA logo. Don't mark it as a + // template icon: macOS would then render it as a solid white square + // because template mode only uses the alpha channel. + isTemplateIcon: false, title: "", tooltip: `9Router - Port ${port}`, items }; - trayInstance = new SysTray({ menu, debug: false, copyDir: true }); + trayInstance = new SysTray({ menu, debug: false, copyDir: false }); isWinTray = false; trayInstance.onClick((action) => { @@ -187,11 +225,21 @@ function initUnixTray(options) { }); }); - trayInstance.onReady(() => {}); - trayInstance.onError(() => {}); + if (isV2) { + // systray2 exposes a ready() promise instead of onReady/onError. Surface + // failures (binary crash, EACCES, etc.) so users can see why the icon + // didn't appear instead of getting a misleading "running in tray" log. + trayInstance.ready().catch((err) => { + process.stderr.write(`[9router] tray failed to start: ${err && err.message ? err.message : err}\n`); + }); + } else { + trayInstance.onReady(() => {}); + trayInstance.onError(() => {}); + } return trayInstance; } catch (err) { + process.stderr.write(`[9router] tray init error: ${err.message}\n`); return null; } } @@ -207,7 +255,10 @@ function killTray() { if (instance) { try { if (wasWin) instance.kill(); - else instance.kill(true); + // systray2.kill(true) defaults to calling process.exit(0) which aborts + // the rest of cleanup (server SIGKILL, MITM/tunnel cleanup). Pass false + // so callers stay in control of process exit. + else instance.kill(false); } catch (e) {} } }