diff --git a/.github/workflows/linux-desktop.yml b/.github/workflows/linux-desktop.yml new file mode 100644 index 00000000..c938ba60 --- /dev/null +++ b/.github/workflows/linux-desktop.yml @@ -0,0 +1,39 @@ +name: Linux desktop packages +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +concurrency: + group: linux-desktop-${{ github.ref }} + cancel-in-progress: true +jobs: + distro: + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - distro: debian + image: debian:13 + - distro: fedora + image: fedora:43 + - distro: arch + image: archlinux:base + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - run: npm ci --omit=dev + - run: bash scripts/build-posix.sh linux + - name: Install and test in distro userland + env: + DISTRO_IMAGE: ${{ matrix.image }} + DISTRO: ${{ matrix.distro }} + run: | + docker run --rm -e DISTRO -v "$PWD:/source:ro" "$DISTRO_IMAGE" bash /source/scripts/linux-distro-lab.sh diff --git a/docs/linux-desktop-testing.md b/docs/linux-desktop-testing.md new file mode 100644 index 00000000..e724acf1 --- /dev/null +++ b/docs/linux-desktop-testing.md @@ -0,0 +1,52 @@ +# Linux desktop dev-build acceptance + +The Linux desktop implementation uses the existing browser setup and a native +GTK/AppIndicator tray helper. Start-at-login remains opt-in in Settings. No +separate setup window, terminal or automatic login entry is required for normal +installed launch. The CLI remains available; `start --no-tray --no-setup` is the +headless route. + +Native packages use a shared bundle under `/opt/nowplaying`, a menu launcher, +and distro-declared desktop dependencies. The package builders support x86_64 +Debian (.deb), Fedora (.rpm) and Arch (.pkg.tar.zst). They do not publish anything. + +## Automated evidence + +Planned coverage (not yet a passed distro guarantee): `Linux desktop packages` +is intended to install a built package in Debian 13, Fedora 43 and +Arch's rolling container image. Each is intended to run the installed app as an unprivileged +user with Xvfb and a private session bus. A synthetic StatusNotifier host is intended to check: + +- Actual GTK/AppIndicator process registers an Active tray item. +- First-run Settings is served on loopback by the installed app. +- Open Web UI, Settings and Logs activate the validated opener. A stand-in + xdg-open records the destinations, all Settings while first-run is incomplete. +- Activating Quit closes the installed app, its observed tray helper process + exits, and the WebUI port no longer accepts connections. + +Once the exact-head matrix passes, this will be protocol and install evidence, +not a screenshot of a real desktop. Until then these checks are planned, not +confirmed. Image tags, Node 22 setup and package installs float for this dev +lab; re-run after dependency changes rather than claim reproducible versions. +The containers do not prove GNOME extension configuration, icon rendering, +Wayland integration or behavior on every release of each distro. They do not +promise compatibility beyond the tested x86_64 releases. + +## Real desktop acceptance required + +On each intended desktop (KDE, GNOME with indicator support, XFCE): + +1. Install through the package manager and confirm dependencies resolve. +2. Launch from the application menu, with no terminal. Fresh Settings opens. +3. Sign in to a media server through the existing WebUI and confirm playback. +4. Confirm the tray icon and menu render correctly. Capture a screenshot. +5. Close the browser. Presence and the WebUI keep running. Settings/Logs reopen + from the tray; during first-run those commands return to setup instead. +6. Quit from the tray. Confirm app and tray processes exit and the port closes. +7. Launch twice: document any duplicate-instance behavior before release. +8. Toggle start-at-login, sign out/back in, and confirm behavior both on and off. +9. Upgrade the dev package; preserve credentials, configuration and working cards. + +A missing indicator host or dependency must produce a clear warning while the +local server keeps running, not silently report a working tray. GNOME typically +needs its AppIndicator extension. Headless installs are not desktop acceptance. diff --git a/scripts/build-linux-native.sh b/scripts/build-linux-native.sh index 9d1fe97c..061f8224 100644 --- a/scripts/build-linux-native.sh +++ b/scripts/build-linux-native.sh @@ -28,6 +28,8 @@ if [[ "$kind" = rpm ]]; then mkdir -p "$stage/rpm/"{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} tar -czf "$stage/rpm/SOURCES/payload.tar.gz" -C "$stage/payload" . cat > "$stage/rpm/SPECS/nowplaying.spec" < + + + +''' +bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) +bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.kde.StatusNotifierWatcher", 0)), None, Gio.DBusCallFlags.NONE, -1, None) +registered = [] +def method(_connection, sender, _path, _interface, _name, parameters, invocation): + item = parameters.unpack()[0] + registered.append((sender, item if item.startswith('/') else '/StatusNotifierItem')) + invocation.return_value(None) +def prop(_connection, _sender, _path, _iface, name): + return GLib.Variant('b', True) if name == 'IsStatusNotifierHostRegistered' else GLib.Variant('i', 0) +bus.register_object('/StatusNotifierWatcher', Gio.DBusNodeInfo.new_for_xml(XML).interfaces[0], method, prop, None) +context = GLib.MainContext.default() +def wait_for(check, message, timeout=10): + end = time.monotonic() + timeout + while time.monotonic() < end: + while context.pending(): context.iteration(False) + result = check() + if result: return result + time.sleep(.03) + raise AssertionError(message) +def call(sender, path, interface, name, args): + return bus.call_sync(sender, path, interface, name, args, None, Gio.DBusCallFlags.NONE, 1000, None).unpack() +def collect(node): + result = [(node[0], node[1].get('label'))] + for entry in node[2]: + result += collect(entry.unpack() if hasattr(entry, 'unpack') else entry) + return result + +with tempfile.TemporaryDirectory(prefix='np-desktop-smoke-') as temp: + root = Path(temp) + opened = root / 'opened' + # Exercise real opener validation/spawn without needing an actual browser. + opener = root / 'xdg-open' + opener.write_text('#!/bin/sh\nprintf "%s\\n" "$1" >> "' + str(opened) + '"\n') + opener.chmod(0o755) + env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH']) + with (root / 'app.log').open('w+') as log: + child = subprocess.Popen(['/usr/bin/nowplaying', 'start', '--no-setup'], stdout=log, stderr=log, env=env) + passed = False + helpers = [] + try: + def registration(): + if child.poll() is not None: raise RuntimeError('app exited before registration') + return registered + wait_for(registration, 'tray did not register', 20) + sender, item_path = registered[0] + url = 'http://127.0.0.1:47832' + connection = http.client.HTTPConnection('127.0.0.1', 47832, timeout=5) + try: + connection.request('GET', '/settings') + response = connection.getresponse() + assert response.status == 200 + response.read() + finally: + connection.close() + def active_menu(): + try: + props = call(sender, item_path, 'org.freedesktop.DBus.Properties', 'GetAll', GLib.Variant('(s)', ('org.kde.StatusNotifierItem',)))[0] + if props.get('Status') != 'Active': return None + menu = props['Menu'] + layout = call(sender, menu, 'com.canonical.dbusmenu', 'GetLayout', GLib.Variant('(iias)', (0, -1, ['label'])))[1] + labels = dict((label, key) for key, label in collect(layout) if label) + if {'Open Web UI', 'Settings', 'Logs', 'Quit'}.issubset(labels): return menu, labels + except GLib.Error: pass + menu, labels = wait_for(active_menu, 'Active tray/menu did not become ready') + def activate(label): + call(sender, menu, 'com.canonical.dbusmenu', 'Event', GLib.Variant('(isvu)', (labels[label], 'clicked', GLib.Variant('s', ''), 0))) + for index, label in enumerate(['Open Web UI', 'Settings', 'Logs'], 1): + activate(label) + wait_for(lambda: opened.exists() and len(opened.read_text().splitlines()) >= index, 'menu did not open browser: ' + label) + # First-run guard must send all three commands to Settings. + assert opened.read_text().splitlines() == [url + '/settings'] * 3 + children_file = Path(f'/proc/{child.pid}/task/{child.pid}/children') + helpers = [int(pid) for pid in children_file.read_text().split()] + assert helpers, 'no helper process observed' + activate('Quit') + assert child.wait(timeout=10) == 0, 'Quit did not close app' + wait_for(lambda: all(not Path(f'/proc/{pid}').exists() for pid in helpers), 'helper remained after Quit') + with socket.socket() as probe: + assert probe.connect_ex(('127.0.0.1', 47832)) != 0, 'WebUI port remained open after Quit' + passed = True + print('PASS: installed app, Settings, native tray/menu activation, Quit, helper exit, closed port') + finally: + if child.poll() is None: + child.terminate() + try: child.wait(timeout=3) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + if not passed: + log.flush() + log.seek(0) + print(log.read()) diff --git a/scripts/linux-distro-lab.sh b/scripts/linux-distro-lab.sh new file mode 100644 index 00000000..3391f450 --- /dev/null +++ b/scripts/linux-distro-lab.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Runs as root inside a disposable distro container. Package builders and app +# run unprivileged. Synthetic StatusNotifier host tests protocol, not pixels. +set -euo pipefail +case "${DISTRO:?}" in + debian) + apt-get update + apt-get install -y --no-install-recommends fakeroot dpkg-dev curl dbus-x11 xvfb xauth python3-gi gir1.2-gtk-3.0 gir1.2-ayatanaappindicator3-0.1 libsecret-tools gnome-keyring xdg-utils + format=deb ;; + fedora) + dnf install -y rpm-build curl dbus-x11 xorg-x11-server-Xvfb xorg-x11-xauth python3-gobject gtk3 libayatana-appindicator-gtk3 libsecret gnome-keyring xdg-utils + format=rpm ;; + arch) + pacman -Syu --noconfirm --needed base-devel curl dbus xorg-server-xvfb xorg-xauth python-gobject gtk3 libayatana-appindicator libsecret gnome-keyring xdg-utils + format=arch ;; + *) exit 2 ;; +esac +useradd -m builder +cp -a /source /home/builder/source +chown -R builder:builder /home/builder/source +runuser -u builder -- bash /home/builder/source/scripts/build-linux-native.sh "$format" /home/builder/source/dist/linux/nowplaying 0.2.1+dev /home/builder/packages +case "$format" in + deb) apt-get install -y /home/builder/packages/*.deb ;; + rpm) dnf install -y /home/builder/packages/*.rpm ;; + arch) pacman -U --noconfirm /home/builder/packages/*.pkg.tar.zst ;; +esac +/usr/bin/nowplaying --version +# appPaths/first-run and real GTK helper run in a private user session. +runuser -u builder -- xvfb-run -a dbus-run-session -- python3 /home/builder/source/scripts/linux-desktop-smoke.py diff --git a/test/linux-native.test.js b/test/linux-native.test.js index 1f135cda..6c7eed98 100644 --- a/test/linux-native.test.js +++ b/test/linux-native.test.js @@ -73,3 +73,23 @@ test("native builder rejects unsupported versions and bundled arm64 before packa } } }); + +test("prebuilt RPM spec disables debug packages and has a changelog", linuxOnly, () => { + const dir = mkdtempSync(join(tmpdir(), "np-rpm-spec-")); + const bundle = join(dir, "bundle"); const bin = join(dir, "bin"); + mkdirSync(join(bundle, "runtime"), { recursive: true }); + mkdirSync(join(bundle, "app/assets/brand/png"), { recursive: true }); + mkdirSync(bin); + for (const file of ["nowplaying", "runtime/node"]) { + writeFileSync(join(bundle, file), "#!/bin/sh\nprintf x64\n"); chmodSync(join(bundle, file), 0o755); + } + writeFileSync(join(bundle, "app/assets/brand/png/icon-512.png"), "test"); + writeFileSync(join(bin, "rpmbuild"), '#!/bin/sh\ncp "$4" "$SPEC_CAPTURE"\n'); + chmodSync(join(bin, "rpmbuild"), 0o755); + const capture = join(dir, "generated.spec"); + execFileSync("bash", [join(root, "scripts/build-linux-native.sh"), "rpm", bundle, "0.2.1+dev.abc123", join(dir, "out")], { env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, SPEC_CAPTURE: capture } }); + const spec = readFileSync(capture, "utf8"); + assert.match(spec, /^%global debug_package %\{nil\}$/m); + assert.match(spec, /^Version: 0\.2\.1\+dev\.abc123$/m); + assert.match(spec, /^%changelog\n\* [A-Z][a-z]{2} [A-Z][a-z]{2} \d{2} \d{4} rowkav09 - 0\.2\.1\+dev\.abc123-1\n- Package the prebuilt development bundle\./m); +});