feat(tray): system-tray status icon for the host (Windows + Linux)

New crates/punktfunk-tray — a small per-user companion showing the host service
state at a glance (running / stopped / starting / degraded / failed + the live
session in the tooltip) with one-click actions: open web console, approve a
pending pairing request, start/stop/restart, open logs. No more digging through
logs to learn whether the service came back after a reboot or an update.

Status is service-manager-FIRST (SCM / systemd user unit — a port squatter can
never fake Running), then the new loopback-only unauthenticated
GET /api/v1/local/summary (counts/booleans only; the mgmt token and cert.pem
are SYSTEM/Admins-DACL'd on Windows, so a non-elevated tray cannot bearer-auth).

Windows: windows_subsystem binary (a console exe in the Run key would flash a
terminal at sign-in), Shell_NotifyIcon + hidden window, per-session single
instance, TaskbarCreated re-add, --quit for the uninstaller; service actions
elevate per click via ShellExecuteW "runas" onto the new
`punktfunk-host service restart` (stop → wait Stopped → start).
Linux: ksni/StatusNotifierItem over zbus, systemctl --user actions (no polkit),
/etc/xdg/autostart entry whose --autostart self-gates to actual host users.
Icons: scripts/gen-tray-icons.py (pure stdlib) renders the brand lens + status
dot into committed .ico/hicolor assets; deb/rpm/arch ship binary+autostart+icons.

Live-validated: Linux on the headless KDE session (SNI registration, state
transitions, menu-driven start, dbusmenu layout); Windows on the RTX box
(session-1 launch with no NIM_ADD failure, single instance, --quit, restart
round-trip, summary loopback-200/LAN-401).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 12:09:35 +00:00
parent 01fcb01019
commit 8005b11faf
35 changed files with 2166 additions and 19 deletions
+12 -1
View File
@@ -50,7 +50,7 @@ build() {
# The host's zero-copy FFI link-needs libcuda at build time; nvidia-utils provides it on an
# NVIDIA builder. On a GPU-less builder symlink the CUDA stub into the link path first (same
# caveat the RPM documents): ln -s "$(find / -name libcuda.so -path '*stubs*'|head -1)" /usr/lib/
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux -p punktfunk-tray
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
# built AND run with bun.
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
@@ -95,6 +95,17 @@ package_punktfunk-host() {
# connect). See the file's header comment.
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Host.desktop" \
"$pkgdir/usr/share/applications/io.unom.Punktfunk.Host.desktop"
# Status tray: per-user SNI icon + XDG autostart entry (self-gating: --autostart exits silently
# for users who don't run a host) + the hicolor status icons it names.
install -Dm0755 "$T/punktfunk-tray" "$pkgdir/usr/bin/punktfunk-tray"
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Tray.desktop" \
"$pkgdir/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop"
local sz png
for sz in 22x22 48x48; do
for png in "$R"/packaging/linux/icons/hicolor/$sz/apps/*.png; do
install -Dm0644 "$png" "$pkgdir/usr/share/icons/hicolor/$sz/apps/$(basename "$png")"
done
done
# headless session helpers + env templates + OpenAPI doc
install -Dm0755 "$R/scripts/headless/run-headless-kde.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-kde.sh"
install -Dm0755 "$R/scripts/headless/run-headless-sway.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-sway.sh"
+15
View File
@@ -28,6 +28,11 @@ if [ ! -x "$BIN" ]; then
echo "==> building $PKG (release)"
PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p "$PKG" --locked # stamp --version (build.rs)
fi
TRAY_BIN="target/release/punktfunk-tray"
if [ ! -x "$TRAY_BIN" ]; then
echo "==> building punktfunk-tray (release)"
cargo build --release -p punktfunk-tray --locked
fi
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
@@ -57,6 +62,16 @@ sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk
# connect, so it has to be present before the host ever connects. See the file's header comment.
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
"$STAGE/usr/share/applications/io.unom.Punktfunk.Host.desktop"
# Status tray: the per-user SNI icon + its XDG autostart entry (self-gating: --autostart exits
# silently for users who don't run a host) + the hicolor status icons it names.
install -Dm0755 "$TRAY_BIN" "$STAGE/usr/bin/punktfunk-tray"
install -Dm0644 packaging/linux/io.unom.Punktfunk.Tray.desktop \
"$STAGE/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop"
for sz in 22x22 48x48; do
for png in packaging/linux/icons/hicolor/$sz/apps/*.png; do
install -Dm0644 "$png" "$STAGE/usr/share/icons/hicolor/$sz/apps/$(basename "$png")"
done
done
install -Dm0755 scripts/headless/run-headless-kde.sh "$SHAREDIR/headless/run-headless-kde.sh"
install -Dm0755 scripts/headless/run-headless-sway.sh "$SHAREDIR/headless/run-headless-sway.sh"
install -Dm0644 scripts/headless/kde-authorized "$SHAREDIR/headless/kde-authorized"
Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

@@ -0,0 +1,15 @@
[Desktop Entry]
Type=Application
Name=punktfunk host status
Comment=Tray icon showing the punktfunk host service status
# --autostart exits silently unless this user actually runs a host (~/.config/punktfunk exists or
# the punktfunk-host user unit is enabled) — the package installs this for every desktop user.
Exec=/usr/bin/punktfunk-tray --autostart
Icon=punktfunk-tray
# Autostart-only: not a launcher entry (launch it from a terminal as `punktfunk-tray` if wanted).
NoDisplay=true
# KDE: start after plasmashell so the StatusNotifierWatcher is up (harmless elsewhere; the tray
# also waits for the watcher when started early).
X-KDE-autostart-after=panel
X-GNOME-Autostart-enabled=true
Categories=Network;Utility;
+15 -1
View File
@@ -167,7 +167,7 @@ export RUSTUP_TOOLCHAIN=stable
# Stamp the exact NVR into the binary for --version / mgmt /health provenance (build.rs reads it).
export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
# --locked: reproducible from (commit + Cargo.lock), matching the .deb build path.
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux -p punktfunk-tray
%if %{with web}
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
@@ -211,6 +211,17 @@ sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#%{_datadir}/%{name}/
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
# Status tray: the per-user SNI icon + its XDG autostart entry (self-gating: --autostart exits
# silently for users who don't run a host) + the hicolor status icons it names.
install -Dm0755 target/release/punktfunk-tray %{buildroot}%{_bindir}/punktfunk-tray
install -Dm0644 packaging/linux/io.unom.Punktfunk.Tray.desktop \
%{buildroot}%{_sysconfdir}/xdg/autostart/io.unom.Punktfunk.Tray.desktop
for sz in 22x22 48x48; do
for png in packaging/linux/icons/hicolor/$sz/apps/*.png; do
install -Dm0644 "$png" %{buildroot}%{_datadir}/icons/hicolor/$sz/apps/"$(basename "$png")"
done
done
# --- client subpackage ---
install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
@@ -275,11 +286,14 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
%doc README.md design/implementation-plan.md packaging/README.md
%{_bindir}/punktfunk-host
%{_bindir}/punktfunk-tray
%{_udevrulesdir}/60-punktfunk.rules
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
%{_userunitdir}/punktfunk-host.service
%{_userunitdir}/punktfunk-kde-session.service
%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
%{_sysconfdir}/xdg/autostart/io.unom.Punktfunk.Tray.desktop
%{_datadir}/icons/hicolor/*/apps/punktfunk-tray*.png
%dir /etc/gamescope-session-plus
%dir /etc/gamescope-session-plus/sessions.d
%config(noreplace) /etc/gamescope-session-plus/sessions.d/steam
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+5 -2
View File
@@ -42,6 +42,8 @@ $here = Split-Path -Parent $MyInvocation.MyCommand.Path
$iss = Join-Path $here 'punktfunk-host.iss'
$exe = Join-Path $TargetDir 'punktfunk-host.exe'
if (-not (Test-Path $exe)) { throw "missing build artifact 'punktfunk-host.exe' in $TargetDir (did 'cargo build --release -p punktfunk-host --features nvenc' run?)" }
$trayExe = Join-Path $TargetDir 'punktfunk-tray.exe'
if (-not (Test-Path $trayExe)) { throw "missing build artifact 'punktfunk-tray.exe' in $TargetDir (did 'cargo build --release -p punktfunk-tray' run?)" }
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) ---------------------------------------
@@ -110,14 +112,15 @@ function Sign-File([string]$Path) {
}
}
# --- sign the inner exe before it's packed ----------------------------------------------------
# --- sign the inner exes before they're packed -------------------------------------------------
Sign-File $exe
Sign-File $trayExe
# --- resolve + validate the installer's source files ------------------------------------------
$repoRoot = (Resolve-Path (Join-Path $here '..\..')).Path
$hostEnvSrc = Join-Path $repoRoot 'scripts\windows\host.env.example'
$readmeSrc = Join-Path $here 'README.md'
foreach ($p in @($exe, $hostEnvSrc, $readmeSrc, $iss)) {
foreach ($p in @($exe, $trayExe, $hostEnvSrc, $readmeSrc, $iss)) {
if (-not (Test-Path -LiteralPath $p)) { throw "installer source file missing: $p" }
}