feat(tray): system-tray status icon for the host (Windows + Linux)
New crates/punktfunk-tray — a small per-user companion showing the host service state at a glance (running / stopped / starting / degraded / failed + the live session in the tooltip) with one-click actions: open web console, approve a pending pairing request, start/stop/restart, open logs. No more digging through logs to learn whether the service came back after a reboot or an update. Status is service-manager-FIRST (SCM / systemd user unit — a port squatter can never fake Running), then the new loopback-only unauthenticated GET /api/v1/local/summary (counts/booleans only; the mgmt token and cert.pem are SYSTEM/Admins-DACL'd on Windows, so a non-elevated tray cannot bearer-auth). Windows: windows_subsystem binary (a console exe in the Run key would flash a terminal at sign-in), Shell_NotifyIcon + hidden window, per-session single instance, TaskbarCreated re-add, --quit for the uninstaller; service actions elevate per click via ShellExecuteW "runas" onto the new `punktfunk-host service restart` (stop → wait Stopped → start). Linux: ksni/StatusNotifierItem over zbus, systemctl --user actions (no polkit), /etc/xdg/autostart entry whose --autostart self-gates to actual host users. Icons: scripts/gen-tray-icons.py (pure stdlib) renders the brand lens + status dot into committed .ico/hicolor assets; deb/rpm/arch ship binary+autostart+icons. Live-validated: Linux on the headless KDE session (SNI registration, state transitions, menu-driven start, dbusmenu layout); Windows on the RTX box (session-1 launch with no NIM_ADD failure, single instance, --quit, restart round-trip, summary loopback-200/LAN-401). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@@ -23,8 +23,9 @@
|
|||||||
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
|
# (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.
|
# 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
|
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
|
||||||
# .def with llvm-dlltool (no GPU/SDK at build time).
|
# 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
|
# - 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.
|
# 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).
|
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
||||||
@@ -37,6 +38,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'crates/punktfunk-host/**'
|
- 'crates/punktfunk-host/**'
|
||||||
- 'crates/punktfunk-core/**'
|
- 'crates/punktfunk-core/**'
|
||||||
|
- 'crates/punktfunk-tray/**'
|
||||||
- 'packaging/windows/**'
|
- 'packaging/windows/**'
|
||||||
- 'scripts/windows/**'
|
- 'scripts/windows/**'
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
@@ -109,21 +111,22 @@ jobs:
|
|||||||
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
Write-Output "host version $v"
|
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)
|
- name: Build (release, nvenc + amf-qsv)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
|
# 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
|
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
|
shell: pwsh
|
||||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
# 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)
|
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
(`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`),
|
**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-<index>`, and
|
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
|
||||||
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
|
`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
|
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
|
||||||
the remaining piece.)
|
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
|
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`;
|
**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-<i>`, 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
|
`--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
|
(`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;
|
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
|
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
|
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/`.
|
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
|
## What's left
|
||||||
|
|
||||||
@@ -447,6 +489,7 @@ crates/punktfunk-host/
|
|||||||
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
|
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
|
||||||
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
|
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
|
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/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
|
||||||
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
||||||
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · 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/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
|
||||||
clients/decky/ Steam Deck Decky plugin
|
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-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)
|
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)
|
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
|
||||||
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
||||||
|
|||||||
@@ -228,6 +228,67 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "async-recursion"
|
name = "async-recursion"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -239,6 +300,30 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -434,6 +519,19 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -2002,6 +2100,23 @@ dependencies = [
|
|||||||
"libloading",
|
"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]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -2561,6 +2676,12 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@@ -2599,6 +2720,17 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
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]]
|
[[package]]
|
||||||
name = "pipewire"
|
name = "pipewire"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
@@ -2654,6 +2786,20 @@ version = "0.3.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
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]]
|
[[package]]
|
||||||
name = "polyval"
|
name = "polyval"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2899,6 +3045,23 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"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]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -5221,8 +5384,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
|
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-broadcast",
|
"async-broadcast",
|
||||||
|
"async-executor",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-process",
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
|
"async-task",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"blocking",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"event-listener",
|
"event-listener",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ members = [
|
|||||||
"crates/punktfunk-core",
|
"crates/punktfunk-core",
|
||||||
"crates/punktfunk-host",
|
"crates/punktfunk-host",
|
||||||
"crates/punktfunk-host/vendor/usbip-sim",
|
"crates/punktfunk-host/vendor/usbip-sim",
|
||||||
|
"crates/punktfunk-tray",
|
||||||
"crates/pf-driver-proto",
|
"crates/pf-driver-proto",
|
||||||
"clients/probe",
|
"clients/probe",
|
||||||
"clients/linux",
|
"clients/linux",
|
||||||
|
|||||||
@@ -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": {
|
"/api/v1/logs": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"LogEntry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "One captured log event.",
|
"description": "One captured log event.",
|
||||||
|
|||||||
@@ -739,7 +739,7 @@ NOTES:
|
|||||||
"\nWINDOWS SERVICE (end-user deployment — replaces a manual launch):\n\
|
"\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 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 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\
|
\x20 config: %ProgramData%\\punktfunk\\host.env\n\
|
||||||
\nWINDOWS DIAGNOSTICS:\n\
|
\nWINDOWS DIAGNOSTICS:\n\
|
||||||
\x20 punktfunk-host hdr-p010-selftest GPU colour check for the PUNKTFUNK_HDR_SHADER_P010 path\n\
|
\x20 punktfunk-host hdr-p010-selftest GPU colour check for the PUNKTFUNK_HDR_SHADER_P010 path\n\
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(list_gpus))
|
.routes(routes!(list_gpus))
|
||||||
.routes(routes!(set_gpu_preference))
|
.routes(routes!(set_gpu_preference))
|
||||||
.routes(routes!(get_status))
|
.routes(routes!(get_status))
|
||||||
|
.routes(routes!(get_local_summary))
|
||||||
.routes(routes!(list_paired_clients))
|
.routes(routes!(list_paired_clients))
|
||||||
.routes(routes!(unpair_client))
|
.routes(routes!(unpair_client))
|
||||||
.routes(routes!(get_pairing_status))
|
.routes(routes!(get_pairing_status))
|
||||||
@@ -353,6 +354,30 @@ struct StreamInfo {
|
|||||||
codec: ApiCodec,
|
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<SessionInfo>,
|
||||||
|
/// 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.
|
/// A paired (certificate-pinned) Moonlight client.
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
struct PairedClient {
|
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
|
/// 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).
|
/// (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
|
/// ([`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.
|
/// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default.
|
||||||
async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response {
|
async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response {
|
||||||
if req.uri().path() == "/api/v1/health" {
|
if req.uri().path() == "/api/v1/health" {
|
||||||
return next.run(req).await; // liveness probe is always open
|
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::<PeerAddr>()
|
||||||
|
.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
|
// 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
|
// 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
|
// 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<Arc<MgmtState>>) -> Json<RuntimeStatus> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Arc<MgmtState>>) -> Json<LocalSummary> {
|
||||||
|
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
|
/// List paired clients
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -2031,6 +2116,61 @@ mod tests {
|
|||||||
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
|
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]
|
#[tokio::test]
|
||||||
async fn bearer_token_is_enforced() {
|
async fn bearer_token_is_enforced() {
|
||||||
let app = test_app(test_state(), Some("sekrit"));
|
let app = test_app(test_state(), Some("sekrit"));
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ pub fn main(args: &[String]) -> Result<()> {
|
|||||||
Some("uninstall") => uninstall(),
|
Some("uninstall") => uninstall(),
|
||||||
Some("start") => sc(&["start", SERVICE_NAME]),
|
Some("start") => sc(&["start", SERVICE_NAME]),
|
||||||
Some("stop") => sc(&["stop", SERVICE_NAME]),
|
Some("stop") => sc(&["stop", SERVICE_NAME]),
|
||||||
|
Some("restart") => restart(),
|
||||||
Some("status") => sc(&["query", SERVICE_NAME]),
|
Some("status") => sc(&["query", SERVICE_NAME]),
|
||||||
_ => {
|
_ => {
|
||||||
eprintln!(
|
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 uninstall stop + remove the service + firewall rules\n\
|
||||||
\x20 punktfunk-host service start start the service now\n\
|
\x20 punktfunk-host service start start the service now\n\
|
||||||
\x20 punktfunk-host service stop stop the service\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\
|
\x20 punktfunk-host service status query the service\n\n\
|
||||||
Config: %ProgramData%\\punktfunk\\host.env Logs: %ProgramData%\\punktfunk\\logs\\"
|
Config: %ProgramData%\\punktfunk\\host.env Logs: %ProgramData%\\punktfunk\\logs\\"
|
||||||
);
|
);
|
||||||
@@ -691,6 +693,40 @@ fn install(args: &[String]) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
fn uninstall() -> Result<()> {
|
||||||
use windows_service::service::ServiceAccess;
|
use windows_service::service::ServiceAccess;
|
||||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<OnceLock<Poller>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ksni::Icon> {
|
||||||
|
// 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<ksni::MenuItem<Self>> {
|
||||||
|
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<std::fs::File> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -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<Args> {
|
||||||
|
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 <IP>] [--mgmt-port <N>] [--web-port <N>]"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -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<SessionInfo>,
|
||||||
|
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<Summary>, 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<Shared>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Shared {
|
||||||
|
poked: Mutex<bool>,
|
||||||
|
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<dyn Fn(TrayStatus) + Send>,
|
||||||
|
) -> 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<dyn Fn(TrayStatus) + Send>,
|
||||||
|
) {
|
||||||
|
// 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<TrayStatus> = 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<Instant> = 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<Summary> {
|
||||||
|
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<std::path::PathBuf> {
|
||||||
|
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<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
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::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
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::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
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<Summary>, 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TrayStatus>,
|
||||||
|
poller: OnceLock<Poller>,
|
||||||
|
/// `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<std::path::PathBuf>,
|
||||||
|
/// The installer bundled the web console (detected via `{app}\web\web-run.cmd`).
|
||||||
|
web_console: bool,
|
||||||
|
web_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
static APP: OnceLock<App> = OnceLock::new();
|
||||||
|
|
||||||
|
fn app() -> &'static App {
|
||||||
|
APP.get().expect("APP initialized before window creation")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_wide(s: &str) -> Vec<u16> {
|
||||||
|
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::<NOTIFYICONDATAW>() 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 <verb>`.
|
||||||
|
/// 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::<NOTIFYICONDATAW>() 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) },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ build() {
|
|||||||
# The host's zero-copy FFI link-needs libcuda at build time; nvidia-utils provides it on an
|
# 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
|
# 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/
|
# 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),
|
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
|
||||||
# built AND run with bun.
|
# built AND run with bun.
|
||||||
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||||
@@ -95,6 +95,17 @@ package_punktfunk-host() {
|
|||||||
# connect). See the file's header comment.
|
# connect). See the file's header comment.
|
||||||
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Host.desktop" \
|
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Host.desktop" \
|
||||||
"$pkgdir/usr/share/applications/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
|
# 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-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"
|
install -Dm0755 "$R/scripts/headless/run-headless-sway.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-sway.sh"
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ if [ ! -x "$BIN" ]; then
|
|||||||
echo "==> building $PKG (release)"
|
echo "==> building $PKG (release)"
|
||||||
PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p "$PKG" --locked # stamp --version (build.rs)
|
PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p "$PKG" --locked # stamp --version (build.rs)
|
||||||
fi
|
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)"
|
STAGE="$(mktemp -d)"
|
||||||
trap 'rm -rf "$STAGE"' EXIT
|
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.
|
# 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 \
|
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
|
||||||
"$STAGE/usr/share/applications/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-kde.sh "$SHAREDIR/headless/run-headless-kde.sh"
|
||||||
install -Dm0755 scripts/headless/run-headless-sway.sh "$SHAREDIR/headless/run-headless-sway.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"
|
install -Dm0644 scripts/headless/kde-authorized "$SHAREDIR/headless/kde-authorized"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 473 B |
|
After Width: | Height: | Size: 485 B |
|
After Width: | Height: | Size: 474 B |
|
After Width: | Height: | Size: 483 B |
|
After Width: | Height: | Size: 483 B |
|
After Width: | Height: | Size: 856 B |
|
After Width: | Height: | Size: 868 B |
|
After Width: | Height: | Size: 859 B |
|
After Width: | Height: | Size: 867 B |
|
After Width: | Height: | Size: 866 B |
@@ -0,0 +1,15 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=punktfunk host status
|
||||||
|
Comment=Tray icon showing the punktfunk host service status
|
||||||
|
# --autostart exits silently unless this user actually runs a host (~/.config/punktfunk exists or
|
||||||
|
# the punktfunk-host user unit is enabled) — the package installs this for every desktop user.
|
||||||
|
Exec=/usr/bin/punktfunk-tray --autostart
|
||||||
|
Icon=punktfunk-tray
|
||||||
|
# Autostart-only: not a launcher entry (launch it from a terminal as `punktfunk-tray` if wanted).
|
||||||
|
NoDisplay=true
|
||||||
|
# KDE: start after plasmashell so the StatusNotifierWatcher is up (harmless elsewhere; the tray
|
||||||
|
# also waits for the watcher when started early).
|
||||||
|
X-KDE-autostart-after=panel
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
Categories=Network;Utility;
|
||||||
@@ -167,7 +167,7 @@ export RUSTUP_TOOLCHAIN=stable
|
|||||||
# Stamp the exact NVR into the binary for --version / mgmt /health provenance (build.rs reads it).
|
# Stamp the exact NVR into the binary for --version / mgmt /health provenance (build.rs reads it).
|
||||||
export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
|
export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
|
||||||
# --locked: reproducible from (commit + Cargo.lock), matching the .deb build path.
|
# --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}
|
%if %{with web}
|
||||||
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
|
# 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 \
|
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
|
||||||
%{buildroot}%{_datadir}/applications/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 ---
|
# --- client subpackage ---
|
||||||
install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client
|
install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client
|
||||||
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
|
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
|
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
||||||
%doc README.md design/implementation-plan.md packaging/README.md
|
%doc README.md design/implementation-plan.md packaging/README.md
|
||||||
%{_bindir}/punktfunk-host
|
%{_bindir}/punktfunk-host
|
||||||
|
%{_bindir}/punktfunk-tray
|
||||||
%{_udevrulesdir}/60-punktfunk.rules
|
%{_udevrulesdir}/60-punktfunk.rules
|
||||||
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||||
%{_userunitdir}/punktfunk-host.service
|
%{_userunitdir}/punktfunk-host.service
|
||||||
%{_userunitdir}/punktfunk-kde-session.service
|
%{_userunitdir}/punktfunk-kde-session.service
|
||||||
%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
|
%{_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
|
||||||
%dir /etc/gamescope-session-plus/sessions.d
|
%dir /etc/gamescope-session-plus/sessions.d
|
||||||
%config(noreplace) /etc/gamescope-session-plus/sessions.d/steam
|
%config(noreplace) /etc/gamescope-session-plus/sessions.d/steam
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -42,6 +42,8 @@ $here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|||||||
$iss = Join-Path $here 'punktfunk-host.iss'
|
$iss = Join-Path $here 'punktfunk-host.iss'
|
||||||
$exe = Join-Path $TargetDir 'punktfunk-host.exe'
|
$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?)" }
|
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
|
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||||
|
|
||||||
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) ---------------------------------------
|
# --- 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 $exe
|
||||||
|
Sign-File $trayExe
|
||||||
|
|
||||||
# --- resolve + validate the installer's source files ------------------------------------------
|
# --- resolve + validate the installer's source files ------------------------------------------
|
||||||
$repoRoot = (Resolve-Path (Join-Path $here '..\..')).Path
|
$repoRoot = (Resolve-Path (Join-Path $here '..\..')).Path
|
||||||
$hostEnvSrc = Join-Path $repoRoot 'scripts\windows\host.env.example'
|
$hostEnvSrc = Join-Path $repoRoot 'scripts\windows\host.env.example'
|
||||||
$readmeSrc = Join-Path $here 'README.md'
|
$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" }
|
if (-not (Test-Path -LiteralPath $p)) { throw "installer source file missing: $p" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-<state>.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[-<state>].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("<HHH", 0, 1, len(pngs))
|
||||||
|
entries = b""
|
||||||
|
blobs = b""
|
||||||
|
offset = len(header) + 16 * len(pngs)
|
||||||
|
for size, png in pngs:
|
||||||
|
entries += struct.pack(
|
||||||
|
"<BBBBHHII", size if size < 256 else 0, size if size < 256 else 0, 0, 0, 1, 32, len(png), offset
|
||||||
|
)
|
||||||
|
blobs += png
|
||||||
|
offset += len(png)
|
||||||
|
return header + entries + blobs
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ico_dir = REPO / "packaging/windows/branding"
|
||||||
|
for state, spec in STATES.items():
|
||||||
|
pngs = [
|
||||||
|
(s, png_bytes(s, render(s, spec["dot"], spec["gray"])))
|
||||||
|
for s in (16, 20, 24, 32, 48)
|
||||||
|
]
|
||||||
|
out = ico_dir / f"punktfunk-tray-{state}.ico"
|
||||||
|
out.write_bytes(ico_bytes(pngs))
|
||||||
|
print(f"wrote {out.relative_to(REPO)}")
|
||||||
|
|
||||||
|
for s in (22, 48):
|
||||||
|
name = "punktfunk-tray" if state == "running" else f"punktfunk-tray-{state}"
|
||||||
|
png_dir = REPO / f"packaging/linux/icons/hicolor/{s}x{s}/apps"
|
||||||
|
png_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out = png_dir / f"{name}.png"
|
||||||
|
out.write_bytes(png_bytes(s, render(s, spec["dot"], spec["gray"])))
|
||||||
|
print(f"wrote {out.relative_to(REPO)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||