Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion scripts/build-posix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions scripts/linux-tray.py
Original file line number Diff line number Diff line change
@@ -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)
36 changes: 29 additions & 7 deletions scripts/nowplaying.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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`;

Expand All @@ -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);
Expand Down Expand Up @@ -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() {
Expand Down
69 changes: 69 additions & 0 deletions src/linux-tray.js
Original file line number Diff line number Diff line change
@@ -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(); } });
}
118 changes: 118 additions & 0 deletions test/linux-tray.test.js
Original file line number Diff line number Diff line change
@@ -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"); }
});
10 changes: 5 additions & 5 deletions test/unix-entry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(" "));
}
Expand All @@ -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);
Expand Down Expand Up @@ -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"] });
Expand Down
Loading