From 8005b11faf3a941354dec922f5dd8c3bf37facbe Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 3 Jul 2026 12:09:35 +0000 Subject: [PATCH] feat(tray): system-tray status icon for the host (Windows + Linux) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/windows-host.yml | 23 +- CLAUDE.md | 50 +- Cargo.lock | 169 ++++++ Cargo.toml | 1 + api/openapi.json | 95 ++++ crates/punktfunk-host/src/main.rs | 2 +- crates/punktfunk-host/src/mgmt.rs | 142 ++++- crates/punktfunk-host/src/windows/service.rs | 36 ++ crates/punktfunk-tray/Cargo.toml | 56 ++ crates/punktfunk-tray/build.rs | 27 + crates/punktfunk-tray/src/linux.rs | 290 +++++++++++ crates/punktfunk-tray/src/main.rs | 97 ++++ crates/punktfunk-tray/src/status.rs | 489 ++++++++++++++++++ crates/punktfunk-tray/src/win.rs | 476 +++++++++++++++++ packaging/arch/PKGBUILD | 13 +- packaging/debian/build-deb.sh | 15 + .../22x22/apps/punktfunk-tray-degraded.png | Bin 0 -> 473 bytes .../22x22/apps/punktfunk-tray-error.png | Bin 0 -> 485 bytes .../22x22/apps/punktfunk-tray-stopped.png | Bin 0 -> 474 bytes .../22x22/apps/punktfunk-tray-streaming.png | Bin 0 -> 483 bytes .../hicolor/22x22/apps/punktfunk-tray.png | Bin 0 -> 483 bytes .../48x48/apps/punktfunk-tray-degraded.png | Bin 0 -> 856 bytes .../48x48/apps/punktfunk-tray-error.png | Bin 0 -> 868 bytes .../48x48/apps/punktfunk-tray-stopped.png | Bin 0 -> 859 bytes .../48x48/apps/punktfunk-tray-streaming.png | Bin 0 -> 867 bytes .../hicolor/48x48/apps/punktfunk-tray.png | Bin 0 -> 866 bytes .../linux/io.unom.Punktfunk.Tray.desktop | 15 + packaging/rpm/punktfunk.spec | 16 +- .../branding/punktfunk-tray-degraded.ico | Bin 0 -> 2872 bytes .../windows/branding/punktfunk-tray-error.ico | Bin 0 -> 2917 bytes .../branding/punktfunk-tray-running.ico | Bin 0 -> 2925 bytes .../branding/punktfunk-tray-stopped.ico | Bin 0 -> 2870 bytes .../branding/punktfunk-tray-streaming.ico | Bin 0 -> 2914 bytes packaging/windows/pack-host-installer.ps1 | 7 +- scripts/gen-tray-icons.py | 166 ++++++ 35 files changed, 2166 insertions(+), 19 deletions(-) create mode 100644 crates/punktfunk-tray/Cargo.toml create mode 100644 crates/punktfunk-tray/build.rs create mode 100644 crates/punktfunk-tray/src/linux.rs create mode 100644 crates/punktfunk-tray/src/main.rs create mode 100644 crates/punktfunk-tray/src/status.rs create mode 100644 crates/punktfunk-tray/src/win.rs create mode 100644 packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray-degraded.png create mode 100644 packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray-error.png create mode 100644 packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray-stopped.png create mode 100644 packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray-streaming.png create mode 100644 packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray.png create mode 100644 packaging/linux/icons/hicolor/48x48/apps/punktfunk-tray-degraded.png create mode 100644 packaging/linux/icons/hicolor/48x48/apps/punktfunk-tray-error.png create mode 100644 packaging/linux/icons/hicolor/48x48/apps/punktfunk-tray-stopped.png create mode 100644 packaging/linux/icons/hicolor/48x48/apps/punktfunk-tray-streaming.png create mode 100644 packaging/linux/icons/hicolor/48x48/apps/punktfunk-tray.png create mode 100644 packaging/linux/io.unom.Punktfunk.Tray.desktop create mode 100644 packaging/windows/branding/punktfunk-tray-degraded.ico create mode 100644 packaging/windows/branding/punktfunk-tray-error.ico create mode 100644 packaging/windows/branding/punktfunk-tray-running.ico create mode 100644 packaging/windows/branding/punktfunk-tray-stopped.ico create mode 100644 packaging/windows/branding/punktfunk-tray-streaming.ico create mode 100644 scripts/gen-tray-icons.py diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index 649e939..49484cd 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -23,8 +23,9 @@ # (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1. # # GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer. -# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export -# .def with llvm-dlltool (no GPU/SDK at build time). +# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at +# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on +# AMD/Intel-only boxes before main). # - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared # tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer. # lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265). @@ -37,6 +38,7 @@ on: paths: - 'crates/punktfunk-host/**' - 'crates/punktfunk-core/**' + - 'crates/punktfunk-tray/**' - 'packaging/windows/**' - 'scripts/windows/**' - 'web/**' @@ -109,21 +111,22 @@ jobs: "PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 Write-Output "host version $v" - - name: Generate NVENC import lib - shell: pwsh - run: | - & packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc - "PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - name: Build (release, nvenc + amf-qsv) shell: pwsh # All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR). run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv - - name: Clippy (host, Windows) + - name: Build (release, status tray) + shell: pwsh + # The per-user notification-area companion the installer bundles (punktfunk-tray.exe). + run: cargo build --release -p punktfunk-tray + + - name: Clippy (host + tray, Windows) shell: pwsh # First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code). - run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings + run: | + cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" } + cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" } - name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer) shell: pwsh diff --git a/CLAUDE.md b/CLAUDE.md index 5ab54f5..b97f5f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,16 +100,39 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI (`packaging/windows/drivers/`) and installed by the Inno Setup installer via - `punktfunk-host.exe driver install --gamepad`. + `punktfunk-host.exe driver install --gamepad`. The gamepad drivers' **business logic is 100 % safe + Rust**: every raw shared-memory / sealed-channel / WDF-request operation lives behind + `pf-umdf-util` (the audited unsafe layer — `section::MappedView` checked accessors, the + `#![forbid(unsafe_code)]` `channel::ChannelClient` state machine, `wdf::Request` tokens), so a + memory-safety bug can only live in that one small crate. The whole drivers workspace is lint-gated + (`deny(unsafe_op_in_unsafe_fn)` + `deny(clippy::undocumented_unsafe_blocks)`) with a + `cargo clippy -D warnings` step in `windows-drivers.yml`; pf-vdisplay stays FFI-bound (D3D11/IddCx) + but every `unsafe {}` there now carries a `// SAFETY:` proof (unsafe-audited, not unsafe-free). **Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`), the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-`, and `UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) — validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is the remaining piece.) -- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends +- **Windows host: implemented and shipping (all-vendor, x64-only, Windows 11 22H2+).** The OS floor + is HARD: pf-vdisplay is built against IddCx 1.10 (1.10 stub + HDR `*2` DDIs + FP16 caps, no runtime + downgrade) — on Windows 10 (incl. LTSC) / Win11 21H2 the driver installs but the device fails start + with Code 10 `STATUS_DEVICE_POWER_FAILURE` (field-reported 2026-07); the installer gates on + `MinVersion=10.0.22621`. `#[cfg(windows)]` backends behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx **pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`; - DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`), GPU encode (NVENC + DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`). The host↔driver frame + ring is a **sealed channel** (proto v2, `design/idd-push-security.md`): all shared objects + UNNAMED, handles `DuplicateHandle`d into the driver's WUDFHost and delivered as values over + `IOCTL_SET_FRAME_CHANNEL` (SY+BA-only control device) — only the two endpoint processes can ever + reach a frame (DDA's isolation property in user mode; adopt-on-success handle-ownership contract, + newest-delivery-wins re-attach). *Sealed channel: CI-pending + on-glass revalidation pending.* + The **gamepad SHM channels are sealed the same way** (gamepad proto v2, + `design/gamepad-channel-sealing.md`): the pad DATA sections (`XusbShm`/`PadShm`, now with a + driver-validated `pad_index`) are unnamed + handle-duplicated into the pad WUDFHost + (`gamepad_raii.rs` `PadChannel`); since the HID minidrivers have no control device, the handshake + runs over a tiny named bootstrap mailbox (`Global\pf…-boot-`, pid + handle value only — tampering + is DoS-bounded). *Sealed pad channel: needs both pad drivers redeployed with the host, physical-pad + validation pending.* GPU encode (NVENC `--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers (`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK; @@ -155,6 +178,25 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`. +- **Status tray (`crates/punktfunk-tray`, Windows + Linux).** A small per-user companion binary + showing the host service state at a glance (running / stopped / starting / degraded / failed + + streaming session in the tooltip) with one-click actions: open web console, approve-pairing + shortcut, start/stop/restart, open logs, exit. Status precedence is **service manager first** + (SCM / systemd user unit — a port-squatter can't fake Running), then the new **loopback-only + unauthenticated** `GET /api/v1/local/summary` (counts/booleans only — no PINs/fingerprints/names; + gated in `require_auth` by peer address, needed because `mgmt-token`/`cert.pem` are + SYSTEM/Admins-DACL'd on Windows so a non-elevated tray cannot bearer-auth). Windows: + `#![windows_subsystem = "windows"]` hidden-window + `Shell_NotifyIconW` (per-session `Local\` + mutex, TaskbarCreated re-add, `--quit` for the uninstaller), actions elevate per click via + `ShellExecuteW "runas"` on `punktfunk-host.exe service start|stop|restart` (new `service restart` + subcommand: stop → wait Stopped → start); installed by the Inno `trayicon` task (HKLM Run key). + Linux: ksni (SNI over zbus, `async-io`+`blocking` features), `systemctl --user` actions (no + polkit), `/etc/xdg/autostart` entry whose `--autostart` self-gates (silent exit unless + `~/.config/punktfunk` exists or the unit is enabled); deb/rpm/arch ship binary + autostart + + hicolor icons. Icons generated by `scripts/gen-tray-icons.py` (pure-stdlib; committed .ico/.png, + brand lens + status dot). *Linux live-validated on the headless KDE session (SNI registration, + stop/start transitions, menu-driven start, dbusmenu layout); Windows code MSVC-cross-type-checked + + clippy-clean but real Windows CI build + on-glass validation pending.* ## What's left @@ -447,6 +489,7 @@ crates/punktfunk-host/ capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs windows/{service,install,interactive}.rs SCM service + in-binary driver/web install capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs +crates/punktfunk-tray/ per-user status tray (Win32 Shell_NotifyIcon · Linux ksni/SNI); icons via scripts/gen-tray-icons.py clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool) clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3) clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3) @@ -454,6 +497,7 @@ clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameCon clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core) clients/decky/ Steam Deck Decky plugin packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI) +packaging/windows/drivers/pf-umdf-util/ audited unsafe layer (safe shm + sealed-channel + WDF request primitives) — gamepad drivers' logic is 100% safe over it web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs) packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs) tools/{loss-harness,latency-probe}/ measurement (plan §10) diff --git a/Cargo.lock b/Cargo.lock index 3a719be..bb53b44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,6 +228,67 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -239,6 +300,30 @@ dependencies = [ "syn", ] +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -434,6 +519,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -2002,6 +2100,23 @@ dependencies = [ "libloading", ] +[[package]] +name = "ksni" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9eeb3f510b6148ae68f963af2c1fbb0de4d9e4e05f82813cfb319837c3ad2b" +dependencies = [ + "async-executor", + "async-io", + "async-lock", + "futures-channel", + "futures-lite", + "futures-util", + "pastey", + "serde", + "zbus", +] + [[package]] name = "latency-probe" version = "0.5.1" @@ -2561,6 +2676,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem" version = "3.0.6" @@ -2599,6 +2720,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pipewire" version = "0.9.2" @@ -2654,6 +2786,20 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2899,6 +3045,23 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "punktfunk-tray" +version = "0.5.1" +dependencies = [ + "anyhow", + "ksni", + "libc", + "rustls", + "serde", + "serde_json", + "sha2", + "ureq", + "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-service", + "winresource", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -5221,8 +5384,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" dependencies = [ "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", "async-recursion", + "async-task", "async-trait", + "blocking", "enumflags2", "event-listener", "futures-core", diff --git a/Cargo.toml b/Cargo.toml index d0fff14..97914c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/punktfunk-core", "crates/punktfunk-host", "crates/punktfunk-host/vendor/usbip-sim", + "crates/punktfunk-tray", "crates/pf-driver-proto", "clients/probe", "clients/linux", diff --git a/api/openapi.json b/api/openapi.json index c9dcae6..de7fa1d 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -578,6 +578,41 @@ } } }, + "/api/v1/local/summary": { + "get": { + "tags": [ + "host" + ], + "summary": "Local status summary for the tray icon", + "description": "Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device\nnames). Unauthenticated, but served to loopback peers only.", + "operationId": "getLocalSummary", + "responses": { + "200": { + "description": "Non-sensitive local host status (loopback peers only)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalSummary" + } + } + } + }, + "401": { + "description": "Non-loopback peer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + {} + ] + } + }, "/api/v1/logs": { "get": { "tags": [ @@ -2083,6 +2118,66 @@ } } }, + "LocalSummary": { + "type": "object", + "description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.", + "required": [ + "version", + "video_streaming", + "audio_streaming", + "paired_clients", + "native_paired_clients", + "pin_pending", + "pending_approvals" + ], + "properties": { + "audio_streaming": { + "type": "boolean", + "description": "True while the audio stream thread is running." + }, + "native_paired_clients": { + "type": "integer", + "format": "int32", + "description": "Number of paired native (punktfunk/1) devices.", + "minimum": 0 + }, + "paired_clients": { + "type": "integer", + "format": "int32", + "description": "Number of pinned (paired) GameStream client certificates.", + "minimum": 0 + }, + "pending_approvals": { + "type": "integer", + "format": "int32", + "description": "Native pairing knocks awaiting the operator's approval (count only).", + "minimum": 0 + }, + "pin_pending": { + "type": "boolean", + "description": "True while a GameStream pairing handshake is parked waiting for the user's PIN." + }, + "session": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SessionInfo", + "description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)." + } + ] + }, + "version": { + "type": "string", + "description": "Host version (mirrors `/health`)." + }, + "video_streaming": { + "type": "boolean", + "description": "True while the video stream thread is running." + } + } + }, "LogEntry": { "type": "object", "description": "One captured log event.", diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index cd8a1f3..8d49904 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -739,7 +739,7 @@ NOTES: "\nWINDOWS SERVICE (end-user deployment — replaces a manual launch):\n\ \x20 punktfunk-host service install register an auto-start SYSTEM service + firewall rules\n\ \x20 punktfunk-host service uninstall remove the service + firewall rules\n\ - \x20 punktfunk-host service start|stop|status\n\ + \x20 punktfunk-host service start|stop|restart|status\n\ \x20 config: %ProgramData%\\punktfunk\\host.env\n\ \nWINDOWS DIAGNOSTICS:\n\ \x20 punktfunk-host hdr-p010-selftest GPU colour check for the PUNKTFUNK_HDR_SHADER_P010 path\n\ diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index ba7edf1..fda1bef 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -157,6 +157,7 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(list_gpus)) .routes(routes!(set_gpu_preference)) .routes(routes!(get_status)) + .routes(routes!(get_local_summary)) .routes(routes!(list_paired_clients)) .routes(routes!(unpair_client)) .routes(routes!(get_pairing_status)) @@ -353,6 +354,30 @@ struct StreamInfo { codec: ApiCodec, } +/// Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values, +/// no fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see +/// `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the +/// per-user tray process cannot authenticate — this narrow read-only route is its status source. +#[derive(Serialize, ToSchema)] +struct LocalSummary { + /// Host version (mirrors `/health`). + version: String, + /// True while the video stream thread is running. + video_streaming: bool, + /// True while the audio stream thread is running. + audio_streaming: bool, + /// The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop). + session: Option, + /// Number of pinned (paired) GameStream client certificates. + paired_clients: u32, + /// Number of paired native (punktfunk/1) devices. + native_paired_clients: u32, + /// True while a GameStream pairing handshake is parked waiting for the user's PIN. + pin_pending: bool, + /// Native pairing knocks awaiting the operator's approval (count only). + pending_approvals: u32, +} + /// A paired (certificate-pinned) Moonlight client. #[derive(Serialize, ToSchema)] struct PairedClient { @@ -488,13 +513,34 @@ where /// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token /// (from a **loopback** peer only) — required always (the host runs with a token by construction). -/// `/api/v1/health` stays open for probes. The cert path authorizes only the read-only allowlist +/// `/api/v1/health` stays open for probes; `/api/v1/local/summary` is open to loopback peers only +/// (the tray icon's status source). The cert path authorizes only the read-only allowlist /// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined /// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default. async fn require_auth(State(st): State>, req: Request, next: Next) -> Response { if req.uri().path() == "/api/v1/health" { return next.run(req).await; // liveness probe is always open } + // The tray icon's status source: non-sensitive counts/booleans only, unauthenticated but + // confined to LOOPBACK peers. The bearer-token file (and cert.pem) are SYSTEM/Administrators- + // DACL'd on Windows, so the per-user tray process cannot authenticate — this one narrow + // read-only route is deliberately all it needs. Not on the cert allowlist: LAN mTLS clients + // already have the richer `/status`. (No PeerAddr ⇒ a unit test → treat as loopback, matching + // the bearer path below.) + if req.uri().path() == "/api/v1/local/summary" { + let from_loopback = req + .extensions() + .get::() + .is_none_or(|a| a.0.ip().is_loopback()); + return if from_loopback { + next.run(req).await + } else { + api_error( + StatusCode::UNAUTHORIZED, + "the local summary is loopback-only", + ) + }; + } // A paired native client authenticates by its mTLS certificate — the same identity + trust the // QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert // authorizes only the safe, read-only status routes, NOT state-changing or pairing-administration @@ -944,6 +990,45 @@ async fn get_status(State(st): State>) -> Json { }) } +/// Local status summary for the tray icon +/// +/// Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device +/// names). Unauthenticated, but served to loopback peers only. +#[utoipa::path( + get, + path = "/local/summary", + tag = "host", + operation_id = "getLocalSummary", + // Override the document-global bearerAuth: loopback peers are exempt in `require_auth`. + security(()), + responses( + (status = OK, description = "Non-sensitive local host status (loopback peers only)", body = LocalSummary), + (status = UNAUTHORIZED, description = "Non-loopback peer", body = ApiError), + ) +)] +async fn get_local_summary(State(st): State>) -> Json { + let session = st.app.launch.lock().unwrap().map(|l| SessionInfo { + width: l.width, + height: l.height, + fps: l.fps, + }); + let (native_paired_clients, pending_approvals) = st + .native + .as_ref() + .map(|n| (n.status().paired_clients, n.pending().len() as u32)) + .unwrap_or((0, 0)); + Json(LocalSummary { + version: env!("PUNKTFUNK_VERSION").into(), + video_streaming: st.app.streaming.load(Ordering::SeqCst), + audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst), + session, + paired_clients: st.app.paired.lock().unwrap().len() as u32, + native_paired_clients, + pin_pending: st.app.pairing.pin.awaiting_pin(), + pending_approvals, + }) +} + /// List paired clients #[utoipa::path( get, @@ -2031,6 +2116,61 @@ mod tests { assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION); } + /// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is + /// rejected even though the route needs no bearer token, and the body never carries secret + /// material (no PIN values, no fingerprints, no device names — counts/booleans only). + #[tokio::test] + async fn local_summary_is_loopback_only_and_non_sensitive() { + let np = Arc::new( + crate::native_pairing::NativePairing::load_with( + Some( + std::env::temp_dir() + .join(format!("pf-mgmt-summary-{}.json", std::process::id())), + ), + None, + false, + ) + .unwrap(), + ); + np.add("secret-device-name", "deadbeefcafe0123").unwrap(); + let app = test_app_native(test_state(), np); + + // Loopback peer, NO auth header → 200 with the expected shape. + let mut req = get_req("/api/v1/local/summary"); + req.extensions_mut() + .insert(PeerAddr("127.0.0.1:40000".parse().unwrap())); + let (status, body) = send(&app, req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["video_streaming"], false); + assert_eq!(body["native_paired_clients"], 1); + assert_eq!(body["pending_approvals"], 0); + assert!(body["version"].is_string()); + // No secret material anywhere in the body (paired name / fingerprint must not leak). + let raw = body.to_string(); + assert!( + !raw.contains("deadbeefcafe0123") && !raw.contains("secret-device-name"), + "summary must not leak fingerprints or device names: {raw}" + ); + + // The same request from a LAN peer → rejected (route is loopback-gated, not just tokenless). + let mut req = get_req("/api/v1/local/summary"); + req.extensions_mut() + .insert(PeerAddr("192.168.1.50:40000".parse().unwrap())); + let (status, _) = send(&app, req).await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "the local summary must be rejected for a LAN peer" + ); + + // IPv6 loopback counts as loopback. + let mut req = get_req("/api/v1/local/summary"); + req.extensions_mut() + .insert(PeerAddr("[::1]:40000".parse().unwrap())); + let (status, _) = send(&app, req).await; + assert_eq!(status, StatusCode::OK, "::1 is a loopback peer"); + } + #[tokio::test] async fn bearer_token_is_enforced() { let app = test_app(test_state(), Some("sekrit")); diff --git a/crates/punktfunk-host/src/windows/service.rs b/crates/punktfunk-host/src/windows/service.rs index 800bd15..17d987a 100644 --- a/crates/punktfunk-host/src/windows/service.rs +++ b/crates/punktfunk-host/src/windows/service.rs @@ -91,6 +91,7 @@ pub fn main(args: &[String]) -> Result<()> { Some("uninstall") => uninstall(), Some("start") => sc(&["start", SERVICE_NAME]), Some("stop") => sc(&["stop", SERVICE_NAME]), + Some("restart") => restart(), Some("status") => sc(&["query", SERVICE_NAME]), _ => { eprintln!( @@ -102,6 +103,7 @@ pub fn main(args: &[String]) -> Result<()> { \x20 punktfunk-host service uninstall stop + remove the service + firewall rules\n\ \x20 punktfunk-host service start start the service now\n\ \x20 punktfunk-host service stop stop the service\n\ + \x20 punktfunk-host service restart stop, wait for exit, start again\n\ \x20 punktfunk-host service status query the service\n\n\ Config: %ProgramData%\\punktfunk\\host.env Logs: %ProgramData%\\punktfunk\\logs\\" ); @@ -691,6 +693,40 @@ fn install(args: &[String]) -> Result<()> { Ok(()) } +/// `service restart`: stop, wait for the service to actually reach Stopped (a bare +/// `sc stop && sc start` races the stop — START fails with "instance already running" while the +/// old process winds down), then start. The tray icon's Restart action runs this, elevated. +fn restart() -> Result<()> { + use windows_service::service::{ServiceAccess, ServiceState}; + use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; + + let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) + .context("open Service Control Manager (run elevated)")?; + let svc = manager + .open_service( + SERVICE_NAME, + ServiceAccess::STOP | ServiceAccess::QUERY_STATUS | ServiceAccess::START, + ) + .context("open service (run elevated)")?; + // Best-effort stop: ERROR_SERVICE_NOT_ACTIVE just means restart == start. + let _ = svc.stop(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let state = svc.query_status().context("query service status")?; + if state.current_state == ServiceState::Stopped { + break; + } + if std::time::Instant::now() >= deadline { + anyhow::bail!("service did not stop within 30 s"); + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + svc.start(&[] as &[&std::ffi::OsStr]) + .context("start service")?; + println!("Restarted service '{SERVICE_NAME}'."); + Ok(()) +} + fn uninstall() -> Result<()> { use windows_service::service::ServiceAccess; use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; diff --git a/crates/punktfunk-tray/Cargo.toml b/crates/punktfunk-tray/Cargo.toml new file mode 100644 index 0000000..34d3ace --- /dev/null +++ b/crates/punktfunk-tray/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "punktfunk-tray" +description = "System-tray status icon for the punktfunk streaming host (Windows notification area / Linux StatusNotifierItem)" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[[bin]] +name = "punktfunk-tray" +path = "src/main.rs" + +# Deliberately does NOT depend on punktfunk-host: the tray needs only the service name, the mgmt +# port, and the summary JSON shape — a dependency would drag the whole host (FFmpeg, PipeWire, …) +# into a 2 MB helper and make it un-buildable standalone. Non-Windows/non-Linux targets build a +# stub main (same pattern as the platform-gated clients). +[dependencies] +anyhow = "1" + +[target.'cfg(any(windows, target_os = "linux"))'.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Loopback HTTPS poll of GET /api/v1/local/summary. Same sync ureq + rustls(ring) stack and +# custom-verifier pattern as the Linux client's library fetch (clients/linux/src/library.rs) — +# but ring-only (no default aws-lc-rs provider: it needs a C toolchain per target and the agent +# pins the ring provider explicitly anyway). +ureq = { version = "2", default-features = false, features = ["tls"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } +sha2 = "0.10" + +[target.'cfg(windows)'.dependencies] +# SCM QUERY_STATUS works unprivileged — the service-state probe. Same crate the host service uses. +windows-service = "0.7" +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_Security", # CreateMutexW's SECURITY_ATTRIBUTES parameter type + "Win32_System_LibraryLoader", + "Win32_System_Threading", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } + +[target.'cfg(target_os = "linux")'.dependencies] +# StatusNotifierItem (pure Rust, zbus — the same zbus the host already pulls via ashpd). The tray +# is a plain-threads poller, so the blocking API over the small async-io executor (`blocking` +# alone is just the wrapper — zbus still needs an executor; no tokio runtime in a tray icon). +ksni = { version = "0.3", default-features = false, features = ["async-io", "blocking"] } +libc = "0.2" + +# Build-time icon embedding (exe icon + the status-variant tray icons), host-gated like the +# Windows client's build.rs — cross-builds from Linux CI runners skip it. +[target.'cfg(windows)'.build-dependencies] +winresource = "0.1" diff --git a/crates/punktfunk-tray/build.rs b/crates/punktfunk-tray/build.rs new file mode 100644 index 0000000..a442d62 --- /dev/null +++ b/crates/punktfunk-tray/build.rs @@ -0,0 +1,27 @@ +//! Embed the Windows version-info + icon resources into `punktfunk-tray.exe`: ordinal 1 is the +//! exe/file icon, ordinals 2–6 are the status-variant tray icons `src/win.rs` loads by id +//! (running / stopped / error / streaming / degraded). Same winresource pattern as +//! `clients/windows/build.rs`. + +fn main() { + // cfg(windows) is the HOST (skips the Linux/macOS workspace stub build); CARGO_CFG_WINDOWS + // is the TARGET (mirrors the Windows client's build.rs). + #[cfg(windows)] + if std::env::var_os("CARGO_CFG_WINDOWS").is_some() { + let branding = "../../packaging/windows/branding"; + let icons = [ + (format!("{branding}/punktfunk.ico"), "1"), + (format!("{branding}/punktfunk-tray-running.ico"), "2"), + (format!("{branding}/punktfunk-tray-stopped.ico"), "3"), + (format!("{branding}/punktfunk-tray-error.ico"), "4"), + (format!("{branding}/punktfunk-tray-streaming.ico"), "5"), + (format!("{branding}/punktfunk-tray-degraded.ico"), "6"), + ]; + let mut res = winresource::WindowsResource::new(); + for (path, id) in &icons { + println!("cargo:rerun-if-changed={path}"); + res.set_icon_with_id(path, id); + } + res.compile().expect("embed windows icon resources"); + } +} diff --git a/crates/punktfunk-tray/src/linux.rs b/crates/punktfunk-tray/src/linux.rs new file mode 100644 index 0000000..bd11222 --- /dev/null +++ b/crates/punktfunk-tray/src/linux.rs @@ -0,0 +1,290 @@ +//! Linux tray: a StatusNotifierItem (ksni/zbus) fed by the status poller. The host runs as the +//! systemd **user** unit `punktfunk-host.service`, so start/stop/restart are plain +//! `systemctl --user` calls — no polkit, no elevation. KDE (the project's primary Linux desktop) +//! renders SNI natively; GNOME needs the AppIndicator extension (without it the icon is invisible +//! — `--autostart` exits silently rather than erroring at every login). + +use std::os::fd::AsRawFd; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock}; + +use crate::status::{self, Poller, TrayStatus}; + +/// The tray's D-Bus/menu model. `status` is the only mutable state; the poller rewrites it via +/// `Handle::update`, which re-emits the SNI properties (icon, tooltip, menu). +struct HostTray { + status: TrayStatus, + web_port: u16, + web_console: bool, + /// Filled right after `spawn` (the poller needs the tray handle first) — lets menu actions + /// force an immediate re-poll instead of waiting out the cadence. + poller: Arc>, +} + +impl HostTray { + fn systemctl(&self, verb: &str) { + let _ = std::process::Command::new("systemctl") + .args(["--user", verb, status::UNIT_NAME]) + .status(); + if let Some(p) = self.poller.get() { + p.poke(); + } + } + + fn open_console(&self) { + let url = format!("https://127.0.0.1:{}", self.web_port); + let _ = std::process::Command::new("xdg-open").arg(url).spawn(); + } +} + +impl ksni::Tray for HostTray { + fn id(&self) -> String { + "punktfunk-tray".into() + } + + fn title(&self) -> String { + "punktfunk host".into() + } + + fn status(&self) -> ksni::Status { + match &self.status { + TrayStatus::Error(_) => ksni::Status::NeedsAttention, + s if s.pairing_attention() => ksni::Status::NeedsAttention, + _ => ksni::Status::Active, + } + } + + /// Hicolor theme names (installed by the packages); `icon_pixmap` below is the fallback so a + /// `cargo run` from the repo shows an icon too. + fn icon_name(&self) -> String { + match &self.status { + TrayStatus::Running(_) if self.status.is_streaming() => { + "punktfunk-tray-streaming".into() + } + TrayStatus::Running(_) => "punktfunk-tray".into(), + TrayStatus::Starting | TrayStatus::Degraded => "punktfunk-tray-degraded".into(), + TrayStatus::Error(_) => "punktfunk-tray-error".into(), + TrayStatus::Stopped | TrayStatus::NotInstalled => "punktfunk-tray-stopped".into(), + } + } + + fn icon_pixmap(&self) -> Vec { + // Same dot palette as scripts/gen-tray-icons.py. + let rgb = match &self.status { + TrayStatus::Running(_) if self.status.is_streaming() => (0xb4, 0x4c, 0xf0), // violet + TrayStatus::Running(_) => (0x2e, 0xcc, 0x71), // green + TrayStatus::Starting | TrayStatus::Degraded => (0xf0, 0xa0, 0x30), // amber + TrayStatus::Error(_) => (0xe7, 0x4c, 0x3c), // red + TrayStatus::Stopped | TrayStatus::NotInstalled => (0x8a, 0x8a, 0x8a), // gray + }; + vec![dot_icon(22, rgb), dot_icon(48, rgb)] + } + + fn tool_tip(&self) -> ksni::ToolTip { + ksni::ToolTip { + title: self.status.headline(), + ..Default::default() + } + } + + fn menu(&self) -> Vec> { + use ksni::menu::*; + let running = matches!( + self.status, + TrayStatus::Running(_) | TrayStatus::Starting | TrayStatus::Degraded + ); + let startable = matches!( + self.status, + TrayStatus::Stopped | TrayStatus::Error(_) | TrayStatus::NotInstalled + ); + vec![ + StandardItem { + label: self.status.headline(), + enabled: false, + ..Default::default() + } + .into(), + MenuItem::Separator, + StandardItem { + label: "Open web console".into(), + visible: self.web_console, + activate: Box::new(|t: &mut Self| t.open_console()), + ..Default::default() + } + .into(), + StandardItem { + label: "Approve pairing request…".into(), + visible: self.web_console && self.status.pairing_attention(), + activate: Box::new(|t: &mut Self| t.open_console()), + ..Default::default() + } + .into(), + MenuItem::Separator, + StandardItem { + label: "Start host".into(), + visible: startable && !matches!(self.status, TrayStatus::NotInstalled), + activate: Box::new(|t: &mut Self| t.systemctl("start")), + ..Default::default() + } + .into(), + StandardItem { + label: "Stop host".into(), + visible: running, + activate: Box::new(|t: &mut Self| t.systemctl("stop")), + ..Default::default() + } + .into(), + StandardItem { + label: "Restart host".into(), + visible: running || matches!(self.status, TrayStatus::Error(_)), + activate: Box::new(|t: &mut Self| t.systemctl("restart")), + ..Default::default() + } + .into(), + MenuItem::Separator, + StandardItem { + label: "Exit tray".into(), + activate: Box::new(|_: &mut Self| std::process::exit(0)), + ..Default::default() + } + .into(), + ] + } + + /// Keep waiting when the watcher drops (plasmashell restart, GNOME shell reload) — the item + /// re-registers when it returns. Only `--autostart` runs get here with SNI truly absent, and + /// lingering invisibly is the documented trade-off (see `assume_sni_available` below). + fn watcher_offline(&self, _reason: ksni::OfflineReason) -> bool { + true + } +} + +/// A flat antialiased status dot — the pixmap fallback when the hicolor icons aren't installed +/// (dev runs from `target/`). ARGB32, network byte order (per the SNI spec). +fn dot_icon(size: i32, (r, g, b): (u8, u8, u8)) -> ksni::Icon { + let mut data = Vec::with_capacity((size * size * 4) as usize); + let center = (size as f32 - 1.0) / 2.0; + let radius = size as f32 * 0.38; + for y in 0..size { + for x in 0..size { + let d = ((x as f32 - center).powi(2) + (y as f32 - center).powi(2)).sqrt(); + // 1 px antialiasing ramp at the rim. + let alpha = ((radius - d + 0.5).clamp(0.0, 1.0) * 255.0) as u8; + data.extend_from_slice(&[alpha, r, g, b]); + } + } + ksni::Icon { + width: size, + height: size, + data, + } +} + +/// Does this user's box run (or intend to run) a punktfunk host? Gates `--autostart` so the +/// packaged autostart entry doesn't put an icon in every desktop user's tray. +fn host_present() -> bool { + if status::punktfunk_config_dir().is_some_and(|d| d.exists()) { + return true; + } + std::process::Command::new("systemctl") + .args(["--user", "--quiet", "is-enabled", status::UNIT_NAME]) + .status() + .is_ok_and(|s| s.success()) +} + +/// The web console is a separate optional unit/package — only offer "Open web console" when it +/// exists for this user. +fn web_console_installed() -> bool { + let unit = "punktfunk-web.service"; + if ["/usr/lib/systemd/user", "/etc/systemd/user"] + .iter() + .any(|d| std::path::Path::new(d).join(unit).exists()) + { + return true; + } + if let Some(home) = std::env::var_os("HOME") { + if std::path::PathBuf::from(home) + .join(".config/systemd/user") + .join(unit) + .exists() + { + return true; + } + } + false +} + +/// One tray per session: `flock` on a runtime-dir lockfile (held for the process lifetime). +fn acquire_instance_lock() -> Option { + let dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(dir.join("punktfunk-tray.lock")) + .ok()?; + // SAFETY: `file` is an open, owned fd for the duration of the call; LOCK_NB makes this a + // non-blocking advisory lock attempt with no other side effects. + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + (rc == 0).then_some(file) +} + +pub fn run(args: crate::Args) -> anyhow::Result<()> { + if args.quit { + // Windows-only convenience for the uninstaller; nothing to do here. + return Ok(()); + } + if args.autostart && !host_present() { + return Ok(()); // not a host box — stay out of this user's tray + } + let Some(_lock) = acquire_instance_lock() else { + return Ok(()); // another instance already runs in this session + }; + + let poller_slot = Arc::new(OnceLock::new()); + let tray = HostTray { + status: TrayStatus::Stopped, // placeholder; the poller fires within its first cycle + web_port: args.web_port, + web_console: web_console_installed(), + poller: poller_slot.clone(), + }; + // Autostart races the desktop (the watcher may register after us) → be lenient and wait for + // it. A manual launch should fail loudly instead (e.g. GNOME without the AppIndicator + // extension) so the user learns why there is no icon. + use ksni::blocking::TrayMethods; + let handle = match tray.assume_sni_available(args.autostart).spawn() { + Ok(h) => h, + Err(e) if args.autostart => { + eprintln!("punktfunk-tray: no StatusNotifier host ({e}); exiting"); + return Ok(()); + } + Err(e) => anyhow::bail!( + "no StatusNotifier tray available ({e}) — on GNOME, install the AppIndicator extension" + ), + }; + + let dead = Arc::new(AtomicBool::new(false)); + let dead_flag = dead.clone(); + let update_handle = handle.clone(); + let poller = Poller::spawn( + args.mgmt_addr.clone(), + args.mgmt_port, + Box::new(move |st| { + if update_handle + .update(|t: &mut HostTray| t.status = st) + .is_none() + { + dead_flag.store(true, Ordering::SeqCst); // tray service shut down + } + }), + ); + let _ = poller_slot.set(poller); + + // The SNI service runs on its own thread; park here until it dies (shell logout etc.). + while !dead.load(Ordering::SeqCst) && !handle.is_closed() { + std::thread::sleep(std::time::Duration::from_secs(2)); + } + Ok(()) +} diff --git a/crates/punktfunk-tray/src/main.rs b/crates/punktfunk-tray/src/main.rs new file mode 100644 index 0000000..3639db2 --- /dev/null +++ b/crates/punktfunk-tray/src/main.rs @@ -0,0 +1,97 @@ +//! punktfunk-tray — a small per-user system-tray companion for the punktfunk host service. +//! +//! Shows at a glance whether the host is running / stopped / degraded / failed (no more digging +//! through logs after a reboot or an update), and offers the common one-click actions: open the +//! web console, start/stop/restart the service (UAC-elevated per action on Windows, +//! `systemctl --user` on Linux), review a pending pairing request, exit. +//! +//! Status comes from two sources, service manager FIRST (a fake listener on the mgmt port can +//! never make a stopped service look running): the SCM / systemd user unit for the process state, +//! then the host's loopback-only unauthenticated `GET /api/v1/local/summary` for the streaming +//! details. Windows-subsystem binary — a console exe in the HKLM Run key would flash a terminal +//! window at every sign-in. +#![cfg_attr(windows, windows_subsystem = "windows")] + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(any(windows, target_os = "linux"))] +mod status; +#[cfg(windows)] +mod win; + +/// CLI configuration (hand-rolled parse, house style). The mgmt address/port default to the +/// host's defaults; they are flags because the tray cannot read `host.env` on Windows (it is +/// DACL-locked to SYSTEM/Administrators), so an operator who moved `--mgmt-bind` adjusts the +/// autostart command line instead. +pub struct Args { + /// Ask an already-running tray instance to exit (Windows; used by the uninstaller). + pub quit: bool, + /// Launched from the desktop autostart entry: exit silently when this box doesn't run a host + /// (Linux; the package installs the autostart file for every desktop user). + pub autostart: bool, + /// Management API address to poll (loopback only; the summary route rejects anything else). + pub mgmt_addr: String, + pub mgmt_port: u16, + /// Web console port for the "Open web console" action. + pub web_port: u16, +} + +impl Default for Args { + fn default() -> Self { + Args { + quit: false, + autostart: false, + mgmt_addr: "127.0.0.1".into(), + mgmt_port: 47990, + web_port: 47992, + } + } +} + +fn parse_args() -> anyhow::Result { + let mut args = Args::default(); + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + let mut value = |flag: &str| { + it.next() + .ok_or_else(|| anyhow::anyhow!("{flag} needs a value")) + }; + match a.as_str() { + "--quit" => args.quit = true, + "--autostart" => args.autostart = true, + "--mgmt-addr" => args.mgmt_addr = value("--mgmt-addr")?, + "--mgmt-port" => args.mgmt_port = value("--mgmt-port")?.parse()?, + "--web-port" => args.web_port = value("--web-port")?.parse()?, + "--version" | "-V" => { + println!("punktfunk-tray {}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); + } + other => anyhow::bail!( + "unknown argument '{other}'\n\nUSAGE:\n punktfunk-tray [--autostart] [--quit] \ + [--mgmt-addr ] [--mgmt-port ] [--web-port ]" + ), + } + } + Ok(args) +} + +fn main() -> anyhow::Result<()> { + let args = parse_args()?; + run(args) +} + +#[cfg(windows)] +fn run(args: Args) -> anyhow::Result<()> { + win::run(args) +} + +#[cfg(target_os = "linux")] +fn run(args: Args) -> anyhow::Result<()> { + linux::run(args) +} + +#[cfg(not(any(windows, target_os = "linux")))] +fn run(_args: Args) -> anyhow::Result<()> { + // Workspace-stub build (macOS CI etc.) — the tray ships on Windows and Linux only. + anyhow::bail!("punktfunk-tray supports Windows and Linux hosts only") +} diff --git a/crates/punktfunk-tray/src/status.rs b/crates/punktfunk-tray/src/status.rs new file mode 100644 index 0000000..72ea9f4 --- /dev/null +++ b/crates/punktfunk-tray/src/status.rs @@ -0,0 +1,489 @@ +//! Host status model + the poller thread feeding the platform tray implementations. +//! +//! Two sources, service manager FIRST: the SCM (Windows) / systemd user unit (Linux) decides +//! stopped-vs-running — a malicious local process squatting the mgmt port while the service is +//! down can never make the tray say Running. Only when the service manager reports Running does +//! the poller consult the host's loopback-only `GET /api/v1/local/summary` for streaming detail. + +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +/// What the service manager reports for the host service. +#[derive(Clone, Debug, PartialEq)] +pub enum ServiceState { + NotInstalled, + Stopped, + StartPending, + StopPending, + Running, + /// Linux `ActiveState=failed` (with the sub-state), or a Windows stop with a failure exit code. + Failed(String), +} + +/// `GET /api/v1/local/summary` — the non-sensitive counts/booleans the host serves to loopback +/// peers without authentication (mgmt.rs `LocalSummary`). Unknown fields are ignored so a newer +/// host can grow the summary without breaking an older tray. +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +pub struct Summary { + pub version: String, + pub video_streaming: bool, + pub audio_streaming: bool, + pub session: Option, + pub paired_clients: u32, + pub native_paired_clients: u32, + pub pin_pending: bool, + pub pending_approvals: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)] +pub struct SessionInfo { + pub width: u32, + pub height: u32, + pub fps: u32, +} + +/// What the icon shows. +#[derive(Clone, Debug, PartialEq)] +pub enum TrayStatus { + NotInstalled, + Stopped, + /// Service starting, or running with the mgmt API not answering yet (within [`START_GRACE`]). + Starting, + Running(Summary), + /// Service running but the summary unreachable past the grace period — amber, not red: a + /// custom `PUNKTFUNK_HOST_CMD` (no mgmt API) or a relocated `--mgmt-bind` is legitimate. + Degraded, + Error(String), +} + +impl TrayStatus { + /// One-line headline for the tooltip / the disabled menu header. + pub fn headline(&self) -> String { + match self { + TrayStatus::NotInstalled => "punktfunk host — not installed".into(), + TrayStatus::Stopped => "punktfunk host — stopped".into(), + TrayStatus::Starting => "punktfunk host — starting…".into(), + TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(), + TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"), + TrayStatus::Running(s) => match (&s.session, s.video_streaming) { + (Some(sess), true) => format!( + "punktfunk host {} — streaming {}×{}@{}", + s.version, sess.width, sess.height, sess.fps + ), + (_, true) => format!("punktfunk host {} — streaming", s.version), + _ => format!("punktfunk host {} — idle", s.version), + }, + } + } + + pub fn is_streaming(&self) -> bool { + matches!(self, TrayStatus::Running(s) if s.video_streaming) + } + + /// A pairing attempt is waiting on the operator (shown as an extra menu entry). + pub fn pairing_attention(&self) -> bool { + matches!(self, TrayStatus::Running(s) if s.pin_pending || s.pending_approvals > 0) + } +} + +/// How long a running service may leave the summary unreachable before Starting turns Degraded. +/// Also re-applied mid-life: the SYSTEM supervisor relaunching a crashed host child looks like +/// "running, briefly unreachable" — that shows as Starting again, not an alarming flicker to red. +pub const START_GRACE: Duration = Duration::from_secs(15); + +/// Pure status mapping (unit-tested): service-manager state first, summary second, grace third. +pub fn map_status(svc: &ServiceState, summary: Option, grace_expired: bool) -> TrayStatus { + match svc { + ServiceState::NotInstalled => TrayStatus::NotInstalled, + ServiceState::Stopped | ServiceState::StopPending => TrayStatus::Stopped, + ServiceState::StartPending => TrayStatus::Starting, + ServiceState::Failed(e) => TrayStatus::Error(e.clone()), + ServiceState::Running => match summary { + Some(s) => TrayStatus::Running(s), + None if !grace_expired => TrayStatus::Starting, + None => TrayStatus::Degraded, + }, + } +} + +// ── Poller ────────────────────────────────────────────────────────────────────────────────────── + +pub struct Poller { + shared: Arc, +} + +struct Shared { + poked: Mutex, + cv: Condvar, +} + +impl Poller { + /// Spawn the poll thread; `on_change` fires (from that thread) whenever the status changes. + pub fn spawn( + mgmt_addr: String, + mgmt_port: u16, + on_change: Box, + ) -> Poller { + let shared = Arc::new(Shared { + poked: Mutex::new(false), + cv: Condvar::new(), + }); + let thread_shared = shared.clone(); + std::thread::Builder::new() + .name("status-poll".into()) + .spawn(move || poll_loop(&thread_shared, &mgmt_addr, mgmt_port, on_change)) + .expect("spawn status-poll thread"); + Poller { shared } + } + + /// Force an immediate re-poll (right after a start/stop/restart menu action). + pub fn poke(&self) { + *self.shared.poked.lock().unwrap() = true; + self.shared.cv.notify_one(); + } +} + +fn poll_loop( + shared: &Shared, + mgmt_addr: &str, + mgmt_port: u16, + on_change: Box, +) { + // IPv6 literals bracketed, like the Linux client's `base_url`. + let url = if mgmt_addr.contains(':') { + format!("https://[{mgmt_addr}]:{mgmt_port}/api/v1/local/summary") + } else { + format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary") + }; + let agent = agent(load_pin()); + let mut last: Option = None; + // When the summary became unreachable while the service was running (grace anchor). + // Runs for the process lifetime (the tray exits by process exit; nothing to unwind). + let mut unreachable_since: Option = None; + loop { + let svc = probe_service(); + let summary = if svc == ServiceState::Running { + let s = fetch_summary(&agent, &url); + match s { + Some(_) => unreachable_since = None, + None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()), + None => {} + } + s + } else { + unreachable_since = None; + None + }; + let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE); + let status = map_status(&svc, summary, grace_expired); + if last.as_ref() != Some(&status) { + on_change(status.clone()); + last = Some(status); + } + // 3 s while there is anything to watch; back off when the box just doesn't run a host. + let cadence = match last { + Some(TrayStatus::Stopped) | Some(TrayStatus::NotInstalled) => Duration::from_secs(10), + _ => Duration::from_secs(3), + }; + let mut poked = shared.poked.lock().unwrap(); + if !*poked { + (poked, _) = shared.cv.wait_timeout(poked, cadence).unwrap(); + } + *poked = false; + } +} + +// ── Summary fetch (loopback HTTPS) ────────────────────────────────────────────────────────────── + +fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option { + let body = agent.get(url).call().ok()?.into_string().ok()?; + serde_json::from_str(&body).ok() +} + +/// The host identity cert's SHA-256, when `cert.pem` is readable (Linux: same-user file). On +/// Windows the file is SYSTEM/Administrators-DACL'd, so the per-user tray can't pin — `None` = +/// accept any cert. That is acceptable here: the connection is loopback, carries no credentials, +/// and only *reads* non-sensitive data; stopped-vs-running is decided by the service manager, so +/// a port-squatter gains nothing but a fake "streaming" tooltip on an already-compromised box. +fn load_pin() -> Option<[u8; 32]> { + use rustls::pki_types::pem::PemObject; + use sha2::Digest; + let pem = std::fs::read(punktfunk_config_dir()?.join("cert.pem")).ok()?; + let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).ok()?; + Some(sha2::Sha256::digest(der.as_ref()).into()) +} + +/// The host's config dir, mirroring `gamestream::config_dir()` without linking the host crate: +/// `PUNKTFUNK_CONFIG_DIR` override, else `$XDG_CONFIG_HOME`/`~/.config` + `punktfunk` (Linux). +/// `None` on Windows — everything the tray would read there is SYSTEM/Admins-DACL'd anyway. +pub fn punktfunk_config_dir() -> Option { + if let Some(d) = std::env::var_os("PUNKTFUNK_CONFIG_DIR") { + if !d.is_empty() { + return Some(std::path::PathBuf::from(d)); + } + } + #[cfg(target_os = "linux")] + { + if let Some(x) = std::env::var_os("XDG_CONFIG_HOME") { + if !x.is_empty() { + return Some(std::path::PathBuf::from(x).join("punktfunk")); + } + } + std::env::var_os("HOME").map(|h| { + std::path::PathBuf::from(h) + .join(".config") + .join("punktfunk") + }) + } + #[cfg(not(target_os = "linux"))] + None +} + +/// A sync HTTPS agent over the same rustls(ring) stack the rest of the workspace uses, with a +/// pin-or-accept-any verifier (the Linux client's `PinVerify` pattern, `library.rs`). +fn agent(pin: Option<[u8; 32]>) -> ureq::Agent { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let cfg = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .expect("rustls default protocol versions") + .dangerous() + .with_custom_certificate_verifier(Arc::new(PinVerify { pin })) + .with_no_client_auth(); + ureq::AgentBuilder::new() + .tls_config(Arc::new(cfg)) + .timeout_connect(Duration::from_secs(2)) + .timeout(Duration::from_secs(2)) + .build() +} + +/// Trust = the SHA-256 of the host's self-signed leaf (or any cert when un-pinned). Handshake +/// signatures are still verified for real — CertificateVerify proves the peer holds the key. +#[derive(Debug)] +struct PinVerify { + pin: Option<[u8; 32]>, +} + +impl rustls::client::danger::ServerCertVerifier for PinVerify { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + use sha2::Digest; + if let Some(expected) = self.pin { + let fp: [u8; 32] = sha2::Sha256::digest(end_entity.as_ref()).into(); + if fp != expected { + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + )); + } + } + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +// ── Service-manager probe ─────────────────────────────────────────────────────────────────────── + +/// The SCM name registered by `punktfunk-host service install` (windows/service.rs SERVICE_NAME). +#[cfg(windows)] +pub const SERVICE_NAME: &str = "PunktfunkHost"; + +#[cfg(windows)] +pub fn probe_service() -> ServiceState { + use windows_service::service::{ServiceAccess, ServiceExitCode, ServiceState as Scm}; + use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; + // CONNECT + QUERY_STATUS work unprivileged. Re-opened every poll on purpose: a reinstall + // (delete + create) invalidates old handles, and this picks the new service up within a poll. + let Ok(manager) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) + else { + return ServiceState::NotInstalled; + }; + let Ok(svc) = manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) else { + return ServiceState::NotInstalled; // ERROR_SERVICE_DOES_NOT_EXIST et al. + }; + let Ok(status) = svc.query_status() else { + return ServiceState::NotInstalled; + }; + match status.current_state { + Scm::StartPending => ServiceState::StartPending, + Scm::StopPending => ServiceState::StopPending, + Scm::Running | Scm::ContinuePending | Scm::PausePending | Scm::Paused => { + ServiceState::Running + } + Scm::Stopped => match status.exit_code { + // 0 = clean; 1077 = never started since boot (ERROR_SERVICE_NEVER_HAS_BEEN_RUN? no — + // "no attempts to start have been made"): both are an ordinary Stopped, not a failure. + ServiceExitCode::Win32(0) | ServiceExitCode::Win32(1077) => ServiceState::Stopped, + ServiceExitCode::Win32(code) => ServiceState::Failed(format!("exit code {code}")), + ServiceExitCode::ServiceSpecific(code) => { + ServiceState::Failed(format!("service error {code}")) + } + }, + } +} + +/// The systemd user unit the Linux packages install (scripts/punktfunk-host.service). +#[cfg(target_os = "linux")] +pub const UNIT_NAME: &str = "punktfunk-host.service"; + +#[cfg(target_os = "linux")] +pub fn probe_service() -> ServiceState { + // `systemctl show` exits 0 even for unknown units (LoadState=not-found) — parse, don't rely + // on the exit code. + let Ok(out) = std::process::Command::new("systemctl") + .args([ + "--user", + "show", + UNIT_NAME, + "--property=LoadState,ActiveState,SubState", + ]) + .output() + else { + return ServiceState::NotInstalled; // no systemctl → nothing to watch + }; + let text = String::from_utf8_lossy(&out.stdout); + let prop = |key: &str| { + text.lines() + .find_map(|l| l.strip_prefix(key)?.strip_prefix('=')) + .unwrap_or("") + .to_string() + }; + if prop("LoadState") == "not-found" { + return ServiceState::NotInstalled; + } + match prop("ActiveState").as_str() { + "active" | "reloading" => ServiceState::Running, + "activating" => ServiceState::StartPending, + "deactivating" => ServiceState::StopPending, + "failed" => ServiceState::Failed(prop("SubState")), + _ => ServiceState::Stopped, // "inactive" and anything new + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn summary(streaming: bool) -> Summary { + Summary { + version: "0.5.1".into(), + video_streaming: streaming, + audio_streaming: streaming, + session: streaming.then_some(SessionInfo { + width: 2560, + height: 1440, + fps: 120, + }), + paired_clients: 1, + native_paired_clients: 2, + pin_pending: false, + pending_approvals: 0, + } + } + + /// The full (service state × summary × grace) table. + #[test] + fn status_mapping_table() { + use ServiceState as S; + use TrayStatus as T; + let cases: Vec<(S, Option, bool, T)> = vec![ + (S::NotInstalled, None, false, T::NotInstalled), + (S::Stopped, None, false, T::Stopped), + (S::StopPending, None, false, T::Stopped), + (S::StartPending, None, false, T::Starting), + ( + S::Failed("code 3".into()), + None, + false, + T::Error("code 3".into()), + ), + // Running + summary → Running regardless of grace. + ( + S::Running, + Some(summary(false)), + true, + T::Running(summary(false)), + ), + // Running + unreachable: Starting within grace, Degraded past it. + (S::Running, None, false, T::Starting), + (S::Running, None, true, T::Degraded), + // A summary while the SCM says Stopped is impossible by construction (the poller only + // fetches when Running) — but the mapping must still trust the service manager. + (S::Stopped, Some(summary(true)), false, T::Stopped), + ]; + for (svc, sum, grace, want) in cases { + assert_eq!( + map_status(&svc, sum.clone(), grace), + want, + "{svc:?} {sum:?} grace={grace}" + ); + } + } + + #[test] + fn headline_shows_session_and_reason() { + assert_eq!( + TrayStatus::Running(summary(true)).headline(), + "punktfunk host 0.5.1 — streaming 2560×1440@120" + ); + assert_eq!( + TrayStatus::Running(summary(false)).headline(), + "punktfunk host 0.5.1 — idle" + ); + assert!(TrayStatus::Error("exit code 3".into()) + .headline() + .contains("exit code 3")); + assert!(TrayStatus::Degraded + .headline() + .contains("status unavailable")); + } + + #[test] + fn pairing_attention_flags() { + let mut s = summary(false); + assert!(!TrayStatus::Running(s.clone()).pairing_attention()); + s.pending_approvals = 1; + assert!(TrayStatus::Running(s.clone()).pairing_attention()); + s.pending_approvals = 0; + s.pin_pending = true; + assert!(TrayStatus::Running(s).pairing_attention()); + assert!(!TrayStatus::Degraded.pairing_attention()); + } +} diff --git a/crates/punktfunk-tray/src/win.rs b/crates/punktfunk-tray/src/win.rs new file mode 100644 index 0000000..e28de01 --- /dev/null +++ b/crates/punktfunk-tray/src/win.rs @@ -0,0 +1,476 @@ +//! Windows tray: a hidden top-level window + `Shell_NotifyIconW`, fed by the status poller. +//! +//! The host service (`PunktfunkHost`, LocalSystem) supervises from session 0 and its `serve` +//! child runs as SYSTEM — neither can own a per-user tray icon, so this is a separate small +//! process the installer puts in the HKLM `Run` key (one instance per interactive session, +//! enforced by a `Local\` mutex). Start/Stop/Restart open one UAC consent prompt each +//! (`ShellExecuteW "runas"` on `punktfunk-host.exe service …`) — service control is deliberately +//! left admin-gated rather than DACL-opened to every local user. + +use std::os::windows::ffi::OsStrExt; +use std::sync::atomic::{AtomicIsize, Ordering}; +use std::sync::{Mutex, OnceLock}; + +use windows::core::{w, PCWSTR}; +use windows::Win32::Foundation::{ + GetLastError, ERROR_ALREADY_EXISTS, HWND, LPARAM, LRESULT, WPARAM, +}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::Threading::CreateMutexW; +use windows::Win32::UI::Shell::{ + ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIM_ADD, + NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NIN_SELECT, NOTIFYICONDATAW, NOTIFYICON_VERSION_4, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, DestroyWindow, + DispatchMessageW, FindWindowW, GetCursorPos, GetMessageW, LoadIconW, PostMessageW, + PostQuitMessage, RegisterClassW, RegisterWindowMessageW, SetForegroundWindow, + SetMenuDefaultItem, TrackPopupMenuEx, TranslateMessage, HICON, MF_GRAYED, MF_SEPARATOR, + MF_STRING, MSG, SW_HIDE, SW_SHOWNORMAL, TPM_BOTTOMALIGN, TPM_RIGHTBUTTON, WINDOW_EX_STYLE, + WM_APP, WM_CLOSE, WM_COMMAND, WM_CONTEXTMENU, WM_DESTROY, WM_ENDSESSION, WM_NULL, WNDCLASSW, + WS_OVERLAPPED, +}; + +use crate::status::{Poller, TrayStatus}; + +/// Keyboard "select" on the icon (Enter/Space) — `NIN_SELECT | NINF_KEY`; the windows crate +/// exports only NIN_SELECT. +const NIN_KEYSELECT: u32 = NIN_SELECT | 0x1; + +/// Posted by the poller thread when the status changed (never touch TLS on the UI thread). +const WMAPP_STATUS: u32 = WM_APP + 2; +/// The notify-icon callback message (NOTIFYICON_VERSION_4 semantics). +const WMAPP_NOTIFYCALLBACK: u32 = WM_APP + 1; + +// Menu command ids (WM_COMMAND LOWORD(wParam)). +const IDM_HEADER: usize = 0x0100; // disabled status line +const IDM_OPEN_WEB: usize = 0x0101; +const IDM_START: usize = 0x0102; +const IDM_STOP: usize = 0x0103; +const IDM_RESTART: usize = 0x0104; +const IDM_LOGS: usize = 0x0105; +const IDM_EXIT: usize = 0x0106; +const IDM_PAIRING: usize = 0x0107; + +/// Icon resource ordinals (embedded by build.rs). +fn icon_ordinal(status: &TrayStatus) -> u16 { + match status { + TrayStatus::Running(_) if status.is_streaming() => 5, + TrayStatus::Running(_) => 2, + TrayStatus::Stopped | TrayStatus::NotInstalled => 3, + TrayStatus::Error(_) => 4, + TrayStatus::Starting | TrayStatus::Degraded => 6, + } +} + +/// Global tray state — a tray has exactly one window and one wndproc, which cannot carry a +/// closure environment, so the state lives in a `OnceLock` set before window creation. +struct App { + hwnd: AtomicIsize, + status: Mutex, + poller: OnceLock, + /// `TaskbarCreated` broadcast id — Explorer restarted, re-add the icon. + taskbar_created: u32, + /// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`). + host_exe: Option, + /// The installer bundled the web console (detected via `{app}\web\web-run.cmd`). + web_console: bool, + web_port: u16, +} + +static APP: OnceLock = OnceLock::new(); + +fn app() -> &'static App { + APP.get().expect("APP initialized before window creation") +} + +fn to_wide(s: &str) -> Vec { + std::ffi::OsStr::new(s).encode_wide().chain([0]).collect() +} + +/// Best-effort log for a windows-subsystem process (no stderr): `%LOCALAPPDATA%\punktfunk\tray.log`. +fn log(msg: &str) { + let Some(base) = std::env::var_os("LOCALAPPDATA") else { + return; + }; + let dir = std::path::PathBuf::from(base).join("punktfunk"); + let _ = std::fs::create_dir_all(&dir); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("tray.log")) + { + use std::io::Write; + let _ = writeln!(f, "{msg}"); + } +} + +pub fn run(args: crate::Args) -> anyhow::Result<()> { + let _ = args.autostart; // Linux-only flag, accepted for a uniform command line + if args.quit { + return quit_existing(); + } + + // One tray per session: `Local\` scopes the mutex to this logon session, so fast-user-switched + // sessions each keep their own icon. Handle deliberately leaked (held for the process life). + // SAFETY: CreateMutexW with a valid nul-terminated name and no security attributes; the + // returned handle is never closed (process-lifetime singleton guard). + let already = unsafe { + match CreateMutexW(None, false, w!("Local\\PunktfunkTray")) { + Ok(_) => GetLastError() == ERROR_ALREADY_EXISTS, + Err(_) => false, // can't tell — carry on rather than losing the icon + } + }; + if already { + return Ok(()); + } + + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())); + let host_exe = exe_dir + .as_ref() + .map(|d| d.join("punktfunk-host.exe")) + .filter(|p| p.exists()); + let web_console = exe_dir + .as_ref() + .is_some_and(|d| d.join("web").join("web-run.cmd").exists()); + + // SAFETY: RegisterWindowMessageW with a static nul-terminated literal. + let taskbar_created = unsafe { RegisterWindowMessageW(w!("TaskbarCreated")) }; + APP.set(App { + hwnd: AtomicIsize::new(0), + status: Mutex::new(TrayStatus::Stopped), + poller: OnceLock::new(), + taskbar_created, + host_exe, + web_console, + web_port: args.web_port, + }) + .ok() + .expect("run() is called once"); + + // Hidden top-level window (NOT message-only — those never receive the TaskbarCreated + // broadcast, which is how the icon survives an Explorer restart). + // SAFETY: standard window-class registration + creation; the class name literal outlives the + // call, wndproc is a valid extern "system" fn, and the window is created on this thread which + // then runs the message loop. + let hwnd = unsafe { + let hinstance = GetModuleHandleW(None)?; + let class = WNDCLASSW { + lpfnWndProc: Some(wndproc), + hInstance: hinstance.into(), + lpszClassName: w!("PunktfunkTrayWindow"), + ..Default::default() + }; + if RegisterClassW(&class) == 0 { + anyhow::bail!("RegisterClassW failed: {:?}", GetLastError()); + } + CreateWindowExW( + WINDOW_EX_STYLE(0), + w!("PunktfunkTrayWindow"), + w!("punktfunk tray"), + WS_OVERLAPPED, + 0, + 0, + 0, + 0, + None, + None, + Some(hinstance.into()), + None, + )? + }; + app().hwnd.store(hwnd.0 as isize, Ordering::SeqCst); + + // First NIM_ADD retried across the logon race (the taskbar may not exist yet at sign-in). + let mut added = false; + for _ in 0..10 { + if update_icon(hwnd, true) { + added = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + if !added { + log("Shell_NotifyIconW(NIM_ADD) kept failing — no taskbar?"); + } + + // The poller owns all network/SCM I/O; it only posts a message here. + let poller = Poller::spawn( + args.mgmt_addr.clone(), + args.mgmt_port, + Box::new(move |st| { + *app().status.lock().unwrap() = st; + let hwnd = HWND(app().hwnd.load(Ordering::SeqCst) as *mut _); + // SAFETY: PostMessageW is documented thread-safe; a stale/destroyed hwnd fails + // harmlessly with an error we ignore. + unsafe { + let _ = PostMessageW(Some(hwnd), WMAPP_STATUS, WPARAM(0), LPARAM(0)); + } + }), + ); + let _ = app().poller.set(poller); + + // SAFETY: classic message pump on the window's owning thread. + unsafe { + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).into() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + Ok(()) +} + +/// `--quit`: ask a running instance (this session) to exit — used by the uninstaller before file +/// deletion. High-IL callers may message a medium-IL window (UIPI blocks only low→high). +fn quit_existing() -> anyhow::Result<()> { + // SAFETY: FindWindowW/PostMessageW on a class-name literal; both fail harmlessly when no + // instance is running. + unsafe { + if let Ok(hwnd) = FindWindowW(w!("PunktfunkTrayWindow"), PCWSTR::null()) { + let _ = PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0)); + } + } + Ok(()) +} + +/// Build/refresh the notify icon from the current status. Returns false when the shell rejected +/// the call (no taskbar yet). +fn update_icon(hwnd: HWND, add: bool) -> bool { + let status = app().status.lock().unwrap().clone(); + let mut nid = NOTIFYICONDATAW { + cbSize: std::mem::size_of::() as u32, + hWnd: hwnd, + uID: 1, + uFlags: NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP, + uCallbackMessage: WMAPP_NOTIFYCALLBACK, + ..Default::default() + }; + // SAFETY: LoadIconW by ordinal from this exe's embedded resources (build.rs); the ordinal is + // one of the ids compiled in, and a failure falls back to a null icon rather than UB. + nid.hIcon = unsafe { + LoadIconW( + Some(GetModuleHandleW(None).unwrap_or_default().into()), + PCWSTR(icon_ordinal(&status) as usize as *const u16), + ) + } + .unwrap_or(HICON(std::ptr::null_mut())); + // Tooltip: truncate to the szTip capacity (127 UTF-16 units + nul). + let tip = to_wide(&status.headline()); + let n = tip.len().min(nid.szTip.len() - 1); + nid.szTip[..n].copy_from_slice(&tip[..n]); + + // SAFETY: nid is fully initialized with a correct cbSize; NIM_* calls only read it. + unsafe { + if add { + if !Shell_NotifyIconW(NIM_ADD, &nid).as_bool() { + return false; + } + let mut v = nid; + v.Anonymous.uVersion = NOTIFYICON_VERSION_4; + let _ = Shell_NotifyIconW(NIM_SETVERSION, &v); + true + } else { + if !Shell_NotifyIconW(NIM_MODIFY, &nid).as_bool() { + // Icon vanished (Explorer crash we missed) — re-add. + return update_icon(hwnd, true); + } + true + } + } +} + +/// The right-click menu, rebuilt from the live status each time. +fn show_menu(hwnd: HWND) { + let status = app().status.lock().unwrap().clone(); + let running = matches!( + status, + TrayStatus::Running(_) | TrayStatus::Starting | TrayStatus::Degraded + ); + let startable = matches!(status, TrayStatus::Stopped | TrayStatus::Error(_)); + let can_control = app().host_exe.is_some(); + + // SAFETY: menu handle created and destroyed here; AppendMenuW copies the item strings, whose + // wide buffers outlive each call. TrackPopupMenuEx requires the foreground quirk handled + // below (SetForegroundWindow before, WM_NULL after) per the Shell_NotifyIcon docs. + unsafe { + let Ok(menu) = CreatePopupMenu() else { return }; + let add = |id: usize, text: &str, grayed: bool| { + let wide = to_wide(text); + let flags = if grayed { + MF_STRING | MF_GRAYED + } else { + MF_STRING + }; + let _ = AppendMenuW(menu, flags, id, PCWSTR(wide.as_ptr())); + }; + add(IDM_HEADER, &status.headline(), true); + let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); + if app().web_console { + add(IDM_OPEN_WEB, "Open web console", false); + let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0); + if status.pairing_attention() { + add(IDM_PAIRING, "Approve pairing request…", false); + } + let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); + } + if can_control { + if startable { + add(IDM_START, "Start host", false); + } + if running { + add(IDM_STOP, "Stop host", false); + add(IDM_RESTART, "Restart host", false); + } else if matches!(status, TrayStatus::Error(_)) { + add(IDM_RESTART, "Restart host", false); + } + } + add(IDM_LOGS, "Open logs folder", false); + let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); + add(IDM_EXIT, "Exit tray", false); + + let mut pt = Default::default(); + let _ = GetCursorPos(&mut pt); + let _ = SetForegroundWindow(hwnd); + let _ = TrackPopupMenuEx( + menu, + (TPM_RIGHTBUTTON | TPM_BOTTOMALIGN).0, + pt.x, + pt.y, + hwnd, + None, + ); + let _ = PostMessageW(Some(hwnd), WM_NULL, WPARAM(0), LPARAM(0)); + let _ = DestroyMenu(menu); + } +} + +/// `ShellExecuteW` "open" on a URL / folder. +fn shell_open(hwnd: HWND, target: &str) { + let wide = to_wide(target); + // SAFETY: all strings nul-terminated and live across the call. + unsafe { + ShellExecuteW( + Some(hwnd), + w!("open"), + PCWSTR(wide.as_ptr()), + PCWSTR::null(), + PCWSTR::null(), + SW_SHOWNORMAL, + ); + } +} + +/// One UAC prompt per service action: relaunch the host exe elevated with `service `. +/// A declined prompt (ERROR_CANCELLED) is deliberately ignored. +fn elevate_service(hwnd: HWND, verb: &str) { + let Some(exe) = app().host_exe.as_ref() else { + return; + }; + let exe_w = to_wide(&exe.to_string_lossy()); + let params = to_wide(&format!("service {verb}")); + // SAFETY: nul-terminated strings live across the call; "runas" spawns the elevated child + // (hidden console — the tray re-polls for the outcome instead of scraping its output). + unsafe { + ShellExecuteW( + Some(hwnd), + w!("runas"), + PCWSTR(exe_w.as_ptr()), + PCWSTR(params.as_ptr()), + PCWSTR::null(), + SW_HIDE, + ); + } + if let Some(p) = app().poller.get() { + p.poke(); + } +} + +fn open_web_console(hwnd: HWND) { + shell_open(hwnd, &format!("https://localhost:{}", app().web_port)); +} + +fn open_logs(hwnd: HWND) { + let Some(base) = std::env::var_os("ProgramData") else { + return; + }; + let dir = std::path::PathBuf::from(base) + .join("punktfunk") + .join("logs"); + shell_open(hwnd, &dir.to_string_lossy()); +} + +extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + let Some(app) = APP.get() else { + // SAFETY: pass-through for messages arriving before APP is set (CreateWindowExW sends + // WM_NCCREATE/WM_CREATE synchronously — APP is set before that, but stay defensive). + return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }; + }; + match msg { + WMAPP_STATUS => { + update_icon(hwnd, false); + LRESULT(0) + } + WMAPP_NOTIFYCALLBACK => { + // NOTIFYICON_VERSION_4: LOWORD(lParam) is the event. + match (lparam.0 as u32) & 0xffff { + WM_CONTEXTMENU => show_menu(hwnd), + x if x == NIN_SELECT || x == NIN_KEYSELECT => { + if app.web_console { + open_web_console(hwnd); + } else { + show_menu(hwnd); + } + } + _ => {} + } + LRESULT(0) + } + WM_COMMAND => { + match (wparam.0) & 0xffff { + IDM_OPEN_WEB => open_web_console(hwnd), + IDM_PAIRING => open_web_console(hwnd), + IDM_START => elevate_service(hwnd, "start"), + IDM_STOP => elevate_service(hwnd, "stop"), + IDM_RESTART => elevate_service(hwnd, "restart"), + IDM_LOGS => open_logs(hwnd), + // SAFETY: DestroyWindow on the wndproc's own window/thread. + IDM_EXIT => unsafe { + let _ = DestroyWindow(hwnd); + }, + _ => {} + } + LRESULT(0) + } + WM_CLOSE | WM_ENDSESSION => { + // SAFETY: as above — triggers WM_DESTROY below. + unsafe { + let _ = DestroyWindow(hwnd); + } + LRESULT(0) + } + WM_DESTROY => { + let nid = NOTIFYICONDATAW { + cbSize: std::mem::size_of::() as u32, + hWnd: hwnd, + uID: 1, + ..Default::default() + }; + // SAFETY: minimal, correctly sized nid; NIM_DELETE only reads hWnd/uID. + unsafe { + let _ = Shell_NotifyIconW(NIM_DELETE, &nid); + PostQuitMessage(0); + } + LRESULT(0) + } + m if m == app.taskbar_created => { + // Explorer restarted — the icon is gone; add it back. + update_icon(hwnd, true); + LRESULT(0) + } + // SAFETY: default handling for everything else. + _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }, + } +} diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 779fa77..22950c6 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -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" diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index 9a732f2..d1795bf 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -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" diff --git a/packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray-degraded.png b/packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray-degraded.png new file mode 100644 index 0000000000000000000000000000000000000000..c1328847d92a56ed1f9087a78fa2da14f047d36e GIT binary patch literal 473 zcmV;~0Ve*5P)Y(7%JBdh#4ldoIqk@Fa9fF`B>S%E?AfjV}i%_J42r7a>7e5xk z!NnniKOll%X&sLDO_MgQ`M7B>JX6SX&OPT1k)#Z#y7W;BAxpy^F3~tPhx#xEF5tgR z37}wpkWNVeE34QR6az(6$}&iSo+>>yT(-aSt<~1PXr=N=Lyvxx|SQqwB|;UODD2ln5>; zMM=Z*yX&sp*!!W%!Yj=d)@f{ng?Zwr!r}ld(>ia__GW{|M~k#vV$@h=^mfSTzQ(Aw z$Y?6(6)-Z1!NIOg6S*RpRYvdTe_nJ()03WDhgcRBE9DKZ(i(o`5ZiHuu6t^M z7Cp!*B8Bb8iL2#yrZfLhkW)ko+wbqxmpU9a(C-R4#dWcL%oBvZK2Q1wr!{>Ni+%?o P00000NkvXXu0mjfn;O$< literal 0 HcmV?d00001 diff --git a/packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray-error.png b/packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray-error.png new file mode 100644 index 0000000000000000000000000000000000000000..8dc5e4cb610adf944f698395a3cd85095f335753 GIT binary patch literal 485 zcmVX6Z~gI{SKj`s~sO>5HJq!*qkY_^M(k5kF%0}PuU0w-5w^%Gjs;Ep$0C{ zc$qqYf{`Gd5(k=JvCb(5GOtwhAq53b>8Wan2fJVSowZL|&3#bU<6y!(Lub%Mu}07L zKPKnL22TOiO{zj@Q9Lo4VxX1lUUJ2)Q)=h-y51tcD%ISiRmVItN!3Y=t{*C`a?G78 z5loPBlG^2WH-B<<|C@44uap>Hr*MyG=ZT?ki!HEB?XphWnLj%&&&`^n zag3T9cmdb%BnIh$Xj2@rJ`680X6hwg9GWEi5+e-FMT$7e5Mxok#I^!5qtGaV5h=J4 zo*`1$ekUnz)SNNzK~51VY~M}1JQg!E?fZ{{oFY=#etV}4X_X-c`duNXxGuJjc>>$l bXG(toqJU@g>eF-k00000NkvXXu0mjfrx?`v literal 0 HcmV?d00001 diff --git a/packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray-stopped.png b/packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray-stopped.png new file mode 100644 index 0000000000000000000000000000000000000000..1de90151e777416580e3b59adb4c64e4c14ef755 GIT binary patch literal 474 zcmV<00VV#4P);=ds6V5bg2=W0ZRQm|yGgD5D(sSX{A;ttR`zNC7iW zYC`B^+jbxZyIwV3fo^KMMuGm zMGta{NMZYN;;X{7m+91h6yy|YjsDF_xi1kuSQbaLoW97L(3ic>2h;udvLf^?9Ax)^m4 zMG%|>hb-<9{A%lPyl+TqT9f9cz3@yS&pG#;H$)IZoZS3-(nUn*_Ap7FVKAr#wc`Tz z%hU!GjCIl}aiE748=PVw^Gd}KQcwz%9;sDvXY(t+vGhrc*$?V^988#J7z{eqYV>sX zV{x)y0NmU3Pw8Lsc4D?DpNUr#GO0C>n)SKkz<(i+=^vpApm|kLZeSh67$K1IR z!30S!sat+~`6pMmzbQNOLgR@QiuQVX!-EsFO_D4>QCgWaM*!~GH!MvtkIdHct~)H5Zb(~y@Sj_``*biwM>OX^UZys}2) z88tuf0n)7u&}?f$QtD Zq`z01iIz(JRqg-)002ovPDHLkV1il1+lv4I literal 0 HcmV?d00001 diff --git a/packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray.png b/packaging/linux/icons/hicolor/22x22/apps/punktfunk-tray.png new file mode 100644 index 0000000000000000000000000000000000000000..81059ab272eae7d699e8fb5cac0f57d8e52a16e6 GIT binary patch literal 483 zcmV<90UZ8`P)Ml@z znF2sTv7Sze10$-~O2ihfedGtW$7d5O{WQ@&b`xpO6g z3DRCtxBTJmPp<8MQ+Dc&Mh4cYrAc!0!pLxo9k5K}xIcjfCQOD#Mbsx-8Uw)Z- z^#w{OGPR0>D4<0WgQGo@67dvu?Mzeu^U6PxSBn%+4nwX&Ebxlui4D6`FR4S%@w`16 zu<7jXR`F=!h~Rm6hxVT6e>(=f3(nkM%WMTX%cMI2>_v8YI5R{@!!mWyCS8QciZ z5GiawNQxU(X3Tq#Q$z~e_Y-%{jN6%x{6|4f5h-lHw$rlIVn}uUu8>n)7u&}?f$QtD ZrN51#X6N)ZYY+ec002ovPDHLkV1haM*q8tS literal 0 HcmV?d00001 diff --git a/packaging/linux/icons/hicolor/48x48/apps/punktfunk-tray-degraded.png b/packaging/linux/icons/hicolor/48x48/apps/punktfunk-tray-degraded.png new file mode 100644 index 0000000000000000000000000000000000000000..e2da7a99e9482cf6fca1a3b1fededdd8269fb520 GIT binary patch literal 856 zcmV-e1E>6nP)D~O?8TduJq0|7bCM#0@z!`K(Thb9)_9O&4vV4?tdN7|B2rK&6vPRoB81wY z^xzjR=1@Nn1N{eb^dhWawP|`7=WS=!$!4>=vzggV!G-6Nul+qc@65cDiHZML_*K66 z%cNM8s(?+&k8@~)1?^Bs2rdOv&^U#+Sui3Ah!|Nvb(|o$NGFrgVBy;%h8N2Ur7eIJ zg%>4^w89q^hvztHl}<;K08rE|U}+4me-so>+%Qa?>p5d-R z$y4m*#cgNl{vT4i^_x`Yw#HY0g|;Yd3gNtYu|qaL?|L}vJpw}4OIzb3z=FGCaL&s{ zzX0cJYhOg6Kr`u6$3ek4jpc5j0O!@(j;hh9CJEmT3(xuTp%*FvWu-s@$qF>%gb$hr z!z4y&uVusAal`X6YH#dDO5lgvNd+v~(s5-3;*J84V5x1(KK-g}c+SW7y-0!8dROnE zh6d>*6@XHTn$WPkhK0U*{yUjH)7oSDveBng?hTwB{QcR1L2P6 z5UHF)q;}yS<--2!zR$W8OS^!-<$zHEA!~NNFVNX3MBd&(jZJZJ6ZKslLQAg@#4)Ktm4vFwwN7zsxl z1y@J-yYm};H9tytjyNYo0jxuySh(%oENa5W1%4+;0O=4onk#rqrHb$zzQp=hBc-G8 zWbudhq+p#_DFFLKkbt3bE{OFHo0Ccb_`LxEp_JSME1*!qoh#vmOaz2-auSAz?;&W3 zxd7Cx_Bk*_BrhQXe+KmRDXb+D~O>_zZUWI+fh6jyC+tVN@zR;1=;L4-9IEqVxwLa~P+>P58Rp->b@ilT&S zP;yWam*!AW#6bUn9D5P=V6|y_80U>M%Ve`(vzggV!G-6Nul+qc@65cDfr0;5@OAb4 zFP&f#N*qN(uAf6|G-!i>1F#8*VDwY?A`ONG0TC_jr;cI-=V_!e8Z>;p$MAev8A%Hu zN#S`3EvfK%$>AARQl-;SBmm@93z!PSs~-i46E_H5W%=L)D5?_3B_x1$YW>Tf2QN`m zBjgChrG-sv@%|rt@8)kjJG0Th0yMNqs1q>b8{x6fGTFL?G%Y05O)@y6Dp{iH2u<`P>Q>Sbo-2 zd#E8n8gT`nl)@%7EU!VKFP&S*)2C}~CdQx45AHHFk?=;`@FEhhYxAKY?AU=>oY^yh zNnkG;1Vk{RY*<(ZpQ^3U$$Vkz2R1Sn{X+X;5p?PG@5S=-Ca!&Gb*)e@7MpnLWVtKc z$lxJ-V`v25HjX%4-ap)a-St_UU@8}Iw;V7kAY@L@bp$%Q_b{#=KZ@7SjQaxb!{=Ng zW?}-2>sMYo0ru@ah#!w;1B1JHI6UevA-aKy^5E`TNBEUfV?yB>ua0Hi<)kx#oJxTc z*@AOp(I+1=5*9xSwuj*fvEF1tQU*2c*8X4KS2~@RF)AiY zp`Jf2ULP$rXET>&e!iZL*V&~CSpVHh^uZWn=&rfI7qUytt^$NstHo6k16K}P`yvD6x@Pe02Vo-h~;dvh!c_vcoqa=DD2K6yD8ZsXEjwEcJoy-4oc zJiPyW`nuz@CPo!5;A}ZyR6yup@8e9MTi3SG@uw4%&*v8c@5ARDB5Gs;gfH56JHW;D z8|dBJBhTPYc6Z({mSEk$So!n&?@aiwC*PRD6OK~H&T^88KtiOz=ACEujai?(&q(y> zQ7}b>|M}zBOwBJ7oeklfz4Z{{~)g95+fCZLEAxO!#NUMhKnCy*u9xf*dD zg|oAg{iI->=P3aDgqwiGb1u;J51SKD0r>qAoa$M6weL zmgtciLNCLy7ivT$AY0wz^v2Lth6xXipldf8k%UV3^QBK1_{%7CWduA l^qDvY37MYQ0cp6^{6GDRfo!OET=@V1002ovPDHLkV1f^=k6{1+ literal 0 HcmV?d00001 diff --git a/packaging/linux/icons/hicolor/48x48/apps/punktfunk-tray-streaming.png b/packaging/linux/icons/hicolor/48x48/apps/punktfunk-tray-streaming.png new file mode 100644 index 0000000000000000000000000000000000000000..e5d1ddbc3b27305a235931ae6a16829608abb596 GIT binary patch literal 867 zcmV-p1DyPcP)D~O>_zZUWI@P5DBXjgXwleHDpGP03!<#KXwgHcAeQ!!L-ZnA@lY%jM@mHr z)u7~{BCh68QHp{7137vTRzN-wRG<mAa zYaaMX^wVC8hA&19&&sH=z8flm9&N`JFhxtp77>U#3P6mdwjuiTOQPW!Up}@&1=gOn z)E;U`kY-!~D5bCo4a;j#=&Kig;Hk6q4!N=Ah2aB+CKBF=8(u^Lc5Oa1ggr4ji!+CE zm<0BsNk9Z6%7%qy@S)ZYoy?aezhfhF#V@oU7D1O@|6Z)Ews8GjyJv-Zw$#FtXDU76 zMu&6w@<egvF17ts;DRe!Z*a`w7qB=L9c+R0!lJ=A4^_P1vZw?|2CqDg;I{6V6g8 zBRqpGvF_DK>L@%}{OLR?Sm$L5z&_z6pvjyIeEq}bBvSx>uSb9@C40{bNR+Ur3uqw~ z0j`{^nBn1j2DHRX0BTnG9Pkmz3W&g;0bP9xZHbdvg+fGh+HxQa|Q&z_z3^Xm5j zRTmsa64{9rOIYlN(91~dg{nyj=(Kz6+!(sbIN_lY^z23xk}&yxUUVb^&jV>mNIQ|G tAYr@D#IUZ%J`<-Pq4N_vB2}-N{|ElyRA!4mO-=v+002ovPDHLkV1n&7nL_{o literal 0 HcmV?d00001 diff --git a/packaging/linux/icons/hicolor/48x48/apps/punktfunk-tray.png b/packaging/linux/icons/hicolor/48x48/apps/punktfunk-tray.png new file mode 100644 index 0000000000000000000000000000000000000000..46c171de7a12171e9a4cec1daa0e365ac1c99253 GIT binary patch literal 866 zcmV-o1D*VdP)D~O?8Tdw1vQGFd+<<1aK%e1QgRWiQrHHH6g{j8LNSLN{5Xgd6bc3DNKu4P z4N4Cx;$jXJr5NZxkfRr24_2F|hjHFGvrM-8HJh2;6kK>N`P$#J^UlmW85;V3MPJvi z{L%>)p~_(s%s~#V(V#5?j=&`#f-y+pZ5j*<0wP-8PaUNQ&eO_eG-&u{pW*qkGO`vx zlEU*6T2|pplEX8ctV*Y)NB}6Q7O)hCS3e38CvFtF%JRV(V5<@^GZH{Mwf^PLgO{kS z5ln);IJe=5ofQGgRO#)_oc(aMuzHWIq%N-0tH_PjTBS3?@d~n9w zTCV_Owee3xqCh?EQ%6C;8MVcBpaA3D>rGjsQB4!R85N%K?Qcr6E3E*OQrv`w%Z`d1^Vqp&dd(|b~|&dU^leIiIelQ|do`iIR)rU3ljHUX}b+#M?*QNo=rquE>p zxN>q*hKKJN&=NBNs9EK6AVkCz5P?4fdioUF5@)jt#fa#%)3f#~ajJv;5^)$aqU zE;x*2vJ)$oaM%r@myy^D)w2@NY4_N@F?5w_!b2k%*o|f+Ve$RE=txAK2lA4Tb|T9` s!u6ktVO>vsCeA@Z=O=bT>OnRC7f9i*yXI=g&Hw-a07*qoM6N<$f;Pg7)Bpeg literal 0 HcmV?d00001 diff --git a/packaging/linux/io.unom.Punktfunk.Tray.desktop b/packaging/linux/io.unom.Punktfunk.Tray.desktop new file mode 100644 index 0000000..ed5d01d --- /dev/null +++ b/packaging/linux/io.unom.Punktfunk.Tray.desktop @@ -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; diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index f816be8..b000db8 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -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 diff --git a/packaging/windows/branding/punktfunk-tray-degraded.ico b/packaging/windows/branding/punktfunk-tray-degraded.ico new file mode 100644 index 0000000000000000000000000000000000000000..4ad91b282a4064b79f97dd86378b1c6638213911 GIT binary patch literal 2872 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 Nn?GnUX8gZ%^&d(--vs~w literal 0 HcmV?d00001 diff --git a/packaging/windows/branding/punktfunk-tray-error.ico b/packaging/windows/branding/punktfunk-tray-error.ico new file mode 100644 index 0000000000000000000000000000000000000000..332dd162ed60e3fe47bb593bec23ca11758717a6 GIT binary patch literal 2917 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 G`u_o=PzUw^ literal 0 HcmV?d00001 diff --git a/packaging/windows/pack-host-installer.ps1 b/packaging/windows/pack-host-installer.ps1 index 0982506..c04cc1c 100644 --- a/packaging/windows/pack-host-installer.ps1 +++ b/packaging/windows/pack-host-installer.ps1 @@ -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" } } diff --git a/scripts/gen-tray-icons.py b/scripts/gen-tray-icons.py new file mode 100644 index 0000000..7afa7c1 --- /dev/null +++ b/scripts/gen-tray-icons.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Generate the punktfunk-tray status icons (committed, like the other branding assets). + +Renders the brand mark — the two overlapping circles ("lens") from web's brand-mark.tsx, the +same geometry gen-branding.ps1 uses — with a status dot in the lower-right corner: + + running colored mark + green dot + stopped grayscale mark + gray dot + error colored mark + red dot + degraded colored mark + amber dot (starting / running-but-status-unreachable) + streaming colored mark + bright-violet dot + +Outputs (all checked in; re-run only when the brand or the palette changes): + packaging/windows/branding/punktfunk-tray-.ico 16/20/24/32/48 px PNG-entry icos + (Vista+ format, same as punktfunk.ico) + packaging/linux/icons/hicolor/{22x22,48x48}/apps/punktfunk-tray[-].png + (running is the unsuffixed base name) + +Pure stdlib (zlib PNG writer, analytic 4x-supersampled rasterizer) so it runs on any dev box — +no PIL/ImageMagick/librsvg needed. +""" + +import math +import struct +import zlib +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# Brand-mark geometry in its 1000-unit viewbox (brand-mark.tsx; mirrors gen-branding.ps1). +R = 194.41 +C1 = (403.037, 597.262) # light circle, behind +C2 = (597.8075, 402.8525) # deep circle, in front +BB_MIN = (C1[0] - R, C2[1] - R) +BB_MAX = (C2[0] + R, C1[1] + R) +MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2) +MARK_SPAN = BB_MAX[0] - BB_MIN[0] # the bbox is square + +COL_LIGHT = (0xA7, 0x9F, 0xF8) +COL_DEEP = (0x6C, 0x5B, 0xF3) +COL_HI = (0xD2, 0xC9, 0xFB) +RING = (0x1C, 0x15, 0x30) # dot outline, the brand tile background + +STATES = { + "running": {"dot": (0x2E, 0xCC, 0x71), "gray": False}, + "stopped": {"dot": (0x8A, 0x8A, 0x8A), "gray": True}, + "error": {"dot": (0xE7, 0x4C, 0x3C), "gray": False}, + "degraded": {"dot": (0xF0, 0xA0, 0x30), "gray": False}, + "streaming": {"dot": (0xB4, 0x4C, 0xF0), "gray": False}, +} + + +def luma(c): + y = round(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) + return (y, y, y) + + +def render(size, dot_rgb, gray, ss=4): + """RGBA rows, 4x supersampled: mark centered upper-left-ish, dot lower-right.""" + n = size * ss + mark_c = (0.44 * n, 0.44 * n) + scale = (0.82 * n) / MARK_SPAN + dot_c = (0.76 * n, 0.76 * n) + dot_r = 0.21 * n + ring_r = dot_r + max(0.055 * n, 1.0 * ss) + c_light = luma(COL_LIGHT) if gray else COL_LIGHT + c_deep = luma(COL_DEEP) if gray else COL_DEEP + c_hi = luma(COL_HI) if gray else COL_HI + c1 = (mark_c[0] + (C1[0] - MARK_CENTER[0]) * scale, mark_c[1] + (C1[1] - MARK_CENTER[1]) * scale) + c2 = (mark_c[0] + (C2[0] - MARK_CENTER[0]) * scale, mark_c[1] + (C2[1] - MARK_CENTER[1]) * scale) + r = R * scale + + rows = [] + for y in range(size): + row = bytearray() + for x in range(size): + # Premultiplied accumulation over the ss×ss sample grid (no fringe on the rim). + ar = ag = ab = aa = 0.0 + for sy in range(ss): + for sx in range(ss): + px = x * ss + sx + 0.5 + py = y * ss + sy + 0.5 + d1 = math.hypot(px - c1[0], py - c1[1]) + d2 = math.hypot(px - c2[0], py - c2[1]) + dd = math.hypot(px - dot_c[0], py - dot_c[1]) + col = None + if dd < dot_r: + col = dot_rgb + elif dd < ring_r: + col = RING + elif d1 < r and d2 < r: + col = c_hi + elif d2 < r: + col = c_deep + elif d1 < r: + col = c_light + if col is not None: + ar += col[0] + ag += col[1] + ab += col[2] + aa += 255.0 + samples = ss * ss + a = aa / samples + if a < 1.0: + row += b"\x00\x00\x00\x00" + else: + row += bytes( + (round(ar / aa * 255), round(ag / aa * 255), round(ab / aa * 255), round(a)) + ) + rows.append(bytes(row)) + return rows + + +def png_bytes(size, rows): + def chunk(tag, data): + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) + + ihdr = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0) + idat = zlib.compress(b"".join(b"\x00" + r for r in rows), 9) + return ( + b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"") + ) + + +def ico_bytes(pngs): + """PNG-entry .ico (Vista+; the format punktfunk.ico already uses).""" + header = struct.pack("