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
39 changes: 39 additions & 0 deletions .github/workflows/linux-desktop.yml
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions docs/linux-desktop-testing.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions scripts/build-linux-native.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<SPEC
# Prebuilt Node payload has no source tree/debug symbols to extract.
%global debug_package %{nil}
Name: nowplaying
Version: ${version//-/_}
Release: 1
Expand All @@ -51,6 +53,9 @@ tar -xzf %{SOURCE0} -C %{buildroot}
/usr/bin/nowplaying
/usr/share/applications/nowplaying.desktop
/usr/share/icons/hicolor/512x512/apps/nowplaying.png
%changelog
* $(LC_ALL=C date -u '+%a %b %d %Y') rowkav09 - ${version//-/_}-1
- Package the prebuilt development bundle.
SPEC
rpmbuild --define "_topdir $stage/rpm" -bb "$stage/rpm/SPECS/nowplaying.spec"
find "$stage/rpm/RPMS" -name '*.rpm' -exec cp {} "$out/" \;
Expand Down
110 changes: 110 additions & 0 deletions scripts/linux-desktop-smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""CI real GTK tray protocol coverage, not a real desktop rendering test."""
import os
import socket
import subprocess
import tempfile
import time
import http.client
from pathlib import Path
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib

XML = '''<node><interface name="org.kde.StatusNotifierWatcher">
<method name="RegisterStatusNotifierItem"><arg type="s" direction="in"/></method>
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
<property name="ProtocolVersion" type="i" access="read"/>
</interface></node>'''
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())
29 changes: 29 additions & 0 deletions scripts/linux-distro-lab.sh
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions test/linux-native.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading