From cdf2c23cf0d0703324597512a107f6a7c92aa38d Mon Sep 17 00:00:00 2001 From: rowkav09 Date: Sat, 3 Oct 2026 03:51:52 +0100 Subject: [PATCH] feat(linux): add native tray with WebUI menu and bounded lifecycle --- .github/labeler.yml | 3 + scripts/build-posix.sh | 2 +- scripts/linux-tray.py | 63 +++++++++++++++++++++ scripts/nowplaying.js | 36 +++++++++--- src/linux-tray.js | 69 +++++++++++++++++++++++ test/linux-tray.test.js | 118 ++++++++++++++++++++++++++++++++++++++++ test/unix-entry.test.js | 10 ++-- 7 files changed, 288 insertions(+), 13 deletions(-) create mode 100644 scripts/linux-tray.py create mode 100644 src/linux-tray.js create mode 100644 test/linux-tray.test.js diff --git a/.github/labeler.yml b/.github/labeler.yml index 0b77c7e4..7960d19b 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -148,6 +148,9 @@ area:tray: - "src/tray-*.js" - "src/windows-startup.js" - "src/linux-startup.js" + - "src/linux-tray.js" + - "scripts/linux-tray.py" + - "test/linux-tray.test.js" - "src/macos-startup.js" - "test/tray-*.js" - "test/windows-tray*.js" diff --git a/scripts/build-posix.sh b/scripts/build-posix.sh index d4757e93..c41e2714 100755 --- a/scripts/build-posix.sh +++ b/scripts/build-posix.sh @@ -27,7 +27,7 @@ rm -rf "dist/$os" mkdir -p "$bundle/runtime" "$bundle/app" cp "$(command -v node)" "$bundle/runtime/node" -cp -r src scripts package.json "$bundle/app/" +cp -r src scripts assets package.json "$bundle/app/" cp -r node_modules "$bundle/app/node_modules" cp NOTICE README.md LICENSE "$bundle/" # The Info modal handler reads ../NOTICE, ../README.md and ../LICENSE diff --git a/scripts/linux-tray.py b/scripts/linux-tray.py new file mode 100644 index 00000000..df2899db --- /dev/null +++ b/scripts/linux-tray.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Native tray bridge. stdout is an event protocol; never holds credentials.""" +import os +import signal +import sys + + +def emit(event): + print(event, flush=True) + + +def main(): + import gi + gi.require_version("Gtk", "3.0") + try: + gi.require_version("AyatanaAppIndicator3", "0.1") + from gi.repository import AyatanaAppIndicator3 as Indicator + except ValueError: + gi.require_version("AppIndicator3", "0.1") + from gi.repository import AppIndicator3 as Indicator + from gi.repository import Gtk, Gio, GLib + + # A loaded library is not proof that the desktop can display a tray. + # Don't claim readiness on GNOME without a running indicator host. + watcher = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, Gio.DBusProxyFlags.DO_NOT_AUTO_START, None, + "org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher", + "org.kde.StatusNotifierWatcher", None) + hosted = watcher.get_cached_property("IsStatusNotifierHostRegistered") + if not watcher.get_name_owner() or hosted is None or not hosted.unpack(): + return 2 + if not Gtk.init_check()[0]: + return 2 + menu = Gtk.Menu() + for label, event in [("Open Web UI", "open"), ("Settings", "settings"), ("Logs", "logs")]: + item = Gtk.MenuItem(label=label) + item.connect("activate", lambda _item, action=event: emit(action)) + menu.append(item) + menu.append(Gtk.SeparatorMenuItem()) + quit_item = Gtk.MenuItem(label="Quit") + def quit_app(_item): + emit("quit") + Gtk.main_quit() + quit_item.connect("activate", quit_app) + menu.append(quit_item) + menu.show_all() + indicator = Indicator.Indicator.new("nowplaying", os.path.abspath(sys.argv[1]), Indicator.IndicatorCategory.APPLICATION_STATUS) + indicator.set_title("NowPlaying") + indicator.set_menu(menu) + indicator.set_status(Indicator.IndicatorStatus.ACTIVE) + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, lambda: Gtk.main_quit() or False) + # Parent exit closes stdin. Stop the orphan tray even after a hard crash. + GLib.io_add_watch(sys.stdin, GLib.IO_HUP, lambda *_: Gtk.main_quit() or False) + emit("ready") + Gtk.main() + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (ImportError, ValueError, RuntimeError, OSError): + sys.exit(2) diff --git a/scripts/nowplaying.js b/scripts/nowplaying.js index 9929a4b5..60d17e08 100755 --- a/scripts/nowplaying.js +++ b/scripts/nowplaying.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { createLinuxTray, linuxTrayAvailable } from "../src/linux-tray.js"; import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -23,11 +24,11 @@ import { guardStartup, } from "../src/startup-recovery-store.js"; -// Linux and macOS entry point (#215): the server and card without the tray. +// Linux/macOS entry point. Linux graphical sessions also run a native tray. // Settings open in the browser, sign-ins go to the OS keychain and files live // where appPaths puts them. Windows keeps scripts/windows-entry.js. -const USAGE = `Usage: nowplaying start [--no-setup] (opens WebUI Settings the first time) +const USAGE = `Usage: nowplaying start [--no-setup] [--no-tray] (opens WebUI Settings the first time) nowplaying --version nowplaying --help`; @@ -54,7 +55,7 @@ if (command === "--version" || command === "version") { } async function start() { - const unknown = args.find((arg) => arg !== "--no-setup"); + const unknown = args.find((arg) => arg !== "--no-setup" && arg !== "--no-tray"); if (unknown) { console.error(`nowplaying: unknown start option: ${unknown}`); process.exit(2); @@ -88,14 +89,35 @@ async function start() { throw error; } await logger.event("startup", "ok"); + let tray; + let closing = false; const close = async () => { - // A deliberate stop after a successful start is not a crash (#497). - await recovery?.cleanShutdown?.(); - await liveApp.close(); - await logger.event("startup", "stopped"); + if (closing) return; + closing = true; + try { + tray?.close(); + // A deliberate stop after a successful start is not a crash (#497). + await recovery?.cleanShutdown?.(); + } finally { + await liveApp.close(); + await logger.event("startup", "stopped"); + } }; process.once("SIGINT", close); process.once("SIGTERM", close); + if (!args.includes("--no-tray") && linuxTrayAvailable()) { + tray = createLinuxTray({ + url: app.url, + script: fileURLToPath(new URL("./linux-tray.py", import.meta.url)), + icon: fileURLToPath(new URL("../assets/brand/png/icon-512.png", import.meta.url)), + onQuit: () => { void close(); }, + onUnavailable: () => { + console.error(`NowPlaying tray unavailable. Web UI is still running: ${app.url}/settings. Check Python GTK/AppIndicator packages and your desktop's indicator support.`); + void logger.event("tray", "failed", { level: "warn", code: "TRAY_UNAVAILABLE" }); + }, + }); + if (await tray.ready) await logger.event("tray", "ok"); + } } async function version() { diff --git a/src/linux-tray.js b/src/linux-tray.js new file mode 100644 index 00000000..6609a7a8 --- /dev/null +++ b/src/linux-tray.js @@ -0,0 +1,69 @@ +import { spawn } from "node:child_process"; +// Pass only desktop session variables to the tray/browser children. +export function linuxDesktopEnv(env = process.env) { + return Object.fromEntries(Object.entries(env).filter(([key]) => + ["DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", "HOME", "PATH", "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "XAUTHORITY"].includes(key) || /^XDG_[A-Z_]+$/.test(key))); +} + +export function openLinuxWebUiUrl(url, { spawnProcess = spawn, env = process.env } = {}) { + const parsed = new URL(url); + if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1" || !["/", "/settings", "/logs"].includes(parsed.pathname) || parsed.search || parsed.hash || parsed.username || parsed.password) + throw new TypeError("Web UI URL must be an allowed loopback page"); + const child = spawnProcess("xdg-open", [parsed.href], { env: linuxDesktopEnv(env), detached: true, stdio: "ignore", shell: false }); + child.on?.("error", () => {}); + child.unref?.(); + return parsed.href; +} + +export function linuxTrayAvailable({ platform = process.platform, env = process.env } = {}) { + return platform === "linux" && Boolean(env.DISPLAY || env.WAYLAND_DISPLAY); +} + +// The helper sees only the icon path. Menu events are a small allowlist, not +// commands or URLs supplied by the helper. Missing desktop support leaves the +// WebUI/server running rather than turning Quit into a startup failure. +export function createLinuxTray({ url, script, icon, spawnProcess = spawn, openUrl = openLinuxWebUiUrl, onQuit = () => {}, onUnavailable = () => {}, readyTimeoutMs = 5000, killTimeoutMs = 1000, env = process.env } = {}) { + const target = new URL(url); + if (target.protocol !== "http:" || target.hostname !== "127.0.0.1" || target.username || target.password || target.pathname !== "/" || target.search || target.hash) + throw new TypeError("tray URL must be a loopback app origin"); + let child, buffer = "", closed = false, didQuit = false, readyDone = false, timer; + let resolveReady; + const ready = new Promise((resolve) => { resolveReady = resolve; }); + const settle = (value) => { if (!readyDone) { readyDone = true; clearTimeout(timer); resolveReady(value); } }; + let exited = false, killTimer; + const stopChild = () => { + if (!child || exited) return; + child.stdin.end(); + child.kill("SIGTERM"); + if (!exited) killTimer = setTimeout(() => { if (!exited) child.kill("SIGKILL"); }, killTimeoutMs); + }; + const unavailable = () => { + if (closed || didQuit) return; + closed = true; + settle(false); + stopChild(); + onUnavailable(); + }; + timer = setTimeout(unavailable, readyTimeoutMs); + try { + child = spawnProcess("python3", [script, icon], { shell: false, stdio: ["pipe", "pipe", "ignore"], env: linuxDesktopEnv(env) }); + child.stdin.on("error", () => {}); + child.on("error", unavailable); + child.on("close", () => { exited = true; clearTimeout(killTimer); unavailable(); }); + child.stdout.on("data", (chunk) => { + if (closed || didQuit) return; + buffer += chunk.toString(); + if (buffer.length > 1024) { unavailable(); return; } + let end; + while ((end = buffer.indexOf("\n")) >= 0) { + const event = buffer.slice(0, end); buffer = buffer.slice(end + 1); + if (event === "ready") settle(true); + else if (readyDone && event === "quit") { didQuit = true; onQuit(); break; } + else if (readyDone && ["open", "settings", "logs"].includes(event)) + try { openUrl(event === "open" ? target.origin : `${target.origin}/${event}`); } + catch { /* A browser failure must not terminate the media app. */ } + } + }); + } catch { unavailable(); } + return Object.freeze({ ready, close() { if (closed) return; closed = true; settle(false); stopChild(); } }); +} diff --git a/test/linux-tray.test.js b/test/linux-tray.test.js new file mode 100644 index 00000000..dc0b4536 --- /dev/null +++ b/test/linux-tray.test.js @@ -0,0 +1,118 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { createLinuxTray, linuxTrayAvailable, openLinuxWebUiUrl, linuxDesktopEnv } from "../src/linux-tray.js"; + +function fakeChild() { + const child = new EventEmitter(); + child.stdout = new PassThrough(); child.stdin = new PassThrough(); + child.kill = () => { child.emit("close", null); }; + return child; +} +const options = (child, over = {}) => ({ + url: "http://127.0.0.1:47832", script: "/app/scripts/linux-tray.py", + icon: "/app/assets/icon.png", spawnProcess: () => child, readyTimeoutMs: 30, + ...over, +}); + +test("tray is Linux graphical-session only, never started by headless CI", () => { + assert.equal(linuxTrayAvailable({ platform: "linux", env: { DISPLAY: ":1" } }), true); + assert.equal(linuxTrayAvailable({ platform: "linux", env: { WAYLAND_DISPLAY: "wayland-0" } }), true); + assert.equal(linuxTrayAvailable({ platform: "linux", env: {} }), false); + assert.equal(linuxTrayAvailable({ platform: "darwin", env: { DISPLAY: ":1" } }), false); +}); + +test("ready handshake, split menu events, and Quit close exactly once", async () => { + const child = fakeChild(); const opened = []; + let command; let quits = 0; + const tray = createLinuxTray(options(child, { + spawnProcess: (...args) => { command = args; return child; }, + openUrl: (url) => opened.push(url), onQuit: () => { quits++; }, + })); + child.stdout.write("rea"); child.stdout.write("dy\nsettings\nlogs\nopen\nquit\nquit\n"); + assert.equal(await tray.ready, true); + assert.deepEqual(opened, ["http://127.0.0.1:47832/settings", "http://127.0.0.1:47832/logs", "http://127.0.0.1:47832"]); + assert.equal(quits, 1); + assert.equal(command[0], "python3"); + assert.equal(command[2].shell, false); + assert.equal(command[2].stdio[2], "ignore"); + tray.close(); +}); + +test("missing Python and nonzero exits report unavailable, never quit the server", async () => { + for (const error of [true, false]) { + const child = fakeChild(); let quits = 0; + const tray = createLinuxTray(options(child, { onQuit: () => { quits++; } })); + child.emit(error ? "error" : "close", error ? new Error("ENOENT") : 2); + assert.equal(await tray.ready, false); + assert.equal(quits, 0); + tray.close(); + } +}); + +test("timeout stops a stuck tray helper, not the app", async () => { + const tray = createLinuxTray(options(fakeChild())); + assert.equal(await tray.ready, false); + tray.close(); +}); + +test("reject external URLs and ignore unexpected helper output", async () => { + assert.throws(() => createLinuxTray(options(fakeChild(), { url: "https://outside.example" })), /loopback/); + const child = fakeChild(); const opened = []; + const tray = createLinuxTray(options(child, { openUrl: (url) => opened.push(url) })); + child.stdout.write("ready\nhttps://outside.example\n"); + assert.equal(await tray.ready, true); + assert.deepEqual(opened, []); + tray.close(); +}); + +test("closing before ready settles ready and ignores subsequent menu actions", async () => { + const child = fakeChild(); let quits = 0; + const tray = createLinuxTray(options(child, { onQuit: () => { quits++; } })); + tray.close(); + assert.equal(await tray.ready, false); + child.stdout.write("quit\n"); + assert.equal(quits, 0); +}); + +test("real WebUI opener validates all menu destinations and filters child secrets", () => { + for (const path of ["/", "/settings", "/logs"]) { + let call; + openLinuxWebUiUrl(`http://127.0.0.1:47832${path}`, { spawnProcess: (...args) => { call = args; return fakeChild(); }, env: { HOME: "/tmp/home", DISPLAY: ":1", GH_TOKEN: "must-not-pass" } }); + assert.equal(call[0], "xdg-open"); + assert.equal(call[1][0], `http://127.0.0.1:47832${path}`); + assert.equal(call[2].env.GH_TOKEN, undefined); + } + for (const url of ["http://outside.example/", "http://127.0.0.1/private", "http://127.0.0.1/logs?token=x"]) + assert.throws(() => openLinuxWebUiUrl(url), /allowed loopback/); + assert.deepEqual(linuxDesktopEnv({ HOME: "/home/u", XDG_RUNTIME_DIR: "/run/u", GH_TOKEN: "no", OPENROUTER_API_KEY: "no" }), { HOME: "/home/u", XDG_RUNTIME_DIR: "/run/u" }); +}); + +test("tray subprocess env excludes tokens and opener exceptions cannot crash app", async () => { + const child = fakeChild(); let call; + const tray = createLinuxTray(options(child, { env: { DISPLAY: ":1", GH_TOKEN: "no" }, spawnProcess: (...args) => { call = args; return child; }, openUrl: () => { throw new Error("browser unavailable"); } })); + assert.equal(call[2].env.GH_TOKEN, undefined); + assert.doesNotThrow(() => child.stdout.write("ready\nopen\nlogs\n")); + assert.equal(await tray.ready, true); + tray.close(); +}); + +test("real helper ignoring TERM is killed within the bounded shutdown window", { skip: process.platform === "win32", timeout: 3000 }, async () => { + let child; + const tray = createLinuxTray(options(null, { readyTimeoutMs: 1500, killTimeoutMs: 40, spawnProcess: () => { + child = spawn(process.execPath, ["-e", "process.on('SIGTERM',()=>{}); process.stdout.write('ready\\n'); setInterval(()=>{},1000);"], { stdio: ["pipe", "pipe", "ignore"] }); + return child; + } })); + try { + assert.equal(await tray.ready, true); + const exited = once(child, "close"); + tray.close(); + assert.equal(child.stdin.writableEnded, true); + const [code, signal] = await exited; + assert.equal(code, null); + assert.equal(signal, "SIGKILL"); + } finally { child.kill("SIGKILL"); } +}); diff --git a/test/unix-entry.test.js b/test/unix-entry.test.js index cfb7e0ae..74027c5e 100644 --- a/test/unix-entry.test.js +++ b/test/unix-entry.test.js @@ -35,7 +35,7 @@ test("--version prints the package version", unix, async () => { test("start --no-setup with no config serves WebUI Settings on loopback", unix, async () => { const home = await mkdtemp(join(tmpdir(), "np-unix-")); - const cli = run(home, ["start", "--no-setup"]); + const cli = run(home, ["start", "--no-setup", "--no-tray"]); try { const url = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`WebUI did not come up: ${JSON.stringify(cli.output())}`)), 10000); @@ -54,7 +54,7 @@ test("start --no-setup with no config serves WebUI Settings on loopback", unix, test("unknown commands and options exit 2", unix, async () => { const home = await mkdtemp(join(tmpdir(), "np-unix-")); - for (const args of [["bogus"], ["start", "--no-tray"], ["setup"]]) { + for (const args of [["bogus"], ["start", "--bogus"], ["setup"]]) { const cli = run(home, args); assert.equal(await cli.exited, 2, args.join(" ")); } @@ -71,12 +71,12 @@ test("start runs the server and card from the setup config and the real Secret S await writeFile(join(home, ".config", "nowplaying", "config.json"), serializeSetupConfig({ provider: "jellyfin", serverUrl: "http://127.0.0.1:9", identity: { id: identityId, displayName: "CI" }, credentialStored: true, })); - const missing = run(home, ["start", "--no-setup"]); + const missing = run(home, ["start", "--no-setup", "--no-tray"]); assert.equal(await missing.exited, 1); assert.match(missing.output().stderr, /sign in again/); await store.save({ provider: "jellyfin", identityId }, "ci-token"); - const cli = run(home, ["start", "--no-setup"]); + const cli = run(home, ["start", "--no-setup", "--no-tray"]); try { const url = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`start did not come up: ${JSON.stringify(cli.output())}`)), 30000); @@ -105,7 +105,7 @@ test("start runs the server and card from the setup config and the real Secret S }); test("start and setup without HOME say so plainly, no stack trace (#500)", unix, async () => { - for (const args of [["start", "--no-setup"]]) { + for (const args of [["start", "--no-setup", "--no-tray"]]) { const env = { ...process.env }; delete env.HOME; const child = spawn(process.execPath, [ENTRY, ...args], { env, stdio: ["ignore", "pipe", "pipe"] });