Compare commits

..
244 Commits
Author SHA1 Message Date
enricobuehlerandClaude Fable 5 2e6cd0e235 feat(capture): stall attribution phases A.2+A.3 — micro-probes and DxgKrnl ETW name the class
apple / swift (push) Waiting to run
apple / screenshots (push) Blocked by required conditions
deb / build-publish (push) Waiting to run
deb / build-publish-host (push) Waiting to run
deb / build-publish-client-arm64 (push) Waiting to run
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Waiting to run
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Waiting to run
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Waiting to run
docker / builders-arm64cross (push) Blocked by required conditions
docker / apps (., web/Dockerfile, punktfunk-web) (push) Waiting to run
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Waiting to run
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Waiting to run
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Waiting to run
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Waiting to run
docker / deploy-docs (push) Blocked by required conditions
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Waiting to run
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Waiting to run
windows-drivers / probe-and-proto (push) Waiting to run
windows-drivers / driver-build (push) Waiting to run
windows-host / package (push) Waiting to run
windows-host / winget-source (push) Blocked by required conditions
android / android (push) In progress
arch / build-publish (push) In progress
ci / rust (push) In progress
ci / rust-arm64 (push) In progress
ci / web (push) In progress
ci / docs-site (push) In progress
Phase A.2: a refcounted micro-probe engine (fence round-trip per adapter,
DwmGetCompositionTimingInfo tick, watchdogged DwmFlush, Level-Zero
D3DKMTGetScanLine, CPU jitter sentinel) samples continuously on detached
sacrificial threads; each stall report reads the window back and the verdict
matrix folds it with the driver telemetry into a named class: ours-worker /
ours-delivery / CLASS-1 adapter freeze / CLASS-2 compositor blocked /
frame-generation / unattributed. The metronomic WARN carries the per-class
session tally.

Phase A.3: an event-id-filtered real-time ETW session on
Microsoft-Windows-DxgKrnl (QueryChildStatus 150/151, SetPowerState 154/155,
IndicateChildStatus 272, SetTimingsFromVidPn 430, DisplayDetectControl
1096/1097) rides every stall line as a DDI bracket summary — naming the
servicing call and its duration instead of 'below Windows'. Degrades to
etw=unavailable without admin; probes degrade per-leg (absence is stated,
never guessed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 23:44:35 +02:00
enricobuehlerandClaude Fable 5 b2168dae6a feat(vdisplay): stall attribution v1 — the driver testifies which leg lost the frames
Every IDD-push capture stall now carries a VERDICT instead of a hypothesis list.
The shared ring header grows a v2 telemetry tail (drain-loop heartbeat QPC,
last-acquire QPC, full-width offered counter) the driver stamps on every drain
pass; the host samples it between fresh frames and attributes each stall:
worker-stalled (our thread starved) / compose-silence (DWM composed nothing —
the disturbance is below capture) / delivery-leg (frames existed, our
publish/ring/consume lost them). The metronomic WARN prints the running tally,
so one pasted log line settles the Branch-1/Branch-2 fork of the
vdisplay-disturbance-immunity program per session, per box.

Both directions stay version-safe: the tail is gated on the HOST-stamped header
version (a v2 driver never writes past a v1 host's 64-byte layout — it maps the
whole section instead of a fixed 88 bytes), and a v2 host reads a zero heartbeat
as pre-telemetry driver, no verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 23:44:35 +02:00
enricobuehlerandClaude Opus 5 75e4f00e50 Merge branch 'chore/windows-rerender-semantics' into main
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Waiting to run
windows / build (aarch64-pc-windows-msvc) (push) Waiting to run
windows / build (x86_64-pc-windows-msvc) (push) Waiting to run
ci / docs-site (push) Failing after 31s
ci / web (push) Successful in 54s
apple / swift (push) Successful in 1m17s
release / apple (push) In progress
ci / rust-arm64 (push) Failing after 7m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 58s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 49s
deb / build-publish-client-arm64 (push) Successful in 9m13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
flatpak / build-publish (push) In progress
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
deb / build-publish (push) Successful in 15m0s
android / android (push) Canceled after 15m44s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 15m46s
ci / rust (push) Canceled after 15m45s
deb / build-publish-host (push) Canceled after 15m12s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 44s
windows-host / package (push) Canceled after 15m35s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) In progress
Windows 11 tray theming + per-connect device-name announcement, and the
pairing approve button no longer escapes the canvas on portrait phones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 23:27:36 +02:00
enricobuehlerandClaude Fable 5 a86b4c18ee feat(ci): arch gets its builder image too — the last per-run gigabyte, and sccache through makepkg
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
ci / docs-site (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m3s
docker / deploy-docs (push) Successful in 30s
docker / builders-arm64cross (push) Successful in 17s
ci / web (push) Successful in 3m1s
ci / rust-arm64 (push) Successful in 10m7s
ci / rust (push) Successful in 13m56s
arch / build-publish (push) Successful in 12m30s
The arch job was the one still paying full freight every run: ~1 GB of
pacman across its two install steps (never cached — container layers die
with the job) and cold cargo builds (arch was skipped in the sccache
rollout). punktfunk-arch-ci bakes base-devel + both makepkg legs' deps +
bun + node + sccache; the in-job installs become --needed no-op guards for
the one push where :latest lags. Rolling-release note in the Dockerfile:
packages now build against the image's snapshot, the same staleness the
gamescope cache already embraces, re-snapshotted on any ci/ edit.

sccache reaches makepkg by crossing the sudo boundary explicitly —
env_reset strips ambient env, so the wrapper env rides the existing
`sudo -u builder env ...` list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 22:49:00 +02:00
enricobuehlerandClaude Fable 5 43a9cf741b fix(client/android): the build resolves cargo from CARGO_HOME before guessing ~/.cargo
ci / web (push) Successful in 2m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m3s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / docs-site (push) Successful in 3m27s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 24s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / builders-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 49s
ci / rust-arm64 (push) Successful in 11m57s
android / android (push) Successful in 15m54s
ci / rust (push) Successful in 18m7s
The kts resolves cargo by ABSOLUTE path on purpose (a GUI Android Studio
launch has no ~/.cargo/bin on PATH), but user.home is the wrong anchor in
the CI image, where the shared toolchain lives at CARGO_HOME=/usr/local/cargo
— gradle died starting /root/.cargo/bin/cargo. CARGO_HOME/bin is where
rustup puts binaries whenever the variable is set, so it wins; the
~/.cargo fallback keeps GUI launches working.

Also: android.yml's path filter learns ci/android-ci.Dockerfile — an image
change must exercise its consumer instead of needing a manual rerun to
prove itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:47:07 +02:00
enricobuehlerandClaude Fable 5 12b0ce9b2d fix(ci): the android image learns node, and msix finally builds the CLI it packs
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
ci / docs-site (push) Successful in 1m6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m26s
ci / web (push) Successful in 1m48s
ci / rust (push) Canceled after 2m12s
ci / rust-arm64 (push) Canceled after 2m12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m8s
docker / builders-arm64cross (push) Successful in 1m33s
docker / deploy-docs (push) Successful in 1m30s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m36s
Two first-run discoveries. actions/checkout (and every JS action) execs
`node` inside the job container — the android-ci image didn't ship it, so
the job died at checkout with exit 127 (flatpak.yml documents the same
lesson for fedora:43); a trailing layer keeps the fat SDK/NDK layers
cache-valid. And windows-msix has been red since bf981027 required
punktfunk.exe in the package without adding punktfunk-cli to the build —
the same gap 90c84ef4 already closed for deb, now closed here (rpm and
arch already build it; the CLI has no features, so the arm64 leg's
--no-default-features changes nothing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:36:57 +02:00
enricobuehlerandClaude Fable 5 a420855b5a feat(tray): the Windows tray dresses for Windows 11 and announces connects
The menu was a stock Win32 popup: light-mode on a dark taskbar,
DPI-virtualized (blurry on every scaled laptop), no icons. Now the
process opts into the system dark mode via the uxtheme ordinals
(135/136 - the same undocumented calls Explorer/PowerToys/Notepad++
make; menus never got a documented opt-in, and there is no WinUI tray
API to move to), a PerMonitorV2 manifest makes menu and icon crisp, the
multi-size .ico serves the DPI-correct frame, and items carry Segoe
Fluent glyph bitmaps - with the UAC shield on the elevated service
actions, per Explorer's convention. Rounded corners come free on
Windows 11. Everything degrades gracefully: missing ordinals mean the
classic light menu, a missing icon font means no glyphs.

New: a connect toast on the idle-to-streaming edge - title "<device>
connected" (from the summary's new client_name), body the mode
("Streaming 2560x1440 @ 120 fps") - via NIF_INFO, which Windows 11
renders as a native toast. The tray tags itself with the AUMID
unom.punktfunk.tray and the installer registers it under
Classes\AppUserModelId (DisplayName "Punktfunk" + the brand icon), so
the toast is attributed to Punktfunk with the logo. Unregistered dev
runs degrade to generic attribution, older hosts to a nameless title;
a tray started mid-session never fires a stale toast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:32:50 +02:00
enricobuehlerandClaude Fable 5 9b2404a580 feat(core): every connect introduces the device by name
The wire always had Hello.name and the host always honored it - but the
connect path hardcoded None (only the PIN-pairing ceremony sent a name),
so every no-PIN "request access" knock surfaced as the fingerprint
placeholder "device abcd1234", and approving one without retyping a
name persisted that placeholder into the trust store forever.

NativeClient::connect now takes the device name. The session workers
and the probe connects pass trust::device_name() (the hostname), the C
ABI defaults to the same without a signature change (an ex10 variant
can make it explicit if an embedder wants a custom label), and Android
threads Build.MODEL through nativeConnect - the same convention its
pairing dialogs already use for nativePair.

The host, in turn, resolves the streaming client's display name (trust
store first, so an approval-time rename wins; else the sanitized Hello
name) and exposes it as client_name in GET /api/v1/local/summary for
the tray's connect toast - a deliberate, documented loosening of that
route's "no device names" contract, in the local user's favor: it tells
them who is on their machine. A paired-but-idle device's name still
never appears, which the mgmt tests now pin explicitly. openapi.json,
its docs-site copy, and the SDK bindings regenerate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:32:50 +02:00
enricobuehlerandClaude Fable 5 bf9d101be8 fix(web): the approve button stops hiding off-canvas on portrait phones
The pending-devices table's four p-card cells overflow a 360-430 px
viewport, and the actions cell - Approve - ended up past the right edge
of an overflow-auto wrapper inside the page's overflow-x-hidden column:
the button existed but could not be seen or reached (landscape is wide
enough, which is why it "worked" there). The name cell now flexes and
truncates, and the fingerprint/age columns collapse into a sub-line
under the name on narrow screens instead of widening the row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:32:49 +02:00
enricobuehlerandClaude Fable 5 171f08184f fix(ci/android): cargo-ndk v4 refuses direct invocation — probe it as the subcommand it is
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m45s
docker / builders-arm64cross (push) Successful in 30s
docker / deploy-docs (push) Successful in 38s
ci / rust (push) Canceled after 3m42s
ci / web (push) Successful in 2m29s
ci / docs-site (push) Successful in 2m45s
apple / swift (push) Successful in 6m46s
android / android (push) Failing after 3m54s
ci / rust-arm64 (push) Canceled after 6m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m35s
release / apple (push) Successful in 35m40s
apple / screenshots (push) Successful in 20m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:30:46 +02:00
enricobuehlerandClaude Fable 5 395daa8e98 fix(packaging/rpm): the CLI's %files entry had wandered into %install
bf981027 added the punktfunk CLI to the spec, but its `%{_bindir}/punktfunk`
files entry landed one section too early — inside %install, where rpm's
shell dutifully executed /usr/bin/punktfunk (which doesn't exist on a
builder) and killed both rpm legs at %install ever since. Move it where it
was headed: out of the script, into %files client — which was missing it,
so the fixed %install would otherwise have died again on an unpackaged
/usr/bin/punktfunk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:30:07 +02:00
enricobuehlerandClaude Fable 5 9cda05dffb feat(ci): android gets a baked builder image; apple gets sccache and a pinned DerivedData
Android was the last job still downloading its world every run: ~3 GB of
SDK/NDK/CMake from Google, a from-source cargo-ndk build, and one monolithic
cache key where a gradle edit invalidated the cargo registry and the Rust
target/ with it. punktfunk-android-ci (content-keyed, LAN registry) bakes
JDK 21 + SDK + NDK + cargo-ndk + sccache; the workflow shrinks to checkout →
two caches → gradle. The cargo-home cache joins the fleet-wide namespace
(same lockfile, same layout — it was the same bytes under a private key),
gradle gets its own key shared with android-screenshots (which stored the
identical content under a second name), and target/ leaves the cache —
sccache covers the three ABI builds now.

Apple: sccache (self-healing ~/.local/bin install + already provisioned on
the mini) covers every cargo invocation build-xcframework.sh makes across
the swift job, the screenshots job and release.yml — three jobs that each
recompiled the same core. screenshots.sh learns PF_SHOT_DERIVED_DATA so CI
pins one stable DerivedData root instead of cold-building into two throwaway
mktemp trees per run (release.yml's disease, same cure), and the Simulators
get shut down after capture (the 846-leaked-sims lesson).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:20:59 +02:00
enricobuehlerandClaude Fable 5 fcdb4d147d feat(ci): sccache everywhere a Rust job runs — one warm compile cache for the whole fleet
audit / bun-audit (plugin-kit) (push) Successful in 25s
audit / bun-audit (sdk) (push) Successful in 25s
audit / bun-audit (web) (push) Successful in 37s
audit / docs-site-audit (push) Successful in 36s
audit / pnpm-audit (push) Successful in 29s
audit / cargo-audit (push) Successful in 2m57s
decky / build-publish (push) Successful in 43s
deb / build-publish-client-arm64 (push) Failing after 1m9s
deb / build-publish-host (push) Failing after 1m16s
ci / rust-arm64 (push) Failing after 1m35s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m11s
apple / swift (push) Successful in 5m32s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m3s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 2m53s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3m30s
audit / license-gate (push) Successful in 7m50s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m38s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9m43s
flatpak / build-publish (push) Successful in 10m55s
android / android (push) Successful in 21m25s
windows-host / package (push) Successful in 20m6s
windows-host / winget-source (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 13m57s
docker / deploy-docs (push) Successful in 57s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13m36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9m32s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 3m20s
deb / build-publish (push) Successful in 16m37s
docker / builders-arm64cross (push) Successful in 2m27s
apple / screenshots (push) Successful in 21m3s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 3m12s
ci / rust (push) Canceled after 10m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m42s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 7m29s
arch / build-publish (push) Successful in 15m41s
Backend = the existing RustFS (storage.unom.io, S3, region home-central;
LAN-pinned to home-central's address by ci-core's unbound so cache traffic
never hairpins the router). Repo secrets SCCACHE_ACCESS_KEY_ID/SECRET carry a
keypair scoped to the unom-ci-sccache bucket; keys embed compiler hash +
target + flags, so the Ubuntu, Fedora, cross-arm64 and MSVC universes share
one bucket without ever colliding.

Wired: ci (rust, rust-arm64), deb (all three), rpm, bench,
linux-client-screenshots, windows, windows-msix, windows-host.
CARGO_INCREMENTAL=0 alongside (sccache and incremental are mutually
exclusive, and incremental artifacts are what bloated the persistent Windows
target dirs anyway). The binary is baked into the builder images; a per-job
ensure-step (same pattern as the GTK4 packages step) keeps jobs green while
the running :latest predates the bake, and ensure-windows-toolchain.ps1
self-provisions sccache.exe on the Windows runner. windows-drivers stays
unwrapped (wdk-build owns its build env), arch/android/apple are follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:04:05 +02:00
enricobuehlerandClaude Fable 5 fcdb90c39b feat(ci): builder images move to the LAN registry, content-keyed; fan-out scoped by paths
The five builder images now live on home-ci-core's LAN registry
(192.168.1.58:5010) under content keys — a hash of the ci/ tree (+
rust-toolchain.toml for the cross image). docker.yml builds one only when its
key has no manifest yet, so a push that doesn't touch ci/ costs a curl per
image instead of seven WAN pushes and a set of per-SHA tags that no plain
prune could ever reclaim. Releases pin builders by copying the key manifest
to a vX.Y.Z tag via the registry API — no rebuild, no bytes moved.

Around that: deb/rpm/arch/android/apple/decky get path filters so docs-only
pushes stop lighting up the whole fleet (branch pushes only — tag runs match
tags:, as flatpak/windows-msix releases have proven for months); the
report-only bench job moves to bench.yml (nightly + dispatch) and stops
occupying a fleet slot per push; flatpak caches its Flathub runtimes and
builder state instead of re-downloading multi-GB every run; rpm's cargo
registry cache gets its own key namespace instead of sharing the Ubuntu
jobs'; audit caches cargo bin+registry rather than the whole toolchain dir;
docker-prune.sh loses the local act-cache cap/burst-clear (the cache is
central now — deleting it under disk pressure was how runner-2 ended up
cold-building everything) and gains a leaked-network prune.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:04:05 +02:00
enricobuehlerandClaude Opus 5 90c84ef4bc fix(packaging/deb): build the CLI the client .deb installs
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m12s
apple / swift (push) Successful in 5m23s
arch / build-publish (push) Failing after 5m59s
ci / bench (push) Failing after 6m2s
decky / build-publish (push) Successful in 41s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 39s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
ci / rust-arm64 (push) Successful in 10m24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 12s
docker / build-push-arm64cross (push) Skipped
docker / deploy-docs (push) Skipped
deb / build-publish-client-arm64 (push) Failing after 4m53s
android / android (push) Failing after 11m21s
deb / build-publish (push) Successful in 10m27s
deb / build-publish-host (push) Successful in 12m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 11m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 13m19s
ci / rust (push) Successful in 22m37s
apple / screenshots (push) Successful in 21m5s
With the client crates compiling again, deb got as far as "Build .debs" and died
on `install: No such file or directory` — a confusing way to report that
punktfunk-cli was never built.

Two halves of the same omission, from when the `punktfunk` CLI was added. deb.yml
pre-builds `-p punktfunk-client-linux -p punktfunk-client-session` and stops
there, while build-client-deb.sh installs THREE binaries. Its build-if-missing
guard tested only the first two, so the pre-built pair satisfied it, the fallback
build (which does list punktfunk-cli) was skipped, and the install of a binary
nobody had built failed.

deb.yml now builds all three, and the guard tests every binary it goes on to
install rather than a subset — either change alone fixes it; both together mean
the script is correct however it is called. That asymmetry is also why the arm64
leg passed throughout: it pre-builds nothing, so its guard always fired and built
all three.

arch was already right (`-p punktfunk-cli` in its PKGBUILD, installed at line 255)
and the rpm spec ships no CLI, so this was deb-only.

Verified: guard truth-tabled (skip when all three present, BUILD when the CLI is
missing, BUILD when nothing is), shellcheck clean, and punktfunk-cli builds to
target/release/punktfunk on a real Linux box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:02:36 +02:00
enricobuehlerandClaude Opus 5 ab6d7727d3 fix(ci/runner): the disk guard has to fire before the disk is tight, not after
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 2m23s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
ci / bench (push) Successful in 6m45s
ci / rust-arm64 (push) Successful in 10m32s
ci / rust (push) Failing after 10m39s
decky / build-publish (push) Successful in 38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 22s
docker / build-push-arm64cross (push) Successful in 23s
android / android (push) Successful in 12m39s
docker / deploy-docs (push) Successful in 1m18s
apple / swift (push) Successful in 4m56s
arch / build-publish (push) Successful in 20m44s
apple / screenshots (push) Successful in 22m0s
deb / build-publish-host (push) Successful in 10m47s
deb / build-publish-client-arm64 (push) Successful in 7m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m27s
deb / build-publish (push) Failing after 6m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m40s
Measured over six hours after the last change: zero burst clears fired, while deb
still died of ENOSPC between polls. Both 30 min and 10 min lost the same race —
three concurrent Rust builds fill the disk and drain it again inside the interval,
so every poll landed on a healthy df and the guard concluded all was well.

Two changes, both aimed at that gap rather than at the symptom. The interval goes
to 2 min: the script is a few docker calls and no-ops in about a second, so
sampling five times more often costs nothing worth counting. And MIN_FREE_GB goes
45 -> 60, because the clear only reclaims idle images (~18 G measured) while three
jobs can eat the remainder inside one interval — a guard that waits for "tight"
has already lost. It has to act while there is still room to act in.

This narrows the window; it does not close it. With a 43 G image baseline on a
172 G disk, three concurrent heavy jobs are working inside ~129 G and can exceed
it. Sub-interval spikes are a polling problem, and the honest fixes are fewer
concurrent replicas or more disk.

Deployed to home-runner-1; deployed md5 matches the repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:58:32 +02:00
enricobuehlerandClaude Fable 5 7fc2775fad chore: the third-party notices catch up with the rebase
audit / bun-audit (sdk) (push) Successful in 20s
audit / bun-audit (plugin-kit) (push) Successful in 20s
audit / bun-audit (web) (push) Successful in 21s
audit / pnpm-audit (push) Successful in 9s
audit / docs-site-audit (push) Successful in 1m16s
audit / cargo-audit (push) Successful in 3m51s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 2m54s
ci / web (push) Successful in 3m3s
ci / docs-site (push) Successful in 1m51s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m55s
audit / license-gate (push) Successful in 7m7s
arch / build-publish (push) Failing after 7m56s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m47s
apple / swift (push) Successful in 5m44s
deb / build-publish (push) Failing after 5m39s
ci / rust-arm64 (push) Successful in 10m45s
ci / bench (push) Successful in 6m56s
android / android (push) Failing after 11m37s
decky / build-publish (push) Successful in 27s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 8m57s
deb / build-publish-host (push) Successful in 11m13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m55s
docker / deploy-docs (push) Successful in 59s
flatpak / build-publish (push) Failing after 9m41s
windows-host / package (push) Successful in 13m44s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 22m43s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 4m51s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 12m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m39s
release / apple (push) Successful in 27m44s
Regenerated after rebasing the Windows-client series onto main: the union of the bumped
windows-rs family and main''s os-icons vendored marks, with the conflict the generated
file picked up mid-rebase resolved by regeneration — the only honest edit for a file the
generator owns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:34:22 +02:00
enricobuehlerandClaude Fable 5 6edd7c3377 feat(client/windows): the tile menu grows real submenus
The flyout CAN nest at the current reactor rev (the "no submenus" comment predated the
bump), so the per-profile families move into MenuFlyoutSubItems: one "Connect with"
(bare profile names + Default settings as leaves) and one "Pin tiles" (verb-prefixed
leaves). The top level stays a fixed handful whatever the catalog grows to. The backend
wires submenu clicks recursively and reports LEAF text, so the connect-with leaves are
matched as the fall-through arm against the catalog, and the pin leaves keep their
prefix — that prefix is what tells the two families apart in the shared callback.
UIA-verified through the nesting: Pin tiles -> Pin tile: Work persists and the pinned
tile appears in the same gesture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:33:11 +02:00
enricobuehlerandClaude Fable 5 a9d3ca2fb1 feat(client/windows): the sheets learn to be dismissed, and the profile sheet says Save
Escape closes every overlay — the profile sheet, the host editor, the add-host modal —
and so does a tap on the scrim. The scrim-tap needed one trick: WinUI bubbles `Tapped`
out of the card into the scrim (reactor cannot mark it handled), so the card raises a
shared flag first and the scrim's handler swallows exactly that tap; only a genuine
outside tap dismisses. The profile sheet's dismiss button reads Save (with the Save
glyph) instead of Close — every field commits as you type, so Save is the promise the
button already kept, and a sheet full of edits wants a verb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:33:11 +02:00
enricobuehlerandClaude Fable 5 9614e1a92e fix(client/windows): the switcher becomes what it pretended to be — one native control
The fused combo-plus-pencil was three controls squeezed into a wrapper, and hover gave the
trick away (each kept its own radius and hover fill; the pencil clipped). The honest
verdict: WinUI can build a real input group — CornerRadius is per-corner on every control —
but windows-reactor exposes only a uniform radius on Border, so the fake was never going
to hold. The native answer was next to it all along: the switcher is now ONE
DropDownButton — its label is the scope in play, its menu holds Default settings, the
profiles, "New profile…" and "Edit …" — with one hover state and no seams, and the pencil
folded into the menu. Retired with the ComboBox: the items/selected_index remount hazard
(a button label is one plain prop) and the scope-sentinel id.

UIA-verified: menu -> Work applies the scope (the section repaints, markers included),
menu -> Edit opens the sheet, Close closes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:33:11 +02:00
enricobuehlerandClaude Fable 5 6703be377f feat(client/windows): pinning moves to the tile menu, and the chrome quiets down
Pin/unpin lives in the tile's "…" menu now (review decision, reversing the editor-owns-it
rule): one flat prefix-matched entry per profile — "Pin tile: X" / "Unpin tile: X" —
beside Copy link and Create shortcut, the other tile-shaped actions. The write is paired
with a new `hosts_rev` bump (the hosts-page mirror of `settings_rev`), so the pinned tile
appears — or vanishes — in the same gesture instead of on the next discovery tick.
UIA-verified: menu -> Pin tile: Work -> the pin is in the store and the second tile is on
the grid immediately.

The rest is the review list:
* Both sheets (host editor, profile) put their content in a scroll_view — a window shorter
  than the card scrolls it instead of clipping the bottom controls.
* The home header keeps ONE labelled action: Add host, in accent. Console/Shortcuts/
  Settings drop to icons with tooltips — four written-out buttons read as four competing
  calls to action, and icon-only removes the compact-width special case too.
* The profile switcher becomes one combined element: combo + pencil (icon only, the label
  is gone) inside a shared control-look wrapper — stock 4-epx radius, CardStroke outline,
  the pencil a borderless segment. In the defaults scope the bare combo stands alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:33:11 +02:00
enricobuehlerandClaude Fable 5 8783f11e90 fix(client/windows): pinning works where you can see it, and the rows learn one skeleton
"Can't pin hosts with a certain profile" — live diagnosis found the pin switches WORKED
but sat below the fold: the host editor rendered as an in-grid tile, its grid cell parked
mid-page, and on an ordinary window the pin section's visible rect was a 9-px sliver a
mouse can't hit (UIA's pattern-toggle, which bypasses hit-testing, persisted fine — that
is what isolated it). The editor is now a centred sheet — scrim + card, titled with the
host, Save/Cancel right-aligned — in a stable overlay slot beside the add modal and the
forget dialog. Verified end-to-end with a real mouse click: toggle flips, the pin lands in
client-known-hosts.json, and the pinned tile is on the grid after Save. Pin toggles also
log now — this hunt was blind until they did.

The settings rows learn one skeleton, label / (marker) / input / caption, per review:
* The Overridden capsule ("Overridden │ Reset", one tinted pill, whole-pill tap target)
  sits BETWEEN the row's label and its input, left-aligned — not trailing controls of
  wildly different widths, not mixed into the caption.
* Labels move out of the widgets (`.header` is gone from the row builders) into the row
  itself — a widget-embedded header allows nothing between label and box, and uniform
  6-epx row spacing needs the row to own all four lines.
* The scope bar's combo and Edit button share an explicit 36-epx height, and the bar shows
  the active profile's colour as a 12-epx chip beside the combo (dropdown items are plain
  strings in this toolkit, so the chip cannot ride inside the list).
* The sheet's colour swatches size the disc itself, not its child — they rendered as
  squashed ovals under the sheet's layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:33:10 +02:00
enricobuehlerandClaude Fable 5 ba7667ee2b fix(client/windows): dialogs stop panicking the shell, and the chrome stops shouting
Deleting a profile reliably killed the app with E_BOUNDS ("Daten außerhalb des gültigen
Bereichs"): a ContentDialog is a PHANTOM child in the reactor backend — tracked logically,
never attached to the panel — and the reconciler unmounts a child before removing it, so
by the time `remove_child` runs the dialog's handle is gone, the backend no longer
recognises it as phantom, and it RemoveAt()s a visual child that never existed. (The third
upstream windows-reactor bug this client documents.) Both confirmation dialogs — delete
profile, forget host — are now ALWAYS MOUNTED with `is_open` doing the arming, and both
live in STABLE overlay slots (settings: [nav, sheet slot, dialog]; hosts: [page, add-modal
slot, dialog], each closed slot a same-kind background-less Border) so no pass ever
removes or repositions a dialog. UIA-verified: create profile -> delete -> confirm, the
shell survives.

The rest is the review feedback:
* A group with no fields renders NOTHING — Decoding and Library showed a heading over an
  empty card in profile scope (device facts, never per profile).
* The override marker leaves the caption: a small "Overridden" chip and the Reset button
  sit beside the control, bottom-aligned; the description below stays a description,
  identical in both states.
* Host tiles stop wearing three badges: Paired is the resting state and earns no chip
  (a chip is for a decision — Trusted/PIN/Open), and the bound profile becomes a small
  dot in its accent colour plus the name in caption text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:33:10 +02:00
enricobuehlerandClaude Fable 5 7bbee0eeb6 feat(client/windows): the profile selector settles above the settings surface
Out of the NavigationView for good. The pane footer clipped in the compact rail, the
clip-aware monogram variant read as broken, and reactor offers no pane-opened event to
adapt on — so the switcher is now a slim bar riding an Auto grid row above the nav: an
"Editing" label, the scope combo, and Edit profile… (with its glyph), each vertically
centred against the combo and sharing the content column's 24/28 page margins. Visible
from every section at every width, in no one's way, and the nav below keeps its Star row
so the surface still fills the window. The sheet drops its embedded scope combo — the bar
owns the choice now — and opens only with a profile in scope.

UIA-verified live: scope switch from the bar repaints in place (marker 5 -> 6), the sheet
opens from the bar and closes, Default scope restores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:50 +02:00
enricobuehlerandClaude Fable 5 69743e3a09 feat(client/windows): the profile surfaces get their icons
Reset carries Undo, Edit profile carries Edit, Duplicate/Delete/Close carry Copy, Delete
and Accept — the icon support the bumped reactor added (#4736), applied where a glyph
disambiguates faster than a word. The rest of the shell already had its icons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:50 +02:00
enricobuehlerandClaude Fable 5 e8eb803a3f feat(client/windows): 4:4:4 reaches Windows, and the Overridden marker stops moving the page
The GTK client grew its "Full chroma (4:4:4)" switch in the same commit that fixed its
override marker; Windows got neither until now. The row lands in the Quality group under
HDR with the GTK wording verbatim (same setting, same constraints), a full overridable
profile row — the core overlay and the session binary already spoke `enable_444`, so the
shell was the only gap. OverrideFlags grows the matching flag, and a unit test now pins
the whole overlay -> row-flag mapping (tri-state resolution included): a field that
records without marking its row is the original Overridden-row bug wearing a new face.

And the marker obeys the GTK rule learned the hard way there: nothing that appears on an
edit may move what was edited. Both states of a profile-scope row reserve the same
caption-line height, so the marker + Reset materialise in place instead of shoving every
row below them down by a button's height.

Scope-blind audit (the bitrate-box failure class): clean. Decoder and GPU are hidden in
profile scope as device facts (GTK parity), the forwarded controller stays gated, and
every visible profile row commits through `commit`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:50 +02:00
enricobuehlerandClaude Fable 5 09d1691af4 fix(client/windows): the switcher footer survives the pane clip instead of guessing at it
Binding the footer's form to the WINDOW width was wrong twice over: the pane's open state
belongs to the user (the hamburger), so a wide window with a closed pane still clipped the
combo, and a narrow window with the pane opened still showed the compact disc. Reactor
exposes no pane-opened/closed event — so the footer now needs no pane state at all: its
LEADING 48 epx is the scope's monogram disc (profile accent when set) and the combo + Edit
button sit after it, so the compact rail's clip line falls exactly between them. Closed
pane → a clean disc (which opens the profile sheet); open pane → disc + combo. The same
trick NavigationViewItems use, at every width and in every mode — the display mode goes
back to Auto, the width threshold and the root window-size read are gone.

The sheet is now self-sufficient (title "Profiles"): it always carries the scope combo —
whether the pane's own combo is visible depends on state this page cannot observe — plus
the name/colour/duplicate/delete rows whenever a profile is in scope, so the disc is a
complete profile entry point from the bare rail.

UIA-verified: scope pick from the pane combo, Overridden marker 5 -> 6 in place, the
sheet opens from Edit profile… and closes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:49 +02:00
enricobuehlerandClaude Fable 5 4b5bca93a0 fix(client/windows): the scope switcher stays in the nav at every width
The width-adaptive fallback moved the switcher OUT of the pane on narrow windows, and in
the pane it could still clip — both read as broken. Now the nav is the switcher's only
home: `Left` mode (always-expanded 280 pane, combo sized to fit) while the content column
gets a workable width, and below that a `LeftCompact` rail whose footer swaps to a fitting
compact form — a 40-square monogram disc for the scope (profile accent when set) instead
of a clipped ComboBox. The disc opens the profile sheet, which in compact mode carries the
scope combo too (keyed per scope against the items/selected_index diff hazard), so
switching, creating, and editing profiles all stay reachable from the rail. The inline
content-column fallback is gone.

UIA-verified on the expanded path: scope pick from the pane, Overridden marker 5 -> 6 in
place, sheet opens and closes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:49 +02:00
enricobuehlerandClaude Fable 5 2409ec9a4b docs(client/windows): the harness passes where the real backend fails, and the note says so
Case 3 of the characterization suite (a sync use_state write from a backend-fired handler
re-renders) is true of the ENGINE and was taken as license to de-hoist event-driven page
state — and the live app immediately disproved it: with settings componentized and its
scope local, a real ComboBox pick changed nothing on screen (UIA-verified, no repaint).
The real WinUI backend's handler wiring still never pumps the pass the harness pumps.

The de-hoist is reverted one commit back; this one makes the record honest. The module
discipline note now separates the three tiers of evidence — engine rules (harness-pinned),
real-backend behaviour (live-verified, hoisting stays), and the tween-keyed corollary —
and case 3's comment warns it must not be read as de-hoist permission without a live UIA
check. The upstream gap worth reporting grows to two: the AsyncSetState HostId registry
miss, and the backend event wiring that bypasses the render pump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:49 +02:00
enricobuehler 27039e04ff Revert "refactor(client/windows): page-only state climbs back down from root, on the measured rules"
This reverts commit 427f99a06647fd99e5c72d18cb7a710d8a7ac885.
2026-07-29 18:31:49 +02:00
enricobuehlerandClaude Fable 5 69c13b5aae fix(client/windows): the settings pane stops auto-collapsing under the default window
WinUI Auto mode collapses the NavigationView pane below 1008 epx — narrower than the
app's own 1000-wide default window, so the scope switcher kept getting booted out of the
nav into its inline fallback. The page now forces the mode: Left (always expanded, at a
tighter 250 pane length) down to 720 epx, LeftMinimal below that — the switcher lives in
the pane wherever the content column still gets a workable width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:48 +02:00
enricobuehlerandClaude Fable 5 6ab2ee4819 refactor(client/windows): page-only state climbs back down from root, on the measured rules
The root component had absorbed every page's UI state because of re-render
folklore; the characterization suite (tests/reactor_semantics.rs) replaced the
folklore with measured rules at the current windows-reactor pin: a child's sync
use_state re-renders its owner reliably, including from backend-wired event
handlers (MenuFlyout clicks, pointer enter/exit, ComboBox selection) and under
element-equal wrappers. Only a background thread's AsyncSetState write is still
dropped. So user-event-driven page state moves into the pages that own it:

* hosts_page: forget, rename and hover become local sync use_state; the
  HostsProps fields, their PartialEq arms and the root plumbing are gone.
* settings_page: converted from a hook-free 14-parameter fn into a real
  component(...) with a SettingsProps (data-only PartialEq, the HostsProps
  pattern). Scope, the delete confirmation, the edit-modal flag and the repaint
  revision are its own sync use_state; commit() and the row helpers take the
  sync setter now.

What deliberately stays rooted, each with its reason in place: show_add and the
settings section drive root tween workers (a thread writing root async state
can only be started by the owner of the trigger), the tween progress values are
those workers' output, and everything thread-fed (discovery, HUD, probes,
pads, deep links, speed, library) keeps the async-drop rule. window size stays
a root read so resize re-renders the tree.

One deliberate behaviour shift: page-local state now resets when its page
unmounts, so re-entering Settings lands on the Default settings scope (the
section still persists, being root state).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:48 +02:00
enricobuehlerandClaude Fable 5 9907ce6229 test(client/windows): the re-render rules become measured, and the switcher survives a collapsed pane
Adopts windows-reactor's own headless harness (the `test` feature + the upstream repo's
`test_reactor`/RecordingBackend crate, same pinned rev) and pins the three semantics this
client's state placement depends on, in tests/reactor_semantics.rs:

* A child's sync `use_state` re-renders it through element-equal wrappers — the pre-bump
  reconciler pruned at the wrapper; fixed upstream (`nested_state_rerender.rs`).
* A sync write from a backend-fired event handler re-renders — the "MenuFlyout handlers
  bypass the flush" claim does not hold at the engine level on this rev.
* A child-owned `AsyncSetState` write still does NOT re-render its owner — newly diagnosed:
  every component RenderCx draws a fresh HostId but rerender callbacks are registered only
  for the root, so the child's request is a registry miss, silently dropped; the value
  surfaces on the next unrelated pass. The test asserts the broken behaviour on purpose, so
  the bump that fixes it upstream turns the test red and the discipline can be relaxed.
  (Upstream's own async test installs the guard for the very cx it tests and cannot see
  this; worth reporting with this repro.)

The stale discipline note in app/mod.rs — written against the June rev — is replaced by the
measured rules, each citing its test. Net: per-screen EVENT-driven UI state may live in the
screen's own component; THREAD-driven state stays hoisted at root, now for a proven reason.

Riding along (field report from the live review): the scope switcher no longer clips when
the NavigationView collapses. WinUI's Auto mode shrinks the pane below 1008 epx and CLIPS
pane-footer content to the rail; reactor exposes no pane-opened event, so root now reads
the window size and the switcher follows the same threshold — expanded pane → pane footer,
collapsed → a slim row leading the content column (an Auto/Star grid row, not a vstack,
so the scroll viewport survives).

Test builds use their own CARGO_TARGET_DIR by convention: windows-reactor-setup stages
resources.pri next to the test binary and a RUNNING dev client holds that file mapped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:48 +02:00
enricobuehlerandClaude Fable 5 1c1b81133c fix(client/windows): the settings surface fills the window again
The redesign left the page rooted in a vstack, and a StackPanel hands its child the
DESIRED height — the NavigationView was clipped in a short window and floated in a tall
one. The nav, the Edit-profile scrim and the delete ContentDialog now share one grid, so
every layer stretches with the window (the dialog is its own WinUI layer and only needs
to be in the tree).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:48 +02:00
enricobuehlerandClaude Fable 5 6b956a2bf4 fix(client/windows): the Overridden marker appears on edit, and profile chrome leaves the page
Committing a profile-scope edit wrote the catalog and nothing else — no reactor-observable
state changed, so no render pass ran, OverrideFlags was never recomputed, and the
"Overridden here" row only surfaced after some unrelated navigation. The mirror of the
Linux fix ("the override marker appears on touch"): `commit` now ends every save with a
`settings_rev` bump, threaded through `setting_combo`/`setting_toggle`, so the marker lands
in the same pass that recorded the override. The bitrate box goes through `commit` too —
it wrote the GLOBAL settings regardless of scope, which corrupted the defaults from inside
a profile and could never mark its row.

The profile chrome moves out of the content column while its wiring is open. The scope
switcher sits in the NavigationView's pane footer (`pane_footer`, new in the bumped
reactor) where it reads as navigation chrome — which layer am I editing — instead of a bar
of controls above every section. Rename, colour, duplicate and delete hide behind an
"Edit profile…" modal (the Add-host scrim-and-card pattern; ContentDialog still fits no
text box), and "New profile…" lands straight in that modal to be named. The footer combo
is keyed by scope + name list so a rename or delete remounts it past the documented
items/selected_index diff hazard; renames repaint the dropdown on modal Close, not per
keystroke.

The absorb-level semantics (record the touched field against the effective snapshot; a
value equal to the global is still a pin) were already covered by pf-client-core's
`absorb_records_the_touched_field_only`; render-level tests wait for the reactor test
harness. Verified live via UIA on the running shell: with the "Game" profile in scope,
changing Render scale grew the marker count 5 -> 6 in place with no navigation, the
override landed in client-profiles.json, the global settings file stayed byte-identical,
and the Edit modal opens and closes from the pane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:47 +02:00
enricobuehlerandClaude Fable 5 35c61fee64 chore(client): the windows-rs pin moves a month forward, onto the SDK-metadata bindings
The July 2026 windows-rs brings a reconciler keyed-child-order fix (#4728), widget
validation (#4727), a DPI collision fix (#4751), icon elements (#4736), multi-window
support (#4730) and scroll virtualization (#4710) — the re-render fixes the Windows
client has been working around at the architecture level. All three pinned deps
(windows-reactor, windows, windows-reactor-setup) move together so windows-core
stays unified across the swap-chain hand-off, and pf-client-core moves with them.

The bulk of the diff is #4689: windows/windows-sys now generate straight from the
Windows SDK, so the `Win32_*` namespace features became one feature per SDK header
(winuser, dxgi, d3d11, …), the PascalCase namespace modules became header-named
modules, struct-returning COM methods take explicit out-params and return HRESULT,
Win32 functions return their raw BOOL/HANDLE instead of Result, and flag constants
are plain integers. Both crates' Win32 code is rewritten to that shape; behaviour
is unchanged on every path.

Riding along, all already stale before the bump: the README and the three Windows
workflows stop claiming windows-reactor's build.rs needs CARGO_WORKSPACE_DIR (that
build.rs no longer exists — staging moved to windows-reactor-setup via OUT_DIR);
the README layout section stops describing modules that moved into the session
binary long ago and gains the manual smoke checklist; the notices generator learns
the SPDX for crates that ship license files without a `license` field, which turns
windows-reactor-setup's UNKNOWN into MIT OR Apache-2.0; and the crate records its
real rust-version (1.96) instead of inheriting the workspace's 1.82.

Verified: cargo check/clippy/fmt clean on punktfunk-client-windows, pf-client-core
and punktfunk-client-session; both bins build; --discover finds the LAN hosts; the
GUI shell comes up (WinAppSDK bootstrap intact under the new reactor-setup).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:47 +02:00
enricobuehlerandClaude Fable 5 ddd0e5cc5d fix(vdisplay/kwin): primary is a PRIORITY, and the log now reads the answer back
audit / bun-audit (plugin-kit) (push) Successful in 19s
audit / bun-audit (sdk) (push) Successful in 17s
audit / pnpm-audit (push) Successful in 10s
audit / docs-site-audit (push) Successful in 28s
audit / bun-audit (web) (push) Successful in 30s
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 2m26s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 3m35s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 2m50s
ci / bench (push) Successful in 6m47s
deb / build-publish (push) Failing after 6m36s
audit / license-gate (push) Successful in 7m54s
decky / build-publish (push) Successful in 32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 55s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 28s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
ci / rust-arm64 (push) Successful in 10m48s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m55s
apple / swift (push) Successful in 4m50s
android / android (push) Successful in 12m33s
deb / build-publish-client-arm64 (push) Failing after 5m44s
deb / build-publish-host (push) Failing after 10m5s
ci / rust (push) Failing after 13m19s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m4s
docker / deploy-docs (push) Successful in 29s
docker / build-push-arm64cross (push) Successful in 37s
audit / cargo-audit (push) Successful in 2m46s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m2s
flatpak / build-publish (push) Failing after 8m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 12m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 12m18s
arch / build-publish (push) Successful in 15m43s
apple / screenshots (push) Canceled after 0s
release / apple (push) Canceled after 18m42s
windows-host / package (push) Canceled after 13m31s
windows-host / winget-source (push) Canceled after 0s
A field report (CachyOS, KWin 6.7.3, Plasma): topology primary streams
the new virtual display but the desktop stays on the physical — while
the host logs primary_taken=true on every apply. Both halves were real:

* KWin's handler for kde_output_configuration_v2.set_primary_output is
  literally '// intentionally ignored' (verified in the 6.7.3 source).
  The output order is driven by per-output set_priority (management >= 3;
  we bind up to v22 and never called it). Exclusive only ever LOOKED
  right because disabling every other output leaves KWin nothing else to
  promote. Now: ours gets priority 1 and every other enabled output is
  renumbered uniquely behind it, exactly what kscreen-doctor does — the
  reporter proved that path applies and sticks on their box.
  set_primary_output is still sent for pre-v3 compositors.

* primary_taken echoed the REQUEST. The log now waits one sync barrier
  for KWin's post-apply priority events and reports primary_verified
  from the read-back — a request the compositor ignores can never ship
  a green log again; the mismatch is a WARN naming the symptom.

First-slot-wins refinement the working priorities made necessary: a
SAME-NAMED output is this slot's own predecessor mid-supersede (mode
switches create the replacement before dropping the old), not a sibling
whose primary must be respected — deferring to it handed primary to
whatever KWin promoted when the predecessor vanished a moment later.

On-glass (nested KWin 6.7.3): fresh create, high-refresh custom-mode
create, and the mode-switch supersede all read back verified=true;
exclusive still disables + restores the other output in-process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:59:24 +02:00
enricobuehlerandClaude Fable 5 a8744b98d1 feat(devtools): reproduce the capture-model cursor path without a real client
punktfunk-probe grows --cursor-capture: advertise CLIENT_CAP_CURSOR,
flip the channel to the capture model (CursorRenderMode client_draws=
false), and wiggle RELATIVE pointer motion for the whole dump — decode
the .h265 and the host-composited pointer must be in the pixels. Plus
--codec pyrowave (advertised only on request so the dump format of
existing recipes never changes).

tools/cursor-probe stands up the capture side alone: virtual output with
the out-of-band cursor, production PipeWire capturer, production
injector, absolute then relative motion — and reports whether
SPA_META_Cursor ever yields an overlay. It is how Mutter's
pointer-in-stream metadata gate was isolated on-glass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:59:04 +02:00
enricobuehlerandClaude Fable 5 751fbd6c47 fix(host/linux): park the seat pointer on the streamed surface — a capture-mode client cannot
ROOT CAUSE of the GNOME capture-mode cursorless stream (and the same
latent hole on KWin, whose screencast also gates cursor data on
includesCursor): a virtual output is created fresh per session while the
seat pointer stays wherever it was — usually a physical monitor. A
capture-model (pointer-lock) client sends only RELATIVE deltas, so
nothing ever moves the pointer INTO the streamed output: input lands on
the wrong monitor, Mutter suppresses SPA_META_Cursor entirely
(should_cursor_metadata_be_set: visible AND in-stream), and both the
embedded and the encoder-blend composite models stream cursorless.

The stream loop now parks the pointer at the streamed surface's centre
through the session's own input pipeline (capability routing, region
ladder, anchor — the path client events take): armed per (re)built
display and by the capture-model flip, retried on a schedule because the
first park of a session can land on a still-cold EIS connection (devices
not resumed — the injector drops it; observed on-glass), and kept trying
while a capture-model session still has no live overlay. A desktop-model
client overrides the park with its first absolute move.

Verified end-to-end on GNOME 50.3/Mutter (RTX 5070 Ti): the reference
client in capture mode now shows the host-composited cursor moving in
the decoded H.265 dump, and the PyroWave session hands the overlay to
the wavelet CSC blend. One-shot per-session/per-stream breadcrumbs
(first meta region, first bitmap, first overlay handed to the blend /
still-cursorless) make the next field triage a grep instead of a rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:59:03 +02:00
enricobuehlerandClaude Fable 5 39aa0c57ce fix(inject/libei): a scaled output's region is the streamed mode in logical pixels — match it
A display scale s shrinks an output's EI region to logical pixels (Mutter
advertises 853x533 for a 1280x800 output at 1.5), so the exact-size rung
missed every scaled output and absolute input fell through to
regions.first() — the wrong monitor whenever another region sorts first
(on-glass: a lingering sibling virtual display). New rung between exact
and first: one consistent scale factor (1..=4, fractional included) must
map the region onto the mode on both axes, with per-axis rounding slack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:59:03 +02:00
enricobuehlerandClaude Fable 5 73e742d877 fix(client/session): the declared GTK deps reach the lockfile
audit / bun-audit (web) (push) Successful in 17s
audit / docs-site-audit (push) Successful in 18s
audit / pnpm-audit (push) Successful in 11s
audit / bun-audit (plugin-kit) (push) Successful in 47s
apple / swift (push) Successful in 1m39s
audit / bun-audit (sdk) (push) Successful in 1m32s
android / android (push) Canceled after 3m51s
audit / cargo-audit (push) Successful in 3m46s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m58s
audit / license-gate (push) Canceled after 3m23s
ci / bench (push) Canceled after 7s
ci / rust (push) Canceled after 3m13s
ci / rust-arm64 (push) Canceled after 3m6s
ci / docs-site (push) Canceled after 11s
ci / web (push) Canceled after 2m12s
deb / build-publish (push) Canceled after 5s
deb / build-publish-host (push) Canceled after 3s
deb / build-publish-client-arm64 (push) Canceled after 2s
decky / build-publish (push) Canceled after 2s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 3s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 2s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 5s
release / apple (push) Canceled after 2m51s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5s
windows-host / package (push) Canceled after 4m6s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
61bdf11e declared gtk4/libadwaita/relm4/async-channel on the session shell but
the corresponding Cargo.lock entries never rode along, and CI's `--locked`
clippy does not degrade to a re-resolve — it fails outright. One resolve,
no version changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Fable 5 67ab64ed06 feat(web): the Host page names — and marks — the operating system
The Identity card gains an "Operating system" row: the pretty name as text,
the raw identity chain as its tooltip, and a leading OsIcon resolved by the
same most-specific-first walk as the clients. lucide deliberately ships no
brand marks, so os-icon.tsx vendors the ten as inline currentColor SVGs
(the brand-mark precedent, the plugin-registry pattern), falling back to a
generic monitor for a chain nothing recognizes. Row grows optional icon/title
props rather than a fork; stories cover Bazzite, Windows and the
unknown-distro degradation; labels land in both message files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Fable 5 2ca3f729fc feat(client/decky): host rows wear the OS mark for free
react-icons already ships every brand mark, so this client costs nothing:
the avahi parser and the saved-hosts feed surface the new `os` chain
(optional on SavedHost — the installed flatpak client may predate the field),
the merge model threads it through with the live advert preferred, and the
row label leads with the resolved mark via the same most-specific-first walk
as every other client. A payload without `os` renders exactly as today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Fable 5 63224cd31d feat(client/android): the eighth field carries the OS, and cards wear it
The JNI discovery record appends `os` as its eighth ␟-field (append-only —
the Kotlin parser's arity guard already tolerates both old and new records,
now pinned by tests in both directions), sanitized on the Kotlin side by the
mirrored chain grammar next to the shared `osIconTokens` walk. `KnownHost`
persists it additively (optString — no schema bump, migration passes it
through) with `learnOs` beside `learnMac`, learned on the same discovery tick.

Compose ships no brand icons, so OsIcons.kt vendors the ten marks as raw SVG
path strings built into ImageVectors via PathParser (lazy, cached) — they tint
with the Material theme like any Icon. The host card's address line leads with
the mark, live advert preferred over the stored chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Fable 5 88c8688b47 feat(client/apple): host cards wear the OS mark, and the store learns it
`DiscoveredHost` reads the new `os=` TXT (sanitized in PunktfunkShared's
OsChain.swift — the pure grammar + walk, mirrored from pf-client-core so every
platform resolves identically, and dependency-free so the widget could use it);
`StoredHost` appends optional `osChain` per the frozen app↔widget contract
(optional + appended last; legacy JSON still decodes, pinned by the round-trip
tests), and `HostStore.updateOsChain` learns it beside the MACs.

The art rides PunktfunkKit's proven resource path (the fonts precedent): ten
template vector imagesets in Resources/OsIcons.xcassets, compiled by the Xcode
build into the Kit bundle's Assets.car (verified: all ten resolve in the built
app) and tinting via foregroundStyle like an SF Symbol. Saved and discovered
cards lead their status line with the mark; a host that advertises nothing
renders exactly as before. GamepadHome tile + widget marks are follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Fable 5 944c03dd32 feat(client): the desktop clients wear the host's OS mark
The client half of the host's new `os=` advert, shared once in pf-client-core:
`sanitize_os` (mDNS is unauthenticated input — lowercase `[a-z0-9._-]` tokens,
capped) and `os_icon_tokens`, the most-specific-first walk with the brand
aliases (`macos`→apple, `steamos`→steam) every platform resolves through.
`DiscoveredHost` carries the chain, `KnownHost` persists it (`serde(default)`,
elided when empty — older stores load unchanged and older clients read back
exactly what they wrote), `upsert` moves it only when carried, and `learn_os`
mirrors `learn_mac` — no-op, no disk write when unchanged — so the mark
survives the host going to sleep.

GTK shells: the card's status row leads with a recolorable symbolic glyph.
That needed real embedded assets — the shells had none — so `data/` gains the
ten `pf-os-*-symbolic` SVGs compiled into a gresource (new build.rs,
glib-build-tools) and registered on the icon theme at startup; the Adwaita
theme then tints them like every other status glyph.

WinUI shell: reactor renders raster-from-URI only, so the embedded mid-gray
PNGs (legible on both themes) materialize once into
%LOCALAPPDATA%\punktfunk\os-icons\ — the library's poster-art pattern — and
the tile's status row leads with a 16px image.

The couch UI plumbs `HostRow.os` (live advert preferred, else the store) for a
Skia glyph that is a declared follow-up; `--list-hosts` / `hosts --json` emit
the stored chain so the Decky plugin can read it. A host that advertises no
`os` renders everywhere exactly as it did before the field existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Fable 5 4c2a6dd091 feat(host,api): the host knows what OS it runs, and says so
Clients want to show which OS/distro a host runs. The host now detects it once
per process (new osinfo module: compile-time on Windows/macOS, os-release on
Linux) and advertises an icon-friendly specificity chain, generic → specific —
`windows`, `macos`, `linux[/<family>][/<id>]`, e.g. `linux/fedora/bazzite`,
`linux/arch/steamos`. A client walks the chain most-specific-first and shows the
first token it has art for, so an unknown distro degrades to its family's mark
and finally to plain Tux — the host emits the full chain precisely so clients
need zero distro→parent knowledge. The middle token is the first recognized
ID_LIKE ancestor (the spec orders them most-similar-first); the leaf is ID
verbatim, sanitized to TXT-safe `[a-z0-9._-]` because it feeds a DNS record.

Two carriers, both additive: a new advisory mDNS `os=` TXT key (same trust
posture as `mac` — unauthenticated, a wrong value only draws a wrong icon), and
`HostInfo.os` + `HostInfo.os_name` on the mgmt API (`os_name` is the os-release
PRETTY_NAME, REST-only so the TXT stays small). GameStream serverinfo and the
QUIC Welcome are untouched. api/openapi.json regenerated (drift test green on
Linux) and the TS SDK gen refreshed from it — which also catches the committed
gen up with earlier spec changes it had missed (monitors, native_paired_clients,
encoder_backend).

assets/os-icons/ gains the ten master SVG marks every client derives its
per-platform art from (Font Awesome Free brands CC BY 4.0 + Simple Icons CC0 —
provenance in its README), with attribution folded into THIRD-PARTY-NOTICES.txt
via the generator (regenerating also catches the stale crate manifest up with
the current lockfile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehlerandClaude Opus 5 e44baf6768 fix(client/android): a decoder fed nothing it can decode must ask for a new anchor
android / android (push) Canceled after 33s
apple / swift (push) Canceled after 27s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 34s
ci / rust (push) Canceled after 8s
ci / rust-arm64 (push) Canceled after 3s
ci / web (push) Canceled after 2s
ci / docs-site (push) Canceled after 3s
ci / bench (push) Canceled after 2s
deb / build-publish (push) Canceled after 6s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 6s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6s
An Android TV box reported a black screen with a perfectly healthy HUD: AUs
arriving at 40 fps, ~312 bytes each. That size is all-P-frames — no IDR anywhere
in the window, and nothing asking for one. The stats being readable is the rest
of the story: the overlay is a layer over the SurfaceView in the same window, so
the panel was fine and the surface simply never received a frame.

The decode thread only starts at `surfaceCreated`, so a slow box can be handed
the stream mid-GOP. A hardware decoder does not error on references it never
had; it emits nothing at all. Under infinite GOP the host sends no further IDR
unless asked, and neither Android loop ever asked: every recovery trigger they
have keys off a drop, a gap or a decode error, and a decoder that quietly
produces nothing trips none of them. The session stayed black for its whole life.

The shared gate has this case (`on_no_output`, which pf-client-core and the Apple
client both feed) but its per-AU streak counts one-in/one-out decodes, and
MediaCodec is pipelined — "this AU produced no output" is not something these
loops can observe. A wall-clock silence window is the same signal in the shape
Android can measure: fed for 500 ms with nothing coming back arms the freeze and
requests a re-anchor keyframe, and the gate's deadline keeps re-asking until one
lands. 500 ms so it can never fire on a decoder that is merely slow to spin up.

Also log the first presented frame. The periodic tally starts at 300 rendered
frames, which is no help whatsoever on a session that renders none — its absence
is what separates "never reached glass" from "reached glass and looked wrong".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:57:13 +02:00
enricobuehlerandClaude Opus 5 1862010003 fix(client/android): a settings edit belongs to the scope the chips show
Change a setting on the defaults, switch to a profile, change the same row: the
globals moved again and the profile recorded nothing. Switch back, and the next
edit went into the profile — which reads as "the default settings can't be
changed any more".

`update`/`resetField` reached the rows as `::update` — callable references, and
two of those compare equal however different the scope they closed over. Compose
saw an unchanged callback and skipped the whole detail page on a scope switch
that moved no value on screen, which is the ordinary case: a profile inherits the
globals until it overrides something. The row went on calling the reference it
was first handed, one scope behind for the rest of the session.

Resolve the scope from the live state at the edit instead of closing over it, so
the write follows the chips whether or not the page recomposed. `key(active?.id)`
around the detail page covers the other half — a row's own `remember` is
per-scope state too, and "Custom…" picked while editing a profile has no business
still being picked over on the defaults.

The model tests can't see this: it takes the real Compose runtime to skip a
composable. SettingsScopeTest drives the screen itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:57:13 +02:00
enricobuehlerandClaude Opus 5 5f29db3975 fix(ci/runner): grow the runner disk, and simplify the burst condition
ci / rust-arm64 (push) Canceled after 42s
android / android (push) Canceled after 39s
apple / swift (push) Canceled after 41s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 44s
ci / rust (push) Canceled after 44s
ci / web (push) Canceled after 41s
ci / docs-site (push) Canceled after 41s
ci / bench (push) Canceled after 40s
deb / build-publish (push) Canceled after 7s
deb / build-publish-host (push) Canceled after 1s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 1m17s
windows-host / package (push) Canceled after 2m16s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
The capacity lever, taken: home-runner-1's LXC rootfs went 123 G -> 175 G
(`pct resize 116 rootfs +50G` on home-node-1, online, no downtime, thin pool had
~607 G spare). Free space went 77 G -> 124 G, which is the part that actually
gives three concurrent Rust builds room; the 10-minute prune is now a backstop
rather than the only thing standing between a push-storm and ENOSPC.

MIN_FREE_GB stays at 45. It is deliberately an absolute floor, not a percentage:
what three concurrent target/ dirs need does not change when the disk is resized,
but a percentage threshold silently does — 80% meant ~25 G free before and ~35 G
now. That is exactly why the percentage alone was the wrong instrument.

Also replaces the multi-line `{ …; } || { …; }` burst condition with two flat
tests into a flag. Same semantics, and shellcheck parses it — the brace-group
form across a line break did not survive an edit to the comment above it.
Truth-tabled: quiet at 25%/124G, fires on either signal alone, and stays quiet
when df returns nothing rather than treating an empty reading as pressure.

Deployed to home-runner-1; deployed md5 matches the repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:19:07 +02:00
enricobuehlerandClaude Opus 5 7fc387bcbf fix(ci/runner): the disk guard had been dead code, and polled too slowly to matter
arch has been failing with `as: BFD (GNU Binutils) 2.47 assertion fail`, which
reads like a toolchain regression and is not one. The line above it is `can't
write 10 bytes to section .text._ZN6Vulkan...: 'No space left on device'` — the
assembler handling ENOSPC badly. Both recent arch failures are the runner filling
its disk, and by the time anyone looks, df reports 37% used.

Three things were wrong with the hygiene that was supposed to prevent this.

The cache cap and the burst-clear were dead code. They looked up the runner as
`docker ps -f name=gitea-runner-runner`, which matches zero containers now that
the replicas are `gitea-runner-fleet-runner-N-1`, so $RUNNER was always empty and
both branches were skipped. The store also moved: the fleet runs a standalone
cache-server bind-mounting a HOST directory, so no docker exec is needed at all.

The routine prune reclaimed nothing. `--filter until=6h` on a runner that rebuilds
its CI images every push means every image is younger than the window — measured:
0B reclaimed while docker system df reported 22.76 GB reclaimable. Now until=2h.

The burst guard never fired. It polled every 30 minutes for >=80% used, but three
concurrent Rust builds fill the disk and drain it again well inside that window,
so the poll kept landing on a healthy df. Now every 10 minutes, and it triggers on
a free-space FLOOR too — 80% of 123 G still leaves only ~25 G, which three jobs
swallow before the next poll.

Deployed to home-runner-1 and exercised: shellcheck clean, timer active on the new
interval, one run reclaimed 551 MB. Honest limit: this improves the odds, it does
not fix the cause. The remaining 22 GB of idle images are the fedora-rpm bases the
next run wants back, so there is no free headroom to reclaim — three replicas
building this workspace share one 123 G disk. The lever is capacity (grow the LXC)
or concurrency (drop to two replicas), and that is a judgement call, not a script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:13:24 +02:00
enricobuehlerandClaude Opus 5 61bdf11e4d fix(client/session): declare the GTK deps the session shell has been using
b0ea1e6b added nine files to clients/session/src/ — app.rs, cli.rs, the four
ui_*.rs, shortcuts.rs, spawn.rs — and did not touch clients/session/Cargo.toml.
The sources `use adw::prelude::*` and `use gtk::{gdk, gio, glib}`; the manifest
declared neither. Every build that compiles this crate has been failing since:
327 errors, all E0433 "cannot find module or crate", taking rpm and deb down with
it. main has been red for both ever since.

Adds a linux-only dependency block — the modules are #[cfg(target_os = "linux")]
in main.rs, and the Windows leg of this crate is a stub that must not pull GTK.
Versions track clients/linux verbatim (gtk4 0.11 "v4_16", libadwaita 0.9 "v1_5",
relm4 0.11 "libadwaita", async-channel 2): the two crates compile the same widget
vocabulary through relm4, where a version skew surfaces as unreadable type
mismatches rather than as a version complaint.

glib/gio/gdk/pango are deliberately NOT declared — the sources reach them as
gtk::glib, gtk::gio, gtk::gdk, gtk::pango, gtk4's own re-exports. Declaring them
separately invites resolving a different version of a sys crate than the one gtk4
links against.

Also makes serde_json non-optional on Linux. It was optional behind the `ui`
feature, but cli.rs's `--json` host listing is a CLI feature, not a console-UI
one — so `--no-default-features`, which this crate's own comments document as a
supported Linux build ("same streaming, stats on stdout only"), did not compile.
That was latent, not part of the CI breakage; found by building it.

Verified on a real GTK4 box (gtk4 4.22.4, libadwaita 1.9.2): cargo check passes
both --no-default-features and default features, the latter being what rpm and
deb build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:13:07 +02:00
enricobuehlerandClaude Opus 5 907985a0b9 docs(packaging/windows): record the driver-signing fingerprint
The stable cert is live and CI is signing with it. Fills in the placeholder in
both docs and adds the one-liner to check an installed driver against it.

Thumbprint 4B8493E7CD565758D335F8F4F05C5A7261A13E02, verified off the .cer a real
windows-host build produced on the runner, not from the key material: RSA 3072,
valid to 2036, extensions 2.5.29.15/37/14 and nothing else. The decisive part is
that the pf-vdisplay and gamepad bundles now carry the SAME thumbprint — the
per-build fallback had each script mint its own 2048-bit cert, so they never
matched. That is what proves the secret reached the build rather than the
fallback quietly running again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:01:42 +02:00
enricobuehler dff61ecea2 fix(client/windows): the scope switcher leaves the page content, and the polish from the GTK round comes across
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m8s
decky / build-publish (push) Successful in 36s
ci / bench (push) Failing after 6m40s
deb / build-publish-client-arm64 (push) Failing after 5m55s
ci / rust-arm64 (push) Failing after 7m3s
arch / build-publish (push) Failing after 7m26s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 36s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 31s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 35s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 32s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 27s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m0s
apple / swift (push) Successful in 5m9s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m53s
deb / build-publish-host (push) Successful in 10m36s
docker / build-push-arm64cross (push) Successful in 13s
android / android (push) Successful in 12m36s
docker / deploy-docs (push) Successful in 25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m21s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m0s
deb / build-publish (push) Failing after 6m54s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m17s
ci / rust (push) Failing after 8m37s
flatpak / build-publish (push) Failing after 8m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 9m54s
apple / screenshots (push) Successful in 20m39s
Four things the Linux review found, applied here without waiting to re-find them:

The profile switcher was rendered as the first group INSIDE each section's
content, so on About — a page with barely any rows — it read as an About
setting, and on every other page it read as one more row. It is chrome: which
layer am I editing. It now sits in its own card above the NavigationView,
visible from every section and never mistakable for a setting.

Profiles get their colour here too, from the same eight-swatch palette as GTK,
and the chip on a host tile is tinted with it — a profile that is red on Linux
is red on Windows. An unparsable accent falls back to the neutral chip rather
than being handed to the renderer.

The tile flyout had become a list of everything, with connect/library/speed
buried under list management. Two rules thin it: anything that CONFIGURES the
host moves into the editor — the pinned tiles join the default profile and the
clipboard toggle there, since a pin is a property of the record — and what
remains is grouped by separators, so a glance lands on the right third.

Compiled on .221; the last one-line clippy fix went in after the box dropped.
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 b498b47228 fix(client/linux): the third-party notices were being dropped, not just logged about
Opening About logged a Pango error and, more to the point, LOST the section it was about:
`adw_about_dialog_add_legal_section` takes markup, not plain text, and the notices are
generated from crate metadata — so the first author address in them, `<name@example.com>`,
reads as an unclosed tag ("is not a valid name: @") and Pango refuses the whole blob. A
third-party attribution page that silently renders nothing is a licensing problem wearing
a log line's clothes.

All three licence fields are escaped now. None of them wants markup: they are a BSD text
and two generated notice blobs. Opening About went from 16538 lines of stderr to none.

The system-library blurb moves next to the notices it belongs with, so all three inputs to
the dialog are constants that get the same treatment rather than one inline string that
could quietly skip it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 cec3955872 feat(client/linux): the app icon reaches About — and the launcher, which never had it either
Adding the icon to the About dialog turned out to be one line and a missing install: the
icon existed, but only the flatpak ever shipped it. The deb, rpm, arch and nix entries all
carried `Icon=video-display` — a stock monitor glyph — so on every non-flatpak install the
launcher, the taskbar and the window switcher have been showing a generic icon this whole
time. About would have shown the same nothing.

So the icon moves out of `packaging/flatpak/` into `packaging/linux/icons/hicolor/` beside
the tray icons, which is where a shared asset belongs, and all five packagings install it
to the same hicolor path. The desktop entry names the app's own icon, and `AboutDialog`
names the app id — one identity resolved through the icon theme rather than a path anyone
has to keep in step.

A `PUNKTFUNK_SHOT_SCENE=about` scene comes along so the dialog is capturable like every
other surface; that is how the icon above was verified to actually resolve rather than
silently falling back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 56efe6ff9c fix(client/linux): nothing that appears on click may move what was clicked, and captions stop starving their controls
Two settings-row defects, both found by using it.

**The reset button was a misclick trap.** It was a suffix, so the instant a row gained an
override its control shifted left to make room — and the second click on Bitrate's "+"
landed on a revert button that had just moved under the pointer, silently undoing the edit
that created it. Both marker widgets now sit in the prefix: the control never moves, and
the general rule is worth stating since the next affordance will face it too — nothing that
appears in response to a click may displace the thing that was clicked.

**Long captions ellipsized the value beside them** ("2× (su…"). A row lays title and caption
out in one box and the control as a suffix, and that box asks for as much width as its
longest caption line wants, so the caption ate the control's space. The repo's standing rule
was "keep captions to one line, ~66 chars", which held until a row grew a second suffix —
exactly what the reset button did. Captions are now capped structurally instead, in one pass
over each page after the rows exist rather than sixteen easy-to-forget per-row calls.

The cap needs all three of `max_width_chars`, `hexpand(false)` and `halign(Start)`, and it
is worth knowing why: the first alone only limits what the label ASKS for, and a filled,
expanding label is still allocated the whole box and goes back to one long line. That is
commented at the call site so it doesn't get tidied away.

Also: the render-scale value is just "2×" now (its caption already explains what above and
below 1× mean), and the 4:4:4 caption I added last round is down from three lines to two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 31a30038fe fix(client/linux): reverting an override acts in place, and the card menu stops being a list of everything
Four things from driving it.

**Reverting one row rebuilt the whole dialog.** It closed and reopened to re-render, which
animated the entire surface for a one-row change and dropped the user back on the General
page — a heavy, disorienting answer to "undo this". A reset now clears the field, puts that
one control back to the inherited value, and drops the marker, all in place. Doing that
needs the programmatic change not to read as a touch (it would instantly re-create the
override the reset just removed), so `Touched` gained a suspend flag the revert holds; and
it needs the seed pass and the revert to agree on where each picker sits, so that index
maths is now one shared `index` module instead of two copies drifting apart.

**Hidden markers still held their space.** The dot and reset were built invisible and
revealed on touch, but an invisible prefix still costs its slot, so every un-overridden row
carried a dot's worth of inset and the column read as misaligned. They are built lazily
now — the row has nothing until it has an override.

**The colour swatches squeezed their own label.** Nine of them as an ActionRow suffix left
the title unreadable; squeezing the label to fit the control is backwards. They get a
full-width row with the caption above and a wrapping FlowBox beneath (and not a homogeneous
one, which stretched 26px circles into rounded rectangles).

**The card menu had grown to eleven entries and three submenus** — the useful ones
(connect, library, speed) buried in list management. Two rules thin it: anything that
CONFIGURES the host moves to the edit sheet, which is where you already go to change its
name; and what remains is sectioned, so a glance lands on the right third instead of
scanning eleven similar lines. The default profile and the pinned cards are properties of
the record, so they now live in the sheet — pins as a switch per profile — and the menu
keeps "Connect with", the things you look at, the two link actions, and host management.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 b0ea1e6b51 fix(client/linux): the override marker appears on touch, profiles get a colour, and 4:4:4 gets a switch
Three things found by actually driving the client.

**The marker didn't appear until you reopened the dialog.** It was rendered once, at build
time, from the stored overlay — so changing a setting inside a profile looked like it did
nothing. The design says touching a control creates the override and the marker appears
immediately, and it has to: a user who changes a row and sees no acknowledgement has no
reason to believe it took. Every profileable row now builds its dot and reset hidden, and
the same handler that records the touch reveals them. Resetting a row touched in the same
sitting also un-touches it, so the commit can't re-write what the reset just removed.

**Profiles had no colour.** `accent` has been in the schema since P0 and nothing could set
it, which left every chip the same grey — and telling profiles apart at a glance across a
grid is the entire reason chips exist. Creating a profile now picks a colour in the same
breath as its name (hunting for it afterwards is what leaves them all grey), an existing
profile has a Colour row, and host-card chips are tinted with it. A palette of eight rather
than a free picker: legibility across light and dark is the job, and the schema still
accepts any `#RRGGBB` a hand-edit or a future picker writes. Anything that isn't `#RRGGBB`
is refused rather than interpolated into CSS, and each distinct colour registers one
display-wide rule (per-widget providers are gone since GTK 4.10).

**4:4:4 had no switch anywhere but Apple.** `VIDEO_CAP_444` has been on the wire for a
while with only `punktfunk-probe`'s env var to set it. It is now a setting — and a
profileable one, which is the point: full chroma is what makes small text and thin UI lines
crisp, so a "Work" profile wants it where "Game" usually doesn't. The host still gates it
on its own policy, HEVC, and a GPU that can actually encode it; advertising only says "I
can decode this and I want it".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 9243a770bc fix(host/encode): an Automatic bitrate follows the pixels actually encoded
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 2m10s
ci / bench (push) Successful in 7m34s
deb / build-publish (push) Failing after 6m38s
decky / build-publish (push) Successful in 40s
apple / swift (push) Successful in 5m5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
ci / rust-arm64 (push) Failing after 9m29s
ci / rust (push) Failing after 9m31s
deb / build-publish-host (push) Failing after 7m15s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 30s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 17s
android / android (push) Successful in 12m31s
apple / screenshots (push) Canceled after 4m1s
arch / build-publish (push) Canceled after 13m14s
deb / build-publish-client-arm64 (push) Canceled after 5m40s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 3m48s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3m39s
windows-host / package (push) Successful in 16m35s
windows-host / winget-source (push) Skipped
`build_pipeline` opens the encoder at the DELIVERED frame size but was handed a
bitrate resolved from the NEGOTIATED mode. Those agree for a virtual display,
which is created at the mode that was asked for. They do not agree for a monitor
mirror: `MirrorDisplay::create` ignores the requested mode by design (a physical
head runs at the mode its owner set), so a client asking for 1080p and mirroring a
4K panel encoded four times the pixels at the 1080p rate — a quarter of the bits
per pixel the codec was sized for, which arrives as a soft, stuttering picture
rather than as the mismatch it actually is. This is the reported "laggy mirror".

Re-resolve for what will really be encoded, and thread the applied rate back out
so the session adopts it: `bitrate_kbps` is what the ABR controller climbs from,
what the console samples, and what a `SetBitrate` ack is measured against, so
letting it disagree with the live encoder makes each of those reason about a
stream that does not exist. The mid-stream rebuild paths (mode switch, compositor
switch, capture-loss) adopt it too — each can land on a different source than it
left.

Automatic only. An explicit client rate is the operator's statement about their
own link and is never second-guessed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:23:32 +02:00
enricobuehlerandClaude Opus 5 664c26cb2b fix(vdisplay): a pin with no heads to mirror must not refuse the session
The streamed-screen pin is host-wide and persisted, but the session it applies to
is not. A box that boots between a desktop (heads to mirror) and a nested or
headless Game Mode (none) carried the pin into a session where `monitors::resolve`
could only fail — and since `vdisplay::open` is the one place every session opens a
display, that failure was a host refusing to stream at all rather than one
streaming the normal way. Pinning a monitor in Desktop mode bricked Game Mode.

A compositor reporting NO heads whatsoever now degrades to the virtual-display
path with the reason logged. Narrow on purpose: a pin that misses among heads that
DO exist stays the hard error §5.2 makes it — showing someone a different screen
than they asked for is the failure worth refusing over, and "there are no screens
here" is not that. An enumeration ERROR also stays on the mirror path, so the
session fails with the real reason instead of quietly ignoring the operator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:23:32 +02:00
enricobuehlerandClaude Opus 5 028462ef6d feat(vdisplay/gamescope): a Game Mode session drives a real head — list it, and mirror it
The per-monitor capture feature shipped with gamescope written off as "nested,
therefore no physical heads of its own", and `monitors::list` returned a
hard-coded empty Vec for it. That premise holds for a gamescope nested in another
compositor and for the headless ones this crate spawns — but not for the
deployment people actually stream from: a Bazzite/SteamOS Game Mode session is
the DRM master and drives the TV directly. On such a box the console's picker was
permanently empty, on exactly the hardware the feature was asked for.

`gamescope/heads.rs` answers the narrow question instead — is this gamescope on
the DRM backend, and which connector is it lighting? — from `/proc/<pid>/cmdline`
(the backend flag and `--prefer-output`; gamescope publishes no protocol that
reports its own output) and `/sys/class/drm` (what is plugged in, plus each
connector's EDID for the picker label and the panel's preferred timing). The
nested/headless shapes still answer an empty list, which is what they always
meant.

Mirroring needs no new capture code: gamescope composites its one head into a
PipeWire node it already publishes, so the mirror arm is an ATTACH to that node —
the gaming session is not stopped, relaunched or re-moded, which is the whole
difference between "stream the panel I am looking at" and the MANAGED takeover
that deliberately blanks it.

Refresh comes from the EDID and never from `--nested-refresh`: that flag is the
rate gamescope composites the nested game at, and reading it as the connector's
rate would let a session launched at 60 cap a 120 Hz TV for the whole stream.

Verified against home-bazzite-1's live EDID: manufacturer `1e6d` -> GSM, name
descriptor -> "LG TV SSCR2", preferred timing 594 MHz / 4400x2250 -> 3840x2160@60.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:23:32 +02:00
enricobuehler 8fee1139f6 chore(client/android): adopt AGP 9.3.1 / Gradle 9.5.0, and tell CI about it
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 3m15s
android / android (push) Canceled after 4m30s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 4m51s
ci / bench (push) Canceled after 4m42s
ci / rust (push) Canceled after 5m2s
ci / rust-arm64 (push) Canceled after 5m3s
deb / build-publish (push) Canceled after 4m25s
deb / build-publish-client-arm64 (push) Canceled after 36s
deb / build-publish-host (push) Canceled after 1m45s
decky / build-publish (push) Canceled after 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2s
The bump itself came from Android Studio; this makes the rest of the repo
agree with it.

The toolchain line at the top of the root build file still claimed AGP 9.2.0
and Gradle 9.4.1, and both Android workflows named "AGP 9.2" as the reason
they pin JDK 21 — the sort of comment that is trusted precisely because it
looks maintained.

The Gradle caches also needed telling: they cache the DISTRIBUTION under
`~/.gradle/wrapper`, keyed on a hash of `*.gradle.kts` only. This bump moved
both files so the key changed anyway, but a wrapper-only bump would have
restored a key that can never contain the new distribution. The wrapper
properties are in the key now.

Verified with the workflows' own commands on the new toolchain:
`:app:testDebugUnitTest -PskipRustBuild --stacktrace --rerun-tasks` (the
screenshot job, 45 tasks from cold) and `:app:assembleDebug` including the
cargo-ndk native build. AGP 9.3.1 resolves no build-tools newer than the
37.0.0 both jobs install, so their sdkmanager lines still cover it.
2026-07-29 12:17:18 +02:00
enricobuehler 93fe9f134e refactor(client/android): the colour palette reads as one object
Three things were wrong with it, and they compounded.

**The order was arbitrary** — orange, blue, pink, green, amber, violet, cyan,
rose jumped back and forth across the wheel, so it looked like a bag of
colours rather than a choice. It is sorted by hue now, one clean sweep, with
the degrees in the source so it stays that way when a colour is swapped. That
is also the order creation hands them out in, so someone making profiles one
after another walks the spectrum.

**The layout fought the form.** A fixed-size grid centred in the dialog
floated free of the left-aligned labels above it; distributed across the full
width the swatches stopped looking related to each other; and eight colours
wrapped to a short second row that read as the grid running out. Each row now
FILLS the width with its swatches sharing it equally, so the palette's edges
line up with the name field's and both rows are the same length. The palette
gained two hues to make that work: ten divides by five, eight didn't divide by
anything useful.

**Selection was a heavier circle.** A border drawn on the swatch's own edge
reads as a thicker ring, not as "this one". It is a ring OUTSIDE the disc with
a gap, plus a check — which also means the selection survives a reader who
can't tell two of these hues apart. The ring's space is always reserved, so
picking never nudges the grid.
2026-07-29 12:17:18 +02:00
enricobuehler a3ec30c287 refactor(client/android): manage a profile from its own chip, and pick its colour up front
Two things about the profile UI were backwards.

**The manage menu was parked after the last chip.** A single overflow button
at the end of a horizontally-scrolling row, acting on whichever profile
happened to be selected — so reaching it meant scrolling past every profile,
and nothing on screen tied the button to its target. It now lives ON the chip:
the selected profile grows a chevron, tapping it again opens Edit / Duplicate
/ Delete anchored underneath, and the action is attached to the object it acts
on. The wandering ⋮ is gone.

**Colour was an afterthought.** A profile was named at creation and coloured
later, through a menu item most people would never find — so every profile
looked colourless until someone went hunting, and the accent is precisely the
signal that has to be right from the first moment (it is all a bound host
card's chip and a pinned card's tint have to go on). Name and colour are now
decided together, in one dialog that serves both creation and editing, with
the next unused colour pre-selected. "Rename…" and "Change colour…" collapse
into one "Edit…", which is three menu items instead of four and one dialog
instead of two.

The chevron rides inside the chip's label rather than the `trailingIcon` slot,
for the same reason the accent dot does: those slots reserve an 18dp icon and
shrink the chip's padding, which is what made a chip with a dot sit
differently from "Default settings" beside it.

Driven on glass: created a profile with a colour chosen in the dialog, opened
the menu from the selected chip, and edited name and colour together.
2026-07-29 12:17:18 +02:00
enricobuehler f2874a5324 feat(client/android): a profile gets a colour, and you can change it
The accent was reserved in the schema and used everywhere it mattered — the
scope chip, a bound host card's chip, a pinned card's tint — but nothing ever
set one. `newProfile` left it null, and design §5.1's "Change color" was the
one item of the scope menu I didn't build. So every profile a user actually
created was colourless, and the only one that wasn't was a test profile I had
seeded by hand through adb. That inconsistency was the whole visible symptom.

Creation now hands out the first unused colour from an eight-entry palette, so
profiles are distinguishable from the moment they exist — which is the point
of the accent on the surfaces where a profile has no room for its name. The
palette avoids the presence green, which means "this host is up" and nothing
else. Past eight it wraps rather than handing out nothing: a repeated colour
beats an invisible chip, and the picker is right there.

"Change colour…" joins Rename / Duplicate / Delete, with "no colour" offered
as a real choice rather than only an initial state — a profile made before
this existed keeps working, and its chip falls back to the theme's accent.

The colour is presentation, not a setting: it is not in the overlay, so it
never reaches a resolved connect. Tested, and driven on glass end to end —
two profiles created through the dialog, distinct accents on their chips.
2026-07-29 12:17:18 +02:00
enricobuehler 4803260aff fix(client/android): a settings row's gaps are set, not inherited
One `spacedBy(4.dp)` was doing three different jobs in a settings row, and got
all of them wrong. The caption sat as close to its control as the override
marker did, so the row read as one undifferentiated stack — and the marker,
being the FIRST child of a card that already pads 16dp, added its own vertical
padding on top of that, so a marked row started with visibly double the gap
every other row has.

Each gap is now stated where it belongs: the marker has no vertical padding of
its own (it sits exactly at the card's padding, level with an unmarked row's
control) and owns the 6dp down to the field it annotates; the caption owns the
10dp up to it. Tighter above, roomier below — so the marker groups with its
control and the caption reads as a separate note rather than part of it.

The "Reset" hit area loses most of its vertical padding to make that work; it
keeps the horizontal padding and the full-width row, so the target is wide
rather than tall. Confirmed on glass, not just in the screenshots.
2026-07-29 12:17:18 +02:00
enricobuehler 7f40afc525 fix(client/android): the settings page stops shouting
Three things made the settings surface hard to read, all of them mine.

**The captions were desktop prose on a phone.** A1 took the Windows client's
`described()` text close to verbatim, which is right for a wide desktop row
and wrong for a ~340dp column: four-line paragraphs under every control, so
the page read as documentation with some dropdowns in it. Every caption is now
one line, two at the outside. The wording still says the thing that isn't
obvious from the label — "Native follows this device's refresh rate", not a
restatement of "Refresh rate" — it just stops explaining the parts nobody
needed explained.

**The override marker was taller than the control it annotates.** "Reset to
default" was a `TextButton`, which brings its own 48dp touch target, so a
marked row grew a half-height band above it that dwarfed both the field and
the caption underneath. It is one compact line now, with a padded hit area on
the word itself — the right trade for a secondary action inside a dense list.

**The scope note's spacing was lopsided**: 8dp above it, 4dp below, so it
drifted toward the chips instead of sitting between them and the divider.

The two settings screenshots cover all three.
2026-07-29 12:17:18 +02:00
enricobuehler cee39b3751 fix(client/android): two spacing slips in the profile UI
The name field in "New profile" sat flush against its caption — the `Column`
holding them had no arrangement at all, so the explanatory line read as part
of the input.

And a profile's scope chip didn't line up with the chips beside it: the accent
dot was in `FilterChip`'s `leadingIcon` slot, which reserves an 18dp icon and
shrinks the chip's leading padding to suit. A 10dp dot in that slot left the
chip's insets visibly different from "Default settings" next to it. The dot
now rides inside the label, so every chip keeps the same padding and the gap
to the name is ours to set rather than a side effect.

Both were eyeball-only surfaces: no screenshot covered either. The name field
now has one — the dialog itself can't be captured (a focused text field inside
a Dialog window never reaches idle under Robolectric, the same trap the PIN
scene documents), so its body is extracted and the scene renders exactly that,
in both the normal and duplicate-name states. The chip row was already in the
profile-scope shot.
2026-07-29 12:17:18 +02:00
enricobuehler b6819b80e2 refactor(client/android): the host card says as much in a third less space
Three stacked badges, each a coloured dot beside a word — presence, trust,
profile — turned a host card into a legend for itself, and made it ~220dp tall
for four facts. Two of the three didn't need to be badges at all.

**Presence moves onto the avatar**, the idiom every contact list already uses:
a dot on the corner, and one fewer labelled row. It is GREEN when the host is
up — a fixed green, not the scheme's primary, because Material You's primary
is whatever the wallpaper says and might itself be a green that then means
nothing. Offline is a hollow ring rather than a differently-coloured dot, so
the state survives a colour-blind reader and a greyscale screenshot; TalkBack
gets the word either way.

**Trust moves to the free top-left corner** as a glyph mirroring the overflow
on the right — locked (paired), a key (this host will ask for a PIN), an open
lock (trust-on-first-use). It costs no height at all, and it is a state you
glance at rather than read; the label rides along as the content description,
and the dialogs that actually make the trust decision spell it out in
sentences. That also retires the pill whose long label ("Trust on first use")
was what made cards tower over their neighbours in the first place.

**The profile chip stays a chip** — it is the one badge that earns the accent,
because it is the only one that says what a tap will DO.

Net: ~220dp down to ~150dp, one badge instead of three, and the only reserved
slot left is the chip's (the row-height rule still applies to it).
2026-07-29 12:17:18 +02:00
enricobuehler 6236989b03 fix(client/android): host cards in one row are the same height again
`LazyVerticalGrid` sizes a row to its tallest item but does NOT stretch the
others, so anything variable inside a card shows up as cards stepping up and
down within a single row. Two things varied.

The new one: the profile chip. A bound host's card grew ~34dp its unbound
neighbour didn't have. The chip's space is now reserved on every card in a
section as soon as ANY card there carries one — so a user with no profiles
still never sees the gap, and a mixed row is flush.

The older one, which profiles only made more visible: the trust pill. "Online"
and "Trust on first use" were laid out on one Row, so the long label wrapped
to three lines INSIDE its pill and that card towered over a "Paired" one. The
pills now wrap as pills (a FlowRow), one line each, in a reserved slot that a
two-pill card and a one-pill card both fit inside.

Both slots are `heightIn(min =)` rather than fixed, so a large accessibility
font scale grows the card instead of truncating a host's trust state — and the
pill slot is sized with room to spare rather than to an exact two lines,
because the equal-height guarantee only holds while every card fits INSIDE the
reservation.

The hosts screenshot scene now orders its mocks so an unchipped card sits
beside a chipped one, and a long trust label beside a short one — the two
shapes that used to step. Verified there and on the emulator.
2026-07-29 12:17:18 +02:00
enricobuehler 6ea10ab383 feat(client/android): the console settings read like every other surface
The console page had its own category names — Stream / Video / Audio /
Controller / Interface — which is a sixth mental model for the same settings,
on the surface least able to afford one (you navigate it a row at a time with
a D-pad). Its headers are now the shared map with the same sub-sections the
touch settings and the desktop clients use, so a setting sits in the same
place whichever surface you found it on, and its groups appear in the same
order.

The ROWS stay the couch-relevant subset. A pad can't drive a touch-input
picker, and adding one for the sake of symmetry would be parity in name only.

Retitled "Default settings": this page edits the base layer only — the console
honours a host's bound profile but doesn't edit profiles (design §5.4) — and a
bare "Settings" quietly implies it changes whatever that host streams with.
Same reason the session console and Decky are retitled.

Also carries A2's SC2 fix onto this surface: the passthrough toggle was absent
from the console page entirely, on the machines where a Steam Controller 2 is
most often the only input.
2026-07-29 12:17:18 +02:00
enricobuehler 02f7cfbb1d feat(client/android): the speed test, writing where the tested host actually reads
Android had no speed test at all — the one client where "what bitrate should
I use?" had no answer but guessing. It measures over the REAL data plane: a
minimal 720p connect, then the host bursts filler for two seconds, so the
answer is about the link this host's stream will take rather than generic
throughput. Two new JNI calls (`nativeSpeedTest` / `nativeProbeResult`) front
the core's probe, deliberately measure-only.

The measurement is the easy half. The half that was wrong on every client for
a long time is WHERE the answer goes. A measured bitrate belongs in the layer
the tested host actually resolves bitrate from (design §5.3): its bound
profile's override if it has one, the global if the host is unbound — and if
the host is bound to a profile that INHERITS bitrate, both are defensible, so
the user gets both buttons instead of us guessing. That target depends only on
the host, so it is known before the result lands and the button can say where
it will write: "Apply to “Travel”". Writing the global unconditionally — the
old behaviour everywhere — is what made measuring the slow box downstairs
quietly re-tune the desktop.

Reachable from the host card's overflow and from the console's host options: a
TV box on a powerline adapter is exactly the machine whose link is worth
measuring, even though profile editing stays off that surface.

While there: a successful write no longer renders in the error container. The
connect screen's one status line was red by design — correct for a failure,
a small lie for "75 Mbit/s set in “Travel”" — so confirmations got their own.

Verified on the emulator against a real host: 108 Mbit/s measured on a host
bound to a bitrate-setting profile, target resolved to that profile, and Apply
wrote 75397 kbps into the profile's overlay with the global untouched and the
profile's other overrides unmoved.
2026-07-29 12:17:18 +02:00
enricobuehler f6f991648d fix(client/android): a pinned console tile is a shortcut, not a second host
The touch grid withholds Edit / Forget / Wake from a pinned card on purpose —
a pin is a shortcut to one host+profile combination, and offering the host's
destructive actions on it blurs exactly that. The console carousel didn't:
its pinned tiles carried the host record, so Up on one opened the full host
options (including Forget), and Y opened the host's library.

They now carry which profile they pin, so the options dialog offers the one
action a pin has — Unpin — and says what unpinning does and doesn't touch.
2026-07-29 12:17:18 +02:00
enricobuehler 42bd5be941 fix(client/android): a link must not end the stream it isn't allowed to preempt
Found on glass. "A URL may never preempt a live session" was enforced inside
the composition — which is the one place that can't see it happen. With
`launchMode = standard` a `punktfunk://` link arrives as a SECOND activity
instance in its own task; that instance's session state is empty, so it
routed the link as a fresh connect, and the streaming task being backgrounded
ended the session it was supposed to protect. The rule held only for the rare
`FLAG_ACTIVITY_SINGLE_TOP` caller that lands on `onNewIntent`.

The live session is now published process-wide, and `onCreate` refuses there
— before this instance is ever resumed, so finishing it leaves the streaming
task in front, untouched. Static state is what crosses the gap between two
activity instances that know nothing about each other; the process dying
resets it, which is also the right answer.

Verified on the emulator against a real host: with a stream up, a link naming
a DIFFERENT host now leaves it running (same session, same HUD, no second
connect) instead of tearing it down.
2026-07-29 12:17:18 +02:00
enricobuehler 8172e819a4 fix(client/android): the JNI crate wouldn't build under the unsafe-op lint
`unsafe_op_in_unsafe_fn` is denied workspace-wide, and two operations in the
render-callback path were still bare inside their `unsafe fn` — so every
Android build failed at `cargo ndk`, before any Kotlin work could reach a
device. Pre-existing on main and unrelated to the Android settings/profiles
work; found by building the APK for it.

Both get the explicit block and the SAFETY note their neighbours in the same
file already carry: the reclaimed pointer is the one `install_render_callback`
leaked, and the callback's `userdata` is that same pointer, alive for as long
as the codec that delivers the call.
2026-07-29 12:17:18 +02:00
enricobuehler b63f9f4ac0 feat(client/android): punktfunk:// links open a stream
Android was the last client with no URL door at all: no VIEW intent filter,
no `onNewIntent`, no parser. Now a Playnite entry, an OS shortcut, a Stream
Deck macro or a wiki link can open a stream on a host this device already
trusts.

The parser is a PORT, not a new design. `clients/shared/deeplink-vectors.json`
is the cross-language contract, and the Kotlin suite runs the same 44 cases
the Rust one does — including every refusal code — so three parsers cannot
drift into three different security postures. It is resolved from the shared
path rather than copied, because a copy would be a fourth contract free to go
stale. Strict percent-decoding with a REPORTING UTF-8 decoder, so `%FF` is a
refusal rather than a U+FFFD that survives into a log line or a filename.

The routing lives in `ConnectScreen` because that is what owns the connect
path — trust decisions, the local-network grant, wake-and-retry — and a link
must go THROUGH all of it, never around it. The rules are absolute and each
one is a branch you can point at: a known, pinned host does exactly what
tapping its card does; an unknown or never-pinned one gets the confirmation
sheet, from which the normal pairing flow proceeds under the user's eyes; an
`fp=` that contradicts the stored pin is a hard refusal; an ambiguous host
name or a profile this device doesn't have refuses with a notice naming what
failed, because a "Work" shortcut streaming with the wrong settings is worse
than an error; a link arriving mid-stream never preempts it (pointing at the
host already being streamed is a no-op — the intent has already brought the
app forward, which is what focusing it means). `wake` and `browse` parse, and
are refused with a notice rather than silently connecting.

`launch=` and `profile=` ride the whole path, including through a trust
decision: a link to a host that still needs pairing keeps its game and its
profile across the confirmation instead of quietly landing on a plain desktop
session.

`launchMode` stays `standard` and the `configChanges` set is untouched — its
`keyboard` entry is what keeps an SC2 claim from killing a running stream.
The VIEW intent is read in both `onCreate` and `onNewIntent`, which is what
that launch mode requires.
2026-07-29 12:17:18 +02:00
enricobuehler 46461eb0b6 feat(client/android): profiles — per-host settings, one settings UI
Every client setting was global: pick 4K@120 HEVC and it applies to the
beefy desktop, the work laptop and the retro box alike. Android now has the
same answer the desktop clients got — named bundles of overrides, bound per
host.

A profile is a bundle of OVERRIDES, not a snapshot, and getting that right
is the whole feature. An untouched field keeps following the global live, so
fixing a global once fixes it everywhere. A touched field is recorded even
when it equals today's global — that is a pin, and it must survive the global
later moving. The only way back to inheriting is an explicit reset, never a
diff at save time. `SettingsOverlay.absorb` is the seam that makes this work
with a per-control commit: it compares against what the control was SHOWING,
not against the globals.

One settings UI, not two. A scope chips row on top switches the whole
surface between the defaults and one profile — same categories, same rows,
same captions — so a profile editor cannot drift from the thing it
overrides. In profile scope only profileable rows render (the console-UI
toggle, the library, auto-wake, the controller diagnostics are facts about
this device and simply aren't there), every row shows the effective value,
and an overridden row carries a marker and a reset.

On the host side: the Edit sheet binds a default profile and pins profiles
as cards; a bound card wears a chip naming what a tap will do; a pinned
host+profile combination gets its own card right after its host, one tap
instead of a menu — which is also what makes profiles usable on the console,
where menus are not. "Connect with" is a one-off from any card and never
rebinds; rebinding is always the explicit act of opening the Edit sheet.
Precedence is the cross-client one — one-off, else binding, else none — with
the empty reference meaning "force the defaults", a real choice on a bound
host. A deleted profile leaves a dangling binding that resolves to none and
a pin that stops rendering: never an error, never a blocked connect.

Resolution happens ONCE per connect, in `ConnectScreen`, and the resolved
settings ride the `ActiveSession` into the stream — so the "applies from the
next session" footers stay true and the stream can't disagree with the
connect that opened it. The stats overlay's first line names the profile,
which answers "which profile am I on?" from inside the stream.

Nine tests cover the model against the Rust suite's cases (apply, absorb,
pins, clear, unknown-key carry-through, id-before-name resolution with
ambiguity refused, precedence, deletion) and two Roborazzi scenes cover the
surfaces: a profile being edited, and a host grid with a bound chip and a
pinned card.
2026-07-29 12:17:18 +02:00
enricobuehler 35ce0401a8 feat(client/android): hosts get a stable identity, and own their clipboard
The host store was keyed by `"address:port"`, which had two consequences.
Editing a host's address had to re-key its record — delete the old key,
write the new one — and nothing could hold a durable reference to a host at
all, because the key moved whenever the host did. Profile bindings, pinned
cards and `punktfunk://` shortcuts all need exactly such a reference, so the
store is now keyed by a minted stable id: a lowercase UUID, the same shape
Apple's `StoredHost.id` and the Rust `KnownHost.id` already carry, so a host
reference is one grammar on every platform. The re-key-on-edit dance is
gone; an edit is a plain save.

`clipboardSync` moves from a global onto the record. It was always a
decision about a HOST — whether text on this device may cross to that
machine — and one global for the work box and the couch box was the wrong
shape; the desktop clients have had it per-host since it shipped. It is
edited from the host's Edit sheet, which is also where the profile binding
will live.

Both, plus the minted ids, ride ONE migration pass. The store is being
rewritten anyway and every extra pass is another chance to strand somebody's
hosts. It runs once against real user data, so its pure half is tested
against a verbatim pre-migration blob — every host survives with its pin,
paired flag and MACs, IPv6 addresses included; each lands on its own id;
whatever the global clipboard setting said lands on every host, on or off;
and a second pass over its own output changes nothing.

Re-trusting a host now goes through `KnownHostStore.trust`, which preserves
the existing record's identity and everything the user set on it. Three call
sites used to construct a fresh `KnownHost` — harmless while the key was the
address, but with an id it would have forked the record on every re-pair.

While the wiring was open: `StreamScreen` takes an `ActiveSession` — the
settings the connect actually resolved, plus that host's clipboard answer —
instead of re-reading `SettingsStore` behind its own connect's back. That is
also the seam profiles need.
2026-07-29 12:17:18 +02:00
enricobuehler e7c0024543 feat(client/android): the settings the shared struct had and Android didn't
Gap closure against the cross-client settings struct, plus one real bug.

`pointer_capture` was Android's private spelling of `mouse_mode`: same two
states (pointer lock + relative deltas, or free absolute pointing), a
different name and a Boolean shape, so a profile or another client could
never carry it. It becomes `MouseMode` with the shared `capture`/`desktop`
names and the shared labels, migrating from the old Boolean — whose `false`
default IS `desktop`, so an install that never touched the toggle lands
exactly where it already was. Android keeps `desktop` as its default rather
than the desktop clients' `capture`: a phone or TV is far more often driven
by touch or a pad than by a locked mouse, and that is what this platform
already did.

The pad picker gains Steam Deck, and stops assuming its index IS the wire
byte — `GamepadPref` has eleven values now and the offered subset is not a
prefix of them (Steam Deck is 6; 5, the classic Steam Controller, is not
offered, exactly as on the desktop clients).

PyroWave joins the codec table so the value is representable, but is not
offered: it is a Vulkan-compute codec that lives in `pf-presenter`, this
client decodes through MediaCodec and never advertises the bit, so choosing
it would silently resolve to HEVC. AV1's existing capability gate and this
one now share `codecOptionsFor`, which also keeps whatever IS stored
selectable — a codec chosen on another device must survive being looked at
here.

And the fix: "Steam Controller 2 passthrough" was gated on the device having
a body vibrator. It is a USB/BLE capture with nothing to do with rumbling
this device, and the gate hid it on exactly the machines that most want it —
TV boxes, where an SC2 is the whole input story. Only the rumble-mirroring
row, which genuinely needs a motor to mirror onto, stays gated.
2026-07-29 12:17:18 +02:00
enricobuehler 24a2d3a81b feat(client/android): the settings read like every other client's
Android was the last client on its own settings map (Display / Audio /
Controls / Interface / About) while the desktop and Apple clients had
converged on General / Display / Input / Audio / Controllers / About with
sub-sections inside each. Same settings, six different mental models is
five too many, and the profile scope switcher A4 adds hangs off exactly
this structure.

So: the shared category map, the sub-section headers the desktop clients
group by ("Resolution" / "Quality" / "Decoding" / "Host output"), and the
`described()` idiom — `SettingDropdown` gained a `caption` parameter, so a
dropdown's explanation belongs to the dropdown instead of floating as a
loose paragraph two rows below it. The only form-level notes left are the
two "applies from the next session" footers, one per affected category,
matching the decision Apple made.

About finally names the app and its version, the way the WinUI and Apple
About pages do.

`SettingsScreen` takes an optional `initialCategory` so the screenshot
harness can capture a category page — the headers, the captions and the
footers only exist inside one, so the root shot alone couldn't catch a
regression in them. Two new Roborazzi scenes (Display, Input) use it.
2026-07-29 12:17:18 +02:00
enricobuehlerandClaude Opus 5 861b1ffe26 fix(packaging/windows): keep the installer-run scripts ASCII, as the gate requires
ci / docs-site (push) Successful in 1m7s
android / android (push) Canceled after 1m38s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m0s
ci / web (push) Successful in 2m6s
ci / rust (push) Canceled after 2m13s
ci / rust-arm64 (push) Canceled after 2m13s
ci / bench (push) Canceled after 1m51s
deb / build-publish (push) Canceled after 58s
deb / build-publish-host (push) Canceled after 41s
deb / build-publish-client-arm64 (push) Canceled after 14s
decky / build-publish (push) Canceled after 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 6s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11s
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-host went red at the locale-safety gate: I wrote em-dashes and box-drawing
characters into four scripts under packaging/windows/, and that gate exists
precisely to stop that. Windows PowerShell 5.1 reads a BOM-less .ps1 in the active
ANSI codepage, so a non-ASCII byte mis-decodes on a German box and the script dies
with "unterminated string" — which is how the pf-vdisplay driver install once
failed silently in the field. The whole reason the install logic moved into the
compiled host exe was this exact hazard, and I reintroduced it in the comments.

Substituted to ASCII across all four (- for em-dash and the box-drawing rules).
No logic touched. The gate's own check now passes locally, all four still parse on
the runner, and make-driver-cert.ps1 -TestOnly still runs end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:15:53 +02:00
enricobuehlerandClaude Opus 5 f8cf3a0cf2 fix(packaging/windows): the decoded signing key must not outlive the build
windows-host / package (push) Failing after 22s
windows-host / winget-source (push) Skipped
android / android (push) Canceled after 2m11s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m28s
ci / rust (push) Canceled after 2m34s
ci / rust-arm64 (push) Canceled after 2m33s
ci / web (push) Canceled after 2m33s
ci / docs-site (push) Canceled after 2m34s
ci / bench (push) Canceled after 1m52s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 16s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 14s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 4s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 8s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 3m3s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 3m14s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m58s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m50s
Both driver builds base64-decode DRIVER_CERT_PFX_B64 to `driver-signing.pfx` on
disk and never delete it. The existing cleanup line only removes the EPHEMERAL
cert from the store — it is guarded on $cleanupCert, which is null on exactly the
path that writes this file.

That was harmless while the cert was a per-build throwaway. It is not harmless
now: the key is stable and trusted as a machine root on every box that installs
punktfunk, so a .pfx sitting in a build directory is a standing credential on the
machine that runs build jobs. Every windows-host build would have left one.

Adds Remove-SigningPfx, called after the last signing step and from a script-scope
trap so a mid-build failure doesn't strand the key either. Verified on the runner:
both scripts parse, the key is gone on the success path, gone on the failure path,
and a second call is a no-op.

The `break` in that trap is explicitness, not correctness. I first wrote it
claiming a bare trap would resume past the error and let a build finish with
unsigned drivers; measuring on the runner disproved that — bare and break behave
identically (exit 1, no resumption) for `throw` at script scope, `throw` inside a
function, and a cmdlet error under EAP=Stop. Kept, with the comment saying what
was actually measured rather than what I assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 30710b689d feat(packaging/windows): make-driver-cert.ps1 — one command for the signing key
The runbook was a dozen copy-pasted lines with three separate ways to get it
subtly wrong, so it is a script now. It prints the thumbprint, writes the two
secret values to files (not to the terminal, so they stay out of scrollback), and
never puts the private key in a certificate store — the only thing to clean up is
the output folder. `-TestOnly` does a full dry run and keeps nothing.

Three things the script exists to get right, all of which bit during testing:

Generation uses the .NET CertificateRequest API rather than
New-SelfSignedCertificate, so no key container is involved and it works over SSH.
New-SelfSignedCertificate fails there with NTE_PERM 0x80090010 — a network logon
has no key container. CONSUMING a .pfx still needs one, so the signtool self-test
reports SKIPPED over SSH instead of failing; distinguishing "cannot test here"
from "test failed" matters, because the first is a property of the session and the
second is a broken key. Any other signtool error is still fatal.

The extension set is explicit and matches what the drivers have actually been
signed with, read off the certs sitting on the runner: KeyUsage=DigitalSignature
(critical), EKU=codeSigning (non-critical), SubjectKeyIdentifier, and NO
basicConstraints. Improvising here would surface as a failed driver install on a
user's machine rather than as a build error, so it copies the known-good shape.

.NET's PKCS#12 writer, not OpenSSL — OpenSSL 3's default AES-256/PBKDF2 produces a
.pfx that Windows CryptoAPI frequently cannot read. And RandomNumberGenerator for
the passphrase, not Get-Random, which is System.Random.

Dry-run verified on the windows-amd64 runner: 3072-bit, 10-year, the three
expected extensions, self-test correctly SKIPPED with the NTE_PERM reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 622afc2c32 docs(packaging/windows): fix the driver-cert runbook's RNG and secret scope
Get-Random is System.Random — not a cryptographic RNG, and it had no business
generating the passphrase protecting a signing key. Uses RandomNumberGenerator
instead (.Create()/.GetBytes() spelling works on both PS 5.1 and PS 7).

Scope corrected to repo-level, matching MSIX_CERT_PFX_B64 next door: only
unom/punktfunk builds drivers, so there is nothing for an org-level secret to
reach. RPM_GPG_PRIVATE_KEY is org-level because other repos publish RPMs.

Also records where to run it — interactive logon only (New-SelfSignedCertificate
fails NTE_PERM over SSH, no key container on a network logon), and not on the CI
runner, which is the one machine that executes build code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 a49858f194 feat(packaging/windows): give the drivers one publisher identity, and give the trust back
The drivers had no cryptographic identity at all. Every build minted a fresh
`CN=punktfunk-driver` cert (build-pf-vdisplay.ps1, build-gamepad-drivers.ps1), and
install.rs `trust_cert` adds whatever .cer it finds in the unpacked bundle to
machine Root AND TrustedPublisher. So the signature vouched for nothing an
attacker couldn't restage — replace the bundle, ship your own cert beside your own
driver, install proceeds identically. It was ceremony to make PnP install quietly.

Worse, it leaked. `trust_cert` runs once per driver (install.rs:102 and :155), so
every upgrade added TWO more self-signed root CAs under the same name, and nothing
ever removed them: uninstalling punktfunk left trust behind that the user had no
reason to keep granting.

So: both build scripts now take a stable cert via DRIVER_CERT_PFX_B64 (they already
read the env var — windows-host.yml just never passed it) and fail closed on a v*
tag, same rule as the host and MSIX packers. `driver uninstall` purges every
`CN=punktfunk-driver` cert from both stores, and `driver install` purges before
adding, so an upgrade also collects the historical pile instead of adding to it.

Purge-before-add lives ONLY on the pf-vdisplay install path, not the gamepad one.
The installer runs vdisplay first and gamepad second; purging in both would have
the gamepad leg delete the cert the vdisplay leg just added whenever the two
bundles carry different certs — which is exactly what canary's per-build fallback
produces. Purging by subject rather than thumbprint is deliberate too: it is what
lets one install clean up certs from builds that no longer exist anywhere, and it
needs no parsing of certutil's localized output (this module exists because
locale-parsed PowerShell broke the driver install on a German box).

This does NOT make the driver download authenticated — a self-signed leaf is its
own root, so the installer must trust it for the driver to install at all. What it
buys is a fingerprint we can publish out-of-band so a substituted driver is
detectable, an allowlistable publisher, continuity across releases, and no root
accumulation. Attestation signing remains the real fix; documented as such.

Documented the key-custody trade honestly in packaging/windows/README.md: a stable
key trusted as a machine root on every install is worth stealing in a way a
throwaway never was, with no revocation path. CI secret only.

⚠️ The secrets must exist before the next v* tag or the release will fail — that
is the guard working, and the generation runbook is in that README. The fingerprint
line there is a placeholder until the key is generated.

Verified: punktfunk-host compiles clean on the windows-amd64 runner with this
install.rs (exit 0, no warnings); both build scripts parse; cargo fmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 93902ff60e feat(packaging/bazzite): the sysext feed is signed, and the client refuses one that isn't
`punktfunk-sysext` checked the SHA256 of every image it downloaded, which sounds
like verification but isn't: SHA256SUMS lives on the same registry as the images
it describes, so anything able to replace an image could replace its checksum in
the same request. The checksum only ever proved the download wasn't corrupted.

Each feed now carries SHA256SUMS.asc, a detached OpenPGP signature over the
manifest, and the client verifies it before believing a line of it. The key is
packages@unom.io (AF245C506F4E4763) — the same one that already signs our RPMs,
so boxes have one key to trust and we have one key to rotate. Its public half is
baked into the script rather than fetched from the feed, because a key you fetch
from the thing you're authenticating authenticates nothing; gpg (present on
Bazzite) does the verifying against a throwaway keyring holding only that key, so
"good signature" and "signed by us" are the same statement.

Rollout: stable feeds only publish on a tag, so a `--seal` mode re-signs an
existing manifest without rebuilding an image, and every rpm.yml run seals the
OTHER channel of its Fedora major too. Canary pushes are frequent, so all live
feeds seal within a day of this landing and a key rotation propagates without
republishing anything. Until a feed is sealed the client refuses it and says so,
naming PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 as the informed way through.

Two things testing changed. The baked-key fingerprint check compared against an
empty string — the armor block is a single-quoted shell literal, so the extracted
range carried `FEED_KEY='` on its first line and gpg saw no armor at all; every
publish would have "mismatched" and, on a tag, failed the release. And `status`
captured fetch_manifest's stderr but only printed it on failure, swallowing the
ALLOW_UNSIGNED warning precisely when someone was running unverified.

Verified end to end on Bazzite 44 against a file:// feed with a throwaway key:
unsigned feed refused; ALLOW_UNSIGNED=1 proceeds and says so; wrong signer
rejected; tampered manifest rejected; good signature accepted; `update` exits 1
before touching anything on a bad feed. Publisher side: signs when the baked key
matches, refuses on mismatch, refuses with no key, and its signature round-trips
through the real client. shellcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 7b053a4a39 fix(packaging/rpm): a release must not publish unsigned RPMs into a gpgcheck=1 repo
Signing was already live and the docs were half right about it. `RPM_GPG_PRIVATE_KEY`
is an ORG-level secret on unom, so it is invisible in this repo's Actions secrets —
which reads exactly like "never set up", and both the rpm.yml step name and
sign-rpms.sh's header still said "dormant". Checked it on the wire instead: a
published punktfunk-web RPM carries an OpenPGP V4 EdDSA header signature from
af245c506f4e4763, the same key committed at packaging/rpm/RPM-GPG-KEY-punktfunk.

The real gap was the failure mode. README.md hands users a repo file with
gpgcheck=1, but sign-rpms.sh exits 0 when the key is missing — so an org secret
that got rotated, renamed, or not inherited would publish an unsigned release into
a repo that rejects unsigned packages, and every user's `dnf upgrade` would break
with us none the wiser. On refs/tags/v* that is now a build failure. Other builds
still fall through unsigned so forks and local builds keep working.

Docs corrected to match: the org-level location (with a wire-level check that
doesn't depend on where the secret lives), the fail-closed rule, and a note that
`rpmkeys --checksig` reporting NOKEY still means signed.

Guard tested locally: exit 1 on refs/tags/v0.21.0, exit 0 on refs/heads/main and
on an unset ref.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 5db93c1156 fix(packaging/windows): a release must not sign itself with a throwaway cert
Both pack scripts fell back to a fresh `New-SelfSignedCertificate` whenever
MSIX_CERT_PFX_B64 was absent, and said nothing about it beyond one Write-Host.
That fallback is right for canary and dev builds. On a tag it is not: an ephemeral
cert is regenerated per build, so nobody can pin it, and a release signed with one
is indistinguishable from a release signed by whoever else got to the artifact.
A secret that got renamed, rotated away, or simply wasn't inherited by a new
workflow would have downgraded a real release silently and shipped it.

pack-msix.ps1 and pack-host-installer.ps1 now resolve -RequireSignedCert (default
'auto' = true iff GITHUB_REF is refs/tags/v*) and throw instead of falling back;
pack-host-installer.ps1 also refuses -NoSign on the same condition. Reading
GITHUB_REF inside the scripts rather than taking a workflow flag means a future
packaging workflow inherits the guard instead of having to remember it.

No effect on today's releases: MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD are both
set as repo secrets, so the supplied-cert branch is the one tag builds take. This
only closes the trapdoor underneath it.

Verified on the windows-amd64 runner: both files parse, and the guard resolves
true for refs/tags/v0.21.0, false for refs/heads/main and for an unset ref, with
'true'/'false' overriding as intended.

Not touched: the pf-vdisplay / gamepad driver builds use the same pattern with
DRIVER_CERT_PFX_B64, which has no secret behind it — those need attestation
signing (a Partner Center action), and a guard there would just fail every tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 06ffca985d feat(ci/release): every release asset now ships its own SHA256 sidecar
A release page offered a DMG, an MSIX, a setup.exe, an APK and a decky zip with
nothing to check them against — the download either matched what we built or it
didn't, and there was no way for anyone to tell which.

upsert_asset now attaches `<asset>.sha256` next to each asset, so verifying is
`sha256sum -c punktfunk-1.2.3.dmg.sha256` in the download directory. Doing it in
the helper rather than in the callers means all eight packaging workflows inherit
it at once, and a future one can't forget.

Sidecars rather than one shared SHA256SUMS: those workflows attach to the SAME
release object concurrently, so a single manifest would be a read-modify-write
race that silently drops whichever leg lost. One file per asset has no shared
mutable state.

The digest is over the file, but the name written into the sidecar is the ASSET
name — callers rename on upload (Punktfunk-$VERSION.dmg), and `sha256sum -c` looks
up the name it reads. The PowerShell twin writes the line byte-exactly (LF, no
BOM): GNU sha256sum folds a trailing CR into the filename, so PowerShell's default
CRLF would have failed every check on the box doing the verifying.

Verified on both sides — bash and POSIX sh locally (`shasum -a 256 -c` passes),
pwsh on the windows-amd64 runner (91 bytes, last byte 0x0A, no CR, no BOM, same
digest as the bash path).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:12:21 +02:00
enricobuehlerandClaude Opus 5 80c0ca69fa fix(client/apple): take the accent wash off the scope menu too
ci / web (push) Successful in 58s
apple / swift (push) Successful in 1m27s
ci / docs-site (push) Successful in 2m58s
android / android (push) Canceled after 5m20s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 5m37s
ci / rust (push) Canceled after 5m44s
ci / rust-arm64 (push) Canceled after 5m42s
ci / bench (push) Canceled after 5m42s
deb / build-publish (push) Canceled after 4m56s
deb / build-publish-host (push) Canceled after 2m51s
deb / build-publish-client-arm64 (push) Canceled after 33s
decky / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 20s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 20s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5s
release / apple (push) Successful in 20m41s
Same cause as the host grid's sort menu: a Menu draws its label — and the icons of
everything inside it — in the accent colour, so a control whose only meaningful
colour is the profile chips came out purple throughout.

The chips are unaffected because they are rendered bitmaps rather than tinted
symbols (see MenuIcon), so the colour that means something keeps it and the
decoration loses its. The red trash stays red for the same reason. macOS only, as
before — iOS menus already draw their icons in the label colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 d8252bb777 fix(client/apple): the sort menu was brand-purple beside two plain toolbar buttons
A Menu draws its label in the ACCENT colour where a Button draws it in the label
colour, so this one came out tinted next to Add Host and Settings. macOS only: on iOS
every toolbar item is accent-tinted already, and pinning this one to primary would
make it the odd one out there instead of in step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 2e7b4aea79 fix(client/apple): a pinned card belongs to its profile, not to its host's binding
Grouping by profile asked each HOST which profile it was bound to, then handed the
answer to that host's every card. So a card visibly wearing a "Gaming" chip — a pin,
on a host bound to nothing — was filed under "No Profile" along with the rest. Every
pinned card was, which is most of what the grouping exists to separate.

Pins are not bindings. The arrangement works on CARDS now: a host's own card follows
its binding, a pinned card follows its pin, and both can land in the same band when a
host pinned the profile it is also bound to. The expansion moved out of the view and
into the arrangement, because it is exactly what the grouping has to see.

The regression is pinned by a test whose fixture is the shape that broke: a host
bound to nothing, carrying two pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 f071467cbb feat(client/apple): sort and group the host grid
The grid had no ordering story — hosts sat in the order they were added and that was
the whole of it. A toolbar menu now offers Sort by (Date Added, Name, Last Connected)
and Group by (None, Profile, Status), both per device: this is a window on a list,
not something about how a host streams. The control stays out of the toolbar until
there are two hosts to arrange.

Grouping by profile is the one the profiles made possible: bands in catalog order,
each header wearing its profile's colour, then the unbound hosts. Pinned cards stay
with their host — a pin is presentation of that host, not a host of its own — and a
dangling binding lands in "No Profile" rather than in a band named after a profile
that no longer exists. Empty bands aren't drawn.

The ordering is pure and tested, which earned its keep immediately: the test caught
that undated hosts were sorting first, and made me work out whether that was a bug.
It isn't — no date means saved before `addedAt` existed, which means older, and in a
real store those are a prefix, so the default sort leaves an upgraded grid exactly as
it was. Two other traps are pinned by tests: `sorted` is not stable in Swift, so
every comparison tie-breaks on the stored index or equal rows swap on redraw; and a
never-connected host sorts LAST under Last Connected, not first as `.distantPast`
would have it.

`StoredHost` gains `addedAt` for the date the sort actually needs — optional and
appended last, so the widget contract holds and hosts saved before it keep working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 556ece4bc0 fix(client/apple): don't call a paid app "free software"
The term means liberty, but it is read as price — and this app is sold on the App
Store, so the person most likely to read that line is the one who just paid for it.
The licences are unchanged and the claim was true in its own sense; it was the wrong
sense to leave ambiguous on a purchase.

It now says what is unambiguously so: the SOURCE is open under MIT or Apache-2.0.
Both copies (the form footer and the tvOS page), with a note on the wording so it
doesn't drift back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 9dcf943802 fix(client/apple): About stranded the licenses on iPad, and drew the icon unrounded
The iPad settings detail column is deliberately NOT a NavigationStack — an inner one
doubles the title bar, which the code says right where it sets it up. I put a
NavigationLink in it anyway, so opening Acknowledgements pushed into a context with
no back button and left the license wall with no way out. It is a sheet on iOS now,
the same one macOS already used; only tvOS still pushes, where the page really is
inside a stack.

And iOS hands over the app icon UNMASKED: the springboard applies the rounded shape
at draw time, so used raw it is a hard-cornered square — the filled corners. It gets
the squircle radius applied here. macOS is left alone: it bakes its own shape and
margins into the image, and clipping that would cut into the icon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 d2523a3b90 fix(client/apple): a tinted icon in a menu is a stencil, so the colour never arrived
Both the profile chips and the red trash failed for one reason: SwiftUI hands a menu
row's icon to UIKit/AppKit as a TEMPLATE image, and a template is a stencil — the
tint is discarded and the system fills in its own colour. `.foregroundStyle` on the
label's icon was never going to survive, and the destructive role only colours the
ROW (on iOS; macOS menus have no destructive styling at all), never the symbol.

`MenuIcon` rasterises the icon first and marks the bitmap `.original`, which is not a
stencil. The appearance goes into the render: `Color.brand` and the system reds are
dynamic, and a renderer with no appearance resolves them to the light variant
whatever the app is showing. Results are cached per icon and appearance — a menu
rebuilds its rows on every open, and re-rendering a bitmap to draw a 12pt dot each
time would be silly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 8233722e9e feat(client/apple): the scope dropdown carries the colours, the icons, and Delete
Every profile row in the layer picker now shows its colour chip, so the dropdown is
where you SEE the catalog rather than read it. It stays a Picker for that: the
platform draws the selection checkmark in its own column, which leaves each row's
icon free to be the chip. Hand-rolling the selection would have cost one or the
other.

Delete comes back to the menu it was taken from, next to New, Edit and Duplicate —
all four with icons now. Its warning goes with it: the count of hosts that fall back
and pinned cards that disappear is in the question, not discovered afterwards. The
editor sheet drops its own delete section rather than offering the same destructive
action in two places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 586da1451c feat(client/apple): one sheet for a profile, with the colours visible
The create/update flow was four menu items, three bare text alerts and a submenu of
colour NAMES with no colour anywhere on it. Making a "Work" profile meant: name it in
an alert, find it again in the scope menu, open a submenu, and pick "Amber" on faith.

A profile is a name and a colour, so they are decided together now — in one editor
sheet that serves create, duplicate and edit, with a live chip at the top showing
exactly what the host cards will render. The palette is swatches you can see, in a
grid, with a checkmark AND a ring on the chosen one (the tick alone washes out on the
pale hues) and a 44pt target under each 30pt dot. Default leads the row, so "no
colour" is a choice on the same shelf as the rest rather than the absence of one.

Duplicate opens the sheet rather than committing on the spot: it arrives carrying the
source's colour and overrides with a free name filled in, so it can be renamed before
it exists rather than after. Creating lands you in the new profile — being left on
the defaults is how you end up editing the wrong layer.

Delete moved into the sheet with the rest, and its warning is where the button is:
the count of hosts and pinned cards that will change sits under it before you press
it, not only in the confirmation after.

The name's uniqueness check now reports inline and in red instead of through a
disabled button and an alert message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 f50d13752d feat(client/apple): an About page worth opening, and profile colours you can actually pick
About was a bare "Punktfunk / Version x" header over 885 KB of license text — it
answered the one question nobody opens About to ask. It is now the shape every Mac
user already knows: the app's icon, its name, the version (selectable, because a bug
report is worth more with it), the tagline, and then the ways out — documentation,
community, source, licenses. The license wall is still `AcknowledgementsView`, one
push in on iOS/tvOS and a sheet on macOS, where a preferences tab has no navigation
stack to push onto.

Every platform hides the app icon somewhere different and tvOS can't hand it over at
all (layered assets, no single image), so there's a drawn fallback in the same brand
gradient and Geist monogram as the host cards — where the real icon is unavailable it
reads as a mark, not as a broken image. tvOS also has no browser: the addresses are
text to read off the screen rather than links to nowhere.

And profile colours were half-built by me: the catalog stored `accent`, the chips
tinted from it, and nothing in the app ever SET one. The scope menu grows "Color ▸"
over a fixed palette — the chip is small tinted text on a tinted capsule, and a
colour picked freehand lands somewhere unreadable often enough to matter. The palette
is what this client OFFERS, not what it accepts: any `#RRGGBB` another platform
writes still renders, which is what Android needs from this field.

The colour now shows wherever the profile is named — the scope switcher's dot, the
card chips, and the HUD's session line, which tints to the same colour the card that
launched it wore. A colour is an identifier, and an identifier that changes between
surfaces isn't one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 8b17e72af0 fix(client/settings): one marker per override, footers at footer size, air under Reset
The resolution marker hung off the Match-window toggle, which read as if that toggle
alone were overridden. Match-window, width and height are ONE override — they reset
together — so the marker belongs to the group, under the size control that ends it,
not to the first of the two rows that write it.

The new section footers inherited the app's 17pt body font, so the profile picker's
description sat visibly larger than every other description in Settings. Same style
as the footers that were already there.

And the marker carries a bordered button, which needs more air under the control
above it than a line of text does — most visible under the segmented refresh picker.
Added inside the marker, so every row that shows it is spaced alike.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 e9abc1a61f fix(client/settings): Reset belongs on the row's edge, not in the caption's column
The override marker sat inside the caption's width rule, so its Reset stopped where
the text stops — stranded mid-row, short of the switch it undoes. The rule is for
TEXT: Reset is a control, and it lines up with the row's own control above it. The
marker now spans the cell and only the caption is capped and inset.

The caption's reserved column widens with it (60 → 76): it should stop visibly short
of the control, not graze it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 c1d2efe112 fix(client/apple): the Name field didn't say it was the name either
Same cause as the MAC one, and it deserved the same fix rather than another
one-off: on iOS a TextField's title becomes an accessibility label the moment a
prompt exists and nothing is drawn, so "Optional — e.g. Living Room" was the whole
of what the field said about itself.

The prompt is now built per platform. macOS draws the title as a leading label, so
its prompt only hints at the value; iOS has no label to lean on, so the prompt names
the field too. One string for both leaves you either a Mac panel that says
everything twice — which is what the MAC field started doing in the last commit — or
a phone form of unlabelled boxes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 34a9e32bf8 fix(client/apple): put the add-host sheet back, and fix the MAC field where it's read
The footer was the wrong instrument and I should have checked what the screen
actually shows before adding one. On iOS a field's title becomes its accessibility
label once a prompt is given, so "MAC" was never on screen — the PROMPT was, and
"Wake-on-LAN — auto-filled when known" read as if that were the value being asked
for. It now says what the field is: "MAC address (for Wake-on-LAN, optional)". No
explanatory paragraph under the group.

Height and scrolling go back to what they were: a 392pt sheet sized to its content
with nothing to scroll. The edit sheet's profile rows are the only thing that can
outgrow that, and only they extend the detent and turn scrolling back on — a single
fixed number is what clipped them in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 282d691b76 fix(client/apple): give the add-host sheet back to adding a host
Adding a host is about reaching it: where it is, and whether we can wake it. Which
settings it streams with is a decision about a host you already have. Stacking the
profile binding and the pin toggles onto the add flow made the first thing a new
user meets a longer form than the one they came for — those rows are edit-only now,
and editing is one context-menu item away.

The pins lost their disclosure with it. A collapsed group had to animate its own
height AND the sheet's, and got both wrong — no transition, then a clipped list.
With a profile or three these are a couple of rows, and rows that are simply there
can't fail to expand.

"MAC" named the value and said nothing about why the field is there, while
"Wake-on-LAN" sat in the placeholder as if it were something to type in. The label
is "MAC address" and the section footer says what it buys: waking a sleeping host,
filled in by itself once the host has been seen. Same relabelling on tvOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 1d51d83acf fix(client/apple): the add-host sheet was Mac-sized on iPhone, and clipped its own pins
Two things, one cause: the sheet was written as a Mac panel and iOS inherited it.

The 12pt Geist and `.controlSize(.small)` exist because a grouped form's system text
reads oversized next to the app's typography in a Mac panel. Applied to iOS as well,
they made this the one sheet in the app you had to squint at — a touch target and a
field label there are not a Mac panel's. macOS keeps them; iOS keeps the app's body
size.

And the sheet's height was a single hardcoded number with scrolling switched off, so
expanding "Pinned cards" grew content into a height nobody recomputed and the toggles
were simply clipped — the disclosure looked broken because there was nowhere for it
to go. The height now follows what the sheet is showing (base fields, the picker when
profiles exist, the toggles while expanded, which is why the disclosure's expansion is
bound state now), scrolling is back on so nothing can be stranded, and `.large` is
offered alongside for the accessibility text sizes these estimates won't cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 cf55a11174 fix(client/settings): captions ran under the switch on iPhone
The `described` idiom capped its caption at 360pt, which was only ever about
READING — past roughly 46 characters a line stops scanning well on a wide Mac window
or an iPad detail pane. On an iPhone the cell is narrower than the cap in the first
place, so the cap did nothing and every caption laid out to the full cell width,
running its last line straight under the row's switch.

`CaptionWidth` now carries both limits: the reading cap, plus an iOS trailing inset
that reserves the control column (a `UISwitch` is 51pt, so 60 with room). Order
matters — the inset shrinks what the text is offered, then the cap applies, so a
narrow phone cell clears the switch and a wide pane still caps.

One rule in one place: the override marker and the iOS resolution wheel's caption
each carried their own copy of the 360, and neither would have grown the inset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 9385b61ac2 fix(client/apple): the scope switcher was a Mac control dropped into an iPhone list
One chrome served both platforms and only worked on one. In the iOS settings list
the macOS shape — a bordered `.menuStyle(.button)` with the caption stacked under it
— rendered as neither a row nor a control: icon only, no label, and four lines of
wrapped caption making the first thing on the screen a block of text.

The menu CONTENTS stay one definition; the chrome is now per platform. macOS keeps
the button menu heading the preferences window. iOS gets a standard value row —
"Editing" on the left, the current layer on the right, the system's up/down chevron
— with the caption where a caption belongs in a grouped list: the section footer.

The two prompts the menu can raise moved into a shared `profilePrompts` so both
chromes carry them without a second copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 afb33d510f fix(client/apple): anchor the profile chip to the card's trailing edge
On the title line it sat immediately after the host name, so it read as part of the
title rather than as a badge on the card. A spacer between them anchors it to the
trailing edge, and the text column now fills the card — without that it hugs its
content and "trailing" only ever means "just after the name".

The spacer is also what keeps the two apart: the host name truncates against the gap
instead of running into the chip, and the chip keeps layout priority because a
truncated host name still reads while a truncated profile name is the one thing the
chip exists to say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 82f5962bec fix(client/apple): three things the first on-glass run found
Reset read as prose. Next to "Overrides Default settings" in the same tint and the
same size, the one action that can undo an override looked like the third word of
the notice. It is a bordered control now, pushed to the far edge of the caption
line with an undo glyph.

The scope switcher showed above the About tab. It sat over the whole macOS TabView,
so on the acknowledgements page — which edits nothing — it read as belonging to
them. The tabs are tagged and the switcher sits that one out.

Pinned cards were taller than their host's. The profile chip had a line of its own,
so a card with a profile and one without were different heights and a pin stuck out
of its grid row. The chip rides the title line instead, which is also where it reads
as "this card connects with that". Prominence is fill and weight only — a chip with
a bigger type size would have brought the height difference straight back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 0a32f4eab1 fix(client/apple): the iOS slice hit the type-checker wall, and tvOS couldn't be checked at all
`ContentView.body` grew two modifiers and stopped type-checking on the iOS slice —
"unable to type-check this expression in reasonable time", a failure macOS builds
never show and the one Apple CI never builds. Split into the screen plus its
lifecycle drivers, then the prompt chain, with each alert's presentation Binding
lifted out the way the deep-link one already was.

tvOS couldn't be checked from the command line at all: HomeView imports the slide
transition, which ships with the Xcode project only because its manifest breaks
SwiftPM's whole-graph validation. `canImport` instead of a bare `os(tvOS)` gate
makes the tvOS sources compile without it, and the app still gets the transition
because there the module is present. Both slices now typecheck by hand.

The scope switcher is tvOS-gated with them: a name prompt and a nested management
menu are not what a remote does well, and §5.4 keeps profile EDITING off
controller-first surfaces in v1 — they honor bindings and render pinned cards.

Also: release the session's settings latch when a connect fails, is refused, or is
abandoned mid-handshake. Only `disconnect` cleared it, so a failed dial left the
would-be session's resolution latched over the globals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 25b127803f feat(client/apple): bind a host to a profile, connect with one, pin one as its own card
The host surfaces (design §5.2/§5.2a). A bound host wears a tinted chip that says
what a click will do. The card menu grows "Connect with ▸" — a ONE-OFF that never
rebinds, with a checkmark on the binding and an explicit "Set Default Profile" as
its last item for the users who do want to rebind from there — plus "Pin as Card ▸"
and "Copy Link".

A pinned host+profile combo becomes its own card next to its host: same record,
same live status, the profile as the prominent subtitle, one click to connect. It
is an extra grid ENTRY, never a duplicated host record — duplicating would fork
pairing, Wake-on-LAN and renames. Its menu carries only connect-shaped actions;
edit, pair, forget and remove stay on the primary card, where the thing they act on
lives. On the gamepad carousel and tvOS the same pins are tiles, which is the whole
point: focus-and-press is what those surfaces do well, and menus are not.

The edit sheet binds and pins; both rows vanish when no profiles exist, so a user
who never makes one sees exactly today's sheet. A dangling binding renders as
"Default settings (profile deleted)" and is cleaned up on save.

The speed test finally writes where the tested host reads. Unbound: the global, as
before. Bound to a profile that overrides bitrate: that override. Bound to one that
inherits it: both are offered, because either is defensible and guessing would
silently pick one. Every button names its target, and the probe now runs at the
mode that host would actually stream — the measurement is the streaming path.

Shortcuts gains `ProfileEntity` over the App-Group catalog and a Profile parameter
on Connect, still round-tripping through the URL router rather than opening a second
connect path. Connect and Wake drop their iOS wall — AppIntents is real on macOS and
tvOS, and "Stream Desktop with Work" from Spotlight was the ask; only the phrases
provider stays iOS-gated, since it bundles the LiveActivityIntent. The deep-link
observer moved out with them: an intent that posts to nobody is a shortcut that
silently does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 3ae9b83290 feat(client/apple): the settings screen edits profiles, and says which rows it changed
One settings surface, two layers. A scope switcher at the top of the Mac window and
the iOS sidebar swaps the whole screen between Default settings and one profile's
overrides; the section builders stay the single definition of every row and just
change which layer their binding reads and writes (design §5.1). A parallel profile
editor would have drifted from this one field by field, and the revamp's captions
and curation would have had to be written twice.

`SettingsFields` is where a row's three faces meet — the UserDefaults key its global
lives under, the overlay slot an override lives in, and the name a reset carries.
Keeping them in one place is what stops a row from writing an override the reset
button can't find.

Every row shows the EFFECTIVE value, so an untouched row reads as the live global.
Touching a control records the override — always, even when the new value equals
today's global, because that is a pin and the profile must keep it when the global
later moves. Nothing is inferred by diffing at save time.

Overridden rows say so (accent dot + "Overrides Default settings") and carry the
only way back: an explicit Reset. That pair is not garnish. The model deliberately
never infers "not overridden" from a value comparison, so without a reset affordance
a profile is a one-way door.

Tier-G and tier-H rows don't render in profile scope at all — this device's speaker,
microphone and channel, its pointer capture and haptics, the HUD corner, the library
switch, the gamepad-UI switch, which physical pad is forwarded, and auto-wake, which
is about a host and a network rather than about Game vs Work. Sections that would be
left empty (Session off macOS, Library) collapse instead of rendering a bare group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehlerandClaude Opus 5 858cf0e535 feat(client/apple): profiles, resolved once per connect instead of ten times mid-session
The Apple half of settings profiles (design/client-settings-profiles.md §4) starts
with the thing the desktop shells got for free from the shared Rust core: a model,
and one place that resolves it.

`SettingsOverlay`/`StreamProfile`/`ProfileCatalog` mirror `pf-client-core::profiles`
field for field, unknown-key carry-through included — an older build that opens a
profile a newer one wrote must not gut it on save. The catalog lives in the App
Group suite beside the saved hosts, because the things that point at it are fields
on the host record: `StoredHost` gains `profileID` and `pinnedProfileIDs`, both
optional and appended last, because that JSON is a widget contract.

`EffectiveSettings` is the resolution — globals with the session's overlay on top,
computed once at connect and latched in `SessionSettings` for the rest of it. That
latch is the point. Ten sites across the app AND the kit read `UserDefaults`
directly mid-session (the presenter's priority and VRR, the vsync flip, the
match-window follower, the scroll sign, the touch model, the mouse model, 4:4:4,
the audio endpoints); a profile that reached some of them and not others would be
worse than no feature at all. They now read the latch, which off-session is the
plain globals — byte for byte what they saw before.

The deep-link grammar grows up with it: `DeepLink` becomes a full port of
`deeplink.rs` — routes, host-ref forms, `fp`/`host` recovery, one-off `profile`,
and every refusal code — and the Swift suite now runs
`clients/shared/deeplink-vectors.json` itself, read from the source tree so there
is no second copy to drift. All 44 cases green. The router refuses what it can't
honor by name: a profile that doesn't exist, an ambiguous one, a fingerprint that
contradicts the pin. A shortcut that streams with the wrong settings is worse than
one that explains itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00
enricobuehler 9b00ad6658 fix(client/windows): prove the deep-link module the new deny caught
ci / docs-site (push) Successful in 1m18s
ci / web (push) Successful in 2m1s
apple / swift (push) Successful in 4m46s
android / android (push) Failing after 6m31s
deb / build-publish-host (push) Failing after 5m29s
ci / rust (push) Failing after 7m33s
arch / build-publish (push) Failing after 7m35s
ci / bench (push) Failing after 7m30s
deb / build-publish (push) Failing after 6m29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 24s
decky / build-publish (push) Successful in 31s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
ci / rust-arm64 (push) Successful in 10m5s
deb / build-publish-client-arm64 (push) Successful in 7m48s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m5s
docker / deploy-docs (push) Successful in 27s
flatpak / build-publish (push) Failing after 8m30s
windows-host / package (push) Successful in 19m0s
windows-host / winget-source (push) Skipped
docker / build-push-arm64cross (push) Successful in 5m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 14m1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 3m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 14m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 2m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m49s
release / apple (push) Successful in 29m9s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m58s
apple / screenshots (push) Successful in 23m16s
`deeplink.rs` arrived with c27065c2 (punktfunk:// handling) while this branch was in flight, so its
eight `unsafe` blocks predate the crate's `deny(clippy::undocumented_unsafe_blocks)` and only became
visible on the rebase. This is the ratchet doing its job on brand-new code, and it is also the whole
argument for turning the convention into a lint: the module was written correctly and documented
prosaically, but nothing had required a proof at each block.

The one worth reading is the `WM_COPYDATA` handler, which dereferences an `lparam` from ANOTHER
PROCESS and builds a `u16` slice from the sender's pointer and length. What makes that sound is not
that the sender is trusted — anyone can post `WM_COPYDATA` — but that the OS marshals both the
struct and its buffer into this process and keeps them valid for the handler's duration, and that
`len` is `cbData / 2` so the slice cannot outrun the copy even for an odd `cbData`. The proof says
that, so the next reader knows which half of it is a guarantee and which is just a tag check.

⚠ Caught only because Windows was re-verified AFTER the rebase. A Linux-only check was clean —
`deeplink.rs` does not exist there — so pushing on that evidence would have re-broken Windows CI,
which is the same mistake as the `warn`-that-was-really-`deny`, one rebase later.

Verified: Windows .47 full CI clippy set + the Windows-only crates, rc=0, pf-capture's 18 tests
pass; Linux .21 fmt + both CI clippy steps rc=0.
2026-07-29 08:54:12 +02:00
enricobuehler bcfb833ff7 fix(abi): the panic boundary was documented as universal and wasn't
`abi.rs`'s header states "panics never cross the boundary: every entry point is wrapped in
`catch_unwind`". Of its 78 `extern "C"` entry points, 16 were not. Ten of those are fine and were
always fine — `punktfunk_abi_version` returns a constant, and the nine `punktfunk_connect*` shims
forward every argument unchanged into a guarded implementation — but five ran real code bare:
`session_free`, `connection_close`, `connection_disconnect_quit`, `reanchor_gate_free`,
`reanchor_gate_arm`.

The three `*_free`/`close` ones are the point. They run `Drop` for an entire `Session` or
`Connection` — transports, threads, mutexes — and a `Drop` impl that unwraps a poisoned lock panics.
Since Rust 1.81 that unwind is a hard abort rather than undefined behaviour, so this is not a
soundness hole; it is worse-behaved than it looks. Aborting the CALLER'S process because one of our
teardown paths hit a poisoned mutex is not an acceptable failure mode for a library, and it would
present as "the app died in punktfunk_session_free" with no Rust backtrace to explain it.

Adds `guard_void`, the sibling of `guard` for entry points with no status to report through: it
catches, logs, and returns — right for teardown, where the object is going away regardless. The five
are wrapped in it.

The header now says what is actually true, including WHICH entry points are deliberately bare and
why, so the next reader can check the claim instead of trusting it. That is the same failure this
sweep found in `MappedView`'s `Sync` proof: a stated invariant that a reviewer would rely on and that
had quietly stopped holding.

Verified: Linux .21 fmt + both CI clippy steps rc=0, and the C ABI harness — which exercises the
`*_free`/`close` paths it touches — still passes, 4 frames byte-exact through lossy loopback.
2026-07-29 08:48:42 +02:00
enricobuehler aa070f2d55 feat(ffi): hand-mirrored C structs are now layout-checked at compile time
The sharpest memory-safety risk left in this codebase is not an `unsafe` block — it is a
hand-written `#[repr(C)]` mirror of an external C struct. Get a field offset wrong and nothing fails
to compile and nothing reliably crashes: the library reads a pointer, a length or a pitch out of the
wrong bytes. Eleven such structs across five files had NO check at all.

Guarded here, each next to the struct it protects:

* `AVCUDADeviceContext`, and `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext`. `ffmpeg-sys-next`
  binds none of them, so these mirrors are the only definitions — and we WRITE through them
  (`cuda_ctx`, `device`, `bind_flags`). ⚠ The D3D11VA pair is duplicated VERBATIM in two crates
  (pf-encode's `ffmpeg_win.rs`, pf-client-core's `video_d3d11.rs`) because neither can depend on the
  other; they must agree with libav and with each other, and now a drift in either is a build error.
* The six cuda.h structs. Three were already asserted — but only in `#[cfg(test)]`, so the check ran
  when someone ran the tests and never in a release build. They are `const` now. The other three,
  including `CUDA_MEMCPY2D` which is filled on EVERY zero-copy frame, had nothing.
* `MsghdrX`, Darwin's `msghdr_x`, which `libc` does not expose. Its layout is not reviewable by eye:
  the 32-bit fields force padding before each following pointer, so `msg_iov` sits at 16 and not 12.
  `sendmsg_x`/`recvmsg_x` take the pointer and length from it.
* `IPolicyConfigVtbl` — the sharpest of the set. It mirrors an UNDOCUMENTED COM interface, and
  `set_default_endpoint` is called by SLOT INDEX through a ten-entry `_reserved` gap that carries no
  names to anchor a review. A field added or resized above it does not break the build; it calls a
  different function pointer through a mismatched signature.

Every assertion is `const _: () = assert!(..)`, so it holds on every build including release and
cannot be skipped. The compiler verified the numbers — the sizes and offsets asserted here are the
ones the target actually produces, on each platform that compiles the struct.

Verified: Linux .21 fmt + both CI clippy steps rc=0 (CUDA + libav CUDA mirrors); Windows .47 full CI
clippy set rc=0 + pf-capture tests (D3D11VA pair, COM vtable); macOS `cargo check -p punktfunk-core`
(MsghdrX — the only platform that compiles it).
2026-07-29 08:48:42 +02:00
enricobuehler 8a5a5edc37 fix(small crates): the proof lint now covers every first-party crate but one
Six crates were still unguarded, and all six are OURS — none vendored: pf-console-ui, pf-gpu,
punktfunk-tray, clients/windows, tools/display-disturb, wdk-probe. Two of them ship (the tray and
the Windows client), so "small" was about item count, not exposure.

Five are closed here. Only 17 of their 75 unsafe items actually lacked a proof — pf-gpu,
punktfunk-tray and display-disturb were already fully documented and needed nothing but the deny,
which is the good case: the convention was being followed, just not enforced.

The 17 that were missing are the usual Win32/COM shapes, and two were worth stating properly.
`clients/windows`'s `GetCurrentPackageFullName` is called with `len = 0` and no buffer — that is the
documented identity PROBE, which writes nothing, and reading it as a normal query would be a
mistake. `pf-console-ui`'s two `destroy_image_view` calls are the load-bearing ones: the comment
above one already argued that in-flight sampling of that slot ended two presents ago (the ring
alternates and the presenter waits its fence before each record), which is exactly the kind of
reasoning a `// SAFETY:` should carry and it was sitting there unlabelled.

Also fixes a real Windows-only clippy error this uncovered: `pf-gpu` had a
`#[cfg(target_os = "windows")]` fn AFTER its `mod tests`, tripping `items_after_test_module`. It
never fired on Linux (the item does not exist there) and no CI job clippies pf-gpu on Windows, so it
sat unseen. Moved above the test module.

Remaining: `wdk-probe` (26 items) alone, and only because it needs the WDK to build — .47 cannot,
so nothing here can verify a deny on it.

Verified: Linux .21 fmt + both CI clippy steps rc=0; Windows .47 the four Windows-relevant crates at
`-D warnings` rc=0.
2026-07-29 08:48:42 +02:00
enricobuehler 65cd388a52 fix(presenter,core): close the last of the proof-lint hole — the Vulkan contract, stated once
pf-presenter's 120 sites are `ash` calls almost without exception, so 120 independent arguments
would have been 120 restatements of the signature — the exact noise this program exists to remove.
They get the `abi.rs` treatment instead: the Vulkan contract stated once in `lib.rs`, each site
naming which of three shapes it is.

The three are not equal, and separating them is the point. CREATE and RECORD carry no real
precondition — the device is owned, the builders are locals, nothing executes until submit. DESTROY
does: the GPU must not still be using the object, and that is established by the path (a fence wait,
a `queue_wait_idle`, a retired swapchain), not by the call. Those sites say so, because getting it
wrong is a use-after-free no type catches. The contract also tells the next person that a block
outside the three shapes needs a real proof, and that writing "as above" is the signal it doesn't
belong in them.

punktfunk-core's Windows half is finished here too: `qos_windows.rs`'s `GetLastError` reads (called
before anything can reset the thread's error slot) and `udp/windows.rs`'s control-message write,
whose argument is that `ctrl` is sized by `WSA_CMSG_SPACE(4)` — computed two lines up — so header
plus payload cannot run past it, and `write_unaligned` is used because `WSA_CMSG_DATA` offers no
alignment guarantee.

⚠️ THE WINDOWS BLIND SPOT BIT A THIRD TIME. A Linux measurement put this crate pair at 113; the
real number was 129 — `d3d11.rs`, `win32.rs`, `qos_windows.rs`, `udp/windows.rs` are all
`cfg`-hidden. Every crate in this sweep had to be finished on .47 after being "done" on .21. For a
cross-platform crate the Linux number is a lower bound, never the answer.

All three crates now deny `undocumented_unsafe_blocks`, which was the goal: it applied to 8 of 11
crates carrying unsafe, and the three exempt ones held 381 items between them — including the C ABI
surface and the presenter. Verified: Linux .21 fmt + both CI clippy steps rc=0; Windows .47 all four
closed crates clippy `-D warnings` rc=0 plus the full Windows CI clippy set and pf-capture's tests.
Only small crates remain unguarded (75 items total, largest 26).
2026-07-29 08:48:42 +02:00
enricobuehler dda9ed1aa2 fix(core): close the proof-lint hole in punktfunk-core — the ABI contract, stated once
146 sites, 141 of them in `abi.rs`, the `extern "C"` surface `cbindgen` turns into
`punktfunk_core.h`. Writing 141 independent arguments would have been the wrong answer and would
have read like it: they are instances of ONE contract in six shapes — opaque handles reached only
through `as_mut()`/`as_ref()` (so null becomes a status, never a dereference), caller-owned
out-params (null-checked exactly where the header calls them optional), NUL-terminated-or-null C
strings through `opt_cstr`, unchanged forwarding to a versioned entry point, fixed-length output
buffers, and `addr_of!` reads.

So the contract is stated once at the top of the file, the way `pf-win-display`'s CCD contract is,
and each site says which instance it is. Two properties that hold everywhere are stated there and
not repeated 141 times: no pointer is retained past the call that received it, and every entry point
runs inside `guard`'s `catch_unwind`, so a panic becomes a status code instead of unwinding into C.

The `addr_of!` sites got a real proof rather than a shape, because that one is load-bearing and
non-obvious: it forms a raw pointer WITHOUT creating a reference precisely because the caller's
struct may be an older, smaller version, so the field must be read by offset and not through a `&`.

`transport/udp/linux.rs`'s five are genuinely individual — two `zeroed()` POD initialisations, and
the `sendmmsg`/`recvmmsg`/`CMSG` trio, where the argument worth writing down is that
`msg_controllen` is set to `CMSG_SPACE(size_of::<u16>())` and the `CmsgBuf` is 64 bytes, so the
kernel cannot write past the control buffer.

punktfunk-core now denies `undocumented_unsafe_blocks`. Verified: Linux .21 fmt + both CI clippy
steps rc=0, and — since this is the C surface and comments alone should not change it — the C ABI
harness still passes, 4 frames round-tripped byte-exact through lossy loopback.
2026-07-29 08:48:42 +02:00
enricobuehler e1ddd49e37 fix(client-core,ffvk): close the proof-lint hole in two of the three unguarded crates
`clippy::undocumented_unsafe_blocks` is what makes the SAFETY convention a rule rather than a habit,
and three crates had never adopted it — pf-client-core (91 unsafe items), pf-presenter (123) and
punktfunk-core (167) — while every other subsystem crate denied it. That gap is why the decoders'
`unsafe impl Send`s carried a one-line aside instead of an argument: nothing required one.

pf-client-core and pf-ffvk now deny it, with a proof written for all 58 + 3 sites they had.

⚠️ 44 of those 58 were WINDOWS-ONLY — `clipboard.rs` 24 and `video_d3d11.rs` 20 — and invisible to
the Linux measurement that sized this work at 14. Same trap as the E0133 sweep: a Linux-only survey
of a cross-platform crate undercounts by whatever the `cfg` hides, here by 3x. Landing the deny on
the strength of that number alone would have re-broken Windows CI, which is exactly the mistake this
session already made once with the `warn`-that-was-really-`deny`.

The proofs say what is actually load-bearing rather than restating the call. In `clipboard.rs` that
is the ownership split Win32 requires and nothing in the code stated: `GetClipboardData` returns a
handle BORROWED from the clipboard (never freed here), while `GlobalAlloc` + `SetClipboardData`
TRANSFERS ownership to it (which is why nothing frees that one either) — two opposite rules, three
lines apart. In `video_d3d11.rs` the recurring one is that libav's `get_format` list is
NUL-terminated by `AV_PIX_FMT_NONE`, which is what keeps the walk in bounds.

Remaining: punktfunk-core (~146, of which `abi.rs` is 141) and pf-presenter (~108). Both want the
"state the contract once" treatment — abi.rs's sites are a handful of repeating shapes (`opt_cstr`
on caller C strings, null-guarded out-param writes, forwarding calls), not 141 distinct arguments.
Note the vendored `fec-rs` (18 sites) is a separate path-dependency crate, so it is out of scope
rather than something to prove.

Verified: Linux .21 fmt + both CI clippy steps rc=0; Windows .47 `-p pf-client-core` clippy
`-D warnings` rc=0 (the only place the 44 are visible), plus the full Windows CI clippy set and
pf-capture's 18 tests.
2026-07-29 08:48:42 +02:00
enricobuehlerandClaude Opus 5 192635545d fix(cli): the lockfile never learned about the new crate
audit / bun-audit (plugin-kit) (push) Successful in 55s
audit / bun-audit (sdk) (push) Successful in 54s
audit / bun-audit (web) (push) Successful in 54s
audit / docs-site-audit (push) Successful in 54s
audit / pnpm-audit (push) Successful in 23s
audit / cargo-audit (push) Successful in 2m30s
ci / docs-site (push) Successful in 1m2s
ci / web (push) Successful in 3m12s
apple / swift (push) Successful in 5m16s
android / android (push) Failing after 6m58s
ci / bench (push) Successful in 5m36s
audit / license-gate (push) Successful in 8m53s
ci / rust-arm64 (push) Failing after 8m52s
ci / rust (push) Failing after 8m53s
deb / build-publish (push) Failing after 5m16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 23s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 22s
decky / build-publish (push) Successful in 29s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
docker / build-push-arm64cross (push) Successful in 18s
docker / deploy-docs (push) Successful in 1m0s
windows-host / package (push) Successful in 12m44s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m47s
arch / build-publish (push) Successful in 15m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m50s
deb / build-publish-client-arm64 (push) Successful in 7m37s
deb / build-publish-host (push) Successful in 10m24s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m21s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m1s
flatpak / build-publish (push) Failing after 9m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 13m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13m55s
release / apple (push) Successful in 29m42s
apple / screenshots (push) Successful in 22m31s
Adding `clients/cli` as a workspace member without regenerating `Cargo.lock` breaks every
packaging script — deb, rpm, arch, nix and flatpak all build with `--locked`, which refuses
to resolve a member the lock doesn't know. It slipped through because the CLI was only ever
built on the remote boxes, and their lock updates stayed on those boxes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 01:19:34 +02:00
enricobuehlerandClaude Opus 5 bf98102714 feat(packaging): ship punktfunk everywhere, and deprecate the session's stray pairing flag
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m5s
android / android (push) Canceled after 3m17s
apple / swift (push) Canceled after 3m18s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m18s
ci / rust (push) Canceled after 3m19s
ci / rust-arm64 (push) Canceled after 3m20s
ci / bench (push) Canceled after 3m18s
deb / build-publish (push) Canceled after 2m23s
deb / build-publish-host (push) Canceled after 2m13s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 3m25s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
The rest of C1. A CLI that only exists in a dev tree is not a door anyone can use, so it
now builds and installs in deb, rpm, arch, nix, flatpak and MSIX alongside the client and
the session. On Windows it gets its own `AppListEntry="none"` application with the alias
`punktfunk.exe` — a command, not a Start-menu tile — which is what the Playnite importer
will shell to for `punktfunk library <host> --json`.

`punktfunk-session --pair` prints a deprecation notice and forwards. Pairing is a trust
ceremony and belongs to the brain, fronted by `punktfunk pair` or a shell; a renderer
owning one is precisely the mixing of concerns this split exists to undo. It keeps working
for a release, because someone's provisioning script is calling it today.

Decky's invocations are untouched and verified still working (`--list-hosts` JSON shape
unchanged, `--reachable` exit codes unchanged): those flags are a frozen compat contract
until Decky migrates to the verbs at its own pace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 01:16:17 +02:00
enricobuehlerandClaude Opus 5 f32c3aaa71 feat(session): spec mode — the renderer stops resolving policy and stops writing settings
C2 of design/client-architecture-split.md, and the last of the session's overreach.

`--resolved-spec <path>` hands the session everything it needs already resolved —
effective settings, the host's clipboard decision, the profile's name — and in that mode it
performs ZERO store reads. It had been re-deriving all three, which meant policy was being
evaluated inside the thing that draws pixels, and that the spawner and the child could
disagree about a file either of them might have written in between. First-party spawns
(the shells, the CLI) always pass one now.

The compat path stays for hand-run `punktfunk-session --connect` and old Decky scripts —
but it calls the SAME helper, so the two modes cannot drift; it is the identical function
invoked in-process instead of by the parent. A spec that is named but unreadable fails
loudly rather than quietly falling back: a spawner that asked for exact settings must not
get store-derived ones instead.

The match-window write-back is gone too. The callback used to load-modify-save the shared
settings file from inside the renderer — one of that file's five concurrent writers, for a
value only the parent needs. It now reports `{"window":{w,h}}` on stdout and the spawner
persists it, on a real change only. A hand-run session still persists its own window,
because nobody is listening to its stdout there and the event alone would drop the value.

Verified on .21: a spec naming a profile that doesn't exist in the catalog is honoured
(proving no lookup happened), a missing spec errors instead of falling back, and the CLI's
spec file is written and cleaned up per launch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 01:16:17 +02:00
enricobuehlerandClaude Opus 5 0594056e02 feat(cli): punktfunk — one headless front-end over the brain
C1 of design/client-architecture-split.md. There were four overlapping, none-complete CLIs:
the Linux shell's rich headless verbs, the Windows shell's near-none (it could not start a
stream at all), the session's own, and punktfunk-probe's diagnostics. This is the one a
script or a plugin should reach for, and it is a FRONT-END, not the brain — policy stays in
`pf-client-core` and the GUI shells keep calling it in-process. Shelling out for connects
would have traded duplicated code for a duplicated IPC protocol.

    punktfunk pair | hosts list/add/forget | wake | library | launch | open
              reachable | speed-test | profiles list | reset

Because it runs the same plan builder and the same wake machine as a card click, `launch`
and `open` WAKE A SLEEPING HOST — which the Linux shell's exec-style `--connect` never did;
it fired a packet at best and dialled into the void. Host references resolve through the
shared `resolve_host`, so `punktfunk launch desk` and `punktfunk://connect/desk` cannot
disagree about which box "desk" is.

Exit codes extend the session's contract so a consumer can branch without parsing prose:
0 ok, 2 connect, 3 trust, 4 renderer, 5 nothing matched what you named, 6 refused because
it needs a person. That last one is why `pair` and `reset` check for a terminal before
prompting — a CLI that blocks a CI job on a hidden question is a hang, not a UX.

Two deliberate non-features. `speed-test` measures and prints but does NOT apply: which
layer a bitrate belongs in is a decision the GUI makes with the user (global vs the bound
profile), and a CLI silently rewriting a profile is exactly the surprise that rule exists
to prevent. And `open` refuses an unknown host rather than pairing it — a URL may never
pair or trust on its own, at any surface.

`library` keeps TSV as its default output because that is what Decky's consumer parses;
`--json` is the door for tools, and the Playnite importer shells to exactly that. The
existing shell flags are untouched: they are a frozen compat contract until Decky migrates.

Verified on .21 against hand-authored stores: hosts list (TSV and JSON, with each host's
resolved profile and pins), profiles list, a launch honouring the binding ("Game"), a
one-off overriding it ("Work"), the same through `open` with `profile=`, and the refusal
codes (6 for an unknown host, 5 for `punktfunk://pair/...`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 01:16:17 +02:00
enricobuehlerandClaude Fable 5 7fb2ad266e ci: a newer push supersedes the queued run — concurrency groups everywhere
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
audit / cargo-audit (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Canceled after 0s
audit / bun-audit (sdk) (push) Canceled after 0s
audit / bun-audit (web) (push) Canceled after 0s
audit / docs-site-audit (push) Canceled after 0s
audit / pnpm-audit (push) Canceled after 0s
audit / license-gate (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Canceled after 0s
windows-drivers / driver-build (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
release / apple (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
Every push-triggered workflow now declares
  concurrency: { group: workflow+ref, cancel-in-progress: true }

A busy push cadence on main was stacking ~10 queued runs per commit —
the fleet was 110 runs behind tonight — while only the newest commit's
canary matters. Now each new push cancels the superseded queued/running
run of the same workflow. Release tags are unaffected: every tag is its
own ref, so tag runs never cancel each other. Gitea honors this for push
triggers (PR triggers don't cancel yet: gitea#35933).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:07:00 +02:00
enricobuehlerandClaude Fable 5 cd67bd5d30 ci(audit): scope decky to shipped deps; make docs-site non-blocking deterministically
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Canceled after 0s
windows-drivers / driver-build (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
audit / cargo-audit (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Canceled after 0s
audit / bun-audit (sdk) (push) Canceled after 0s
audit / bun-audit (web) (push) Canceled after 0s
audit / docs-site-audit (push) Canceled after 0s
audit / pnpm-audit (push) Canceled after 0s
audit / license-gate (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
First real-runner dispatch (run 13627) surfaced two calibration issues:
* pnpm audit flagged 6 high advisories — all in decky's devDependencies
  (the rollup build toolchain), which never ship: the plugin bundle carries
  prod deps only. --prod scopes the gate to what users get, matching the
  locally-validated state (clean).
* docs-site relied on job-level continue-on-error, which act_runner does
  not reliably honor — replaced with a step-level '||' warning so the known
  advisories can never take the run red while staying visible in the log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 00:59:29 +02:00
enricobuehlerandClaude Fable 5 5d018eea7c ci: the supply chain accounts for itself — per-release SBOM, full-tree audits, a real license gate
CRA Annex I Part II groundwork (see punktfunk-planning design/cra-readiness.md, Phase 1):

* sbom.yml + scripts/ci/gen-sbom.sh: every vX.Y.Z release gets a CycloneDX
  SBOM attached — syft over both Cargo.locks, all Bun/pnpm trees and the
  Swift Package.resolved (2,667 components), merged with
  compliance/sbom/manual-components.cdx.json for what no lockfile records
  (pyrowave/Granite/volk/Vulkan-Headers pins, libvpl, FFmpeg, SDL3,
  VB-CABLE, punktfunk-gamescope).
* audit.yml: bun audit now covers sdk + plugin-kit (not just web), decky's
  pnpm tree is scanned, and docs-site runs non-blocking until its known
  CMS-chain advisories are cleared. All shipping trees verified green today.
* license-gate: about.toml's allowlist claim is finally enforced —
  cargo-about 0.9.1 with --fail over BOTH workspaces. The old [crate.clarify]
  license-only syntax fails to deserialize under 0.9; migrated ring to a
  per-crate accepted extension and dropped the stale aws-lc-sys entry
  (workspace is ring-only). Both gates validated green locally.
* drivers/Cargo.lock: sync the pf-dualsense→pf-gamepad rename — the crate
  rename updated the manifest but the Windows-only lockfile was never
  regenerated; cargo-about's metadata pass caught it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 00:59:29 +02:00
enricobuehlerandClaude Opus 5 c27065c260 feat(client/windows): punktfunk:// opens the Windows client, and cards can write a shortcut
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
WP4 / D2. Protocol activation of a full-trust packaged app delivers the URI as the command
line, so a browser prompt, `start punktfunk://…` and a written `.lnk` all arrive the same
way — as a positional argument, the same door the Linux shell uses.

What Windows does not give us is single-instancing: unlike GApplication, a second
activation is simply a second process. So the first instance claims the named mutex
`unom.punktfunk.client` and any later one hands its URL to the winner over `WM_COPYDATA`
and exits — one window, and the link opens where the user's hosts already are. The hand-off
retries while the primary's window is still coming up (a shortcut double-clicked during
startup is the ordinary case), and a hand-off that finds nobody falls through so this
process becomes the shell that opens it. A link is never silently dropped, which is also
why the inbox is a queue: two shortcuts in quick succession are two links.

Routing is the same four lines of translation as on Linux, because the decisions belong to
`plan_from_link`: a resolved link becomes the call a tile click makes, with the same wake,
trust and error surfaces. Never preempting a live session is checked here, since only this
layer knows one is running.

The manifest declares the protocol and an app execution alias rather than writing registry
keys, so uninstall leaves nothing behind — and Windows' own "allow this app to handle
punktfunk links?" prompt is the origin friction the design wants, not duplicated in-app.
(Manifest comments are free of double hyphens: XML forbids them and makepri rejects the
whole file, which is how the console flag broke the v0.15.0 build. A test parses the
substituted manifest and asserts both declarations, so that can't rot silently.)

"Create shortcut…" writes a `.lnk` targeting the ALIAS with the URL as an argument, not a
`.url` internet shortcut: both work while the scheme is registered, only this one still
works if it isn't, and targeting the alias keeps it valid across updates when the package
path changes.

Verified on the CI VM (192.168.1.133): clippy -D warnings clean and its tests green.
Not yet exercised on glass — `start punktfunk://…` cold and with the app running, and a
written shortcut, still want a real desktop session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:49:56 +02:00
enricobuehler d2b6f5b65f docs(unsafe): audit all 49 unsafe impl — one proof was wrong, four were missing
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Canceled after 0s
windows-drivers / driver-build (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
`unsafe impl Send`/`Sync` is the highest-risk unsafe category here and the one this program had
never looked at: a wrong one is cross-thread UB that is invisible at every call site, with no
`unsafe` block to catch a reviewer's eye. 49 of them (41 Send, 8 Sync). Two results.

**`MappedView`'s `Sync` proof was factually wrong.** It read "only exposes accessors that are safe
under concurrent use" — they are not. `read_u8`/`write_u8`/`read_u16` are plain unaligned accesses
through `&self`, and `&MappedView` really is shared across threads: `ChannelState::data()` hands out
`&'static MappedView`, and pf-xusb, pf-mouse and pf-gamepad all dispatch
`WdfIoQueueDispatchParallel` with `NumberOfPresentedRequests = u32::MAX`. The struct's own doc had
the right story — consistency is the channel protocol's job — but the `unsafe impl` stated a
different, stronger claim, which is the one a reviewer checking that line would rely on.

The impl is still sound, for a reason worth writing down: these bytes are mapped into ANOTHER
PROCESS that writes them concurrently, so Rust-level exclusivity over them is unachievable no matter
what this type does. Sync fields go through the atomic accessors; the plain ones cover only
protocol-fenced bytes. The proof now says that, and states the rule it implies for accessors added
later — plain path only for bytes the protocol already fences.

**Four `Send` impls carried a one-line aside instead of a proof** — the pf-client-core decoders and
`DrmFrameGuard`. All four are sound, and each now says why, including the two facts that make them
work and were nowhere stated: libav permits a codec context to be used from a thread other than its
creator provided use is serialised (`&mut self` is that serialisation), and D3D11's immediate
context is thread-AGNOSTIC rather than thread-safe — it wants serialised use, not one fixed thread.
Each also records that it is deliberately not `Sync`, which is the invariant a future `impl Sync`
would silently break.

Every one of the 49 now carries reasoning. Verified: Linux .21 fmt + both CI clippy steps rc=0;
Windows .47 `-p pf-client-core` clippy `-D warnings` rc=0 (it compiles the `video_d3d11` proof the
Linux run cannot see). The pf-umdf-util edit is comment-only — confirmed by diff, since that crate
needs the WDK, which .47 does not have.
2026-07-29 00:37:14 +02:00
enricobuehler e2fd1c586b Merge pull request 'fix(vaapi): use FFmpeg bt2020nc matrix name' (#16) from sassycrown/fix-vaapi-bt2020nc into main
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
Reviewed-on: #16
2026-07-28 22:34:27 +00:00
sassycrownandOpenAI Codex 22936bbc89 fix(vaapi): use FFmpeg bt2020nc matrix name
ci / rust (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 0s
ci / web (pull_request) Canceled after 0s
ci / docs-site (pull_request) Canceled after 0s
ci / bench (pull_request) Canceled after 0s
apple / swift (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
android / android (pull_request) Canceled after 0s
FFmpeg rejects bt2020 as an out_color_matrix value. Use its canonical bt2020nc name for non-constant-luminance BT.2020, matching the existing AVCOL_SPC_BT2020_NCL encoder VUI.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-07-29 00:26:57 +02:00
enricobuehlerandClaude Opus 5 f5de661c9e feat(client/windows): overridden rows are marked and resettable, and the speed test targets the right layer
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
The two remaining pieces of P1 on Windows, both closing the same kind of gap the Linux
client closed earlier.

Overridden rows in profile scope now say so — an accent-marked caption — and carry the
only way back to inheriting. Without it a profile is a one-way door here too, because
overrides are recorded on touch and never inferred from comparing values. Reset calls the
shared `SettingsOverlay::clear`, so the list of what can be cleared stays in one place
across both shells. It also bumps a revision counter: the catalog changes behind the
controls and nothing the page reads as state does, and root state compares before
re-rendering, so without the bump the row would keep showing the value it just dropped.

The speed test writes to the layer the TESTED host reads bitrate from, not the global —
measuring one host used to re-tune every other one. Resolved exactly the way a connect
resolves it: the one-off the test was started with (a pinned tile carries one), else the
host's binding, else the global; a dangling binding falls back. The button names its
target, so it says where it will write before you press it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:21:13 +02:00
enricobuehler 60a85a1344 refactor(encode/windows): fourth fence off — ffmpeg_win.rs, and D3d11Hw::new joins VaapiHw
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
47 sites, 25 of them raw pointer dereferences: the same libav shape as `vaapi.rs`, so the same
verdict — a proof here carries an argument rather than restating a call.

`D3d11Hw::new` loses its marker for exactly the reason `VaapiHw::new` did, and the trio is now the
clearest statement of the rule in the tree: `CudaHw::new` KEEPS it (handed a `CUcontext`),
`VaapiHw::new` and `D3d11Hw::new` do not (a borrowed COM wrapper and scalars, opening what they
need themselves). Three near-identical libav context builders, sorted by whether a caller can hand
them something broken.

Also unmarked, all for the same reason — no parameter a caller can get wrong: `immediate_context`
and `ensure_staging` (`&ID3D11Device` IS the live-device guarantee), `send` and `ensure_sws` (only
scalars, operating on the `AVFrame`/`SwsContext` the struct owns from its constructor to `Drop`),
and `test_hw_device`, whose `# Safety` section described its own body — "calls the DXGI enumeration
FFI and `make_device`" — while taking a single `u32`.

`open_win_encoder` keeps its marker (raw `*mut AVBufferRef` pair) and gains the proof it lacked,
including the null case the system path relies on: both refs may be null, the `is_null` guards keep
`av_buffer_ref` off that path, and the refs it does take are new ones the codec context adopts.
`D3d11Hw::new`'s proof records the ordering the multithread-protection fix depends on — the device
store must precede `av_hwdevice_ctx_init`, which reads it.

14 fenced files -> 10. Verified on the Intel box .47: `-p pf-encode --features nvenc,amf-qsv,qsv`
clippy `-D warnings` rc=0, the full Windows CI clippy set rc=0, pf-capture's 18 tests pass, and —
because this is the live D3D11VA construct path, not dead code — `d3d11hw_alloc_drop_cycles` passes
on real Intel silicon: 8 construct/drop cycles, no abort. Linux .21: fmt + both CI clippy steps rc=0.
2026-07-29 00:20:35 +02:00
enricobuehler 8e7ba00d2d refactor(encode/linux): third fence off — vaapi.rs, and VaapiHw::new needed no marker at all
Same criterion as the last two: `vaapi.rs`'s sites are pointer dereferences and libav ctx calls,
not ash, so a proof here carries an argument. Three regions, three arguments.

`VaapiHw::new` also loses its `unsafe fn` outright, and the contrast with its CUDA twin is the whole
point: `CudaHw::new` keeps the marker because it is HANDED a `CUcontext` the caller must vouch for,
while this one takes four scalars and opens the VAAPI device itself. Two functions of near-identical
shape, opposite answers, decided by whether a caller can supply something broken.

The other two regions keep their markers (`open_vaapi_encoder`/`_mode` are handed raw
`*mut AVBufferRef`s) and gain the proofs they lacked. The encoder-config block now records the fact
that makes it sound rather than obvious: `av_buffer_ref` returns a NEW reference the codec context
adopts, so the callee shares the caller's device/frames buffers instead of consuming them — which is
also why the low-power entrypoint ladder can retry with the same two pointers after a failed attempt.
The frames-pool block reuses the `CudaHw::new` argument: alloc returns null-or-initialized and
`AvBuffer::from_raw` rejects null, so the `?` leaves before any field store can run.

One call site dropped its `unsafe {}` and its comment with it — the comment argued that libav was
initialized, which is a real precondition of the call but not one a caller can violate, so it is now
a note rather than a contract.

14 fenced files -> 11. Verified on .21 (fmt + both CI clippy steps rc=0) and, because this is the
live VAAPI construct path rather than dead code, ON THE AMD 780M (.116, pf-build distrobox):
`vaapi_cpu_encode_smoke`, `dmabuf_inner_alloc_drop_cycles` and `vaapi_probe_smoke` all pass —
3 passed / 0 failed, H265 + AV1 probes still true in both 8- and 10-bit.
2026-07-29 00:20:35 +02:00
enricobuehler ee29b3c3a9 refactor(client-core): three decoder lift methods that took nothing to get wrong
`D3d11Decoder::lift`, `VaapiDecoder::map_dmabuf` and `VulkanDecoder::extract` each take `&mut self`
and NOTHING else. They dereference `self.frame`, the `*mut AVFrame` the decoder allocates in its own
constructor and owns until `Drop` — a pointer no caller supplies, can replace, or can invalidate.
None carried a `# Safety` section, and each body was already one `unsafe {}` with its proof, so the
marker was pure ceremony and its removal moves nothing.

All three live in files the workspace `deny` already covers (`video_vulkan.rs` since the fence came
off it), so they stay fully enforced.

Worth recording why this is where the marker sweep STOPS for these two crates: every remaining
candidate in pf-encode and pf-client-core sits inside a fenced file, and there removing a marker is
not free. The fence excuses `unsafe_op_in_unsafe_fn`, which only applies to `unsafe fn` BODIES — a
safe fn always needs explicit blocks — so unmarking an ash-dense helper forces exactly the
whole-body `unsafe {}` this program rejected when it rejected `cargo fix`. The way to reach those is
to reclaim the file first, not to unmark inside it.

Verified on .21: fmt + `clippy --workspace --all-targets -- -D warnings` + the feature-gated
`-p pf-encode` step, all rc=0.
2026-07-29 00:20:35 +02:00
enricobuehler d72822ced7 refactor(encode/linux): second fence off — CudaHw::new's pointer walk gets its proof
`linux/mod.rs`'s fifteen sites are the same kind as `video_vulkan.rs`'s, not the ash kind: nine raw
pointer dereferences and six libav calls, all inside `CudaHw::new`, which had no `unsafe` block and
therefore no proof of the one thing worth proving here — that the pointer chain it walks is live.

The marker STAYS (`cu_ctx: *mut c_void` is a `CUcontext` the caller must supply valid). The body is
now two blocks, one per phase, because there are two distinct arguments to make. Both turn on the
same non-obvious fact: `av_hwdevice_ctx_alloc`/`av_hwframe_ctx_alloc` return null or a ref whose
`data` libav has ALREADY initialized, and `AvBuffer::from_raw` rejects null — so the `?` leaves
before any of the field stores below it can run. That is what makes the `(*dev_ctx)`/`(*fc)` writes
in-bounds stores on live allocations rather than a hope, and it is exactly the reasoning that was
missing. The device block also records the ordering constraint that was implicit: `cuda_ctx` must be
stored BEFORE `av_hwdevice_ctx_init`, which reads it.

Two files now need no exemption: 14 fenced -> 12. Both were removable for the same reason — their
sites are pointer dereferences, where a proof carries an argument, unlike the ash backends where it
could only restate the call. That is the criterion for which fence to attack next, not file size.

Verified on .21: fmt + `clippy --workspace --all-targets -- -D warnings` + the feature-gated
`-p pf-encode --features nvenc,vulkan-encode,pyrowave` step, all rc=0 with no allow in either file.
2026-07-29 00:20:35 +02:00
enricobuehler ed4bbc6b0b refactor(client-core): the first fence comes off — video_vulkan.rs is at zero
`video_vulkan.rs` was fenced with the other thirteen GPU/FFI backends, but it never belonged with
them: its four sites are not ash calls, they are RAW POINTER DEREFERENCES inside the two FFmpeg
`lock_queue`/`unlock_queue` callback trampolines — exactly the case where the lint pays, and where a
proof carries an argument instead of restating a signature. Both bodies had none at all.

They keep `unsafe extern "C"`: FFmpeg invokes them with a `*mut AVHWDeviceContext`, which is a real
contract, now written down. What was missing is why the dereference chain is sound, and it is worth
stating because it is not obvious — the trampoline casts between pf_ffvk's `AVHWDeviceContext` and
ffmpeg-sys's (the same C struct declared twice), reads `user_opaque`, and dereferences it as a
`QueueLock`. That pointer borrows `VkCtxStorage::_queue_lock`, an `Arc<QueueLock>` whose field doc
already says it exists to outlive every call the context can make. The proof now connects those two
facts, so a future edit to the storage's lifetime has something to contradict.

With that, the file needs no exemption and the workspace `deny` covers it: 14 fenced files -> 13.
This is what removing a fence is supposed to look like — the sites that are worth narrowing get
narrowed, and the exemption disappears rather than being renewed.

Verified on .21: fmt + `clippy --workspace --all-targets -- -D warnings` + the feature-gated
`-p pf-encode` step, all rc=0 with no allow in the file.
2026-07-29 00:20:35 +02:00
enricobuehler 5b2be889f9 refactor(capture/windows): two # Safety sections that stated no contract
The previous commit took the markers with no `# Safety` at all. These two HAVE one, and that is
what makes them worth naming: a section can exist and still describe nothing a caller can violate.

`resolve_render_adapter` takes **no arguments**. Its section read "calls DXGI factory/adapter
enumeration; returns owned COM objects or an error" — a summary of the body. With no parameters and
no globals touched, there is no way to call it wrongly.

`create_nv12` asked that "`device` must be a live D3D11 device". It takes `&ID3D11Device`, a
borrowed reference-counted COM wrapper, so the borrow itself is that guarantee — safe Rust cannot
produce a dangling one. The remainder ("the returned texture is owned by the caller") is an
ownership note, the same distinction `service::open_log_handle` already draws in its doc. Every
other parameter is a plain scalar.

Both bodies already had their explicit block, so again nothing moved. `create_nv12`'s proof said
"on the live `device` borrow (per the contract above)" and now says the borrow is what keeps it
live — a proof that pointed at a contract being deleted had to stop pointing at it.

The other four were read and KEPT, and they are the shape of a real one: `channel::send` and
`duplicate_and_deliver` take raw `HANDLE`s; `prepare_blend_scratch` requires the caller to hold the
slot's keyed mutex before it copies; `pyro_fence_signal` must run on the thread owning the immediate
context, which is load-bearing precisely because `IddPushCapturer` is `unsafe impl Send` and so CAN
be moved. `verify_is_wudfhost` (raw `HANDLE`, and the driver-channel security check) keeps its
marker too.

pf-capture: 21 `unsafe fn` -> 8, every survivor carrying an obligation a caller can actually break.
Verified on .47: pf-capture clippy `-D warnings` rc=0 + 18 Windows tests pass, host/pf-encode/
pf-vdisplay green; Linux .21 fmt + both CI clippy steps rc=0.
2026-07-29 00:20:35 +02:00
enricobuehler 9e8473b46a refactor(capture/windows): eleven D3D11 helpers had no caller contract
The DXGI converters (`HdrP010Converter`, `BgraToYuvPlanes`, `VideoConverter`), the cursor blend
pass, and `create_ring_slots` declared their methods `unsafe fn` because their bodies are D3D11
FFI. That is a property of the body, not an obligation on the caller, and their own proofs said so:
"`?`-checked D3D11 methods on the live `device` borrow". Every parameter is either a borrowed
windows-rs COM wrapper (`&ID3D11Device`, `&ID3D11DeviceContext`, `&ID3D11Texture2D`, the views) or
a plain `u32`/`bool`/`DXGI_FORMAT`; each body builds its own descriptors from those and every
interface it creates owns its reference. There is nothing a caller can pass that makes them
unsound.

Not one of the eleven carried a `# Safety` section — the marker had no stated contract to inherit,
which is the tell. Each body was already one `unsafe {}` with its proof, so this is purely
subtractive: no block moved and no proof was written. The four call sites lost `unsafe {}` wrappers
whose own comments gave the game away — "`X::new` is `unsafe` (it compiles D3D11 shaders…)",
"`create_ring_slots` is an `unsafe fn` (it makes D3D11/DXGI COM calls)" — comments explaining that
the marker described the callee's body rather than anything the caller had to guarantee.

`compile_shader` KEEPS its `unsafe fn`: it takes `PCSTR`, a raw pointer the caller must guarantee
points at a NUL-terminated literal. That is the line — a contract a caller can actually break.
pf-zerocopy was surveyed the same way and deliberately left alone: its CUDA/Vulkan helpers look
identical by signature but state real obligations ("the shared context is current", "single-threaded
use of handles this bridge owns"), so signature screening finds candidates and only reading decides.

pf-capture already denies both `unsafe_op_in_unsafe_fn` and `undocumented_unsafe_blocks`, so the
crate stays fully enforced; the reduction is in obligations, not in warnings. It now has exactly one
`unsafe fn` left. Verified on the Intel box .47: pf-capture clippy `-D warnings` rc=0 and its 18
Windows tests pass, with host/pf-encode/pf-vdisplay clippy still green; Linux .21 fmt + both CI
clippy steps rc=0.
2026-07-29 00:20:35 +02:00
enricobuehler 50531c8e9e fix(host/display): a screen picker that cannot stream a screen now says so instead of saving
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
release / apple (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
audit / cargo-audit (push) Canceled after 0s
audit / bun-audit (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
android / android (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 3m56s
The Windows console's "Streamed screen" picker SAVED and then did nothing. `capture_monitor` is
platform-neutral, so the PUT persisted; every consumer of it is Linux-gated, so a virtual display
was still created on connect. The operator got a control that acknowledged the click and changed
nothing — the worst of the three possible behaviours.

The root cause is not a missing gate, it is a missing BACKEND. Per-monitor capture is Linux/portal
only: `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]` because `pf-capture` has no
Windows entry point that can capture an arbitrary head. Its sole Windows entry point is
`open_idd_push`, a frame channel pushed by our OWN IddCx virtual display; DXGI Desktop Duplication
was deliberately REMOVED (`windows/dxgi.rs` keeps the GPU-preference hook only to stop DXGI
reparenting the virtual display off the pinned adapter, and says so). So there is nothing for a pin
to aim at, and exposing the picker got ahead of that.

Decision of record (user, this session): mark it unsupported now rather than build the Windows
backend here. A Windows mirror backend is a real feature gap and a project of its own — it needs a
duplication capturer plus everything the IDD-push path carries today (cursor sidechannel, 444,
10-bit, PyroWave, HDR) re-plumbed through it — not a follow-on to an enumeration commit.

Three places, so the answer is consistent wherever it is asked:

* `MonitorsResponse.pin_supported` — a CAPABILITY, reported by the build that would have to honor
  the pin rather than sniffed from the OS client-side. When a Windows mirror backend lands this
  flips and the console needs no change. `pinned` stays `None` off-Linux deliberately, and now says
  why: it is what the console highlights as "sessions stream this", and highlighting a head nothing
  will capture is the same lie in a different place.
* `enforced` drops `capture_monitor` off Linux. That list is exactly the "which controls are live vs.
  coming soon" contract, and claiming this one unconditionally is what let the picker ship enabled.
* The PUT drops a non-Linux `capture_monitor` instead of storing it, and logs that it did. COERCED,
  not rejected with a 400: this PUT is WHOLE-OBJECT, so a host that already stored a pin would have
  every later settings save rejected over a field the operator cannot see — taking the other axes
  down with it. This way such a policy self-heals on the next write.

Console: the picker renders read-only with an explanation, reusing the `envLocked` shape rather than
inventing a second one (`locked = envLocked || !pinSupported`). Managed heads were ALREADY excluded
from selection here (`mon.enabled && !mon.managed`), which is the "grey out, never filter blindly"
rule working as intended — the heads stay listed and explicable. `pin_supported` defaults to TRUE
when absent so an older host, which only ever shipped this picker where it worked, is not
retroactively locked out.

⚠️ `api/openapi.json` is hand-patched — punktfunk-host does not build on macOS, so the spec could not
be regenerated from the binary. The shape was verified by running the real generator over it: orval
emits `pin_supported: boolean` (required, no `?`) on `MonitorsResponse`.

Verified: `bun run codegen` (orval + paraglide + i18n parity: 434 messages, en + de), `bun run build`,
`bun run lint` (tsc --noEmit) all clean. Rust verified separately on .173 — see the next commit's
note if that lands, since neither xcheck target covers punktfunk-host.
2026-07-29 00:18:27 +02:00
enricobuehler ed021a13ee test(vdisplay): give the 3.2 case eyes — a subscriber, and the probe that splits the two candidates
§5 3.2 reproduces on glass and the shipped fix is not sufficient: the panel stays dark after
teardown. Two candidates were left undistinguished by the first on-glass run — the adopted snapshot
was already poisoned, or the dark-desk backstop never fired — because the case had no way to tell
them apart. This adds that way.

**The subscriber.** The adoption arm and `restore_displays_ccd`'s backstop announce themselves ONLY
through `tracing`, and a bare `cargo test` harness installs no subscriber, so both events went
nowhere. `tracing-subscriber` becomes a DEV-dependency of pf-vdisplay (the shipped host's closure
through this crate is unchanged) and `init_test_tracing` wires it: `try_init` so a second live case
in the same binary is a no-op rather than a panic, `with_test_writer` so it interleaves with the
harness rather than racing `println!`, `RUST_LOG` still winning over the `debug` default.

`live_inplace_resize` gets it too — its comment claimed tracing-subscriber "is not a dep of this
crate, run the host binary for traced runs", which was the same blindness written down as a
limitation. It no longer holds.

**The probe is the actual discriminator, and it needs no logs at all.** Member 1's isolate is
INJECTED to fail, so nothing of ours deactivates anything there. Sampling `active_physicals()`
immediately after member 1's create therefore answers the question directly:

* empty  -> the arriving IddCx monitor took the desktop on its own. Every snapshot from that instant
  on records "panel off", so member 2's adopted snapshot is POISONED AT BIRTH and restoring it
  faithfully restores darkness. Adoption works; the snapshot SOURCE is the defect.
* non-empty -> poisoning is excluded and the break is downstream: no adoption line means teardown's
  restore was never gated on, a backstop line with a non-zero force-EXTEND rc means the remedy
  itself failed.

The failing assertion now reads that verdict out instead of asserting one cause, so the next on-glass
run reports which link broke rather than only that one did.

⚠️ Still UNRUN on glass — this is the instrument, not the measurement. Needs .173's CONSOLE session
(ssh is session 0 and CCD sees nothing there), and it can leave the desk dark: recover with
`SDC_USE_DATABASE_CURRENT|SDC_APPLY`, not EXTEND, which returns rc=31 against a single display.

Checked while writing this, and recorded so it is not re-derived: the "backstop is structurally
inert on a one-display box" theory does NOT survive. Both real call sites restore BEFORE the virtual
is REMOVEd, so two displays are connected and the preset applies — `live_force_extend_*` measured
exactly that (1 -> 1 -> 2). The documented residual is narrower: a restore that fails once the
virtual is already gone.

Verified: `xcheck.sh windows clippy` clean (it passes `--all-targets`, so this test code really is
compiled), `cargo fmt --all --check` clean.

⚠️ `Cargo.lock` moves with this commit and must not be split from it. CI runs
`cargo clippy --workspace --all-targets --locked`, so a new dependency edge whose lockfile entry is
missing does not degrade to a re-resolve — it FAILS the build outright. The dev-dependency adds one
line (pf-vdisplay gains a `tracing-subscriber` edge; the package itself was already in the graph via
punktfunk-host and pf-encode, so nothing new is vendored).
2026-07-29 00:18:27 +02:00
enricobuehler 8381fd73ef test(vdisplay): bound the 3.2 case's creates, and what it found on glass
A hang must fail the case, not wedge the box. The first attempt at this case hung inside
`create`; killing the harness skipped every `Drop` and leaked an IddCx monitor, and a few of
those exhaust the driver's slot pool — after which every later run wedges too and only a reboot
clears it. Both creates now run on a worker thread with a 45 s budget, so the harness exits
NORMALLY, which is what lets the driver reap the session. Re-run with matched host+drivers: no
hang, no leak, and the box came back with the panel lit.

 With that guard in, §5 3.2 REPRODUCED ON GLASS for the first time (.173, LG TV attached, host
0.21.0 + drivers 9.9.728.2241, first isolate injected to fail):

    physicals before                        : [(4352, "LG TV SSCR2 [HDMI]")]
    after member 1 (isolate INJECTED to fail): [(260, "punktfunk [punktfunk-virtual]")]
    after member 2 (isolate REAL)            : [(260, …), (264, …)]
    physicals during                         : []
    physicals after teardown                 : []   <-- the operator's panel, still dark

So the shipped 3.2 fix — adopt the first SUCCESSFUL isolate as the group's restore snapshot — is
necessary but NOT sufficient. Two candidate reasons, both worth checking before changing code:

* The adopted snapshot is already POISONED. The panel went dark at member 1's create even though
  our isolate never ran (the injected failure), because the arriving IddCx monitor took the
  desktop on its own. Member 2's isolate then snapshotted a topology in which the physical was
  ALREADY inactive, so restoring it faithfully restores "TV off". This is the same
  poisoned-snapshot chain §5 already records from the field logs.
* `restore_displays_ccd`'s dark-desk backstop did not rescue it either. Worth its own look, since
  21eda37a is what first made that condition evaluable at all (before it, our own display counted
  as a lit physical and the guard could never fire).

⚠️ Not yet distinguished, because this case installs no tracing subscriber: whether the adoption
arm fired at all, and whether the backstop ran and failed or never ran. That is the next
diagnostic — `live_inplace_resize` shows the pattern for wiring a subscriber into a live case.

Also confirms 21eda37a in the field: the virtual displays report `tech=punktfunk-virtual` and are
correctly absent from the physicals list, which is what makes "physicals after teardown: []" mean
what it says rather than counting our own output as the operator's panel.
2026-07-29 00:18:27 +02:00
enricobuehler 22f2686680 feat(vdisplay/windows): the console can finally show the operator's real screens
`GET /display/monitors` returned an empty list on every Windows host, plus a LINUX error string:
`monitors::list` is a per-compositor dispatch whose non-Linux arm bails, and it never even got
that far because `detect()` was not cfg-gated — on Windows it fell through to an
`XDG_CURRENT_DESKTOP` sniff and failed with advice about setting `PUNKTFUNK_COMPOSITOR`, which the
handler puts VERBATIM into the response. So the console showed no physical screen and its only
explanation was Linux troubleshooting (sweep §13.17).

The data was there all along. `target_inventory()` already walks the CCD database; it now also
reports the geometry that walk had in hand — the driving source's position and mode, the GDI
device name, the refresh rational, and `primary`. No second CCD walker.

* `monitors::list_windows()` maps that onto `PhysicalMonitor`. Two fields cannot mean on Windows
  what they mean on Linux and are reported honestly rather than invented: `scale` is always 1.0
  (Windows scaling is per-monitor DPI applied per application, not a compositor-global logical
  scale — so the geometry is PIXELS), and an INACTIVE head gets zeroed geometry because the CCD
  mode indices are only valid for active paths. Inactive heads are still listed, per `list`'s own
  contract, so "why can't I pick it?" keeps an answer.
* `managed` is finally truthful. The doc says only KWin can tell a virtual output from a real head;
  Windows can too, via our own EDID manufacturer id in the device path (21eda37a).
* `detect()` gets an honest non-Linux arm, the operator pin gated with it — naming a Wayland
  compositor on Windows could never be honoured either.
* The handler reports `compositor: "windows"` instead of null-plus-a-Linux-error.

ON GLASS (.173, host 0.21.0, TV attached):

    connector=\\.\DISPLAY1  enabled=true  managed=false  primary=true
    3840x2160 @165Hz  pos=(0,0)  "LG TV SSCR2"

Pinned by an `#[ignore]`d hardware case that asserts at least one NON-managed head is visible —
the exact thing that was missing. Read-only, so it is safe against a live host.

This also gives `capture_monitor` a picker on Windows, where the handler currently hardcodes
`pinned: None`; wiring that is left for the per-monitor-capture work rather than smuggled in here.

Verified on .173: `clippy -p punktfunk-host -p pf-vdisplay -p pf-win-display --all-targets
-- -D warnings` clean; both xcheck targets clean.
2026-07-29 00:18:27 +02:00
enricobuehler 735cd99e8f fix(ci/windows): the web install was the one CI site still running lifecycle scripts
apple / swift (push) Successful in 5m12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 12m40s
The Windows host job died in "Build + smoke-boot web console (bun)":

    $ bun2nix -o bun.nix
    error: bun is not installed in %PATH%
    error: postinstall script from "punktfunk-web" exited with 255
    bun install failed (255)

`web`'s `postinstall` is `bun2nix -o bun.nix` — Nix codegen that shells out to `bun` on PATH. This
job runs a fetched PORTABLE bun by absolute path (`$env:BUN_EXE`), so PATH has none and the script
aborted the install, taking the installer, the driver bundle and the publish steps with it.

Every other web install in CI already passes `--ignore-scripts` — ci.yml (twice),
web-screenshots.yml, sdk-publish.yml, plugin-kit-publish.yml, and the SDK install 25 lines further
down THIS SAME FILE. This one line was the lone exception: the same N-1-of-N seam asymmetry the
pf-vdisplay sweep keeps turning up. Nothing here needs those scripts — `bun run build` re-runs its
own `prebuild` codegen, and ci.yml proves the build works after an `--ignore-scripts` install —
and `bun.nix` is a Nix artifact this job neither consumes nor commits. deb.yml still regenerates
it (bun on PATH there), so the self-regenerating contract is unchanged.

⚠️ Honest about the evidence: I could NOT reproduce the failure on .173. Invoking the portable bun
by absolute path with `where bun` empty, the postinstall SUCCEEDED there — bun made itself
resolvable to its own lifecycle script, which the SYSTEM-account runner evidently does not. So this
is not a reproduced-then-fixed A/B. It is justified instead by construction: `--ignore-scripts`
removes the dependency on bun2nix (and on how bun2nix locates bun) altogether, and aligns the step
with every sibling. If the job goes green, that is the confirmation.

⚠️ Related trap for whoever deploys the console by hand: rebuilding `web/.output` does NOT take
effect until the `PunktfunkWeb` SCHEDULED TASK is restarted — restarting the host service does not
touch it. A server left running from before a rebuild serves HTML referencing the OLD asset hashes
while disk holds the new ones, so every asset 500s while `/login` still returns 200. Hit on .173
today; `Stop-ScheduledTask PunktfunkWeb; Start-ScheduledTask PunktfunkWeb` fixes it.
2026-07-29 00:18:27 +02:00
enricobuehlerandClaude Opus 5 3d25482469 feat(client/windows): pinned host+profile tiles render in the grid
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m56s
apple / swift (push) Successful in 5m12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
android / android (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
The menu could pin since the last commit but nothing appeared, which is worse than not
offering it. Each host's pins now render right after it, sharing its live status because
they read the same record, with the profile named on an accent chip. A pin whose profile
was deleted simply doesn't render.

A pinned tile carries no menu of its own: it is a shortcut, not a second host, and
pin/unpin already live on the primary tile's menu — the one place you decide it. Its
hover key is `<fp>#<profile-id>` so two tiles for one host don't light up together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:18:12 +02:00
enricobuehlerandClaude Opus 5 2a088e13a4 feat(client/windows): bind a host to a profile, connect with one, pin one, copy its link
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m25s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m43s
apple / swift (push) Successful in 5m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
android / android (push) Canceled after 2m34s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
ci / rust (push) Canceled after 2m30s
arch / build-publish (push) Canceled after 2m39s
The WinUI half of §5.2: `Target` carries a one-off profile into the session's `--profile`,
the host editor gains the binding picker, and the tile names its bound profile on a chip so
what a plain click will do is visible without opening anything.

Shape notes, both forced by this toolkit rather than chosen:

- The flyout has no submenus, so "Connect with", "Pin as card" and "Unpin card" are flat
  items, one per profile, matched by prefix. The prefixes end in ": " so a profile named
  "Copy link" can't collide with a fixed entry.
- The binding picker commits on change rather than at Save. The rest of that sheet is text
  boxes with draft refs, which need a Save; a ComboBox doesn't, and making it wait would be
  the odd one out.

Semantics stay identical to the Linux client, because they are the design's, not the
platform's: "Connect with" never rebinds, "Default settings" is `Some("")` so it really
does override a bound host for one session, and a binding whose profile was deleted reads
as Default settings and resolves to it.

`pf_client_core::clipboard::set_text` is the small addition the crate needed for "Copy
link" — the OS clipboard plumbing was already there, just private to the streaming bridge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:17:15 +02:00
enricobuehlerandClaude Opus 5 b220daf537 feat(client/windows): the settings screen edits profiles too
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m9s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m53s
apple / swift (push) Successful in 5m13s
ci / docs-site (push) Successful in 1m4s
ci / web (push) Successful in 3m3s
ci / bench (push) Successful in 5m43s
deb / build-publish-host (push) Failing after 6m17s
arch / build-publish (push) Failing after 9m36s
ci / rust-arm64 (push) Failing after 9m41s
ci / rust (push) Failing after 9m44s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 32s
decky / build-publish (push) Successful in 43s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 24s
deb / build-publish (push) Successful in 8m52s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 13m2s
deb / build-publish-client-arm64 (push) Canceled after 6m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2m47s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m42s
P1 on the WinUI shell. The same surface now edits either layer, switched by a combo above
the section title — one settings UI, not a second editor that drifts from the first.

The interesting difference from GTK is where the override gets recorded. This shell
commits PER CONTROL, so it cannot hand over a list of touched rows the way a
commit-on-close dialog can. Instead each control hands `SettingsOverlay::absorb` the
effective settings before and after it fired, and absorb records the field that moved.
The comparison is against what the control was SHOWING, not against the globals, so
setting a value to whatever the global happens to be still records an override — the pin
the design asks for — and nothing is ever removed by inference.

⚠️ The content key becomes `(scope, section)`. Keyed on section alone, switching scope
diffs one scope's controls into the other's, which re-sets each reused ComboBox's items
(clearing WinUI's selection) while skipping `selected_index` wherever the two scopes'
values compare equal — the combos then render blank until touched. That is the same trap
the section key already existed for, one axis wider.

Only profileable rows render in profile scope: auto-wake, the library toggle, the decoder
and GPU pickers and the forwarded-pad picker are facts about this device or this network,
not about "Game vs Work".

Two shapes follow from this toolkit rather than from the design. Creating a profile takes
an auto-numbered name and is renamed from the row below, because ContentDialog here is
text-only and cannot hold a text field — the same constraint that put the host editor in a
tile. And Delete arms a root state that renders the confirmation declaratively, since
dialogs here are elements with `is_open`, not calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:12:43 +02:00
enricobuehlerandClaude Opus 5 8299151a09 fix(client-core): drop nine GTK-client files a stray copy left in the crate
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m7s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m50s
apple / swift (push) Successful in 5m17s
android / android (push) Canceled after 5m13s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 4m47s
ci / bench (push) Canceled after 5s
ci / rust (push) Canceled after 3m13s
ci / rust-arm64 (push) Canceled after 3m12s
ci / web (push) Canceled after 34s
ci / docs-site (push) Canceled after 31s
decky / build-publish (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / build-publish (push) Canceled after 3s
deb / build-publish-host (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3s
An `rsync` with the wrong destination dropped the Linux shell's sources into
`crates/pf-client-core/src/` and they rode along with the previous commit. Nothing
referenced them — `lib.rs` never declared the modules, so they were dead weight rather
than a build break — but a crate that says it is UI-agnostic must not have a GTK shell
sitting in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:04:57 +02:00
enricobuehlerandClaude Opus 5 ad99be2fb2 feat(client): the overlay learns absorb + clear, so per-control shells can edit profiles too
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m47s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m48s
ci / web (push) Successful in 1m8s
apple / swift (push) Successful in 5m16s
ci / docs-site (push) Successful in 1m4s
android / android (push) Failing after 9m5s
ci / bench (push) Successful in 5m34s
decky / build-publish (push) Successful in 21s
ci / rust-arm64 (push) Failing after 8m37s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
ci / rust (push) Failing after 9m28s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
deb / build-publish-host (push) Successful in 10m37s
deb / build-publish-client-arm64 (push) Successful in 9m38s
arch / build-publish (push) Successful in 17m48s
deb / build-publish (push) Successful in 13m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 6m33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m9s
flatpak / build-publish (push) Failing after 9m27s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m9s
Groundwork for the Windows half of P1, and a dedup of the Linux one.

The two shells commit differently: GTK writes once when the dialog closes (so it can hand
over a list of touched rows), WinUI writes on every control change (so it can't). `absorb`
serves the second shape — give it the effective settings before and after one control
fired, and it records the field that moved.

That is deliberately NOT the diff-on-save the design rejects. The comparison is against
the EFFECTIVE settings, i.e. what the control was actually showing, not against the
globals — so setting a value back to whatever the global happens to be still records an
override, which is exactly the pin the design wants. It only ever adds; removing an
override stays an explicit, separate operation.

`clear` is that operation, keyed by the overlay's own field names, with `resolution` as
the one alias for the width/height/match-window tri-state a single control drives on every
client. The GTK reset path now calls it instead of carrying its own `match` — that second
list was already a place where the two could drift, and the next client would have made it
a third.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:04:26 +02:00
enricobuehlerandClaude Opus 5 22cb975659 feat(client/linux): "Create shortcut…" — a double-clickable entry for a host or a pinned profile
ci / web (push) Successful in 1m11s
ci / rust-arm64 (push) Failing after 6m32s
arch / build-publish (push) Failing after 9m5s
ci / docs-site (push) Successful in 1m12s
deb / build-publish-host (push) Failing after 5m9s
android / android (push) Failing after 14m18s
deb / build-publish (push) Failing after 5m9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 19s
decky / build-publish (push) Successful in 26s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
ci / bench (push) Successful in 6m30s
deb / build-publish-client-arm64 (push) Successful in 7m47s
apple / swift (push) Successful in 7m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m53s
flatpak / build-publish (push) Failing after 9m29s
ci / rust (push) Successful in 24m12s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
D1's last Linux piece (design/client-deep-links.md §5). The shortcut is a CONTAINER FOR A
URL, not a second launch mechanism: it invokes the client with a positional
`punktfunk://…`, the same door xdg-open and a browser prompt use. That is what makes it
survive things it has no control over — scheme registration being lost, the host moving to
a new address, the store being wiped — because the URL itself carries the stable id, the
address and the pin.

A pinned card writes its profile into the shortcut, so "Desktop · Work" on the app grid is
one double-click to that exact session. Two sanitisation points that are not cosmetic: the
filename is slugged to ASCII (a host called "Büro · Mac" must still produce a writable
name), and the `Name=` value is flattened to one line — desktop entries are line-oriented,
so a newline in a host name would end the key and turn the rest into another one.

Under flatpak the sandbox cannot write ~/.local/share/applications, so the user gets the
URL to place themselves — the fallback the design already sanctions. The
`org.freedesktop.portal.DynamicLauncher` route, which exists for exactly this and shows
its own confirmation, is the intended upgrade there and is not built yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:01:46 +02:00
enricobuehlerandClaude Opus 5 bd0103b1ac feat(client/linux): a host edit sheet, and with it the clipboard toggle Linux never had
deb / build-publish (push) Canceled after 0s
android / android (push) Canceled after 1m27s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 1m33s
ci / bench (push) Canceled after 0s
ci / web (push) Canceled after 47s
ci / rust (push) Canceled after 1m32s
ci / rust-arm64 (push) Canceled after 47s
ci / docs-site (push) Canceled after 46s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 5s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 6s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 6s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Successful in 7m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 9m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m3s
Linux had "Rename" and nothing else, so two per-host settings that already existed in the
store and on the Apple and Windows clients had no surface here at all: the per-host
clipboard opt-in, and now the profile binding. A Linux user could not turn on a feature
they were already paying the storage for.

The sheet holds exactly the things that are properties of the HOST rather than of the
stream — its name, whether this machine shares its clipboard with it, and which settings
profile a plain click uses. A binding whose profile was deleted reads as "Default
settings" and is cleaned up on save, which is the same "dangling resolves as none" rule
the connect path follows.

The menu entry becomes "Edit…", and the function and message follow: a `rename_dialog`
that opens an "Edit Host" sheet is exactly the drift that confuses the next reader.
Pinning stays in the card menu rather than doubling as a list of toggles here — one place
to pin is enough, and it is the place you are already looking when you decide to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:54:50 +02:00
enricobuehlerandClaude Opus 5 b9a7163556 feat(client/linux): the speed test writes where the tested host reads, and cards can copy their link
android / android (push) Canceled after 4m16s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m28s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
ci / rust (push) Canceled after 2m42s
ci / rust-arm64 (push) Canceled after 2m41s
deb / build-publish (push) Canceled after 2s
deb / build-publish-host (push) Canceled after 2s
deb / build-publish-client-arm64 (push) Canceled after 3s
decky / build-publish (push) Canceled after 2s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 6s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 1m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 46s
Two WP3 pieces.

**Speed test (§5.3).** Measuring the slow retro box downstairs used to re-tune the
desktop upstairs: Apply always wrote the GLOBAL bitrate, which the design calls the
sharpest existing symptom of the missing feature. It now writes the layer that host
actually resolves bitrate from — the bound profile's override if it has one, the global
if the host is unbound. A bound host whose profile inherits bitrate could legitimately
mean either, so it gets both buttons instead of a guess. The target depends only on the
host, so it is resolved before the test finishes and the button says where it will write
("Set in “Game”"). A one-off (a pinned card's profile) wins over the binding, and a
dangling binding falls back to the global — the same precedence a connect uses.

**Copy link.** Host and pinned cards can copy their own `punktfunk://` URL — the
self-emitted form carrying the stable id *and* host+fp, so a link pasted into a Playnite
entry or a Stream Deck macro still resolves after the host re-addresses or the store is
wiped. A pinned card copies its profile with it, which is the whole point of pinning it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:53:20 +02:00
enricobuehlerandClaude Opus 5 08ec0f4239 feat(client/linux): pin a host+profile as its own card
android / android (push) Canceled after 1s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 0s
arch / build-publish (push) Canceled after 4s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / rust (push) Canceled after 8s
ci / bench (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 5s
deb / build-publish (push) Canceled after 3s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 20s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 1m57s
flatpak / build-publish (push) Canceled after 1m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m11s
§5.2a: a profile you reach for regularly shouldn't live behind a context menu. Pinning
one puts *Desktop · Work* in the grid next to *Desktop*, and clicking it is a plain
one-click connect with that profile.

The pin is presentation state on the host record — `pinned_profiles`, which has been in
the store since P0 and unused until now. Deliberately NOT a duplicated host entry: that
would fork pairing, WoL MACs and renames, and every host-level action would then need a
"which card owns this" rule. So a pinned card is the same record rendered again with a
profile attached: it shares the host's live status because it reads the same record, and
a pin whose profile was deleted simply doesn't render.

What each card offers follows from what it is. The primary card keeps the host actions
and gains "Pin as card ▸" (listing only profiles not already pinned; "Default settings"
isn't pinnable — the primary card is that). A pinned card offers the one-offs and its own
removal, and deliberately not pair/rename/forget — it is a shortcut, not a second host,
and offering destructive host actions there would blur the distinction. One action serves
both directions: which kind of card it sits on decides whether it pins or unpins.

Verified on .21: a host bound to "Game" renders its primary card with a neutral Game chip
and, right after it, the pinned "Work" card with an accent chip, both showing the same
online/paired state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:49:06 +02:00
enricobuehlerandClaude Opus 5 b485f91f52 feat(client/linux): overridden rows say so, and can go back to inheriting
apple / swift (push) Successful in 5m3s
android / android (push) Canceled after 1s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 5s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
ci / web (push) Canceled after 6s
ci / rust (push) Canceled after 6s
ci / rust-arm64 (push) Canceled after 7s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / build-publish (push) Canceled after 1s
decky / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
The gap in what the scope switcher shipped: a profile was a one-way door. Overrides are
recorded on touch and never inferred from comparing values — which is the right model,
and exactly why "not overridden" needs an explicit way back.

Each overridden row now carries an accent dot in its prefix and a reset button in its
suffix, so which settings a profile actually changes is legible without reading every
value against the defaults. Reset queues the field, re-opens the dialog in the same scope
and lets the close handler commit everything else first — so one code path still builds
the rows, including the rebuilt ones showing the inherited value again.

Clears are applied after the touched fields for a reason: resetting a ComboRow can fire
its change handler on the way past, and "back to inheriting" is the later, explicit
intent. An unknown field in the cleared set warns rather than silently doing nothing —
this list and the write-back list have to stay in step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:34:42 +02:00
enricobuehlerandClaude Opus 5 fa430646ff fix(packaging): the Pulse shim is never the client's, and never the host's requirement
android / android (push) Failing after 5m55s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 5m22s
ci / rust-arm64 (push) Failing after 8m30s
ci / bench (push) Successful in 5m14s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 23s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
arch / build-publish (push) Successful in 15m26s
deb / build-publish (push) Successful in 8m56s
ci / rust (push) Failing after 17m59s
deb / build-publish-host (push) Failing after 8m49s
apple / screenshots (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 9m1s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 4m14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 6m31s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 3m36s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 30s
Same bug as the Arch package, in the other two packagings.

The .deb CLIENT recommended `pipewire-pulse`. Apt installs Recommends by
default and the shim Conflicts with `pulseaudio`, so this proposed pulling the
user's sound server out from under them in exchange for something the client
cannot use: audio.rs drives libpipewire-0.3 directly and opens no Pulse socket.
(SDL is initialized with zero subsystems — gamepads only, on demand — so it
brings no audio backend either.)

The RPM HOST hard-Required `pipewire-pulseaudio`, which conflicts with
`pulseaudio` on Fedora too — uninstallable for anyone running real PulseAudio.
The host doesn't speak Pulse either; the shim is for the GAMES, which commonly
emit through the PulseAudio API, and real PulseAudio serves those equally well.
Demoted to Recommends, so the default Fedora box is unchanged and the informed
choice stops being blocked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:14:56 +02:00
enricobuehlerandClaude Opus 5 7d18468477 fix(packaging/arch): a client that never speaks Pulse must not demand the Pulse shim
`punktfunk-client` hard-depended on `pipewire-pulse`, which CONFLICTS with
`pulseaudio` — so anyone keeping real PulseAudio could not install the client at
all ("unresolvable package conflicts detected"), even though the client has no
use for the shim: audio.rs drives libpipewire-0.3 directly for both playback and
the mic uplink, and never opens a Pulse socket. Dropped it from the client.

The host doesn't speak Pulse either — it captures the sink monitor natively — but
the shim is genuinely useful there, for the GAMES, which commonly emit through
the PulseAudio API. That's an optdepend, and one real `pulseaudio` satisfies just
as well, so the host stops blocking that choice too.

Both now match what the .deb and the RPM already said (Recommends / "degrade
gracefully without it"); only the Arch package had hardened it into a conflict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:14:56 +02:00
enricobuehler 6de325a6b6 fix(ci): the unsafe lint said warn while CI enforced it as deny, and main went red
windows-host / package (push) Failing after 13m8s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m43s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m55s
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m5s
android / android (push) Failing after 6m25s
ci / bench (push) Successful in 8m21s
deb / build-publish (push) Successful in 8m44s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
decky / build-publish (push) Successful in 32s
deb / build-publish-host (push) Successful in 10m2s
ci / rust (push) Failing after 17m18s
ci / rust-arm64 (push) Successful in 16m6s
arch / build-publish (push) Failing after 17m19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m54s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7m44s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m12s
deb / build-publish-client-arm64 (push) Successful in 14m5s
flatpak / build-publish (push) Failing after 8m59s
apple / swift (push) Failing after 7m20s
apple / screenshots (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 17m16s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 26m3s
`unsafe_op_in_unsafe_fn = "warn"` was adopted workspace-wide in 39513528 on the assumption that
`warn` is a soft setting you can clear at leisure. It is not: ci.yml runs `cargo clippy … -D
warnings`, which promotes it to a hard error, so main has failed on EVERY commit since — Linux
`rust` and `rust-arm64` both dying on `pf-client-core` with 70 E0133 errors, and windows-host.yml
alongside them. A lint level that understates its own severity is worse than a strict one, so this
states what CI already does — `deny` — and writes the exemptions down instead.

Fourteen GPU/FFI backend files take `#![allow(unsafe_op_in_unsafe_fn)]`, each with its reason and
the workspace Cargo.toml carrying the argument in full. They are not "not done yet": measured
across them, 64% of the sites are a single third-party FFI call (ash, pyrowave-sys, libav, the
NVENC/AMF entry tables), and of the 44 `unsafe fn`s only 4 have a body containing no unsafe
operation at all. Since pf-encode also denies `undocumented_unsafe_blocks`, narrowing them means a
hand-written SAFETY comment per line that could only restate the signature — the exact noise that
made `unsafe` stop meaning anything here before. Everything else stays at zero and enforced, and
each allow is removable on its own terms.

Two smaller things this had to clear, both invisible to the job that would have caught them:

- `service.rs`: `undocumented_unsafe_blocks` wants the proof on EACH block, and a comment covering
  a group of consecutive `unsafe` statements only credits the first — so the two `OwnedHandle`
  wraps became their own statements. Windows-gated, so only the .47 gate sees it. 37a42078 fixed
  the same class of failure in pf-frame/dxgi.rs concurrently; that fix is taken as-is here.
- `clients/linux/ui_settings.rs`: `show`'s deletion left its doc comment stranded on `show_scoped`,
  which silently merged two unrelated doc blocks onto one fn. Removed.

Verified by running the real CI commands, not proxies: on .21 fmt + `clippy --workspace
--all-targets -- -D warnings` + the feature-gated `-p pf-encode --features
nvenc,vulkan-encode,pyrowave` + build + test, all rc=0; on the Intel box .47 all four
windows-host.yml clippy invocations, all rc=0. The arm64 job lints pf-client-core and
punktfunk-client-linux, both covered here.
2026-07-28 22:32:30 +02:00
enricobuehler f40c90aa23 refactor(host/inject/win-display): the Win32 unsafe surface is the FFI call, not the function
`spawn_in_active_session` existed only to launder `spawn_inner`'s `unsafe fn` marker — and its
own comment said why that marker was empty: the callee "has no caller-side preconditions, it
validates the session/token itself and owns every handle it opens". A marker that documents the
absence of a contract is not a contract. The two collapse into one safe fn whose `unsafe` now sits
on the eight Win32 calls, each with the proof it actually needs.

Kept `unsafe fn` exactly where a caller CAN break something: `merged_env_block` (a `*const u16`
block it must trust), `spawn_host` (a borrowed job `HANDLE`), `desktop_name` (an `HDESK`),
`inject_following_desktop` (a live synthetic-pointer device). Those bodies gain explicit blocks
instead — `merged_env_block`'s pointer walk is the one place here where the proof is load-bearing
rather than restating a signature, and it now says why the scan cannot run off the block's end.

With those four files at zero, all three crates take `deny(unsafe_op_in_unsafe_fn)` at the root,
next to the `undocumented_unsafe_blocks` deny they already had. The pair matters: alone, the older
deny is satisfied by an `unsafe fn` body, which needs no blocks at all and so can hide an unproven
FFI call. The workspace lint stays `warn` — it is a ratchet over the encoder backends still to be
cleared, and a crate that reaches zero should not be able to drift back silently.

Windows E0133 214 -> 182 (the remainder is the ash/AMF-dense encode backends). Verified on the
Intel box .47 and on Linux .21: no errors, no `unused_unsafe`, no dead-code fallout.
2026-07-28 22:31:11 +02:00
enricobuehlerandClaude Opus 5 808eba7292 feat(client/linux): bind a host to a profile, or connect with one just this once
ci / web (push) Successful in 1m56s
ci / rust (push) Failing after 6m15s
ci / docs-site (push) Successful in 2m1s
android / android (push) Canceled after 11m36s
ci / rust-arm64 (push) Failing after 9m8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 24s
decky / build-publish (push) Successful in 36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 5m23s
deb / build-publish-client-arm64 (push) Successful in 7m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 6m31s
deb / build-publish (push) Successful in 9m19s
arch / build-publish (push) Successful in 18m42s
flatpak / build-publish (push) Failing after 8m19s
deb / build-publish-host (push) Successful in 10m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m15s
apple / swift (push) Successful in 5m30s
docker / deploy-docs (push) Successful in 29s
docker / build-push-arm64cross (push) Successful in 11m22s
apple / screenshots (push) Successful in 27m32s
The other half of P1 on GTK (design/client-settings-profiles.md §5.2): until now a
profile could be created and edited but only *used* through `--profile` or a URL. Host
cards get two menus, and they are separate on purpose — the predictability rule is that
connecting with a profile must never change what the card does next time.

- **Connect with ▸** is a one-off. It rides `ConnectRequest.profile` into the session's
  `--profile` flag and touches no stored state. "Default settings" in that menu is
  `Some("")`, not `None`: on a bound host, asking for the defaults is a real choice and
  has to survive as a value rather than degrade into "use the binding".
- **Default profile ▸** is the explicit rebinding act. It writes straight onto the host
  record — the binding IS a field there, so there is no map to keep in step — matched by
  fingerprint, else by address, like every other per-host lookup in this client.
- The card carries a **chip** naming its bound profile, so what a plain click will do is
  visible without opening a menu. A binding whose profile was deleted shows nothing and
  connects with the defaults, which is exactly what the resolver does.

A URL's `profile=` now takes the same one-off route, since it is the same kind of thing.

Verified on .21: `punktfunk://connect/Bound%20Box?profile=Work` against a host bound to
"Game" spawned a session that logged `profile=Work id=bbbbbbbbbbbb` — the one-off won,
and the binding on disk was untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 22:24:56 +02:00
enricobuehler 37a42078a0 fix(xcheck): mirror [workspace.lints] too — and the two things that hid behind it
windows-host / package (push) Failing after 5m0s
windows-host / winget-source (push) Skipped
ci / rust-arm64 (push) Failing after 22s
ci / web (push) Successful in 2m28s
ci / docs-site (push) Successful in 2m55s
ci / rust (push) Failing after 6m19s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / bench (push) Successful in 6m20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
android / android (push) Successful in 12m2s
deb / build-publish-client-arm64 (push) Successful in 8m59s
arch / build-publish (push) Successful in 14m57s
deb / build-publish (push) Successful in 12m3s
deb / build-publish-host (push) Successful in 13m33s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14m22s
`scripts/xcheck.sh` stopped working the moment the workspace adopted its unsafe discipline
(`52191071`). That commit added `[lints] workspace = true` to every crate manifest, and a member
inheriting a lint table the workspace ROOT does not define does not merely lose the lint — cargo
refuses to parse the manifest at all:

    error inheriting `lints` from workspace root manifest's `workspace.lints`
    `workspace.lints` was not defined

The generated root mirrored `[workspace.package]` but nothing else. It now mirrors every
`[workspace.lints.*]` table generically, so a future table (clippy, rustdoc, …) is picked up
without touching the script again.

That mattered more than a broken helper, because xcheck is the ONLY thing that lints these crates
for Windows — the Windows CI job clippies `-p punktfunk-host -p punktfunk-tray`, which does not
lint a dependency. So while it was down, two real failures sat on main unseen:

* **pf-frame `dxgi.rs`, 8 × `clippy::undocumented_unsafe_blocks`.** `307ca88b`/`0a710fdf` split
  the D3DKMT/DXGI calls into one `unsafe` block per FFI call under a single shared SAFETY comment,
  but the lint wants a proof per block — a comment two lines up with an intervening statement does
  not count. Each site now carries its own. Not "as above", which this sweep already found to be a
  proof that rots when the block it points at moves.
* **pf-win-display `input_desktop.rs`, one E0133.** `GetUserObjectInformationW` is an unsafe
  operation in an `unsafe fn` body, which `unsafe_op_in_unsafe_fn` (now warn workspace-wide, and
  `-D warnings` here) requires to sit in its own block. The SAFETY proof was already written; only
  the block was missing.

The `&&` chain in `elevate_process_gpu_priority` keeps two separate blocks rather than one around
the chain — wrapping it would destroy the short-circuit and always issue the relative-priority
call. Same trap this sweep hit in `wait_mode_settled`.

Verified: both xcheck targets clean again.
2026-07-28 22:17:43 +02:00
enricobuehler 21eda37aa3 fix(win-display): punktfunk's own display is not one of the operator's panels
The IddCx driver declares `DISPLAYCONFIG_OUTPUT_TECHNOLOGY_HDMI` for its monitors
(`packaging/windows/drivers/pf-vdisplay/src/monitor.rs`, `IDDCX_MONITOR_INFO::MonitorType`),
and `output_tech_class`'s allowlist reads HDMI as a real external panel. So every consumer of
`target_inventory` counted OUR OWN virtual display among the operator's physical monitors.
Measured on .173: both targets came back `external_physical`, `[(4352, "LG TV SSCR2", HDMI),
(257, "punktfunk", HDMI)]`.

That is not cosmetic. `restore_displays_ccd` ends with the last-resort guarantee that the desk
is never left all-dark:

    let (connected, lit) = target_inventory().filter(external_physical) …
    if connected > 0 && lit == 0 { force_extend_topology() }

The restore runs BEFORE the virtual is REMOVEd, so our own still-active display kept `lit >= 1`
and the backstop could NEVER fire — in exactly the situation it was written for. It also made
our display a candidate "physical suspect" in the disturbance-attribution inventory, which
`TargetInventory`'s own doc rules out ("only indirect/virtual targets (our own IDD included)").

Targets carrying our EDID manufacturer id ("PNK", stamped by the driver's edid.rs) are now
classified `external_physical = false`, tech `punktfunk-virtual`. Matching on the monitor device
path rather than the friendly name — the path carries the manufacturer id the OS itself derived
— and on both spellings, since the CCD device-interface path uses `#`
(`\\?\DISPLAY#PNK0000#…`) while the PnP instance id for the same monitor uses `\`
(`DISPLAY\PNK0000\1&15ecd195&5&UID264`, observed on .173). Allowlist-shaped like
`output_tech_class`, so a third-party virtual display is never silently adopted.

ON GLASS, read-only, on .173 — and it caught the backstop's trigger live:

  target  264  active=true  external_physical=false tech=punktfunk-virtual "punktfunk"
  target 4352  active=false external_physical=true  tech=HDMI              "LG TV SSCR2"

The physical is dark while our virtual holds the desktop. Post-fix that is `connected=1, lit=0`
— the backstop fires. Pre-fix it was `connected=2, lit=1` — suppressed. The new hardware case is
deliberately READ-ONLY (creates and destroys nothing) so it is safe against a live host;
repeated IddCx create/destroy churn is what wedges the driver's slot pool.

Also adds `manager::FAIL_NEXT_ISOLATES`, a `#[cfg(test)]` seam that fails the next N
`isolate_displays_ccd` calls, with every call site in manager.rs routed through it. The Phase-3
recovery legs fire only on a failed isolate, which real hardware does not produce, so they
cannot otherwise be exercised. `#[cfg(test)]` rather than an env knob: this crate's live tests
compile under `cfg(test)`, so no production switch exists that could disable display isolation
on an operator's box. The §5 3.2 case built on it is included but is NOT yet a passing on-glass
result — see the plan for where it got to.

Verified on .173: clippy -p punktfunk-host -p pf-vdisplay --all-targets and -p pf-win-display
--all-targets clean; pf-vdisplay 56 pass, pf-win-display 2 pass + hardware cases; both xcheck
targets clean.
2026-07-28 22:17:26 +02:00
enricobuehler 2b2c6f045d test(vdisplay): pin the force-EXTEND backstop on glass, with a real panel attached
`force_extend_topology` does two jobs — stop a fresh IddCx monitor being CLONED onto the
existing panel, and serve as `restore_displays_ccd`'s last-resort "the desk is not left
dark" backstop. Probed directly on .173 with only the LG TV connected, its preset returns
rc=31 ERROR_GEN_FAILURE while SDC_USE_DATABASE_CURRENT returns 0, which reads like a
backstop that cannot back anything up.

On glass it is not, and this case is the measurement that settled it. Active paths went
1 -> (virtual up) 1 -> (after force-EXTEND) 2: with one connected display there is nothing
to extend across, hence rc=31; with the virtual present there are two and the preset
applies. Both real call sites run in exactly that state — the restore fires BEFORE the
REMOVE, so the virtual is still there. No defect. Recording it so nobody re-derives the
alarming half from the rc alone.

 It also caught the clone hazard live: the arriving virtual monitor did NOT get its own
active path (1 -> 1); only the forced EXTEND gave it one (-> 2). That is exactly the
"no distinct source -> no frames" case the function's own doc describes, observed rather
than assumed.

⚠️ Residual, noted not asserted: a restore that fails once the virtual is already gone is
back to one connected display, where EXTEND returns 31 and cannot re-light anything.

Context for the sweep's other on-glass claims: the earlier `live_create_drop` and
`live_inplace_resize` passes on this box were taken with the TV POWERED OFF (every monitor
devnode Code 45), so they proved the driver round trip but never exercised deactivating a
real panel. Re-run now with the TV attached, `live_inplace_resize` still reports
in_place=true (target 257 -> 257, 2356x1332), so Phase 6.2 holds with a physical present.

Verified on .173: clippy -p punktfunk-host -p pf-vdisplay --all-targets and
-p pf-win-display --all-targets clean, 56 pass + 3 ignored, no orphans.
2026-07-28 22:17:26 +02:00
enricobuehler ea9a25d3b1 fix(win-display): a host with nothing lit is an ANSWER, not a failed CCD query
`query_active_config` asked `QueryDisplayConfig` for the active topology even when the
sizing call had just answered `numPaths = 0`. Windows rejects a zero-count query rather
than handing back an empty set, so "nothing is active" came back as "the query failed".

Zero active paths is an ordinary state, not a broken one: every panel off or in standby, a
KVM switched away, a headless box between its adapter arriving and its first monitor. The
`None` propagated into the two places that matter — `count_other_active` reported failure
where the honest answer is `Some(0)`, and `isolate_displays_ccd` bailed at its first line,
which is exactly the teardown-gate condition whose recovery legs exist to stop the
operator's physical panels being left dark (sweep §5 3.1).

MEASURED on .173 (RTX 4090, Win11 26200) with the TV powered off — every monitor devnode
Code 45, `numPaths = 0` for `QDC_ALL_PATHS` too — in a live, logged-on CONSOLE session:
`GetDisplayConfigBufferSizes` returns 0 paths and `QueryDisplayConfig` then returns 0x57
ERROR_INVALID_PARAMETER (0x5 ERROR_ACCESS_DENIED from session 0). This is the mechanism
behind the plan's  note that `count_other_active(&[])` yields `None` on that box: not the
API refusing us, just a query nobody should have made.

Three changes, one defect:

* `query_active_config` short-circuits `numPaths == 0` to an empty-but-valid config.
* `isolate_displays_ccd` returns that empty snapshot straight away instead of entering its
  retry loop. Nothing is active, so nothing needs deactivating and nothing needs restoring;
  the loop's re-commit exists to drive the IddCx adapter's COMMIT_MODES, and with no path
  at all there is no config to commit — four attempts would each log a rejected apply and
  sleep 250 ms to reach the topology we already have. `restore_displays_ccd` already
  treated an empty config as the no-op it is, so the round trip stays consistent.
* `set_virtual_primary_ccd` goes through the shared query. It carried a verbatim copy of it
  — same flags, same shape — and was therefore the one CCD entry point that would not have
  inherited the fix. Same seam asymmetry this crate keeps producing, and two hand-written
  unsafe blocks go with it.

Also this file's first tests. The case is `#[ignore]`d hardware, so it reports `ignored`
rather than a vacuous `ok` when nobody runs it. On-glass A/B in the console session on
.173: it passes with the short-circuit and FAILS without it, naming the conflation. A box
that does have a display lit passes either way.

⚠️ The test is read-only and must stay that way — the obvious companion assertion,
`isolate_displays_ccd(&[])`, is deliberately not written: an empty keep set means "keep
nothing", so on a box with displays it would deactivate every one of them.

Verified on .173: `clippy -p punktfunk-host -p pf-vdisplay --all-targets` and
`clippy -p pf-win-display --all-targets` both clean, pf-vdisplay 56 pass, pf-win-display
1 pass + 1 ignored. Both xcheck targets clean.
2026-07-28 22:17:26 +02:00
enricobuehler 5a14d2b3f4 fix(vdisplay): the helper budget must end the tree, not just the process we spawned
`Child::kill` is one `TerminateProcess`: it ends exactly the process we launched. On Unix
that is the whole story here — `kscreen-doctor`, `systemctl`, `pw-dump` are single processes
we exec directly. On Windows there is no direct exec, so every helper is reached through a
shell (`cmd /c …`, `powershell -Command "… | pnputil …"`) and the process that actually
hangs is a GRANDCHILD. Killing the shell left it running, holding the stdio handles and the
working directory it inherited from us, which means the budget bounded nothing.

Not theoretical: it is why a fully green `cargo test -p pf-vdisplay` still failed its CI job
on `33a31427`. The suite's own hung-helper case orphaned a 60-second `ping.exe`; it kept the
build step's stdout pipe open past the runner's 10 s WaitDelay ("exec: WaitDelay expired
before I/O complete") and pinned `crates\pf-vdisplay` so the workspace could not be cleaned
up. The tests reported `55 passed; 0 failed` in the same log.

Spawned children are now enrolled in a Job object. Job membership is inherited across
`CreateProcess`, so one `TerminateJobObject` ends every descendant, and
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` makes that hold on paths that never reach the explicit
call — an early `?`, a panic — because closing the last handle is then itself the kill.
Best-effort: a box that cannot make a job object degrades to the single-process kill it did
before rather than failing the query, the same stance as the already-ignored `Child::kill`.

`output_within` ends the tree BEFORE `wait_with_output`. That read runs to EOF, and a
grandchild outliving the helper holds the write end — the one way this "bounded" helper
could still hang forever.

The new test is the permanent lever, and it is not vacuous: it asserts the grandchild
reached its first marker before asserting it never reached its second. Verified on .173
(Win11 26200): 56 pass, `clippy -p punktfunk-host -p pf-vdisplay --all-targets` clean, no
`ping.exe` survivors. A/B with the job disabled: the test fails with "a grandchild outlived
the budget" and one orphan survives. On the CI runner .133 the job APIs were also checked
directly, as Administrator (already inside a job — nesting works) and as SYSTEM: both kill
the tree.

Trap for anyone editing that test: a .cmd file is read in the OEM code page, so an absolute
path baked into it is mangled the moment the temp dir holds a non-ASCII character
(`C:\Users\Enrico Bühler\…` arrives as `B?hler`) and every redirect fails with "path not
found". It uses `%~dp0` instead, so the file stays pure ASCII.
2026-07-28 22:17:26 +02:00
enricobuehlerandClaude Opus 5 cf419d8b6f feat(client/linux): the settings dialog edits profiles, not just the defaults
ci / web (push) Successful in 2m12s
ci / docs-site (push) Successful in 1m12s
android / android (push) Successful in 12m14s
ci / rust (push) Failing after 9m46s
ci / rust-arm64 (push) Failing after 9m43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 18s
decky / build-publish (push) Successful in 32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
ci / bench (push) Successful in 7m57s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
deb / build-publish-client-arm64 (push) Successful in 7m40s
deb / build-publish (push) Successful in 9m5s
deb / build-publish-host (push) Successful in 10m19s
flatpak / build-publish (push) Failing after 8m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 8m10s
arch / build-publish (push) Successful in 21m3s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
P1's core on GTK (design/client-settings-profiles.md §5.1). The same surface now edits
either layer: a scope switcher at the top of General swaps between "Default settings" and
one profile's overrides. Deliberately not a second editor — a parallel one would drift
from this one field by field, which is exactly what the "one settings UI" principle is
there to prevent.

Three decisions carry the design's semantics into this file:

- Rows always show the EFFECTIVE value — the global underneath with the profile's
  overrides applied — so a row the profile doesn't touch reads as the live global. That is
  what "inherit by default" has to look like.
- Overrides are recorded ON TOUCH, never by diffing values at save time. A profile may
  therefore pin a value that happens to equal today's global and keep it when the global
  later moves. The handlers that record this are installed after the seed pass, or opening
  a profile would override every row in it.
- Only profileable rows render in profile scope. Auto-wake, the library toggle, the
  decoder and GPU pickers, the audio endpoints, the forwarded-pad picker and the detected
  pad list are facts about this device or this network, not about "Game vs Work" (§3's
  tier G/H), so they simply aren't there.

Switching scope closes the dialog — which commits the layer being edited — and asks the
app to re-open in the new one, so there is a single code path that builds the rows and no
ambiguity about commit ordering. Both scopes read the controls through one shared closure;
the tri-state resolution row is the obvious trap if they ever diverged.

Create / Rename / Duplicate / Delete live in the switcher's group. Names are unique
case-insensitively and the prompt refuses duplicates in place rather than after the dialog
is gone. Delete counts what actually breaks — hosts that fall back to the defaults, pinned
cards that disappear — and leaves bindings dangling on purpose: they resolve as "no
profile" everywhere, and rewriting every host record here would be a second, racier source
of truth.

`PUNKTFUNK_SHOT_SETTINGS_SCOPE` puts the screenshot scene into profile scope, so both
halves of the surface are capturable. Verified on .21 under Xvfb: the switcher, the
per-profile actions and the tier filtering render, and the Statistics row seeds to the
Game profile's "Detailed" rather than the global's value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 22:05:29 +02:00
enricobuehlerandClaude Opus 5 6cf5d4ab87 feat(client/linux): punktfunk:// links open the GTK client
ci / web (push) Successful in 2m41s
android / android (push) Successful in 12m27s
ci / docs-site (push) Successful in 3m8s
ci / rust (push) Failing after 10m42s
ci / rust-arm64 (push) Failing after 10m44s
arch / build-publish (push) Successful in 15m31s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 28s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m33s
deb / build-publish-client-arm64 (push) Successful in 7m15s
deb / build-publish (push) Successful in 13m59s
deb / build-publish-host (push) Successful in 14m55s
flatpak / build-publish (push) Failing after 10m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m37s
docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 27s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 3m8s
D1's activation half (design/client-deep-links.md §4.1). The client already had a
`--connect` CLI, but nothing claimed the scheme, so nothing outside a terminal could
start a stream: no browser prompt, no xdg-open, no double-clickable shortcut.

The `.desktop` entries gain `MimeType=x-scheme-handler/punktfunk;` and `Exec=… %u`, and
the rpm scriptlet joins deb and arch in running `update-desktop-database` — an entry
nothing indexes claims nothing. (nix substitutes the Exec line by prefix, so `%u`
survives; flatpak's exported entry is renamed to the app id by the standard mechanism.)

In the app, `HANDLES_OPEN` plus a positional-URL argv is the whole mechanism, and the
second half is the interesting one: argv stays withheld from GApplication as before
EXCEPT when it carries a URL, and then GIO's own single-instance forwarding delivers it
to the running window over D-Bus. Clicking a link with Punktfunk already open reuses that
window instead of racing a second instance, and we wrote no IPC to get it. A URL that
lands during a cold start (before the model exists) is parked rather than dropped —
silently losing a link is the one outcome that would make the feature untrustworthy.

Routing is four lines of translation, because the decisions are the brain's: parse,
resolve the host, resolve the profile, refuse. A link that resolves becomes exactly the
message a card click raises — the same wake, trust gate, and error surfaces — so there is
no second connect path to keep in sync. A link to a host we don't know, or know but never
pinned, opens the ordinary PIN ceremony seeded with what the link claimed, which is how
"a URL may never pair or trust on its own" stays true while still being useful. Never
preempting a live session is checked here, since only this layer knows one is running.

Verified on .21 under Xvfb against hand-authored stores: `connect/Bound%20Box?launch=`
resolved the host by name, carried the launch id through the trust gate, and spawned the
session; `punktfunk://pair/1234` and an unknown `profile=` both refused before any dial.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:37:10 +02:00
enricobuehler c262bf43df refactor(host): three more Windows unsafe fns that had no caller contract
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m16s
android / android (push) Successful in 12m8s
windows-drivers / probe-and-proto (push) Successful in 29s
ci / rust (push) Failing after 10m31s
ci / rust-arm64 (push) Failing after 11m1s
ci / bench (push) Successful in 5m40s
decky / build-publish (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
windows-drivers / driver-build (push) Successful in 1m44s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 16m4s
windows-host / package (push) Failing after 5m12s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 8m59s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m12s
flatpak / build-publish (push) Successful in 7m0s
deb / build-publish-client-arm64 (push) Successful in 9m59s
apple / swift (push) Successful in 5m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m28s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m9s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m32s
deb / build-publish-host (push) Successful in 14m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m45s
docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 30m14s
release / apple (push) Successful in 39m36s
apple / screenshots (push) Successful in 23m3s
Continues the crate-by-crate `unsafe_op_in_unsafe_fn` work. Same finding as
pf-frame: the useful move is often deleting the `unsafe fn` marker, not decorating
the body.

`install_steam_audio_pair` (no arguments) and `try_install_steam_audio(&str)` are now
safe fns. The call sites had already worked this out — both carried a SAFETY comment
opening "`install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s
`newdev.dll`", i.e. the obligation was never the caller's. Making them safe deletes
14 lines of that prose from two call sites and moves the real reasoning next to the
`LoadLibraryExW`/`transmute`/call chain it actually describes. wasapi_mic.rs: 8 -> 0.

`make_job` (no arguments, and it wraps its handle in an `OwnedHandle` before the
first fallible step) and `open_log_handle(&Path)` are safe fns too. The latter
returns a raw `HANDLE`, but that is an ownership obligation, not a safety one —
nothing a caller does with it can cause UB, and it is inherited by the child and
closed there. service.rs: 19 -> 14.

`spawn_host` keeps `unsafe fn`: it takes a raw `HANDLE`, and an invalid one is UB.
That is a contract worth stating, so it keeps the marker.

Windows-only note: `LoadLibraryExW` here already passes
`LOAD_LIBRARY_SEARCH_SYSTEM32`, so the newdev.dll load cannot pick up a planted DLL
from the working directory — now recorded in the SAFETY note rather than left
implicit.

Verified on Windows .47: punktfunk-host clean at EXITCODE=0 — zero errors, zero
unused-unsafe. Crate's own files now 29 E0133 (interactive.rs 15, service.rs 14),
down from 42.
2026-07-28 21:31:42 +02:00
enricobuehler 307ca88ba7 refactor(frame): dxgi.rs to zero E0133, and two of its unsafe fns didn't need to be
First crate done under `unsafe_op_in_unsafe_fn`. 23 sites -> 0, and the file's
`unsafe fn` count drops 5 -> 2, because narrowing was the wrong answer for two of
them:

`enable_inc_base_priority` takes no arguments and touches only the current process's
own token. `hags_enabled` takes a `LUID`, a plain value with no pointer a caller
could get wrong, and builds every gdi32 argument struct locally. Neither leaves a
precondition for a caller to uphold, so neither should have been `unsafe fn` at all —
they are now safe fns with the unsafe confined inside, which deletes the obligation
from their callers rather than restating it. That is a strictly better outcome than
wrapping their bodies, and it is invisible if you treat the lint as a mechanical
find-and-wrap.

`d3dkmt_set_scheduling_priority_class` stays `unsafe fn`: it takes a `HANDLE`, and an
invalid one is UB. That contract is real, so it keeps the marker and gains narrow
blocks inside instead.

The rest is narrowing. The clearest win is `elevate_process_gpu_priority`, whose
`ONCE.call_once(|| unsafe { .. })` had 25 lines inside one block — a `match` on a
config enum, three `tracing!` arms and an early return — for exactly one call that
needed it. That block is gone; only the `d3dkmt_*` call carries `unsafe` now.

Every remaining block is scoped to the FFI call it covers and carries a SAFETY note
naming what makes it hold: which handle is live, which local the callee writes,
which size matches which variable.

Verified: Windows .47 pf-frame clean at EXITCODE=0 — zero errors, zero E0133, zero
unused-unsafe. Linux .21 pf-frame zero errors (dxgi.rs is Windows-gated, checked for
regressions in the shared parts).
2026-07-28 21:31:42 +02:00
enricobuehler 0a710fdfe5 refactor(frame): narrow make_device's unsafe to the FFI calls themselves
First slice of the `unsafe_op_in_unsafe_fn` work on the Windows-gated code, and a
deliberate demonstration of the shape the rest should take.

`cargo fix` is NOT the tool here. Its suggestion for this lint wraps the entire
function body:

    pub unsafe fn make_device(..) -> Result<..> { unsafe {
        ..40 lines..
    }}

One block spanning everything, `{ unsafe {` / `}}` bracing, and — the actual problem
— zero change in how much code runs unchecked. It makes the status quo explicit
rather than smaller, which is the opposite of the goal.

Narrowed by hand instead: four blocks, each around the FFI call that needs it
(`D3D11CreateDevice`, two `SetGPUThreadPriority`, `SetMaximumFrameLatency`), each
with its own SAFETY note. The `Option` handling, the `?`, the `if let Ok(..)` casts
and the `tracing!` calls are back outside, where the compiler checks them. Roughly 40
unchecked lines become 6.

Fixed two of my own mistakes in the same pass: `elevate_process_gpu_priority` and
`auto_priority_gate` are ALREADY safe fns, and I had wrapped their calls in `unsafe`
out of momentum. Unnecessary `unsafe` is not free — it is the same "this is scary"
noise the lint exists to remove, and the compiler only warns about it. That mistake
is a good argument against doing the remaining ~590 sites mechanically.

pf-frame drops 23 -> 19 E0133. The remaining three functions in this file need a
different answer, not just narrowing: `enable_inc_base_priority` takes no arguments
and touches only the current process's own token, so it has NO caller contract and
should become a SAFE fn with unsafe blocks inside — which removes an `unsafe fn`
rather than decorating it. Same likely for `hags_enabled(LUID)`, whose only argument
is a plain value.

Verified on Windows .47: pf-frame checks clean at EXITCODE=0, no errors, no
unused-unsafe warnings.
2026-07-28 21:31:42 +02:00
enricobuehler a3843b6996 test(encode): the VAAPI dmabuf path verified on AMD silicon, and its owner-only fields say so
Closes the last unverified leg of the AvBuffer work. `DmabufInner` was the heaviest
ownership change in the crate — four owned objects (DRM device, derived VAAPI device,
DRM-PRIME frames ctx, filter graph) whose eight failure branches each repeated the
same four-line unwind, once inside a macro, plus a ninth copy in `Drop` — and it had
no coverage at all. `vaapi_cpu_encode_smoke` does not reach it: that drives the
swscale/CPU-upload path, which uses `VaapiHw` and never builds a graph.

`dmabuf_inner_alloc_drop_cycles` loops construct/drop, which is the whole contract
now that every handle releases itself. **It passes on a Radeon 780M**, as do the two
pre-existing VAAPI tests, so `VaapiHw` and `DmabufInner` are both hardware-verified
rather than compile-only.

Also fixes three more owner-only fields the AMD box surfaced: `graph`,
`vaapi_device` and `drm_device` are `never read` since the hand-written `Drop` that
used to read them is gone. Same call as the decoders and the QSV pair — annotate
rather than delete (removing `graph` would free it while `src`/`sink` still point
into it) or underscore-rename (which hides what they hold). `drm_frames` is NOT
annotated: `submit` genuinely reads it per frame.

I had missed these twice: my Linux log greps searched "never constructed|never used",
which does not match "never read". The check now includes all three spellings.

Two environment notes worth keeping, both diagnosed by testing OUTSIDE our code
first (ffmpeg's own CLI reproduced each):
  * Inside a distrobox on an immutable host, VAAPI needs
    `LIBVA_DRIVERS_PATH=/run/host/usr/lib64/dri`. The container's mesa (25.3.6) is
    older than the host's (26.0.4) and every encoder open fails with a bare ENOSYS —
    "Function not implemented", naming nothing. The test doc says so.
  * `cargo check --workspace` in that container fails on `glib-sys` (no GTK dev
    headers). Unrelated to this branch; .21 checks the full workspace clean.

Verified: AMD .116 (Radeon 780M, Mesa 26.0.4) full pf-encode suite 33 passed / 0
failed plus all 3 ignored VAAPI hardware tests green. Linux .21 workspace check exit
0 / zero errors, pf-encode 33/0, pf-client-core 34/0, and zero dead-code warnings
across all three spellings.
2026-07-28 21:31:42 +02:00
enricobuehler 4241bc0383 fix(encode/windows): enable D3D11 multithread protection before libav sees the device
libav turns on `ID3D11Multithread::SetMultithreadProtected` in
`d3d11va_device_create` — the path where it creates the device itself. We take the
other path, `d3d11va_device_init`, because we hand it the capturer's existing
`ID3D11Device`, and that path does not. Nothing enabled it on our side either, so it
was simply off. Two consequences, one of them shipping since long before this branch:

QSV zero-copy could not open at all. `av_hwdevice_ctx_create_derived(QSV <- D3D11VA)`
ends in `MFXVideoCORE_SetHandle`, and MFX rejects a device without multithread
protection as `MFX_ERR_UNDEFINED_BEHAVIOR (-16)`. libav reports that as "Error
setting child device handle", which names neither the cause nor the cure.

AMF — the DEFAULT Windows zero-copy path — has been running with a lock that does
nothing. We deliberately leave `lock`/`unlock` null so libav installs its
`d3d11va_default_lock`, and that lock is `ID3D11Multithread::Enter`/`Leave`, which
are documented no-ops while protection is off. The lock libav installs to serialise
our capture thread against its encode thread has therefore never actually serialised
anything. That is the more serious half of this fix, and it is not QSV-specific.

Measured on the Intel VM (UHD 750, FFmpeg 7.1.5 + libvpl), sweeping device flags x
adapter x protection:

    BGRA            -> QSV derive FAILED (-16)
    BGRA +MT        -> QSV derive OK
    BGRA|VIDEO +MT  -> QSV derive OK

`D3D11_CREATE_DEVICE_VIDEO_SUPPORT` makes no difference in either direction, and the
same result holds on both Intel adapters the box enumerates. Protection is the only
variable that matters, and it read back `was=false` every time, confirming nothing
else had enabled it. The hypothesis came from ffmpeg's own CLI succeeding at the
identical derive (`-init_hw_device d3d11va=d3d -init_hw_device qsv=q@d3d`) — the only
difference being who created the device.

With this, `zerocopy_qsv_alloc_drop_cycles` passes on real Intel silicon, which also
makes the QSV arm of the AvBuffer refactor hardware-verified rather than
compile-only. Its doc comment now points back here, since a future regression will
present as that same opaque "child device handle" line.

NOT flipped: `zerocopy_active` still defaults QSV off. The derive works and the
handles construct and release cleanly, but that is not the same as a validated
streaming session, and the default should move on glass evidence, not on this.

Verified on .47 (Intel UHD 750): full pf-encode --features amf-qsv suite 39 passed /
0 failed, d3d11hw_alloc_drop_cycles green, zerocopy_qsv_alloc_drop_cycles green, and
the native VPL path (qsv_encode_live_smoke) still green. Linux .21: workspace check
exit 0 / zero errors, pf-encode 33/0, pf-client-core 34/0.
2026-07-28 21:31:42 +02:00
enricobuehler 2d78d2a514 test(encode): D3d11Hw verified on real silicon; the QSV derive is broken upstream of us
Ran the QSV probe on the Intel VM (UHD 750, FFmpeg 8). It fails — and not in the
code it was written to cover:

    [AVHWDeviceContext] Error setting child device handle: -16
    ZeroCopyInner::open(QSV) failed on iteration 0: derive QSV device from D3D11VA

That is `MFX_ERR_UNDEFINED_BEHAVIOR` out of `MFXVideoCORE_SetHandle`, raised inside
`av_hwdevice_ctx_create_derived` — libav's own code, called with the same arguments
as before this branch. Confirmed pre-existing by A/B: the same probe, written against
unmodified `origin/main` with the raw-pointer struct, fails byte-identically on the
same box at the same first iteration. The ownership change is not implicated.

It is also not the hardware. `qsv::tests::qsv_encode_live_smoke` — the native VPL
backend, untouched by this branch — encodes real H.264 on that box via VPL 2.15 with
its own D3D11 zero-copy. So QSV works; libav's QSV-from-D3D11VA derive is what does
not. That matches `zerocopy_active` already defaulting QSV **off** pending Intel
validation, and is now a measured fact rather than a suspicion. The test stays,
`#[ignore]`d, with the finding in its doc comment so the next Intel driver / FFmpeg
bump re-checks it instead of the question being quietly dropped.

What that leaves is real coverage of the half that IS reachable anywhere:
`d3d11hw_alloc_drop_cycles` loops construct/drop on `D3d11Hw`, the hwdevice +
frames-pool pair both Windows zero-copy vendors share, so it covers the AMF path's
ownership without needing AMD hardware. **It passes on the Intel box: 8 cycles, no
abort.** Adapter selection is factored into `test_hw_device`, which prefers a vendor
and skips the Microsoft Basic Render Driver — a punktfunk host enumerates our own
virtual-display adapter too, so `EnumAdapters1(0)` is not a safe assumption.

Verified: QSV VM .47 full `pf-encode --features amf-qsv` suite 39 passed / 0 failed
(EXITCODE=0), with `d3d11hw_alloc_drop_cycles` green on real silicon. Linux .21
workspace check exit 0 / zero errors, pf-encode 33/0, pf-client-core 34/0.
2026-07-28 21:31:41 +02:00
enricobuehler 564797a12a test(encode): a QSV construct/drop smoke test, and AvFilterGraph goes Linux-only
Two loose ends ahead of running this on real Intel silicon.

`zerocopy_qsv_alloc_drop_cycles` covers the one ownership question in the crate that
was genuinely ambiguous. `ZeroCopyInner::open` builds a `D3d11Hw` and then DERIVES a
QSV device + frames ctx from it, and the tuple it used to return handed those two
derived pointers out twice — once as the encoder's args, once as the pair moved into
`Self`. Free for raw pointers, two owners for `AvBuffer`. Looping construct/drop is
what separates the outcomes: a double-unref aborts in the CRT, a missed one leaks an
Intel device per session. Nothing else reaches this code — `zerocopy_enabled`
defaults QSV OFF, and the native VPL backend supersedes this whole file unless
`PUNKTFUNK_QSV_FFMPEG=1` — so the test calls `open` directly and sidesteps both
gates, the same shape as `cuda_hw_alloc_drop_cycles`.

`AvFilterGraph` is now `#[cfg(target_os = "linux")]`. The Windows gate had been
reporting `struct AvFilterGraph is never constructed` since a960dff8 and I had been
filtering it out of my own log greps (searching for "never read", which does not
match "never constructed"). It is true: the VAAPI dmabuf path is the only
filter-graph user, and the AMF/QSV backends build no graph. Cfg'd out rather than
`allow`ed, so it cannot outlive its last caller unnoticed.

Verified. Windows .133: `cargo test -p pf-encode --features amf-qsv --no-run` at
EXITCODE=0 with the AvFilterGraph warnings gone (107 -> 105), and the test binary
lists `ffmpeg_win::tests::zerocopy_qsv_alloc_drop_cycles`. Linux .21: workspace
check exit 0 / zero errors, pf-encode 33 passed / 0 failed, pf-client-core 34 passed
/ 0 failed, and zero dead-code warnings there (AvFilterGraph is live on Linux).

NOTE for running it: the test binary needs `C:\Users\Public\ffmpeg\bin` on PATH at
RUNTIME. `FFMPEG_DIR` is build-time only, and without the DLLs the harness exits
silently with no output at all — which reads exactly like a test that does not exist.
2026-07-28 21:31:41 +02:00
enricobuehler e60fee7da6 refactor(encode): the QSV derived pair stops being two owners of the same pointer
`ZeroCopyInner` held the QSV device + frames ctx as nullable raw pointers, where
null meant "AMF, which feeds D3D11 frames directly" — a convention documented in a
comment and enforced by three `is_null()` checks. `Option<AvBuffer>` says it in the
type, so the checks and the hand-written `Drop` both go away.

The reason this one was left out of the previous commit is the aliasing. `open`
built a five-element tuple whose QSV arm handed the SAME two pointers out twice:
once as the encoder's `dev_ref`/`frames_ref`, once as the pair moved into `Self`.
For raw pointers that is free; for an owning type it is two owners and a
double-unref. Ownership and borrowing are now separated — the pair is owned in
`qsv_frames`/`qsv_device`, and the encoder's arguments are `as_ptr()` views taken
from whichever owner applies. `open_win_encoder` takes its own refs of what it is
handed, so lending transfers nothing.

Net: all 13 `av_buffer_unref` calls in this file are gone (the last two hand-written
Drops with them), including the encoder-open failure arm, which collapses to `?`
now that every handle releases itself.

Field order pinned and commented, as with the others: QSV frames, QSV device, then
`enc`, then `hw` — reproducing the old `Drop`, which ran ahead of all fields and so
released the derived pair before the encoder's AddRef'd copies and the D3D11 refs.

`qsv_device` picked up an `#[allow(dead_code)]`: `qsv_frames` is still read by the
send path, but the device is now purely an owner (the frames ctx and the encoder
each hold their own ref). Same call as the decoders — deleting it would free the
device early, an underscore name would hide it.

Verified on BOTH: Windows .133 `cargo check -p pf-encode --features amf-qsv
--all-targets` EXITCODE=0, no dead-code warnings, and confirmed non-vacuous (log
shows `Removed 23 files, 36.2MiB` then a real `Checking pf-encode`). Linux .21
`cargo check --workspace --all-targets` exit 0 / zero errors, pf-encode 33 passed /
0 failed, pf-client-core 34 passed / 0 failed, and `cuda_hw_alloc_drop_cycles` still
passing on real CUDA. The QSV path itself still wants Intel silicon to exercise.
2026-07-28 21:31:41 +02:00
enricobuehler 5b142a7e85 refactor(encode): the Windows D3D11VA hwdevice owns its refs
`D3d11Hw::new` is the third instance of the same shape as `CudaHw` and `VaapiHw` —
alloc a hwdevice, deref to fill it, init, alloc a frames ctx, deref, init — with the
same hand-written unwind: three `av_buffer_unref` calls spread over the failure
branches plus a `Drop` repeating the pair. It gets the same treatment, so all three
libav hwdevice wrappers in the crate now share one release path.

Field order carries the semantics here as in the other two: frames declared before
device, so declaration-order dropping reproduces what the hand-written `Drop` did.

`ZeroCopyInner`'s `qsv_device`/`qsv_frames` are deliberately NOT converted in this
commit. They are nullable (null for AMF, which feeds D3D11 frames directly), and the
QSV branch hands the same two pointers out twice — once as the encoder's
`dev_ref`/`frames_ref` and once as the owned pair moved into the struct. Modelling
that needs `Option<AvBuffer>` plus a borrow/own split so the aliasing does not become
two owners, which is a different change from the mechanical one this commit makes.

Verified on the Windows runner .133: `cargo check -p pf-encode --features amf-qsv
--all-targets` at EXITCODE=0. Confirmed non-vacuous — the log shows `cargo clean -p
pf-encode` removing 23 files / 36.2 MiB followed by a real `Checking pf-encode`,
because a fast green on that box can otherwise just be cargo reusing artifacts whose
timestamps outrank the freshly-extracted sources.
2026-07-28 21:31:41 +02:00
enricobuehler 06a406adf2 refactor(client): the D3D11VA decoder owns its refs too, and the owner-only fields say so
Completes the decoder trio. `D3d11vaDecoder::new` had the same cascading unwind as
the VAAPI and Vulkan constructors — four branches each unref'ing the hwdevice by
hand — and `d3d11va_decode_supported` opened a frames context it released on one
exit path while relying on the null check for the other. Both are `AvBuffer` now.
The one surviving `av_buffer_unref` in the file is deliberate: it runs before
ownership is taken, on the `av_hwdevice_ctx_init` failure ahead of `from_raw`.

The Windows gate then caught something worth keeping: `field hw_device is never
read`, in all three decoders. It was true and it was new — the hand-written `Drop`
used to be the field's only reader, so moving the unref into the type left a field
that is *purely* an owner. The compiler cannot express that, so each one now
carries a doc comment saying what it is and an explicit `#[allow(dead_code)]`.
Deleting the field would free the device early; renaming it `_hw_device` would
silence the lint by hiding what it holds. Neither is what we mean.

Verified on BOTH platforms this time. Windows runner .133 (the toolchain env from
the runner notes, plus `PF_FFVK_VULKAN_INCLUDE` for pf-ffvk): `cargo check -p
pf-client-core --all-targets` at EXITCODE=0, clean of the dead-code warnings. Linux
.21: `cargo check --workspace --all-targets` exit 0 / zero errors, pf-client-core
34 passed / 0 failed, pf-encode 33 passed / 0 failed.
2026-07-28 21:31:41 +02:00
enricobuehler a42ab075a8 refactor(client): the hardware decoders own their hwdevice ref
`VaapiDecoder::new` and `VulkanDecoder::new` create a hwdevice and then do several
more fallible things with it — resolve `vkWaitSemaphores`, find the decoder, alloc
the context, open it — and every one of those branches unref'd the device by hand,
with a `Drop` doing it once more. Six hand-written unrefs between them, each one a
line somebody has to remember when adding a step.

`video_libav::AvBuffer` owns it instead, so an early `bail!` releases it and the
branches carry nothing. It is a deliberate second copy of `pf-encode`'s type: the
crates do not depend on each other (host encode and client decode share no code
path), and this one needs something the host's does not — see below — so hoisting
it to a shared crate would mean giving that crate an ffmpeg dependency and both
sets of semantics to save about twenty lines.

That extra piece is `into_raw`. `pick_vulkan` hands its frames context to the codec
(`(*ctx).hw_frames_ctx = fr` — the codec unrefs it when the context closes), so the
wrapper must give up ownership rather than drop. Making the transfer explicit is
the point: dropping an `AvBuffer` there too would be exactly the double-unref this
type exists to prevent.

Two `av_buffer_unref` calls survive in `video_vulkan.rs` on purpose. One runs before
ownership is taken (the `av_hwdevice_ctx_init` failure, ahead of `from_raw`); the
other releases the codec's OWN pre-existing frames ctx before we replace it, which
was never ours to model.

Field order preserved: `hw_device` stays declared after `ctx`, so it still releases
after each `Drop` frees packet/frame/context — the order the hand-written unref had.

Verified on .21 (CachyOS, FFmpeg 62): `cargo check --workspace --all-targets` clean
at exit 0 with zero errors — the workspace-wide check owed since the AvBuffer
commit — plus pf-client-core 34 passed / 0 failed and pf-encode 33 passed / 0
failed. The decoders themselves still need real VAAPI/Vulkan playback to exercise.
2026-07-28 21:31:41 +02:00
enricobuehler eb9c5be20d refactor(encode): the VAAPI dmabuf path stops unwinding by hand
`DmabufInner::open` builds four owned objects — a DRM device, a VAAPI device
derived from it, a DRM-PRIME frames context, and a filter graph — and every one of
its eight failure branches unwound them by hand. The same four-line block
(`avfilter_graph_free` + three `av_buffer_unref`s) appeared eight times, once
*inside a macro*, plus a ninth copy in `Drop`. Adding a step to that function meant
remembering to extend the unwind at exactly the right depth; getting it wrong leaks
a device per failed session (the persistent listener accumulates them) or frees one
twice.

All eight are gone. `AvBuffer` already owned the buffer refs; `AvFilterGraph` now
does the same for the graph, so each handle is owned the moment it exists and an
early `bail!` releases whatever was built so far. `open` lost ~40 lines of cleanup
and gained none.

Field order in `DmabufInner` is load-bearing and says so: graph, frames, VAAPI
device, DRM device, then `enc` LAST. Fields drop in declaration order, and that
sequence reproduces the old hand-written `Drop` exactly — including that it ran
ahead of every field, so all four were released before ffmpeg-next dropped the
encoder. Everything here holds its own reference, so refcounting makes any order
sound; the ordering is pinned so a future reorder cannot quietly change what ships.

One subtlety preserved deliberately: the buffersrc parameters take `drm_frames`
BORROWED, not ref'd (`av_buffersrc_parameters_set` takes its own ref). That is now
`drm_frames.as_ptr()` — same borrow, same single owned ref, no new leak.

Verified on .21 (CachyOS, RTX 5070 Ti, FFmpeg 62): `cargo check -p pf-encode
--all-targets` clean at exit 0, `cargo test -p pf-encode` 33 passed / 0 failed, and
`cuda_hw_alloc_drop_cycles` still passes against real CUDA. vaapi.rs now contains
zero `av_buffer_unref` and zero `avfilter_graph_free` calls, down from 40 and 8.
The dmabuf path itself still needs AMD/Intel silicon to exercise end to end.
2026-07-28 21:31:41 +02:00
enricobuehler 7adc1db672 refactor(encode): AVBufferRef ownership moves into an RAII handle
`CudaHw::new` and `VaapiHw::new` are the same shape — alloc a hwdevice, deref it
to fill fields, init, alloc a frames ctx, deref, init — and both unwound by hand:
`av_buffer_unref` on every failure branch (three in CUDA, two in VAAPI) plus a
hand-written `Drop` repeating the pair. That shape has two failure modes and the
compiler can see neither: add a branch and forget the cleanup (leak), or let two
cleanup paths run (double-unref, an abort inside glibc).

`libav::AvBuffer` owns the ref instead. It null-checks on the way in — the check
each caller open-coded — and unrefs exactly once on drop, so an early `?` releases
whatever was built so far and the failure branches carry no cleanup at all. Both
hand-written `Drop` impls are gone, and `linux/mod.rs` goes from seven
`av_buffer_unref` calls to zero.

The one subtlety, called out at both structs: these two fields must stay declared
frames-BEFORE-device. Fields drop in declaration order, and the code being replaced
deliberately unref'd frames first (a frames ctx holds its own reference on its
device). Refcounting makes either order sound, but a field reorder should not
silently change what ships, so the ordering is load-bearing and commented as such.

Also adds `cuda_hw_alloc_drop_cycles` — the RAII path had NO test coverage: the
NVENC smoke tests take the CPU path and never construct a `CudaHw`, and the VAAPI
twin's tests need AMD/Intel silicon. Looping construct/drop is what catches the
double-unref (abort) and the leak (allocator growth) this refactor is about.

Verified on Nobara (RTX 5070 Ti): `cargo check -p pf-encode --all-targets` clean at
exit 0, `cargo test -p pf-encode` 33 passed / 0 failed, and
`cuda_hw_alloc_drop_cycles` passes against a real CUDA device — eight
construct/drop cycles, no abort. `VaapiHw` is compile-verified only; it is the
identical shape but no AMD/Intel box was reachable to run its two ignored tests.
2026-07-28 21:31:41 +02:00
enricobuehler 5219107177 chore(unsafe): the workspace adopts the drivers' unsafe discipline
`packaging/windows/drivers/*` has run `deny(unsafe_op_in_unsafe_fn)` +
`deny(clippy::undocumented_unsafe_blocks)` for a while, with `forbid(unsafe_code)`
on the modules that need no unsafe at all. The main workspace had no lint config
whatsoever, so nothing stopped a clean crate from quietly growing an `unsafe`, and
nothing distinguished the handful of genuinely-unsafe lines inside a 600-line
`unsafe fn` from the safe ones surrounding them.

Three things, all mechanical:

* `#![forbid(unsafe_code)]` on the eight crates that already contain zero unsafe
  (`pf-driver-proto`, `pf-host-config`, `pf-paths`, the three clean clients, both
  tools). These were clean by accident, not by contract; now they are clean by
  contract.

* `unsafe_op_in_unsafe_fn = "warn"` workspace-wide. `unsafe fn` states a contract
  the CALLER must uphold — it was never meant to switch off checking for the whole
  body. Measured fallout is 300 sites on Linux, and they are concentrated: six
  files carry all of them, while `punktfunk-core`, `pf-frame`, `pf-clipboard` and
  `pf-vdisplay` are already at zero. `warn` (not `deny`) so the build stays green
  while those six are worked down; it flips to `deny` once they are. This is also
  the Rust 2024 default, so it pays off the edition migration early.

* `proc::current_uid()` replaces eight `unsafe { libc::getuid() }` blocks. Each
  site had copied out the same SAFETY note verbatim, which is the tell: `getuid()`
  is parameterless, always succeeds and touches no memory, so there is no contract
  for a caller to uphold and no reason for the unsafe to be visible eight times.
  One `unsafe` behind a safe wrapper, none at the call sites.

Verified: `pf-vdisplay` builds clean on Linux (Nobara) at zero E0133; the
macOS-buildable crates build clean locally. No behaviour change.
2026-07-28 21:31:41 +02:00
enricobuehlerandClaude Opus 5 37edb1cc1a refactor(clients): both shells wake through the shared state machine
audit / bun-audit (push) Successful in 21s
ci / web (push) Successful in 1m39s
ci / docs-site (push) Successful in 2m7s
audit / cargo-audit (push) Successful in 2m55s
apple / swift (push) Successful in 5m25s
ci / bench (push) Successful in 7m47s
ci / rust-arm64 (push) Successful in 10m28s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
windows-host / package (push) Failing after 11m22s
windows-host / winget-source (push) Skipped
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
deb / build-publish-client-arm64 (push) Failing after 6m0s
deb / build-publish (push) Successful in 13m3s
arch / build-publish (push) Successful in 16m27s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m36s
flatpak / build-publish (push) Successful in 6m50s
android / android (push) Successful in 19m52s
deb / build-publish-host (push) Successful in 16m54s
ci / rust (push) Successful in 22m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 20m33s
release / apple (push) Successful in 30m41s
docker / build-push-arm64cross (push) Successful in 24s
docker / deploy-docs (push) Successful in 26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 28m24s
apple / screenshots (push) Successful in 22m37s
The last of C0's duplication: GTK's `wake_and_connect` and the WinUI shell's ran their own
copies of the same 90 s / 6 s / 1 s loop, and the Windows one's comment said it "mirrors
the Apple HostWaker" — now it literally does, because both drive `WakeWait`. What is left
in each shell is the part that genuinely differs: its dialog or screen, its advert drain,
its re-key, and its route back into the trust gate.

Behavioural review, since a sleeping host to test against wasn't available:

- GTK polled every 500 ms and resent on a wall-clock timer; it now ticks once a second
  like every other implementation. Up to half a second slower to notice a host coming
  back, and exactly the reference cadence in exchange.
- GTK took the FIRST matching advert in a drain and returned mid-loop; it now drains fully
  and takes the last, i.e. the freshest address. Same connect, fresher re-key.
- Windows sent one packet before the loop AND one on its first pass; the machine owns the
  packets now, so that duplicate is gone. Resends still land at 6 s, 12 s, … and the
  budget still expires at 90 s.
- Both keep their own cancel path, their own re-key, and their own error surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:01:47 +02:00
enricobuehlerandClaude Opus 5 983ff8e78d fix(session/console): a profile must not advertise a cursor channel the presenter isn't drawing
Found reviewing the P0 wiring. The console builds its window and input models ONCE, from
the global defaults, and they live across every launch — but each launch now re-resolves
the host's profile. So a profile that sets `mouse_mode: desktop` on a host, while the
global stays `capture`, made the session advertise `CLIENT_CAP_CURSOR` ("this client
draws the host cursor itself") to a presenter latched in capture mode, which draws no
cursor: the host stops compositing the pointer and the stream has no visible cursor at
all — the exact failure the capability's own doc comment warns about.

The advertisement now follows the LATCHED mouse model, which is the one that is actually
true of the presenter. Everything the host is told about the stream itself — mode,
bitrate, codec, audio, pad — keeps honoring the binding, and the comment says which
fields are latched and why, so the console's presentation-tier gap is documented rather
than rediscovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:46:29 +02:00
enricobuehlerandClaude Opus 5 eab4829630 feat(client): the brain layer — one connect plan, one wake machine, one spawn
C0 of design/client-architecture-split.md. Wake-then-connect exists three times today —
GTK's `WakeConnect`/`wake_fallback`, the WinUI shell's `wake_and_connect` (whose comment
says it "mirrors the Apple HostWaker"), and Apple's `HostWaker` itself — and the
deep-link and profile work was about to make it five. `pf-client-core` already held the
ingredients (wol, discovery, trust, library, session); what was missing was the layer
that composes them, so every front-end composed them itself.

`ConnectPlan` is a resolved intent: which host, which pin, which launch id, which
profile, the effective settings, whether to wake. One constructor per door — a card
click, a CLI verb, a URL — one type out. `ConnectPlan::resolve` is pure (stores in,
plan out), which is what lets the URL router be tested without a config directory;
`for_host` is the convenience that loads them.

`plan_from_link` is where the deep-link security rules actually live, rather than in
each shell: a contradicted fingerprint refuses and says which host it was about, an
ambiguous name refuses instead of picking the first, a profile the catalog can't honor
refuses BEFORE anything is dialled, and an unknown — or known-but-never-pinned — host
becomes a confirmation sheet carrying the claimed name and expected pin, so the first
connect is verified rather than blind TOFU. Preempting a live session stays with the
caller: only the front-end knows one is running.

`WakeWait` is Apple's `HostWaker` cadence as a pure step function — packet at 0 s and
every 6 s, presence polled every second, 90 s budget, and a PARK (not an error) at the
end, because "it didn't wake in 90 s" is usually "give it 10 more". As a step function
each front-end drives it from its own loop and they still agree, and the whole cadence
is testable without waiting 90 seconds.

Session spawn, its argv and its stdout contract move here too, and the GTK shell now
spawns through them — so "which flags does a stream get" and "what does ready mean" have
one answer for the shells, the console and the coming CLI. `--profile` deliberately does
NOT ride a plain card click: the session resolves the host's binding through the same
helper, and passing it would be a second source of truth for one decision.

Not yet adopted: the two shells' wake paths. That swap changes the most-used path in the
product and the handoff wants it verified on glass with a genuinely sleeping host, which
this session couldn't do — so the state machine lands, the duplication stays until it
can be verified away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:45:07 +02:00
enricobuehlerandClaude Opus 5 3350d3cfbf feat(client): the punktfunk:// grammar — one parser, one emitter, one vector file
D0 of design/client-deep-links.md. Apple has shipped `punktfunk://connect/<uuid>` for a
while; this is the same grammar, generalised and given a Rust implementation the GTK and
WinUI shells, the session and the coming CLI all share:

    punktfunk://connect/<host-ref>[?fp=…][&host=addr[:port]][&launch=…][&profile=…][&name=…]

The rule the grammar exists to keep is that a URL may only ever do what a click on an
existing card could do, minus trust decisions. So it carries REFERENCES to things that
already exist on this device — never values: no resolution, no bitrate, no codec, so a
web page cannot shape a session beyond picking among the user's own configurations. And
`pair` is not a route: it parses, and refuses, so a link can never start a trust
ceremony. Everything hostile is rejected here, once, for every front-end — 2 KB total
cap, per-parameter caps, strict percent-decoding (a half-escape or invalid UTF-8 is an
error, not a shrug), control characters refused after decoding, launch ids held to the
charset the host and Decky already agree on, and `fp=` that must actually be 64 hex.

Resolution follows the same order everywhere: stable record id → unique host name →
`addr[:port]`, with `host=`+`fp=` as the reinstall recovery path, which is why
self-emitted links carry all three. An ambiguous name refuses rather than guessing; a
stale id with no recovery address refuses rather than dialling "11111111-…" as if it
were a hostname; an unknown-but-plausible address becomes the confirmation sheet, never
an auto-connect.

`pf://` parses as an input alias and is never emitted or registered — claiming a
two-letter scheme on MSIX/Apple is unconditional squatting, and a link that resolves on
one platform only is a trap.

The 44-case `clients/shared/deeplink-vectors.json` is the cross-language contract: this
module's tests run every case, and the Swift and Kotlin ports will run the same file, so
three parsers cannot drift into three different security postures. Refusal codes are
part of that contract, not just the happy path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:32:32 +02:00
enricobuehlerandClaude Opus 5 a2be3f4de9 feat(client): settings profiles — the override catalog, the one resolver, and the session flag
A client setting is global today: pick 4K@120 HDR and the retro box gets it too.
The one escape hatch (per-host `clipboard_sync`) proves the shape works but covers a
single field. This is P0 of design/client-settings-profiles.md — the headless core the
shells, Apple, Android and the deep-link grammar all build against. No UI yet.

A profile is a NAMED BUNDLE OF OVERRIDES, not a snapshot: `SettingsOverlay` is sparse
`Option`s, so a field the user never touched keeps following the global value live, and
fixing a global once fixes it everywhere. `Some(x)` equal to today's global is still
meaningful — it pins x against a later global change — which is why the UI will write
`Some` on touch and `None` on an explicit reset, never by diffing.

The catalog is its own `client-profiles.json`, deliberately not part of the settings
file: that file has five whole-file load-modify-save writers with no merge, so a profile
written by one would be dropped by the next. Which host uses which profile is a field on
the host record instead of a map keyed by host — that is what dissolves the client's
long-standing "what identifies a host record" question for this feature.

Resolution has exactly one implementation, `trust::effective_settings()`:

    effective = overlay(profile).apply(global)
    profile   = one-off pick ?? host binding ?? none

Both session construction sites go through it (`main.rs` AND `console.rs` — touching one
and not the other is a Windows-only build break), so the console, and therefore Decky,
honor host bindings with no work of their own. Nothing here can fail a connect: a
deleted binding resolves as "no profile", and a one-off that can't be honored falls back
to the DEFAULTS rather than to the host's own profile — "connect with Work" must never
quietly stream "Game". `--profile <id|name>` is the one-off door for the shells' coming
"Connect with ▸" and for `punktfunk://…&profile=`; `--profile ""` forces the defaults on
a bound host. The active profile's name closes the stats overlay's first line, so
"which profile am I on?" is answerable without leaving the stream.

Also here, because this effort touches every one of these anyway:

- `KnownHost` gains `profile_id`, `pinned_profiles` and a lazily minted stable `id`. The
  id is groundwork (design §4.5) — no lookup is re-keyed, `fp_hex`/`addr:port` stay.
- `upsert` now preserves the state the USER set on a record — the profile binding, its
  pins, the clipboard decision, the id — against refreshes that carry none of it.
  `clipboard_sync` survived that only because the function happened not to mention it.
- `KnownHost::default()` exists so construction sites read `..Default::default()`; five
  hand-written literals were exactly how a new field gets silently dropped on re-pair.
- All three client stores now write temp+rename. A torn settings file loads as `Default`
  — i.e. silently resets every setting the user has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:22:33 +02:00
enricobuehlerandClaude Opus 5 33a31427ae fix(vdisplay): locks held across seconds-long work, an AB/BA inversion, and a panic that poisons the manager
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m9s
ci / bench (push) Successful in 5m30s
windows-host / package (push) Failing after 7m52s
windows-host / winget-source (push) Skipped
android / android (push) Successful in 12m10s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
ci / rust-arm64 (push) Successful in 13m14s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
deb / build-publish (push) Successful in 9m5s
docker / build-push-arm64cross (push) Successful in 16s
docker / deploy-docs (push) Successful in 25s
arch / build-publish (push) Failing after 17m12s
deb / build-publish-client-arm64 (push) Successful in 8m56s
ci / rust (push) Failing after 17m13s
deb / build-publish-host (push) Successful in 10m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m39s
apple / swift (push) Successful in 5m32s
apple / screenshots (push) Successful in 25m53s
Phase 7's contained half. 7.1's writer split and 7.3's reservation are NOT here — see
the plan; both need more than a local edit.

**Lock-order inversion, gamescope.** `create_managed_session_steamos` held
MANAGED_SESSION while taking STEAMOS_TOOK_OVER (and `persist_takeover`'s locks), while
`do_restore_tv_session` takes STEAMOS_TOOK_OVER first and MANAGED_SESSION second —
AB/BA between a connect and the restore worker, which genuinely run concurrently
(`vd.create` runs off the registry lock; the worker is its own thread). The connect
path now drops its guard before the pair and re-acquires after `poll_managed_node`, the
shape `create_managed_session` already used. And `takeover_live` held all four statics
at once — in a `||` chain every `.lock()` temporary lives to the end of the statement —
for four reads; scoped bindings drop each guard before the next, taking it out of the
ordering graph entirely.

**`observe_session_instance` decided and acted under one lock.** It held LAST_INSTANCE
across `invalidate_backend` (which drops keep-alive displays through the registry lock)
and a `systemctl` shell-out on a 10 s budget, from three call sites including a
per-second watcher. It decides under the lock and acts outside now; the baseline is
advanced inside it, so a concurrent observer cannot run the action twice.

**`admit` held the live-session table across the budget checks** — `manager::snapshot()`
blocks on the manager `state` lock, which is itself held across DDC round trips and
three 3 s activation ladders. Every connect, disconnect and mgmt read queued behind one
slow display operation. The guard is scoped to `decide` alone.

**GameStream never registered**, so its display was invisible to both Windows budgets
(they gate on `!live.is_empty()`) and a native connect could be admitted past capacity
the box had already spent. It registers now, anonymously, for the session's lifetime.

**`ensure_exclusive_watch` panicked on a failed thread spawn** while holding BOTH
`exclusive_watch` and (via its caller) `state` — poisoning the two locks the whole
manager runs on, so every later acquire and mgmt read would panic on `.lock().unwrap()`.
It logs and degrades instead: no re-assert watchdog is a degradation, a wedged manager
is not.

Also fixes two things only a Windows build shows, both mine: Phase 2.3 left
`gamescope_route` unused there (every reader is Linux-gated), which `-D warnings` in
Windows CI would have rejected — I had only run the host clippy on Linux. And Phase
5.4's helper-safening reaches a FOURTH crate, pf-inject, which xcheck does not lint.

Verified on .173: `cargo clippy -p punktfunk-host -p pf-vdisplay --all-targets` clean
across the whole tree; local fmt, both xcheck targets and 53 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 093c66cb1a fix(vdisplay/windows): a failed resize left a phantom monitor, and a refresh-only one silently didn't happen
Three defects in the mid-stream resize path.

**A failed re-arrival put a DEAD monitor back in the slot.** `re_add` REMOVEs the old
driver monitor before it ADDs the new one, so its only fallible step past that point
means the old monitor is gone — yet the caller answered an `Err` by re-inserting the
`Monitor` struct it had handed in, complete with a dead `key`, a departed `target_id`
and a stale `gdi_name`. Every later `acquire` then joined a monitor that did not exist.

It now rolls back: on ADD failure it re-ADDs at the OLD mode, so the session keeps
streaming what it already had. The outcome is a three-way `ReAdd` enum because the
distinction matters and a `Result` cannot carry it — `Arrived` stores the new monitor,
`RolledBack` stores the RECOVERED one (a re-arrival mints a fresh `target_id`, so the
struct the caller handed in is stale either way), and `Lost` means both ADDs failed and
the slot must be left EMPTY so the next acquire creates a monitor rather than joining a
phantom.

**A refresh-only change reported success at the old refresh.** `wait_mode_advertised`
compared resolution only, so a same-WxH new-Hz request answered "already advertised":
the fast path skipped the `IOCTL_UPDATE_MODES` that would have taught the driver the new
rate, `set_active_mode` fell back to the best advertised rate <= requested, and the
resize returned Ok. It matches `dmDisplayFrequency` now, so an unadvertised refresh
routes to UPDATE_MODES / the re-arrival instead.

And `mon.mode = mode` recorded what was ASKED FOR. `set_active_mode` deliberately falls
back to a lower advertised refresh rather than lose the client's resolution, so the
field could claim a rate the display was not running — and it is what the next resize
diffs against and what `/display/state` reports. It records what actually committed now,
read back via the new `active_mode` (`active_resolution` delegates to it), and logs when
the two differ. Deliberately NOT done by tightening `wait_mode_settled`: that gates the
re-arrival fallback, so requiring an exact refresh there would force a full re-arrival
every time the OS legitimately picked a lower rate.

**A short IOCTL_ADD reply leaked an IddCx slot.** The IOCTL had SUCCEEDED — the driver
already created the monitor and took a slot; only the reply was short — and the bail
undid none of it. The slot pool is small enough that ~16 leaks wedge every later ADD at
0x80070490, which is the wedge the ghost-reap in this same function exists to recover
from. It now issues the compensating REMOVE with the session id already in scope, and
says so at error level if that fails too.

Windows runner: clippy --all-targets clean, pf-vdisplay 55 passed, pf-capture 18 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 50a153b72a refactor(win-display): the CCD helpers were unsafe fn for serialization, not soundness
`pf-win-display`'s 21 CCD/GDI helpers were `pub unsafe fn` without a memory-safety
obligation between them. Every one takes `Copy` scalars or borrowed Rust data, returns
owned values, and discharges its FFI preconditions internally —
`retry_set_display_config` even binds the input desktop itself, which is the one thing
a caller could plausibly get wrong. Their own `# Safety` sections say as much: one
reads "safe to call from any thread", and the rest say "call under the manager `state`
lock". That is a SERIALIZATION requirement — calling one unlocked races the topology
mutator and returns a stale answer, a correctness bug, not undefined behaviour.

Encoding it with `unsafe fn` cost 55 `unsafe` blocks across THREE crates whose proofs
could only restate "this takes a u32 and returns a String". That is how `unsafe` stops
meaning anything: the blocks that wrap a real FFI obligation read exactly like the ones
that wrap nothing. The requirement is now prose on the functions that have it, and
`unsafe` marks only the FFI calls inside the helpers.

win_display.rs 97 -> 63 `unsafe`, manager.rs 56 -> 30, pf_vdisplay.rs 34 -> 32, plus 10
sites in pf-capture's IDD-push paths. 55 blocks and 55 orphaned `// SAFETY:` proofs
gone; `#![allow(clippy::missing_safety_doc)]` with them, since nothing is `pub unsafe
fn` any more.

Two things the compiler handed back once the blocks went, both of which existed only to
carry a proof: the `let-else` parens in `force_mode_reenumeration` and
`apply_source_positions`, and the nested `if` in `wait_mode_settled` — which is `&&`
again, still short-circuiting, so the second CCD query on a 25 ms poll is unchanged.

Scope note: the sweep and the plan both scoped this as two crates. It is three —
pf-capture calls the same helpers from 10 sites, and `unused_unsafe` under `-D warnings`
makes that non-optional. Found by re-deriving the count, which the plan asked for
because the underlying finding had been REFUTED in verification; the refutation was
wrong about the magnitude (22 of manager.rs's 45 blocks, against its claimed 24).

Verified on the Windows runner: clippy --all-targets clean over pf-frame,
pf-win-display, pf-capture and pf-vdisplay; pf-vdisplay 55 passed, pf-capture 18 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 029979e824 fix(vdisplay/windows): own the device handle, prove the ladder once, and fix two proofs that were wrong
Three of Phase 5's four items; 5.4 is held for a decision (see below).

`open_device` is a SAFE fn returning an `OwnedHandle`. It never had a caller
obligation — it takes no arguments and every precondition is internal — so the
`unsafe fn` it used to be pushed a proof burden onto four call sites with nothing to
prove, and handed back a raw `HANDLE` that two of them re-owned by hand with
`CloseHandle`. That shape has already leaked once in this file (the wrap-IMMEDIATELY
comment in `open` records it). Ownership is now a `Drop`, `probe()` is
`open_device().map(|_| ())`, `is_available()` is `open_device().is_ok()`, and
`CloseHandle` is no longer imported here at all.

`resolve_target_gdi` ran the same 60 × 50 ms poll three times verbatim, and the 2nd and
3rd copies documented themselves as "SAFETY: as the resolve loop above" — a pointer to
a proof rather than a proof, which rots silently the moment the block it points at
moves. One `poll_gdi_name`, one proof, and the three-stage ladder now reads as the
three stages its doc describes.

Two proofs were simply wrong, both discharged everything except the one obligation
that mattered:

* The SetupAPI detail buffer was a `Vec<u8>` (align 1) written through as
  `SP_DEVICE_INTERFACE_DETAIL_DATA_W` (align 4). The SAFETY comment proved bounds and
  aliasing and was silent on alignment. Now a `Vec<u64>`. Its guard also compared the
  required size against `size_of::<u32>()` while stamping
  `size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>()` into `cbSize` — checking a different
  number than the one it promises the OS.
* `GetMonitorInfoW` was handed `&mut info.monitorInfo`: `cbSize` promises 104 bytes but
  that pointer's provenance covers the 40-byte `MONITORINFO` prefix, so everything the
  OS writes past byte 40 — which is `szDevice`, the only field the caller reads — is out
  of bounds for it. A compiler may assume those bytes were untouched and fold the zeroed
  initializer into the reads, i.e. return an EMPTY device name. That name is what
  `panel_off_except`'s exclusion compares against, so an empty one darkens the panel it
  is meant to spare. The pointer now carries the full struct's provenance.

pf_vdisplay.rs 42 -> 34 `unsafe`, manager.rs 58 -> 56. Windows runner: clippy
--all-targets clean, 48 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 f94191d927 fix(vdisplay/linux): stop destroying the user's portal config, and honour the /tmp promise the docs made
Both wlr-family backends need ONE key set in a config file the user also owns —
`chooser_cmd` in xdpw's `[screencast]`, `custom_picker_binary` in xdph's
`screencopy { … }`. Both did it by `fs::write`-ing a COMPLETE file over whatever was
there, so every other setting the user had in
`~/.config/xdg-desktop-portal-wlr/config` or `~/.config/hypr/xdph.conf` was destroyed
on first connect — silently, permanently, no backup.

They now set that one key in place and keep every other line byte-for-byte, with a
one-time `create_new` backup the first time we touch a file we did not write (once, so
a later edit cannot overwrite the user's ORIGINAL with our own earlier output).

The merge lives in its own module because a merge is only worth doing if it cannot
corrupt what it merges into: seven tests cover the three cases per grammar — block
absent, key present, key absent — plus idempotence (which is what keeps the caller
from restarting the portal on every connect) and the empty-file shape. Four of them
FAIL against the old whole-file write, so they are not vacuous. The module is declared
unconditionally though only Linux calls it: the logic is pure string handling, and
tests that guard a user's real config should run on every platform's CI, not only
where the callers compile.

Separately, seven call sites resolved the runtime dir as
`env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into())` while their own doc
comments promised the opposite — "per-user, 0700 — NOT a world-writable /tmp path
another local user could pre-create or rewrite between our write and xdph's read". One
of those paths is an executable xdph then RUNS. `Ok("")` was the same defect's other
half: it yielded a path relative to the process CWD rather than any runtime dir. All
seven now go through one resolver that applies the rule `session.rs` already had
(non-empty, else `/run/user/<uid>`, never /tmp). The picker shim also gets its mode at
CREATION rather than a chmod afterwards, so it is never briefly world-readable while
being a file something else executes.

And identity.rs was the only writer in the workspace creating
`pf_paths::config_dir()` — which holds the host private key, the pairing allow-list and
the mgmt token — with a plain `create_dir_all` instead of the 0700 `create_private_dir`
every other writer uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 2c015bba30 fix(vdisplay/windows): three teardown gates that each ended with the operator's panels dark
All in the same ~120 lines, all the same shape: a recovery leg gated on the wrong
flag, so the path that turned something off had no path that turned it back on.

**The DDC wake was nested inside the CCD-restore arm.** Panels are commanded dark over
DDC/CI *before* the isolate, but `panel_on_all()` sat inside
`if let Some(saved) = ccd_saved.take()` — and `isolate_displays_ccd` returns `None`
whenever its `query_active_config` fails. So a failed isolate meant the panels were
never woken, and because the link stays live there is no returning signal to trigger a
DPMS wake either: dark for the rest of the host's life. Hoisted out of that arm, next
to the `pnp_disabled` restore which was already outside it for exactly this reason.

**A later member's isolate could deactivate the physicals with nothing able to restore
them.** If the FIRST member's isolate failed, `ccd_saved` stayed `None` and
`ccd_exclusive` `false`; a second member's isolate then succeeded, blanked the
physicals, and discarded its snapshot as "the group restores the first member's" —
except there wasn't one. Teardown's restore is gated on `ccd_saved`, and the re-assert
watchdog never started. The group now adopts the first SUCCESSFUL isolate's snapshot.

**A non-last-member teardown ran the exclusive isolate on a Primary group.** The gate
was `ccd_saved.is_some()`, but `Topology::Primary` stores a snapshot too (from
`set_virtual_primary_ccd`) — so a shrink cleared `DISPLAYCONFIG_PATH_ACTIVE` on every
non-kept path, blanking precisely the physical displays `Primary` exists to keep lit.
It keys off `ccd_exclusive` now (what the re-assert watchdog already checks), and a
Primary shrink re-promotes a survivor instead, since the departing member may have
been the one holding primary.

Also: `apply_group_layout` had only ever run from `acquire`, so a member LEAVING never
re-arranged the survivors — they kept the origins computed while the departing monitor
was still between them, leaving a dead gap in the desktop until the next acquire. Now
called at the end of `teardown_removed`, which is where the slot map has already shrunk
and after the REMOVE, so it covers all six teardown paths at once.

The shrink gate is extracted as `shrink_action` so it can be pinned without a driver or
a desktop — manager.rs had no tests at all, which is how a gate keyed on the wrong flag
survived. Verified on the Windows runner: 48 passed (was 46), and both new tests FAIL
against the old gate, so they are not vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 8b076f5cc9 fix(vdisplay): the gamescope sub-mode is a per-session value, not a process-global env write
Completes what the env-lock fix started. `apply_input_env` published its decision into
PUNKTFUNK_GAMESCOPE_NODE/_SESSION and `GamescopeDisplay::create` read it back out —
with the lock released in between, and with the GameStream plane, the mid-stream
switch watcher and the capture-loss rebuild all re-running the writer. So session B's
routing decision could retarget session A's `create`: A asks for a bare spawn, B
connects, A attaches to B's session instead.

The decision now travels as a `GamescopeRoute` return value and is carried on the
backend INSTANCE via `set_gamescope_route` — the discipline `set_launch_command`
already documents two lines above it ("Carried on the backend instance — NOT a
process-global env var — so concurrent sessions can't stomp each other's launch
target"). Nothing writes the two keys any more; they are read once, as operator
overrides, and `create`/`poolable_now`/`launch_is_nested` all answer from the
session's own route.

The route carries its payload rather than just the verdict, so the managed session
flavour and the attach node id stop being separate env reads too.

Threaded through both planes: handshake -> SessionContext -> `vd`, and the GameStream
`open_source` -> `run`. Three re-resolution points needed the same treatment and each
would have silently downgraded a session to a bare spawn without it — the switch
watcher's rebuilt backend, the capture-loss rebuild (which can swap the whole
instance), and the operator-pinned path, which deliberately skips the input-backend
write but still needs a route. That last one is why the ladder is split out as
`resolve_gamescope_route`: a `PUNKTFUNK_COMPOSITOR` pin must not silently mean "bare
spawn" on a box pinned to the managed session.

`#[must_use]` on both entry points found the capture-loss site, which I had missed.

Verified on 192.168.1.21: whole workspace `cargo check -p punktfunk-host`, clippy
--all-targets clean over the host and pf-vdisplay, 100 pf-vdisplay tests, and the live
GNOME create/drop still green. Windows + Linux xcheck clean on the Mac.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 80e57c70be fix(vdisplay): the env lock covered its writers and not its readers, and the gamescope ladder read back its own output
Two halves of the same problem — process env used as shared mutable state.

`apply_session_env` set XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS / WAYLAND_DISPLAY /
HYPRLAND_INSTANCE_SIGNATURE / SWAYSOCK under ENV_LOCK, while detection read those same
five keys with no lock at all, from five points spread across a `/proc` scan. That is
exactly the hazard ENV_LOCK's own doc names: glibc `setenv` can realloc `environ` and
free the old value string, so a concurrent `getenv` reads freed memory — UB documented
there as "could crash the host". It is reachable today: the host's session watcher
calls `detect_active_session` every second for a stream's lifetime while a second
client's connect runs `apply_session_env` on a spawn_blocking thread.

Detection now samples all five into an `EnvProbe` in ONE locked read and scans from
that snapshot. Taking the lock around the whole of `detect_active_session` would have
been simpler and wrong: it walks `/proc` every second, and holding a process-wide lock
across a directory walk trades this problem for the one Phase 7 is about. The readers
become pure functions of their inputs, which is also what let the tests stop mutating
process env to steer them — `without_inherited_swaysock` is gone, replaced by handing
in a probe. Empty values are now treated as absent, so `XDG_RUNTIME_DIR=""` falls back
to /run/user/<uid> instead of yielding a relative path.

The gamescope sub-mode ladder had a nastier version: `apply_input_env` WRITES
PUNKTFUNK_GAMESCOPE_NODE/_SESSION to publish the sub-mode it chose, and read the same
keys back as operator overrides. The Attach arm sets `_NODE=auto`, and `node_env` sits
at rung 2 of the ladder — above `dedicated_launch` at rung 3 — so one Attach decision
latched Attach for the rest of the host's life and silently overrode
`game_session=dedicated`. Only rung 1 could escape it, because the Spawn arm that
would have cleared the keys sits below the rung that by then always fired. The
overrides are now sampled once, before this module has written anything, which keeps
their actual meaning: "the operator set this before we ran". The live reads that
remain (`launch_is_nested`, `poolable_now`) are deliberate — those consume the
published decision.

And gamescope's `create` read both keys unguarded, racing the writer and skipping the
lock its two siblings take for the same keys; it now takes one guarded snapshot.

Verified on Linux (.25): 100 passed, 1 ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:36 +02:00
enricobuehlerandClaude Opus 5 787756e68b test(vdisplay/mutter): the on-glass lever for the GNOME backend, and what it took to see the leak
`live_mutter_create_drop`, `#[ignore]`d in the same convention as the Windows
backend's live tests: the real four-step handshake, hold, then drop the keepalive and
let the RAII teardown revert it. Verified against gnome-shell on 192.168.1.21 —
node_id came back in ~5 ms, preferred_mode as asked, and the topology reverted.

Two things that would otherwise be re-derived by whoever validates this next, both of
which cost a wrong answer here first.

The virtual monitor does NOT appear in DisplayConfig's GetCurrentState just because
`create` succeeded — its size follows the PipeWire negotiation, so without a consumer
attached there is no monitor to census. A before/after monitor diff therefore proves
nothing about this backend, which is the trap the first attempt fell into.

And a short-lived process cannot exhibit the abandoned-thread leak at all: exiting
drops the D-Bus connection, which makes Mutter tear the session down no matter which
code is running. Reproducing it needs the process held alive past the failed create,
and the observable is the live RemoteDesktop session object, not the monitor:
`gdbus introspect -d org.gnome.Mutter.RemoteDesktop -o /org/gnome/Mutter/RemoteDesktop`
lists a `node Session` child per live session.

With a 1 ms create timeout forced and the process held 20 s, that probe reads 0 with
b89a36a6 and 1 without it — the orphan thread parked on a live session. Both read 0
after exit, which is precisely why this only ever bit a long-running host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 69bc92afed fix(vdisplay/mutter): a create timeout no longer abandons the session thread with the desktop dark
`create` gave up after 45 s and dropped its `stop` flag WITHOUT setting it — the
`StopGuard` that owns the only `store(true)` was built on the success path only. The
thread meanwhile discarded its own send failure with `let _ =`, so it sailed past the
dead channel, made the virtual output PRIMARY, and parked in `while !stop` for the
rest of the process's life holding the D-Bus connection that IS the monitor's
lifetime. Its teardown was unreachable.

Under the default topology that orphan applies a SOLE-monitor APPLY_TEMPORARY config.
Every physical head goes dark, and Mutter reverts such a config only once the virtual
monitor disappears — which the parked thread prevents. There is no in-process
recovery: each retry adds another orphan and another virtual monitor.

The other three portal/D-Bus backends never had this — kwin.rs, hyprland.rs and
wlroots.rs all `?` on that exact send. Mutter is now the same shape, via a shared
`report_node` that stops the session when the opener has gone. Two more layers behind
it, because the send can also LAND in the moment `recv_timeout` gives up: `create`
and `stream_existing_output` build their guards BEFORE the wait, so every error arm
signals the thread on its way out, and the thread re-checks the flag before it touches
topology — so a doomed session never applies a sole-monitor config at all, rather than
applying one and reverting it a tick later.

The fix lands once because the duplication that caused it is gone first: `connect` and
`connect_monitor` shared ~72 verbatim lines, and the mirror path repeating the session
path's `let _ = send` is precisely what that copy bought. Both now share `open_rd_sc`
(steps 1-2) and `start_and_await_node` (step 4).

Also stops a physical hotplug stealing the virtual monitor's identity.
`wait_virtual_connector` took "any connector absent from the pre-snapshot" across a
window spanning a ~10 s stream wait plus 6 s of polling; TOPOLOGY_LOCK keeps sibling
sessions out of it but a hotplug is not ours to serialise, and the session's topology
apply and scale persistence would then be aimed at the operator's new panel. It now
prefers the connector advertising the client's exact WxH — the discriminator
`make_virtual_primary` already matches on — falls back to the old behaviour when that
singles nothing out, and warns when more than one candidate appears.

Tests: `pick_virtual` split out of the polling loop so the choice is testable without
a session bus. Verified on Linux (192.168.1.25): 99 passed, and the hotplug case FAILS
against the old first-new-connector logic, so it is not vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 e7dc7e9d18 fix(win-display): every CCD/GDI operation now carries a proof, not just the blocks that needed one
Companion to c16f2850, which closed the same hole in pf-vdisplay. The file header
claims "Every `unsafe` block in this file carries a `// SAFETY:` proof", and that
was true — it just did not cover the 84 unsafe OPERATIONS sitting directly in
`unsafe fn` bodies, which under edition 2021 need no block and so carry no proof.
That is the whole CCD surface: every QueryDisplayConfig, every SetDisplayConfig,
every DISPLAYCONFIG union read.

Rather than 84 restatements of the same argument, the shared contract is stated ONCE
at the top — how the buffer-size/query pair keeps pointer and count in agreement, and
why the two obligations the compiler cannot see (arrays only ever come from the OS or
from a SavedConfig this module built; SetDisplayConfig serialised under the manager
`state` lock and bound to the input desktop) hold at every site. Per-site comments
then name the site-specific fact instead of repeating the invariant.

The union reads justify differently and the header says so: `modeInfoIdx` and
`ADVANCED_COLOR_INFO.value` overlay same-sized POD, so no discriminant can be got
wrong, while `MODE_INFO.Anonymous.sourceMode` IS discriminated by `infoType` — and
every read of it is guarded by that check. Writing that down found the one place the
guard does NOT gate the read: the `then_some` in set_virtual_primary_ccd evaluates
eagerly, so POD-ness is what carries it there, not the check. The comment says so
rather than implying a guard that is not doing the work.

Two shape changes, both deliberate. `wait_mode_settled`'s two calls are nested rather
than `&&`-joined so the second CCD query stays short-circuited — it polls every 25 ms
and must not run while the target has no active path. And the eager union read in
`set_virtual_primary_ccd` is hoisted to a `let` so its proof has somewhere to attach;
`then_some` already evaluated it eagerly, so nothing changes.

Union field ASSIGNMENTS are safe in Rust — only reads are — so two of them keep no
block. Verified on the Windows CI runner: clippy -D warnings clean across pf-frame,
pf-win-display, pf-capture and pf-vdisplay, and both crates' tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 6782aa0054 fix(vdisplay): stop the suite reporting ok for tests that never ran, and let clippy see the KWin backends
Three coverage holes, all of the same shape — a gate that looks green because
nothing is behind it.

The Windows half (~3,400 lines) had no executed tests. Its only two `#[test]`s
opened with `if env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() { return; }`, so
without the hardware they passed without running, and no CI job compiled the
crate's test targets at all — `-p punktfunk-host` builds pf-vdisplay as a
dependency, which never reaches a `#[cfg(test)]` module. Both live tests are
`#[ignore]`d now (an unrun hardware test should say `ignored`), and windows-host.yml
gains the `--all-targets` lint plus a real test step. The link question that keeps
the host and pf-encode on clippy-only does not apply: pf-encode is `default = []`
and nothing here turns on nvenc/amf-qsv/qsv. Settled on the runner to the same
standard as pf-capture's step — 46 passed, 2 ignored.

`#![allow(clippy::all, ...)]` at the top of kwin.rs and kwin_output_mgmt.rs was
meant for wayland-scanner output, but the generated modules already carry their own
copy of that attribute — so the file-level one was only ever silencing 2,366 lines
of hand-written backend, with `clippy::correctness` (deny-by-default) among the
casualties. Dropping it found a dead match arm in the `kde_output_configuration_v2`
dispatch; that one is now exhaustive on purpose, so a re-vendored XML that adds an
event fails the build instead of dropping it silently. (The sibling `_ => {}` in the
DeviceMode dispatch is NOT dead — `ModeEvent::Removed` really does reach it and get
swallowed, which is a separate defect.)

`dead_code` is now enforced on Linux, where ~10k of the ~17k lines live, instead of
allowed crate-wide behind a rationale that had stopped being true. It turned up no
genuinely dead code: four items, every one live from tests or another platform, now
annotated with which. One deserved the comment most — `Entry::keepalive` is "never
read" because it is an RAII guard whose `Drop` releases the compositor output, so
deleting it as unused would release every pooled display the moment it was created.

Two tests were not testing what they claimed. `another_uids_socket_is_ignored`
asserted the sway-socket ownership guard but never reached it — `find_sway_socket`
filters on the `sway-ipc.<uid>.` filename prefix first, so the foreign-uid file was
rejected by name and the assertion held even with `md.uid() != uid` deleted. It is
renamed to what it does cover, and the real leg is a new `#[ignore]`d root test that
chowns a correctly-named socket (querying with a non-matching pid, or the exact-path
shortcut would return before the guard and make it vacuous all over again). And
proc.rs's tests were ungated while the module is compiled everywhere, so they would
have gone red on Windows the first time anyone ran them: `#[cfg(all(test, unix))]`
now, with a `cmd /c` twin that does run there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 995cac5d03 fix(vdisplay): the three hardest FFI calls were exempt from the crate's own unsafe-proof rule
`#![deny(clippy::undocumented_unsafe_blocks)]` cannot see an unsafe operation that
sits directly in an `unsafe fn` body — in edition 2021 `unsafe_op_in_unsafe_fn` is
allow-by-default, so a bare call inside such a body needs no block and therefore
carries no proof. The file headers claim "every unsafe block carries a SAFETY
proof", and that was true; it just did not cover the calls that needed it most.

Turning the lint on named exactly three: `DeviceIoControl` and the `ioctl` wrapper
in pf_vdisplay.rs — the whole host<->driver control channel — and
`restore_displays_ccd` in manager.rs, the call the teardown path depends on to give
the operator their physical panels back. Each now carries a real proof, and `ioctl`
and `set_render_adapter` grew the `# Safety` heading their callers were owed.

Also teaches scripts/wincheck.sh to cover pf-vdisplay, which is what let the above
be compile-verified rather than reasoned: the crate's Windows half is ~3,400 lines
that Linux CI never compiles. That needed a stub pf-encode (the real one drags
ffmpeg-sys, and the admission gate calls exactly one predicate from it) and
CompositorPref on the stub core. Verified non-vacuous with a planted type error.

pf-win-display's half of the same lint is a separate change — it flags 62 further
operations, and they want proofs written one at a time, not in bulk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 9b38b6a22d feat(stats): a capture records which encoder and GPU produced it
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m4s
apple / swift (push) Successful in 5m41s
ci / bench (push) Successful in 7m17s
windows-host / package (push) Failing after 8m32s
windows-host / winget-source (push) Skipped
deb / build-publish-host (push) Successful in 11m4s
decky / build-publish (push) Successful in 20s
deb / build-publish (push) Successful in 11m33s
ci / rust-arm64 (push) Successful in 12m37s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
android / android (push) Successful in 12m59s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
docker / deploy-docs (push) Successful in 32s
deb / build-publish-client-arm64 (push) Successful in 8m45s
docker / build-push-arm64cross (push) Successful in 4m42s
arch / build-publish (push) Successful in 19m34s
ci / rust (push) Successful in 22m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m1s
release / apple (push) Successful in 31m52s
apple / screenshots (push) Successful in 23m26s
The stage split is the thing an fps-shortfall report is diagnosed from, and
it cannot be read without knowing the backend behind it: a p50 `submit` of
10 ms means "the GPU's CSC+encode throughput is the ceiling" on one backend
and something else entirely on another. The capture's meta carried the mode,
codec and client but neither the encoder nor the GPU, so every report so far
cost a round-trip asking which one it was.

Both come from `pf_gpu::active()` — the record the encoder open itself
writes — so they name the branch that really opened rather than a re-derived
guess that goes stale the moment a backend gains an internal fallback. Read
once per capture, not per frame.

The console shows them next to the mode in the recording's header. Both
fields are `serde(default)`, so a recording made before this still loads and
simply omits them.

Refs #9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:14:10 +02:00
enricobuehlerandClaude Opus 5 eb4ab91627 feat(host): a frame limiter for the game, not for the stream
PUNKTFUNK_MAX_FPS caps how fast the compositor lets the game render. It
deliberately does NOT touch the session: the client still negotiates and
receives its full rate, because the encode loop re-encodes the held frame
whenever the compositor produced no new one, and a repeat of an unchanged
picture is an almost-empty P-frame. So a 60-capped game on a 120 Hz session
still puts 120 frames a second on the wire — and the GPU time the game gives
up goes to capture and encode instead, which is the point (on a laptop or a
handheld, it goes to heat and battery too).

Capping the stream would be a different and mostly unwanted feature: it
hands the client fewer frames than it asked for and saves the game's GPU
nothing.

gamescope is the compositor that has the lever, so it is the one that gets
it: the rate becomes --nested-refresh, which is what gamescope clamps the
game to. All three sessions we own take it — the bare spawn's -r, the
managed gamescope-session-plus wrapper's PF_HZ, and the SteamOS PATH shim's.
In the managed path the limit lands on PF_HZ alone and NOT on
CUSTOM_REFRESH_RATES, so the mode the session advertises stays the client's
— that is what makes games see the real refresh rather than the box's EDID.

Two scoping notes. Under gamescope the cap is the nested output's rate, so
everything it composites moves at it, not the game alone; there is only the
one output. And the attach path (PUNKTFUNK_GAMESCOPE_NODE) mirrors a
gamescope we do not own, so it has no lever to pull and is untouched.

Closes #13

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:14:10 +02:00
enricobuehlerandClaude Opus 5 86b605f01f feat(host): run the virtual display faster than the stream needs
PUNKTFUNK_VDISPLAY_HZ_MULT runs the virtual display at a multiple of the
session's rate without putting one extra frame on the wire. A compositor
paints on its own vblank, so a frame finished just after the capture sampled
waits nearly a full interval to be picked up — the jittery part of the
latency budget, not the steady part; at 2 that worst case halves. It costs
the compositor and GPU the extra composites, so it stays opt-in at 1.

The pacing rate was previously just "whatever the backend achieved". It is
now the session's rate floored by the achieved one — the same value as
before whenever the knob is unset, and the only correct answer when it is
set: never above what the client negotiated, never above what the display
actually produces. Both build sites get it, the initial build and the
in-place resize, so a mid-stream resize can't silently drop back to 1x.

A backend that won't give the multiplied rate now says so at info rather
than warn, since that is the expected outcome of an opt-in knob; falling
short of the SESSION's own rate still warns, because that one costs the
client frames.

Closes #14

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:13:19 +02:00
enricobuehlerandClaude Opus 5 a4aa89be31 fix(decky): drive a native client install, not only the flatpak
Every headless call went through `flatpak run io.unom.Punktfunk`, and the
launch wrapper exec'd the same. On a Deck whose client came from a sysext, a
.deb/rpm, an AUR build or a nix profile there is no such app: discovery
worked (it is mDNS), and everything that needed the client — pairing, the
library fetch, the host store, the stream launch itself — failed.

Resolve the client once. The flatpak still wins when it is actually
installed, so an existing Deck is byte-for-byte unchanged, and a native
`punktfunk-client` is the fallback; `PF_DECKY_CLIENT` forces one on a machine
with both. Both kinds share ~/.config/punktfunk — the flatpak's sandbox HOME
is the real home — so identity, known-hosts and settings need no divergence.

The wrapper takes the resolved binary as PF_CLIENT_BIN and execs it in place
of `flatpak run`; kill_stream sends a name-matched SIGTERM where there is no
flatpak instance to kill; and the client-update check, which reads the
flatpak's tracked remote, simply reports nothing for a native install, whose
updates belong to whatever installed it.

Installed-ness is now decided by the app's exported directory rather than by
the presence of the `flatpak` binary, so a machine that has flatpak but not
this app no longer resolves to a client that cannot run.

Closes #11

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:13:19 +02:00
enricobuehlerandClaude Opus 5 f98547c41f fix(web-console): the paired-clients card counts both pairing planes
GameStream and native (punktfunk/1) clients pair into separate stores, and
`/status` only ever reported the GameStream certificate count. Native is the
DEFAULT plane, so a host whose clients had all paired normally — and which
Moonlight had never touched — showed "0 paired" on the dashboard.

Report the native count alongside it, the way the tray's own summary route
already does, and sum the two in the card.

Closes #10

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:13:19 +02:00
enricobuehlerandClaude Opus 5 198b1d5e80 fix(apple): iPad mouse buttons, held-key repeat, and the scroll direction
Three separate things an iPad's keyboard and mouse got wrong.

Every mouse button past the first two clicked LEFT on the host. UIKit's
button mask is 1-based over the HID order, and only `.secondary` was read —
middle, back and forward all fell into the `else` and became button 1. Map
3/4/5 through `.button(_:)`; only middle and right swap places, because the
wire numbers them the other way round.

Holding a key deleted exactly one character. GameController reports a key
once on press and once on release — it has no repeat channel — and the host
injects exactly what it is told, so nothing downstream ever turned a held key
into a stream of presses. Generate the repeat client-side at the usual
delay-then-rate, newest key only, with modifiers and lock keys excluded.
macOS was never affected: NSEvent hands it the window server's own repeats.

Scrolling ignored the system's Natural Scrolling switch. UIKit has already
applied that preference by the time it hands over a pan translation — it is
what makes every UIScrollView on the device turn the right way — so negating
y pinned the stream to traditional scrolling and inverted the setting for
everyone on the default. Pass the sign through, exactly as the macOS path
passes `NSEvent.scrollingDeltaY` through, and let that one recognizer own
scroll under pointer lock too: it is the only way trackpad two-finger
scrolling ever arrives, so gating it on the lock had been dropping it
entirely there, and the raw GameController axis it deferred to carries no
such preference. iOS installs no GCMouse scroll handler now, so nothing
double-sends; tvOS, which has no scroll pan, keeps it.

Closes #7, closes #15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:13:19 +02:00
enricobuehlerandClaude Opus 5 580ea0d338 fix(android): a mouse's side buttons work even when they arrive as keys
Not every mouse reports back/forward the same way. One that carries them on
the HID button page (BTN_SIDE/BTN_EXTRA) gets BUTTON_BACK/BUTTON_FORWARD in
the motion button state, which is the only shape the forwarder listened for.
A mouse that carries them on the consumer page (AC Back / AC Forward — the
common Bluetooth shape, and what Android TV boxes tend to see) produces only
synthesized KEYCODE_BACK/KEYCODE_FORWARD, and those were swallowed outright
to stop them doubling as Android navigation. On that hardware both side
buttons were simply dead.

Route the key shape into the same wire buttons, deciding by the DEVICE (it
has to be able to be a mouse) rather than by the event's source, since the
consumer-page collection is a separate sub-device that may be stamped
SOURCE_KEYBOARD. A D-pad-capable device is excluded so an air-mouse remote's
BACK stays the couch user's way out of the stream.

Both paths now funnel through one press/release helper whose held-set guard
collapses a device that reports BOTH into a single wire press.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:13:19 +02:00
enricobuehlerandClaude Opus 5 2f39e15e88 fix(ci): the shader drift gate never ran — dash has no process substitution
ci / rust (push) Failing after 5m29s
arch / build-publish (push) Failing after 6m45s
ci / web (push) Successful in 1m50s
ci / docs-site (push) Successful in 2m38s
android / android (push) Successful in 12m23s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / rust-arm64 (push) Successful in 9m36s
ci / bench (push) Successful in 6m51s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 9m47s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m44s
deb / build-publish (push) Successful in 12m27s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m8s
docker / deploy-docs (push) Successful in 13s
deb / build-publish-host (push) Successful in 13m1s
apple / swift (push) Successful in 5m40s
docker / build-push-arm64cross (push) Successful in 3m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m31s
apple / screenshots (push) Successful in 24m33s
`diff <(spirv-dis …) <(spirv-dis …)` is bash syntax, and Gitea's runner executes
a step's `run:` under `sh -e`. dash rejected the script at PARSE time, so the
gate compared nothing — and, being step 2 of the `rust` job, it took Format,
Clippy, Build, Test, the feature-gated encode backends, the C ABI harness and
the committed-header check down with it. Every one of those has been skipped on
the 35 commits between the gate landing (143a707f) and this one.

A gate that cannot run looks exactly like a gate that passes, which is the
precise failure mode it was written to prevent.

Disassemble to files instead. That is dash-clean without depending on whether
act_runner honours a `shell: bash` override, which is the other way to fix it
and the more fragile one.

Verified in CI's own container (ubuntu:resolute, glslang 16.2.0, spirv-tools
2026.1) by running the step body extracted from this YAML under `sh -e`: it
passes on the current tree, and — the half worth checking — it still FAILS on a
deliberately corrupted blob, so it is not passing vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:28:29 +02:00
enricobuehlerandClaude Opus 5 4065b5bd03 docs(gamescope): the missing cursor was never the whole story
ci / rust (push) Failing after 12s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m7s
apple / swift (push) Successful in 5m29s
ci / bench (push) Successful in 7m56s
windows-host / package (push) Failing after 8m13s
windows-host / winget-source (push) Skipped
decky / build-publish (push) Successful in 1m1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
deb / build-publish (push) Successful in 9m28s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
deb / build-publish-host (push) Successful in 10m49s
android / android (push) Successful in 12m19s
deb / build-publish-client-arm64 (push) Successful in 11m37s
ci / rust-arm64 (push) Successful in 13m59s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m45s
arch / build-publish (push) Successful in 21m57s
apple / screenshots (push) Successful in 25m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m15s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 30s
"The mouse cursor isn't included in the captured image" sat in Known Limits
contradicting the section above it, which already explained that the host draws
the pointer back in. Both are half-true and the difference matters to a reader
choosing whether to install anything: gamescope does leave the pointer out, you
do still see one, and what it costs is a full pass over every frame — plus, on
the fastest encode paths, the pointer itself, because a fixed-function front
end has nowhere to blend it.

Which is the real argument for `punktfunk-gamescope` on a box that will never
turn HDR on, and the page never made it.

Release notes gain the spawn-flag verification and the packaging wiring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:03:30 +02:00
enricobuehlerandClaude Opus 5 c801465469 fix(packaging): every channel that ships punktfunk-gamescope now builds it
The docs already told users where to get it — the Bazzite sysext, an Arch
package, a NixOS option — and none of the three were true. `rpm.yml` built the
sysext without ever passing `--gamescope`, `arch.yml` did not know the PKGBUILD
existed, and the nix derivation had never been evaluated once.

Evaluating it found its central assumption wrong: `gamescope.unwrapped` does
not exist on current nixpkgs, where `gamescope` IS the buildable derivation, so
the override threw on every build. It now prefers `.unwrapped` where a future
nixpkgs wraps it and checks the RESULT is patchable — `overrideAttrs` on a
symlinkJoin succeeds and does nothing, which would install an UNPATCHED
gamescope under a name the host reads as a promise of HDR.

Both it and the PKGBUILD had also drifted to a stale patch list: two patches
named where three exist, one of them under the level-1 filename retired when
the cursor patch landed. Both read the patch directory now, so the list cannot
go stale again. The PKGBUILD additionally delegates the whole build to
`build-punktfunk-gamescope.sh` rather than re-deriving the meson invocation —
its copy had already lost `force_fallback_for=wlroots`, and unlike Fedora 43,
Arch ships wlroots, so that package would have linked it shared and shipped a
binary that starts only on machines carrying the dev library. It asserts its
own pinned rev matches the script's, the one thing makepkg needs statically.

Both CI builds are cached on `packaging/gamescope/**`, which is the only reason
this is affordable: that tree depends on nothing else in the repo, so a normal
push restores a binary instead of spending ten minutes on someone else's C++.
And both are best-effort. punktfunk works without this binary — SDR on the
gamescope backend, which is what every release before this one did — so a
hiccup building gamescope must not cost the packages those workflows exist to
publish. A failure warns and is never cached, so the next run retries.

`rpm.yml` also installs Fedora's own gamescope for its runtime libraries: the
sysext verifies our binary by executing `--version`, and on a cache hit nothing
else in the job would have pulled libavif/luajit/seatd in.

Verified by evaluating and patch-phase-building the nix derivation against
nixos-unstable: all three patches apply to nixpkgs' already-patched 3.16.25
tree, and the banner stamps +pfhdr2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:03:30 +02:00
enricobuehlerandClaude Opus 5 0c4e582b29 fix(gamescope): a managed session now proves our flags reached it
The bare spawn builds gamescope's argv itself. The two managed modes hand the
flags over indirectly — `gamescope-session-plus` through `GAMESCOPE_BIN` +
`PF_HDR_ARGS`, SteamOS through a PATH shim — and a session free to ignore
either would exec the distro's gamescope carrying none of them.

Half of that failure was already loud: HDR dies on the bit depth the Welcome
fixed before the display existed. The other half was silent. The host had been
told the compositor would paint the pointer, so it stopped painting one, and
nobody did — a stream that is correct in every respect except that it has no
cursor, which nothing in the logs would have said.

So both managed paths now read the running compositor's `/proc/<pid>/cmdline`
once its node appears, and refuse the session when a flag we passed is missing.
The plan is fixed by then (`cursor_blend` feeds the encoder open, which
precedes the display), so this session cannot be corrected in place; instead
the capability is latched off for the process and the spawn fails, and the
retry resolves a correct SDR host-composited session. One rejected attempt per
boot, then it converges.

Fails OPEN at every ambiguity: no flags expected, or no readable gamescope in
`/proc`, says nothing. And it accepts ANY running gamescope carrying the flags,
because a box commonly has a second one — the Nobara test box runs its own
game-mode `/usr/bin/gamescope --steam` beside ours. That leaves only false
PASSES possible, and the flag that matters cannot produce one:
`--pipewire-composite-cursor` exists nowhere but our patch set.

Two things fell out of writing it. `gamescope_argvs` matches argv[0]'s basename
with `ends_with`, not `==` — the old equality test would have missed our own
`punktfunk-gamescope`, which is what `current_gamescope_output_size` was
already scanning for. And `hdr-probe` gained a line for who paints the cursor,
the one capability with no symptom of its own until you compare two streams.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:03:30 +02:00
enricobuehlerandClaude Opus 5 ee716d0137 fix(encode): pf-encode did not build on Linux without vulkan-encode
`vulkan_encode_available_at` and `vulkan_encode_caps` were gated on bare
`target_os = "linux"`, but the second returns `vulkan_video::VulkanEncodeCaps`
and both reference that module — which only exists under the feature. Every
CALL SITE was already correctly gated, so nothing pointed at them; the two
definitions alone were enough to fail the build.

That is CI's default-feature line (`cargo clippy --workspace --all-targets`),
so this was going to be caught — it was caught on a Fedora 44 box first.

`cursor_blend_capable`'s `ten_bit` goes unused in the featureless arm for the
same reason, which `-D warnings` also rejects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:03:30 +02:00
enricobuehler 0d2cc65f17 fix(gamescope): force the VENDORED wlroots — the binary's portability was luck
Built the patches on a second distro (Nobara 44 / Fedora 44, for the NVIDIA
leg) and the resulting binary would not start on its own host:

    libwlroots-0.19.so: cannot open shared object file

gamescope vendors wlroots as a submodule, but meson prefers a SYSTEM one when
the build host has `wlroots-devel` — and links it shared. Fedora 43 has no such
package, so the `.116` build fell back to the submodule and linked it
statically; Fedora 44's `dnf builddep gamescope` pulls one in, so the same
script produced a binary bound to a library that exists only inside the build
container.

Confirmed rather than assumed: `ldd` on the f43 binary lists no wlroots at all
and its log says `Subproject wlroots finished`, while the f44 log says
`Dependency wlroots-0.19 found` from the system.

The consequence, had Fedora 43 ever shipped `wlroots-devel`: the sysext payload
would have carried a gamescope that cannot start, and it would have surfaced to
users as "HDR just doesn't work" rather than as a build failure. `-Dforce_
fallback_for=libliftoff,vkroots,wlroots` makes every distro produce the same
self-contained binary. All three entries go together — gamescope's own
meson.build hard-errors if the first two are dropped from that list.

Verified after the fix: `ldd` shows no wlroots/liftoff/vkroots and no missing
deps, the binary starts on the Nobara host, and its node offers the same four
formats with the colorimetry props — same result as Bazzite f43.
2026-07-28 18:03:30 +02:00
enricobuehler 3d3ecf1e82 test(encode/nvenc): the NVIDIA HDR leg, verified on an RTX 5070 Ti
The NVIDIA half of "zero-copy, no host CSC, 10-bit HDR" had never met a GPU.
Two `#[ignore]`d smokes now drive it, run on `.41` (Bazzite f43, RTX 5070 Ti,
driver 595.58.03):

* `nvenc_cuda_hdr10_packed_rgb` — a packed 2:10:10:10 CUDA payload straight
  into NVENC as `ARGB10`, HEVC **and** AV1. Asserts what would catch a
  mislabelled stream: the encoder DERIVED 10-bit and HDR from the input format
  rather than being told, and picked `ARGB10` for `X2Rgb10`. That derivation is
  what selects Main10 / AV1-at-10 and the BT.2020 PQ signalling.
* `nvenc_cuda_hdr10_cursor_blend` — `cursor_blend.comp` MODE 3/4, the 10-bit
  channel-unpacking twin. Asserts the blend targets `SlotFormat::X2Rgb10` and
  not the 8-bit layout, which would tint the pointer and shift its channels.

Both green, and the dumped bitstreams decode 0-error reporting `Main 10` /
AV1 `Main`, `yuv420p10le`, `bt2020nc` / `smpte2084` / `bt2020`.

There is no host colour conversion anywhere on this path: the frame arrives as
a LINEAR dmabuf, crosses to CUDA through the Vulkan bridge, and NVENC's ASIC
does the BT.2020 conversion following the VUI the session configured. The
"no CSC" property AMD gets from the EFC, NVIDIA gets from the encoder itself.

The whole pre-existing `nvenc_` suite was re-run alongside them — 16/16, no
regression from the depth-derivation and buffer-format changes underneath it.

Capture half checked too: the patched gamescope binary built on the AMD box
runs unmodified on the NVIDIA one (same Fedora 43 base) and its node offers the
same four formats with the colorimetry props — so headless gamescope + 10-bit
PQ is not AMD-specific.
2026-07-28 18:03:30 +02:00
enricobuehler 576bf7e294 test(encode/vulkan): 10-bit smokes — and they pass on a real AMD GPU
The Vulkan Video 10-bit path was the least-exercised code on this branch:
nothing had ever created a Main10 video session, so the profile query, the
`G10X6…3PACK16` picture allocation, the scratch→plane copy, the hand-packed
AV1 sequence header and the colour signalling were all first-run-on-glass.
Three `#[ignore]`d smokes now drive them, and they were run.

`.116` (Bazzite f43, AMD 780M, RADV / Mesa 26.0.4), all three green:

* `vulkan_smoke_10bit` — HEVC Main10 through the compute CSC;
* `vulkan_smoke_10bit_av1` — AV1 at 10 bits;
* `vulkan_smoke_rgb_10bit` — HDR with NO host CSC, the EFC converting BT.2020
  off the packed 10-bit source. It did **not** soft-skip, which answers the one
  capability in this whole feature I had only ever read in a registry: RADV's
  VCN EFC really does advertise `MODEL_YCBCR_2020` and accept a 10-bit
  packed-RGB encode source.

Every stream decodes 0-error and reports `yuv420p10le` + `bt2020nc` /
`smpte2084` / `bt2020`. The AV1 one parsing at all is the load-bearing result
there: `high_bitdepth` precedes the CICP bytes in `color_config()`, so a wrong
bit would have thrown every later field out of phase rather than merely
mislabelling the depth.

Round-trip on solid frames, fed (160,160,800) as 10-bit codes:

    compute CSC  -> (159, 158, 796)
    EFC          -> (159, 158, 796)
    AV1          -> (158, 159, 794)

Under 0.5%, all of it limited-range quantisation and lossy encode. The compute
and EFC results being IDENTICAL is the strongest check available: two
independent BT.2020 NCL implementations — my shader and AMD's fixed-function
block — agreeing to within rounding.

The smokes deliberately assert structure (submits encode, AU count, the depth
the encoder settled on), not colour: a shader writing the 10 bits into the
wrong end of the word still produces a decodable stream. Colour is the dump +
ffmpeg round-trip above, which is what actually caught nothing this time.

Also corrects a comment: I claimed a wrong store factor was a "~1.6% luminance
error". It is not. The `<< 6` PLACEMENT is the load-bearing part (dropping it
is 64x too dark); `1/1023` vs `64/65535` is ~0.1% and harmless.
2026-07-28 18:03:30 +02:00
enricobuehler 9eb9f74893 fix(gamescope): the patch did not compile on Fedora/Bazzite — three real defects
Built it for the first time, on the AMD Bazzite box (`.116`, RADV 780M,
Fedora 43 toolbox). The patches applied cleanly to the pinned rev and the
configure got all the way through wlroots — then found three things no amount
of reading would have:

**1. `SPA_VIDEO_TRANSFER_SMPTE2084` does not exist on Fedora 43.** PipeWire
1.4.11's transfer-function enum stops at `ADOBERGB`: SPA grew
BT2020_10 / SMPTE2084 / ARIB_STD_B67 as one later block. So the patch simply
did not compile on the distro it is primarily FOR. This is the same trap
punktfunk's own consumer documents and works around — `pf-capture`'s
`pw_pods.rs` spells the value out as `14` for exactly this reason — and the
fix is the same: spell out both colorimetry values, because the enum is wire
ABI mirroring GStreamer's, not a private detail. `SPA_VIDEO_COLOR_PRIMARIES_BT2020`
is present here but gets the same treatment, since its own header comment says
`\since 1.6` and there is no reason to depend on that.

The `PW_CHECK_VERSION(1, 0, 0)` guard was also just wrong: it tests the library
version, which says nothing about which enum members a header names. It now
guards only the FORMAT constants, which are what it was actually right about.

**2. The build pulled in three subprojects we never ship.** gamescope's own
unit tests want Catch2 **v3** (`catch2-with-main`) and Fedora ships v2, so the
configure died on a test suite that is not ours to build. Also disabled: the
OpenVR integration (a code path a headless capture session never enters) and
the WSI layer — which would have been WORSE than wasted work, since the distro
gamescope package installs that same layer and ours would have collided with
it file-for-file.

**3. `dnf builddep gamescope` is not sufficient.** It resolves Fedora's
*packaged* gamescope, which is older than the master we pin, and misses
`xorg-x11-server-Xwayland-devel` — without which wlroots fails several minutes
in with a `xserver.wrap` error that names nothing useful. Documented with the
symptom, so the next person recognises it in one line instead of ten minutes.
2026-07-28 18:03:30 +02:00
enricobuehler fd648aa776 feat(gamescope): put the cursor in the node, so a session can be zero-copy
gamescope keeps the pointer out of its PipeWire node — it lives on a hardware
plane for scanout, and `paint_pipewire()` composites a separate, reduced frame
that never includes it. So punktfunk has always reconstructed it from XFixes
and blended it in host-side.

That blend is what has been blocking the zero-CSC encode path, and the cost is
larger than it sounds: `VulkanVideoEncoder::open` refuses the RGB-direct (EFC)
source for any session with `cursor_blend`, because that front end is
fixed-function and has no blend stage. `cursor_blend_for` sets it
unconditionally for gamescope. Net effect: a gamescope session paid a
full-frame colour-conversion pass per frame, forever, for a pointer.

So put the cursor where it belongs. A second carried gamescope patch adds
`--pipewire-composite-cursor` (off by default — the node has never carried it,
and a consumer that draws its own would get two), painting it with the same
`MouseCursor::paint` call the scanout composite uses. It scales for free:
paint_pipewire has already set `currentOutputWidth/Height` to the capture size,
which is what that function scales against. The repaint test grows the cursor's
state beside the commit ids — a pointer-only move produces no commit, so
without it the composited cursor would freeze on a static screen while the real
one moved, and a cursor that became hidden would never be erased.

Host side, the `+pfhdr` marker becomes a monotonic PATCH LEVEL, so one probe
answers every capability the session must know before it is planned (level 1 =
HDR formats, level 2 = the cursor flag). `cursor_blend_for` and the
`gamescope_cursor` resolver both consult it through one helper, because they
have to agree: the reader without the blend is a wasted X11 connection, the
blend without the reader is a stream with no pointer, and both together with a
gamescope that paints its own would draw two.

⚠ The two indirect spawn modes carry the flag through `PF_HDR_ARGS`, so this
shares a dependency with the HDR flags: a session that ignores
`GAMESCOPE_BIN`/`PATH` and execs the distro's gamescope gets neither. HDR fails
loudly there (negotiation timeout + SDR latch); a missing cursor would be
silent. Noted in packaging/gamescope/README.md as worth a post-spawn
`/proc/<pid>/cmdline` check if it ever bites.
2026-07-28 18:03:30 +02:00
enricobuehler 2a13bc5252 fix(gamestream): advertise AV1 Main10, and honour HDR per negotiated codec
`ServerCodecModeSupport` layered `SCM_HEVC_MAIN10` and never `SCM_AV1_MAIN10`,
on the stated theory that "the GameStream AV1 path is left off until
live-confirmed". But the SDR baseline has always offered AV1 **Main8** to every
client, so that path is either live or it is not — the DEPTH was never the
uncertain part, and the omission only cost AV1-preferring clients their HDR.

Now that the encoders probe 10-bit per codec, each bit is gated on that codec's
own `can_encode_10bit` AND the SDR baseline already advertising it. A box that
does HEVC Main10 but not 10-bit AV1 — or the reverse — advertises the truth
instead of one bit standing in for both.

Two gates follow from that:

* `host_hdr_capable` becomes codec-agnostic (ANY 10-bit-capable codec makes the
  host HDR-capable). It was asking about HEVC alone, which would have hidden
  HDR entirely on a hypothetical AV1-only-10-bit box;
* the RTSP honor gains the per-session half: a client that negotiated the codec
  this host CANNOT do 10-bit with degrades to 8-bit SDR there, rather than
  being handed a PQ label over an 8-bit stream. H.264 always lands there —
  there is no 10-bit H.264 encode anywhere.

The unit test now pins each bit independently, including the two one-without-
the-other cases a single shared flag got wrong in both directions.
2026-07-28 18:03:30 +02:00
enricobuehler cb4690f216 feat(encode/vulkan): probe the device instead of guessing — AV1 10-bit + zero-CSC HDR
Three fixes to the same mistake: deciding what the Vulkan Video backend can do
from a table in our heads rather than from the driver, and routing everything
that didn't fit to libav VAAPI — where a session loses real RFI recovery and
the cursor blend for no reason the hardware asked for.

**Capability probe, per codec AND depth.** `probe_encode_support`'s "is there
an encode queue" boolean becomes `VulkanEncodeCaps { supported, eight_bit,
ten_bit }`, answered by `vkGetPhysicalDeviceVideoCapabilitiesKHR` against the
very profile chain the session open builds. So the dispatcher's prediction
cannot disagree with reality: a capable device keeps the Vulkan path, an
incapable one routes to VAAPI BEFORE burning a failed open, and the
cursor-blend mirror stays honest for free. This is the shape the direct-SDK
NVENC path already uses for its codec GUIDs.

**AV1 10-bit.** It was excluded on a guess about driver coverage; now the
device answers. `color_config()` carries `high_bitdepth` + the BT.2020/PQ CICP
triplet in both the `StdVideoAV1ColorConfig` and the sequence-header OBU we
bit-pack ourselves — they must stay identical or the driver's frame OBUs parse
against a header we didn't write. `high_bitdepth` sits BEFORE the CICP bytes,
so getting it wrong doesn't just mislabel the depth, it puts every following
field one bit out of phase; the new test reads the packed bits back.

**Zero-CSC RGB-direct in HDR.** The EFC probe assumed BT.709 and BGRA. It now
asks for the model this session's colourimetry needs (`MODEL_YCBCR_2020` for
10-bit — the extension has always had it) and for the CAPTURED format as an
encode-source format, and the session create-info selects the matching model.
An HDR session with no pointer to composite therefore hands the captured
buffer straight to the fixed-function front end and runs no host CSC at all.
Sessions that DO composite a pointer keep the compute CSC, unchanged: the EFC
cannot blend, and that rule outranks everything.

Also: `can_encode_10bit` on AMD/Intel now reports the union of VAAPI's and
Vulkan Video's answers instead of VAAPI's alone. `open_amd_intel` tries Vulkan
first and falls back, so either one being able to encode Main10 makes the
session 10-bit-capable — answering `false` because only one of them said yes
stranded encodable HDR sessions at 8 bits.
2026-07-28 18:03:30 +02:00
enricobuehler 479f0965ee feat(encode/vulkan): Vulkan Video encodes 10-bit, so AMD/Intel HDR keeps the good path
The Vulkan Video backend was 8-bit for no structural reason — the API has
`VK_VIDEO_COMPONENT_BIT_DEPTH_10_BIT` and `PROFILE_IDC_MAIN_10` in the very
fields this pinned to 8 and MAIN, and AMD VCN and Intel both encode Main10.
It was six hardcoded sites, and the cost of leaving them was paid twice over:
an HDR session had to take libav VAAPI, losing real RFI loss recovery AND the
compute CSC's cursor blend — which on gamescope is the only way the pointer
reaches the stream at all, since gamescope has no embedded-cursor mode.

An HDR session now opens a Main10 profile with 10-bit component depths, a
`G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture + DPB, and an SPS carrying
`bit_depth_*_minus8 = 2` with the BT.2020/PQ CICP triplet instead of BT.709.

`rgb2yuv10.comp` is the CSC's twin, and the two interesting parts of it are:

* it is a PURE 3x3 matrix. The samples arrive already PQ-encoded (gamescope
  composites into the PQ container), so BT.2020 NCL applies to the code values
  as they are — there is no transfer function to apply here and applying one
  would be wrong;
* the scratch planes are `R16`/`RG16`, not the picture's plane formats. The
  10-bit ycbcr plane formats are not storage-image formats, so the shader
  writes the value into the HIGH bits by hand (`code10 << 6`, hence the
  `64/65535` factor and not `1/1023`) into planes that are merely
  SIZE-compatible with the picture's — which is all `vkCmdCopyImage` requires.

Scope and safety:

* HEVC only. AV1 10-bit encode has far thinner driver coverage, and a session
  open is not the place to gamble on it — those stay on VAAPI, as does a device
  that fails the Main10 profile query inside the open (the pre-existing "failed
  Vulkan open falls back to VAAPI" net, no new probe needed).
* HDR pins the compute-CSC arm over the EFC RGB-direct one, which the EFC could
  not serve anyway: its fixed-function conversion is 8-bit BT.709 narrow with
  no knob for BT.2020.
* `open_inner` binds `hdr` to the parameter-set HEADER bytes, so the depth flag
  is `ten_bit` there — the one name collision this change had to route around.
2026-07-28 18:03:30 +02:00
enricobuehler 86f4f950ba docs: the gamescope path is no longer 8-bit-only
Five pages asserted "gamescope's capture output is 8-bit" as a flat fact. It is
a fact about the STOCK binary, so say that instead, and say what to install to
change it: a new "HDR on gamescope" section covering the extra package, the two
knobs, what has to line up for a session to go HDR at all, and the two things
that surprise people — SDR content rides the same PQ stream at
`--hdr-sdr-content-nits`, and AMD/Intel HDR sessions currently lose the
composited pointer (the encode path that carries 10-bit is the one that cannot
blend it).

The roadmap's "parked / blocked" entry keeps the half that is still blocked
(Mutter's `RecordVirtual` is SDR-only through the GNOME 51 dev branch) and
drops the half that no longer is.
2026-07-28 18:03:30 +02:00
enricobuehler 17f824c3e9 feat(encode/nvenc): an HDR capture stays zero-copy on NVIDIA
AMD/Intel needed no new encoder code for HDR — the VAAPI path already ingests
an XR30 dmabuf into `format=p010:out_color_matrix=bt2020`. NVIDIA did: the
10-bit formats were excluded from the GPU import outright, so an HDR session
fell back to a CPU readback plus swscale, which is the one thing the capture
path is not allowed to ship.

It turns out no CSC kernel is needed. NVENC ingests packed 10-bit RGB natively
as `ARGB10`/`ABGR10` and does the conversion itself following the configured
VUI matrix — which `apply_low_latency_config` already sets to BT.2020 NCL for
an HDR session. So the frame travels LINEAR dmabuf → Vulkan bridge → CUDA →
NVENC unconverted: no host CSC pass, no depth loss, no extra work on a
contended SM.

* invariant 1 is restated rather than dropped: HDR must never take the TILED
  EGL de-tile blit (it renders into an 8-bit `GL_RGBA8` texture). The HDR pods
  are LINEAR-only by construction, so the plan may build the importer; the
  per-frame gate — which sees the negotiated modifier the plan cannot — is what
  enforces the tiled half, and falls back to the CPU path if a producer ever
  ignores our offer.
* …but only where the encoder can actually take the payload
  (`linux_hdr_cuda_ok`). libav's HDR route builds a P010 hardware frames
  context and swscales into it, so on a host without the direct-SDK backend a
  packed-2:10:10:10 CUDA buffer would land in a P010 surface as garbage. Those
  keep the CPU path.
* `nvenc_cuda` stops pinning 8-bit/SDR. Depth and HDR now follow the INPUT
  format, like the Windows backend: a 10-bit session whose capture came back
  8-bit encodes AND labels 8-bit rather than mislabelling.
* the cursor-blend compute shader gains two 10-bit modes, so the pointer
  gamescope leaves out of its node survives the HDR path. Same display-referred
  blend the CPU path's `composite_cursor_rgb10` already does — the samples are
  PQ, and a real sRGB→PQ cursor LUT is polish, not correctness for a pointer.
2026-07-28 18:03:30 +02:00
enricobuehler 689297c86e feat(hdr): a gamescope session streams true HDR10, decided before the Welcome
The Linux native plane has been 8-bit for a reason that stopped being true:
`capturer_supports_hdr()` returned a flat `false` because Mutter's virtual
monitors are SDR-only upstream. That is still right for Mutter, KWin and
wlroots — and wrong for gamescope, whose node can now offer 10-bit BT.2020 PQ
(packaging/gamescope). Open the three gates that held it off, together:

* the capture-side gate becomes SOURCE-AWARE (`capturer_supports_hdr_for`):
  Windows keeps its platform answer, gamescope asks whether the resolved binary
  offers the formats, everything else stays false. It must be truthful rather
  than optimistic — the Welcome is irrevocable, and PQ frames on an 8-bit
  encoder are a deliberate hard error — so every term is a static fact resolved
  before anything is spawned, including "do we spawn gamescope at all" (an
  attach inherits someone else's flags and can promise nothing);
* `want_hdr` reaches the capturer: `open_virtual_output` grew the parameter it
  never had, and the host arm stopped dropping `want.hdr` on the floor;
* the spawn gains `--hdr-enabled --hdr-debug-force-support` on all three
  sub-modes (bare args, the `GAMESCOPE_BIN` wrapper, the SteamOS PATH shim),
  from one shared `hdr_args` — the force flag is what makes it work headless,
  and forgetting it looks exactly like a negotiation failure.

Two things that would have gone wrong quietly:

* the keep-alive reuse key gains `hdr`. gamescope cannot turn HDR on live, so a
  kept SDR display handed to an HDR session would give the game no HDR surfaces
  while the stream negotiated PQ over an SDR composite — wrong, and not
  obviously broken. Same for the managed session-plus/SteamOS reuse check.
* the HDR-negotiation-failure latch becomes PER SOURCE. It was one
  process-wide flag, so a monitor that left HDR mode would have disabled
  gamescope HDR until the host restarted, and vice versa — two facts with
  nothing in common but the word HDR.

GameStream follows the same shape: `host_hdr_capable` gains the gamescope arm,
and the RTSP gate's live BT.2100 monitor probe is scoped to the portal source —
a headless box has no monitor to be in HDR mode, so running it there would
hard-refuse every gamescope HDR session.

Off by default for one release (`PUNKTFUNK_GAMESCOPE_HDR=1`), and
`punktfunk-host hdr-probe` now answers for both Linux HDR sources.
2026-07-28 18:02:13 +02:00
enricobuehler d6818263ce feat(gamescope): a build of gamescope whose capture output can be HDR
gamescope's built-in PipeWire node has always been SDR-only: it offers BGRx
and NV12, and `paint_pipewire()` hardcodes a Gamma-2.2 composite with the SDR
screenshot LUT set. So an HDR game reaches punktfunk already tone-mapped down,
and the whole gamescope backend streams 8-bit no matter what the client can
decode. Games CAN render HDR on a headless gamescope today — only the capture
half is missing.

Carry the two patches that close it (packaging/gamescope/patches): the node
additionally offers `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 +
BT.2020 props, mapped to the same 10-bit capture texture the HDR AVIF
screenshot path already allocates, and `paint_pipewire()` composites into them
with the HDR screenshot LUTs + `EOTF_PQ` — which is exactly the in-tree
`bHDRScreenshot` branch. The new formats are listed LAST, so every existing
consumer keeps negotiating the 8-bit stream bit-for-bit. Offered upstream
against ValveSoftware/gamescope#2126.

Ship it as `punktfunk-gamescope`, beside the distro's own binary rather than
replacing it: a Bazzite sysext payload (`build-sysext.sh --gamescope`, which
refuses a binary without the marker), an Arch package, a nix derivation +
`services.punktfunk.host.gamescopeHdr`, and one distro-agnostic build script
the rest call.

The second patch stamps `+pfhdr1` into the `--version` banner. That is not
cosmetic: punktfunk fixes a session's bit depth in the Welcome, before the
display exists and with no way to take it back, so its capability answer has to
be a static property of the binary it will spawn.
2026-07-28 18:01:20 +02:00
enricobuehler 28c50d1c5b fix(encode): every backend signals its colour, so no decoder has to guess
audit / cargo-audit (push) Successful in 2m38s
audit / bun-audit (push) Successful in 13s
ci / rust (push) Failing after 12s
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 6m59s
ci / rust-arm64 (push) Successful in 10m2s
android / android (push) Successful in 13m6s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
arch / build-publish (push) Failing after 14m19s
deb / build-publish (push) Successful in 11m13s
deb / build-publish-host (push) Failing after 4m36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m6s
docker / build-push-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 35s
windows-host / package (push) Failing after 7m59s
windows-host / winget-source (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 7m11s
apple / swift (push) Successful in 5m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m21s
flatpak / build-publish (push) Successful in 6m43s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m14s
apple / screenshots (push) Successful in 22m57s
Three encode paths shipped a bitstream with no colour description at all,
leaving primaries/transfer/matrix/range "unspecified":

- Vulkan Video HEVC (`vk_build.rs`) built an SPS with no VUI whatsoever.
  This is the DEFAULT backend for AMD/Intel Linux hosts on HEVC/AV1.
- Vulkan Video AV1 packed `color_description_present_flag = 0`.
- The openh264 software path wrote nothing (it converts BT.709 limited and
  relied on decoders defaulting to that).
- The libav-NVENC Linux path excluded packed-RGB 4:2:0, on the belief that
  "NVENC's internal CSC writes its own VUI". It doesn't: libavcodec derives
  `colourDescriptionPresentFlag` from the AVCodecContext colour fields, so
  leaving them unspecified emits none. Reachable on a CPU/dmabuf capture, a
  build without `--features nvenc`, or PUNKTFUNK_NVENC_DIRECT=0.

Unsignalled looks fine on every punktfunk client — `csc_rows` falls back to
BT.709 on "unspecified" — which is why this survived. Vendor TV decoders do
not: they guess colorimetry from RESOLUTION, and an LG webOS panel reads a
4K SDR stream as BT.2020 and renders it visibly washed out.

All four now signal BT.709 limited, which is what every host CSC actually
produces (`rgb2yuv.comp`, `convert_bt709`, the swscale paths) and what the
Welcome's `ColorInfo::SDR_BT709` already advertises out-of-band. NVENC,
VAAPI, QSV, AMF and the Windows libav path were already correct.

Two tests, both parsing the REAL emitted bitstream rather than re-asserting
the constants: an independent bit-walk of the AV1 sequence header (the
packed OBU must stay identical to the `StdVideoAV1ColorConfig` handed to the
driver), and an H.264 SPS/VUI parse proving openh264 honours the request
instead of dropping it.

Not yet verified on hardware: the HEVC VUI depends on the driver's SPS
writer emitting `vui_parameters()`. PUNKTFUNK_VULKAN_ENCODE=0 falls back to
VAAPI if a driver mishandles it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3c56ff5717b2c9a0871953127da3dadd6a84220d)
2026-07-28 17:01:59 +02:00
enricobuehler 1db8f7631b fix(nix): move the bun packages to bun2nix — no more hand-bumped deps hash
`nix build .#punktfunk-web` has been broken since 1e9957d9 re-resolved
web/bun.lock: the console's node_modules came from a fixed-output derivation
whose single aggregate `outputHash` was last refreshed in 4094f620, so every
lockfile change silently invalidated it and the fix required a round-trip on a
Linux nix box (build, read the `got:` hash, paste it back). The runner
(sdk/bun.lock) had the same latent trap.

Replace both FODs with bun2nix (github:nix-community/bun2nix, pinned to 2.1.2).
`fetchBunDeps` turns a generated, committed `bun.nix` into bun's global install
cache — ONE `fetchurl` per package, keyed by the integrity hash already in the
lockfile — and the setup hook then runs a fully offline `bun install` in
`bunRoot`. There is no aggregate hash left to go stale. The `@unom` scope needs
no special handling: bun.lock records those tarballs' full git.unom.io URLs and
the registry is read-public.

`bun.nix` keeps itself in step: `bun2nix` is now a devDependency of both
packages and regenerates the file on every `bun install` — web via
`postinstall`, the SDK via `prepare`, because sdk/ is the published
@punktfunk/host package and a postinstall would fire on consumers' installs.
Both the flake input and the npm devDependency are pinned to the same exact
version; `bun.nix` has no schema stability guarantee across bun2nix releases, so
they move together (README documents this).

Dropped along the way: the manual `cp -R ${deps}/node_modules` + `chmod -R u+w`
+ `patchShebangs web/node_modules` dance, since bun2nix patches shebangs inside
the cache. `dontUseBunPatch` keeps the hook from running `patchShebangs .` over
the whole repo checkout (it would rewrite scripts/web-init.sh, which we ship
verbatim); `dontRunLifecycleScripts` preserves the old `--ignore-scripts`
behaviour, so playwright still never tries to download browsers.

Verified on a Linux nix box (Determinate Nix 3.21.5): `.#punktfunk-web` and
`.#punktfunk-scripting` both build green, offline; the i18n guard reports its
421 compiled messages, the `Bun.serve` bundle guard passes, and
`nix run .#punktfunk-scripting -- --list` discovers an installed plugin.
`nix flake show --all-systems` evaluates every output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4cfe7f05ee608868857be9e7eec079044448a965)
2026-07-28 17:01:59 +02:00
enricobuehler 0d8862457b style(nix): run the repo's own nix fmt (nixfmt-rfc-style) over the flake
flake.nix, packaging/nix/packages.nix and packaging/nix/nixos-module.nix had
drifted from the formatter the flake itself declares (`formatter =
nixfmt-rfc-style`). Pure reformat — no expression changes — split out so the
bun2nix migration that follows is reviewable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 927b27c4fade6d646e3ef822678b6738f1e2f28a)
2026-07-28 17:01:59 +02:00
enricobuehler 347c106498 fix(packaging/tray): the .deb must build the tray in its own cargo invocation
punktfunk-tray panicked at every launch on Debian/Ubuntu:

  zbus-5.16.0/src/abstractions/executor.rs:190
  there is no reactor running, must be called from the context of a Tokio 1.x runtime

Not a code bug — cargo feature unification. zbus picks its executor from
its own feature flags; the host's ashpd enables zbus/tokio while the tray
runs ksni's async-io executor with no tokio runtime by design. Features
are additive across everything built in ONE invocation, and deb.yml built
`-p punktfunk-host -p punktfunk-tray` together, handing the tray a
tokio-flavoured zbus with no runtime anywhere near it.

The invariant is already established and explained inline in the RPM spec,
the Arch PKGBUILD and the Nix packages.nix — all three build the tray
alone, and their comments even claim "(Same split the .deb does.)" The
.deb did not, in two places at once: the workflow co-built it, and
build-deb.sh's own correct standalone build was skipped by an
`if [ ! -x "$TRAY_BIN" ]` guard, so it packaged the poisoned artifact.
That is also why only Debian/Ubuntu users saw it.

Drops the tray from the workflow's host build and makes build-deb.sh's
tray build unconditional — cargo no-ops when the artifact is already
async-io-resolved and rebuilds it when it is not, so a stale co-built
binary can no longer be shipped. Corrects the Nix README note that had
recorded deb/rpm/arch as sharing a "latent" crash, and its nix develop
recipe, which co-built all four.

Verified on a Linux box: co-built reproduces the panic exactly; built
alone, the tray reaches the session bus and exits with the intended
"no StatusNotifier tray available".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f91983a84c56464bd2c18d537963cb7fb3a6e059)
2026-07-28 17:01:59 +02:00
enricobuehler 0868b6a364 fix(vdisplay): compositor availability follows the live session, not the env
GNOME/Mutter reported "Unavailable" on a host sitting in a live Mutter
session — and, on the same request, "Default", because the two columns had
different sources. available() asked each backend, and those probes read
the process env (XDG_CURRENT_DESKTOP for Mutter, WAYLAND_DISPLAY for
KWin's registry handshake, SWAYSOCK for sway) — env a host started outside
the session (systemd --user, a TTY, ssh) never inherited. It is only
retargeted at the live session on the connect path, so the answer also
flipped depending on whether anyone had connected yet.

Both columns now come from the same /proc scan detect() already used: the
live session's compositor is usable by definition, as is an explicit
operator pin, and the per-backend probe stays as the fallback for backends
that are not the live session (gamescope, which spawns its own). A live
KWin without the zkde_screencast grant now surfaces as available and fails
at create with that probe's precise message, which beats "no usable
compositor" on a box visibly running KDE.

Mutter's env sniff stays deliberately narrow — one var, not three.
XDG_CURRENT_DESKTOP is the one apply_session_env owns end to end (written
per connect, scrubbed when nothing is live); sniffing DESKTOP_SESSION
alongside would resurrect the bug that scrub exists to prevent, where a
stale value after a gnome-shell crash routes the next client into a dead
session.

Non-Linux hosts now report no compositors at all rather than five Linux
backends flagged unavailable with no default — on Windows the pf-vdisplay
driver is the only backend and vdisplay::open ignores the argument, so the
old list read as broken detection instead of "not applicable here". The
console says so explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit eb7ba3d6177552f5c3ed6a15439404084869c636)
2026-07-28 17:01:59 +02:00
enricobuehler 3b11288c97 fix(web): unsaved custom display settings are visible and recoverable
Everything else on the Displays page auto-applies — a preset click, the
game-session choice, the experimental toggles — but the Custom block does
not, and its Save button sat at the bottom of a block taller than most
viewports. People edited, never scrolled far enough to find it, navigated
away, and silently lost the lot.

The draft is now compared against the last-seeded server value, and that
one boolean drives the whole affordance: a badge in the card header
(visible without scrolling), an amber ring plus a title on the block, and
a sticky action bar pinned to the viewport for as long as any part of the
block is on screen. The bar states which of the two states you are in
rather than leaving it implied, Save disables when there is nothing to
save, and Discard changes puts the stored policy back.

Two ways edits could still vanish are closed: a preset click now confirms
before overwriting pending edits, and a reload/close warns. Re-picking
Custom while already on Custom is a no-op instead of re-seeding, which
would otherwise fill in defaults for unset fields and report "unsaved
changes" for a click that changed nothing.

Adds a `warning` badge variant + --warning token for the pending state —
attention, not failure, and distinct from the destructive red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c4318609c0da5ae1313081c5e488f685504f70d1)
2026-07-28 17:01:59 +02:00
enricobuehler 5ea087ca47 feat(session): the stats overlay scales with the display's DPI
The stream chrome — stats OSD, capture hint, start banner, resize label —
was hardcoded at 14 px with 12/10/8 px insets. The overlay composites into
the swapchain 1:1 in PHYSICAL pixels, so on a 4K panel at 200 % all of it
rendered at half its intended physical size: fine on a 1080p monitor, a
squint on a HiDPI laptop.

FrameCtx now carries a scale — SDL's window display scale (DPI × the
display's content scale) times a PUNKTFUNK_OSD_SCALE preference — and every
metric moved into a `base` module that is multiplied by it. Re-read per
frame and quantized into the damage key, so dragging the window to a
differently-scaled monitor re-renders at the new size rather than keeping
the stale one. Sanitized because SDL returns 0.0 when it cannot resolve the
window's display, which would collapse the panel to nothing.

Two details worth keeping: the face is re-derived at the scaled size rather
than the canvas transformed, because Skia rasterizes glyphs at the
requested size where a magnified 14 px bitmap would be mush; and the long
capture hint is fit-clamped to the window — at 2× it is wider than a 1080p
screen, so scaling it naively would have traded a small OSD for a truncated
one. Linux and Windows share this path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 744467d13a28b91ba88a23d6038da70263e9b502)
2026-07-28 17:01:59 +02:00
enricobuehler b444308592 feat(host): PUNKTFUNK_HOST_NAME names the host in Moonlight and the clients
A box called `bazzite-htpc` had no way to present itself as "Living Room"
short of renaming the machine. The new knob overrides the name everywhere
a human sees it: the GameStream serverinfo <hostname> element and the
mDNS service instance name both adverts carry. Unset (the default) is the
machine's own hostname, exactly as before.

Free text is the point, so the DNS-level name is now a separate concern
from the display name. The instance label may contain spaces and accents;
an A-record target may not, and mdns-sd rejects the whole ServiceInfo if
the target is not a legal name — which would take discovery down rather
than merely look wrong. dns_label() sanitizes the target and passes an
already-legal name through byte-for-byte, so hosts without the override
advertise precisely what they always did. The display name loses `.` (it
would split the label, and clients derive the name as the first label of
the fullname, so "Ben's PC v1.2" would arrive as "Ben's PC v1") and is
capped at the 63-byte DNS-SD ceiling on a char boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit bbf72261a12e0e67601f549d40db65ae61979268)
2026-07-28 17:01:59 +02:00
enricobuehler 40714317a8 fix(web): empty-state cards get their top padding back
"No games found yet." sat flush against the top edge of its card on any
screen ≥640px. The call sites overrode CardContent's padding with a bare
`p-8`, but tailwind-merge only resolves conflicts within a variant: `p-8`
cancels `p-4` and leaves `sm:p-6 sm:pt-0` standing, so the desktop
breakpoint kept the zero top inset that exists for cards WITH a header.
These three have none.

Uses `flush` — the escape hatch CardContent documents for exactly this —
so the padding is owned outright at every breakpoint instead of fought
from the outside. The library grid is the one that was reported; the
store catalogue and the recordings list are the same idiom with the same
bug. PairedDevices deliberately keeps its `p-6`: it is a table under a
header, where pt-0 is the intended look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 71fc47f32af7c74327a26425035298f8e6464c96)
2026-07-28 17:01:59 +02:00
enricobuehler 188f55d3b1 fix(encode/nvenc): the host advertises what the driver lists, not a superset
Every NVIDIA host advertised a static H.264|HEVC|AV1 superset, so a 1st-gen
Maxwell (GTX 960M, no HEVC/AV1 encode) offered HEVC — a client that believed
it got ~15 s of blank video and a disconnect instead of a stream. Both OSes
now ask the driver itself (nvEncGetEncodeGUIDs) on one throwaway direct-SDK
session: Linux on the shared CUDA context, Windows on the selected render
adapter, wired into host_wire_caps AND the GameStream serverinfo mask (which
had been left on the superset for NVIDIA on both OSes). Fails open — an
unanswerable probe keeps the historical superset, so it can only ever narrow
the advertisement to codecs the GPU really encodes.

The HEVC 4:4:4 answer rides the same session on Linux instead of opening a
libav hevc_nvenc FREXT probe: that open is the prime suspect for the field
bug where one probe wedges NVENC process-wide (NV_ENC_ERR_INVALID_VERSION on
every later session until a host restart), and the direct backend re-checks
the same caps bit at session open anyway. The ffmpeg probe remains only for
hosts that really stream over libav (PUNKTFUNK_NVENC_DIRECT=0 or a build
without the nvenc feature), where ffmpeg's NVENC client runs regardless. The
10-bit probe deliberately stays libav — Linux HDR rides the libav P010 path.

On-hardware: .136 (RTX 5070 Ti) 14/14 nvenc tests in one process incl. the
probe followed by real sessions and dirty teardown; .173 (Windows RTX) probe
+ 47 release lib tests. The Windows probe test documents the pre-existing
MSVC debug-link failure (LNK2019 via the sdk crate's unused lazy loader) —
run it with --release, the same reason windows-host.yml gates with clippy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 0346ec8090568eb499e8cb7d735305b28471185e)
2026-07-28 17:01:59 +02:00
enricobuehlerandClaude Opus 5 560e663aef fix(drivers): the pad channel asks the devnode who to trust, not the mailbox
ci / rust (push) Failing after 12s
windows-drivers / probe-and-proto (push) Successful in 48s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m6s
deb / build-publish-client-arm64 (push) Failing after 10s
decky / build-publish (push) Successful in 47s
windows-drivers / driver-build (push) Successful in 1m40s
apple / swift (push) Successful in 3m6s
ci / bench (push) Successful in 7m39s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8m2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
android / android (push) Successful in 12m28s
deb / build-publish (push) Successful in 12m13s
ci / rust-arm64 (push) Successful in 12m31s
arch / build-publish (push) Successful in 12m40s
deb / build-publish-host (push) Successful in 12m17s
windows-host / package (push) Successful in 18m26s
windows-host / winget-source (push) Skipped
apple / screenshots (push) Successful in 23m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m34s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m24s
A LocalService principal could take over a virtual pad's shared input section and
forge HID input into the interactive desktop.

The host duplicates each pad's unnamed DATA section into the driver's WUDFHost, and
through gamepad proto v2 it learned that process from `driver_pid` in the named
bootstrap mailbox. That mailbox has to be LocalService-writable — that is what the
driver's own WUDFHost runs as — and the delivery gate, verify_is_wudfhost, only checks
that the target's IMAGE is %SystemRoot%\System32\WUDFHost.exe. That image is
world-executable. So anything running as LocalService — notably the deliberately
de-privileged plugin runner — could spawn its own WUDFHost (CREATE_SUSPENDED parks it
indefinitely with the right image path), publish that pid, and be handed
SECTION_MAP_READ|WRITE on a live section. For pf-mouse that section drives a real
absolute pointer, so it was desktop control; for the pads it was forged gamepad input
plus a read of the remote user's controller state.

The module docs claimed mailbox tampering "yields at worst a gamepad DoS, never a read
or an injection". That was wrong, and the reasoning behind it — that a LocalService
token is DACL-denied OpenProcess on a UMDF WUDFHost — only covers the REAL host, not
one the attacker spawned itself.

The pid now comes from the device stack (ChannelProof, proto 2 -> 3). The host asks the
devnode it SwDeviceCreate'd who is serving it, looked up by the instance id PnP handed
back, so a planted look-alike devnode is not a candidate and the kernel — not anything
the attacker supplies — does the routing. Only the driver PnP actually bound to that
device can answer. `driver_pid` survives as a liveness hint; a tamperer can still deny a
pad, which squatting the name always allowed, but can no longer choose the recipient.
Two rules keep the state machine honest around it: a delivery stands until its target
process EXITS (judged on a retained SYNCHRONIZE handle, so a recycled pid cannot fake
it, and UMDF's restart-after-driver-crash still re-attaches), and a pad with no
SwDeviceCreate devnode refuses to deliver rather than fall back — unless an operator
sets PUNKTFUNK_PAD_CHANNEL_TRUST_MAILBOX, which says so loudly.

Three transports, because Windows carries different things to different driver shapes,
and the obvious two did not survive contact with hidclass. Measured on .173 (Win11
26200): HidD_GetIndexedString is NOT forwarded to a UMDF HID minidriver at all — it
failed for every index including ones the driver demonstrably serves through the named
wrappers; and a private device interface registers and enumerates but cannot be OPENED
(ERROR_GEN_FAILURE), because hidclass owns IRP_MJ_CREATE on a devnode it is the FDO for.
That is exactly why pf-xusb was never affected: it is not a HID minidriver, so nothing
sits above it. What works:

  * pf-xusb   — a private IOCTL on its own GUID_DEVINTERFACE_XUSB.
  * pf-mouse  — the HID serial string. Verified: PFCP:3:0:7296, and 7296 was a genuine
                service-spawned WUDFHost.exe. Safe here alone: nothing reads the virtual
                mouse's serial, whereas a pad's is SDL/Steam dedup material.
  * pf-gamepad — a HID feature report, and it cost NO report-descriptor change. The
                captured descriptors already declare far more Feature ids than the driver
                ever served: 0x85 is declared on DualSense, DualShock 4 and Edge alike and
                used to fail with STATUS_INVALID_PARAMETER, so hidclass lets it through and
                nothing can have depended on the old failure. The Deck's one feature report
                is unnumbered and Steam drives it command->response, so its proof rides that
                existing contract via a private two-byte command. Verified: feature 0x85
                returned magic "PFCP", proto 3, pad_index 0, wudf_pid 18456 — and 18456 was
                a WUDFHost — with the product string still 'DualSense Wireless Controller'.

Also renamed pf-dualsense -> pf-gamepad. One driver has always served four identities, so
the old name read as if the other three lived elsewhere. ONLY the package identity moved
(crate, INF/CAT/DLL, UMDF service, build script, CI lines, log file, env var). The four
HARDWARE IDS are deliberately unchanged — they bind every devnode the host creates and
every installed system — as are the Global\pfds-boot-<i> mailbox and PAD_MAGIC, which are
wire contract. `driver install --gamepad` now retires the pre-rename store package first,
matched on pf_dualsense.dll because that string appears only in the OLD inf; matching on
the hardware ids would delete what we are about to install. On .173 that separated 14
stale packages from the 1 new one with 0 ambiguous, and the renamed package binds the old
hwid (devgen root\pf_dualsense -> oem143.inf = pf_gamepad.inf).

The repo's own pre-commit/pre-push rustfmt hooks named the old crate, so they caught the
rename before the commit did — they now check pf-gamepad, and pf-mouse alongside it, which
they had been missing relative to the CI line.

Host and drivers MUST ship together: v2<->v3 fails closed in both directions by design,
with the existing "update host + drivers together" diagnostic.

The rename moved files that also carry the security change, so splitting this into two
commits would mean reconstructing an intermediate state that was never gated. It is one
commit on purpose.

Gated on the windows-amd64 runner with cargo clean first (the box's clock lags, so stale
artifacts would read as a vacuous green): clippy -D warnings clean for pf-inject,
pf-capture and pf-driver-proto, drivers workspace build + the CI clippy line clean,
cargo check --release -p punktfunk-host clean, 19 + 58 tests green. Also fixes pf-mouse
still writing its debug log to world-writable C:\Users\Public, which the 2026-07-17
review moved for the other three drivers and missed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:54:40 +02:00
enricobuehlerandClaude Fable 5 9b3ec9204c fix(capture): a refused EGL→CUDA dmabuf offer stops being asked
ci / rust (push) Failing after 41s
android / android (push) Canceled after 1m19s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 1m20s
arch / build-publish (push) Canceled after 1m23s
ci / rust-arm64 (push) Canceled after 1m24s
ci / web (push) Canceled after 1m24s
ci / docs-site (push) Canceled after 1m23s
ci / bench (push) Canceled after 1m22s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / build-publish (push) Canceled after 46s
deb / build-publish-host (push) Canceled after 2s
decky / build-publish (push) Canceled after 1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 1m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5s
The raw-dmabuf passthrough has had a negotiation-timeout latch since the
hybrid-Intel case: one conclusive timeout and later captures negotiate the
CPU path instead of re-paying it. The EGL→CUDA dmabuf-only offer had no
equivalent — a compositor that accepts none of the importer's modifiers
timed out the same 10 s negotiation on every session, forever, under the
generic 'format negotiation never completed' diagnosis.

Now the symmetric latch (note_gpu_dmabuf_negotiation_failed) gates
build_importer in the one negotiation resolver, scoped to this offer alone
(raw passthrough, worker-death latch, encoder untouched), with the same
operator escape as the raw arm: an explicit PUNKTFUNK_ZEROCOPY=1 keeps
erroring loudly instead of downgrading.

One correctness detail beyond the handoff's sketch: whether the GPU offer
was ACTUALLY advertised is a runtime fact of the PipeWire thread (the
importer may fail to construct — no CUDA — in which case no dmabuf was
offered and a timeout must not latch it off). plan.build_importer alone
cannot answer that, so the thread records the made-offer on CaptureSignals
(gpu_dmabuf_offer) and the timeout diagnosis branches on the offer that
really happened, not the plan's intent.

Also renames vaapi_dmabuf_forced → zerocopy_forced (it now guards both
timeout arms; single caller). New plan invariant pinned in tests: the
latch gates only the importer, never the raw passthrough.

(V3 from design/pf-zerocopy-sweep-handoff.md — the deferred design call,
now decided in favour of the symmetric latch.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:15:36 +02:00
enricobuehlerandClaude Fable 5 e92a0aaa00 fix(zerocopy/vkslot): a timed-out blend can't corrupt its slot, and NV12 cursor chroma sits on the grid
Two [GPU]-class defects from design/pf-zerocopy-sweep-handoff.md; the code
is compile- and unit-verified, the visual halves still owe on-glass time.

- blend_ref reused a fence and command buffer after a wait timeout: on
  TIMEOUT it reset a fence that still had a pending signal and re-recorded
  a command buffer the GPU might still be executing — reached exactly in
  the contended case the CPU-synced path exists for (no timeline export +
  visible cursor + a >1 s GPU stall). A failed wait now drains the device
  before the reset (the VkBridge::import_linear precedent), and free_slots
  quiesces unconditionally instead of only when a timeline exists, so
  teardown after a timed-out sync blend no longer frees objects an
  outstanding submission references. (C1)

- cursor_blend.comp anchored NV12 chroma blocks to the cursor's oy, not
  the surface chroma grid: for odd oy every UV sample averaged luma rows
  one below the rows it covers (a one-row colour fringe on the cursor's
  edges, ~half of all cursor positions) and the cursor's last row's chroma
  was never written. Block rows now anchor to the chroma grid the same way
  spans anchor to the word grid — y0 = floor(oy/2)*2, per-row cy guards
  for the straddle block — and blend_geometry counts blocks from the same
  anchor (one extra block when oy is odd; covered by new tests, including
  negative oy). cursor_blend.spv rebuilt (glslangValidator, spirv-val
  clean, drift gate passes). (C6)

Owed on-glass: a stalled-GPU cursor session for C1; an odd-oy cursor
colour check for C6 (subtle fringe on the cursor's top/bottom edge).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:01:46 +02:00
enricobuehlerandClaude Fable 5 fa083f50d3 fix(zerocopy): unsafe fn bodies join the unsafe-proof program
#![deny(clippy::undocumented_unsafe_blocks)] never inspected the body of an
unsafe fn — operations there are not "unsafe blocks" — so roughly 20
functions' worth of raw GL/CUDA/Vulkan driver calls sat outside the
invariant the crate advertises. Concretely, that blind spot is why the
constructor-leak and teardown-ordering shapes fixed earlier in this series
could ship without ever prompting a reviewer.

#![deny(unsafe_op_in_unsafe_fn)] now closes the gap: every unsafe fn body
is an explicit unsafe block carrying a SAFETY comment that names the
caller contract it relies on (the dlopen'd CUDA wrapper table gets a
uniform forward-to-live-table proof). Mechanical; no behavior change.

(V2 from design/pf-zerocopy-sweep-handoff.md.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:59:15 +02:00
enricobuehlerandClaude Fable 5 c677732c60 fix(zerocopy/client): a wedged worker can't park the reaper, and no worker outlives the host
Three related reaper problems (R5 from design/pf-zerocopy-sweep-handoff.md):

- sweep_reaper called kill() + blocking wait() while holding the global
  REAPER mutex. A worker wedged in a driver ioctl is in D state and ignores
  SIGKILL, so wait() never returned — parking every later spawn() and every
  importer drop() behind the lock. Expired entries are now drained under the
  lock and killed outside it, with a bounded (~100 ms) try_wait poll; a
  worker that still won't die is parked again for a later sweep (re-killing
  is harmless) instead of blocking anyone.
- No PR_SET_PDEATHSIG: if the host died, the worker survived holding its
  CUcontext + BufferPool — by the code's own comment, hundreds of MB of
  VRAM. The pre_exec closure (async-signal-safe: prctl/getppid/dup2/fcntl
  only, no allocation) now arms SIGKILL-on-parent-death with the standard
  getppid race guard.

Not taken: arming the 20 s kill deadline from a timer instead of the next
spawn/drop (the handoff's optional third leg). PDEATHSIG closes the
worst-case orphan; a wedged worker after the LAST capture of a session
still waits for the next spawn to be swept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:53:43 +02:00
enricobuehlerandClaude Fable 5 3c62da3b8e fix(zerocopy/client): renegotiation retires the old generation's IPC mappings
Shared::mappings only ever grew: clear_cache reset sent_keys and told the
worker to drop its fd cache, but nothing closed the host-side CUDA IPC
mappings for the previous pool generation. A session that renegotiates
repeatedly (mode changes, HDR toggles, client reconnects) accumulated a
pool's worth of stale mappings each time, each pinning a host VA
reservation to peer memory the worker had already freed.

Mappings now carry a refcount and a retired flag. clear_cache closes every
unreferenced mapping immediately and marks the rest retired; a retired
mapping closes when its last in-flight DeviceBuffer releases. The worker's
half of the contract: ClearCache also forgets its VA→id map, so anything
delivered after the boundary gets a fresh id WITH its descriptor — without
that, a same-shape renegotiation (worker keeps its pool) would re-deliver
an old id whose host mapping was just closed, and the host would misread
it as a desync. Ids never repeat (next_id only counts up), so fresh ids
cannot collide with the graveyard.

(R6 from design/pf-zerocopy-sweep-handoff.md.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:52:42 +02:00
enricobuehlerandClaude Fable 5 a2033d6c82 fix(zerocopy/vulkan): VkBridge bring-up and the CSC build unwind instead of leaking
VkBridge::new leaked its instance (and past device creation, the device and
command pool too) on every error path — reached repeatedly, because a box
whose Vulkan device refuses the external-memory extensions retries the
bridge on every LINEAR frame. Pre-device failures now destroy the instance
explicitly (the VkSlotBlend::new shape); after device creation the
remaining objects build into an incrementally-filled struct whose existing
Drop tolerates the nulls a partial init leaves (Vulkan destroy calls are
defined no-ops on VK_NULL_HANDLE).

ensure_csc had the same hole across its six-object pipeline build; the
fallible half now fills the Csc front to back and a mid-build failure
destroys exactly what was created, leaving self.csc None for a clean retry.

(R3 from design/pf-zerocopy-sweep-handoff.md.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:49:42 +02:00
enricobuehlerandClaude Fable 5 8d02255703 fix(zerocopy/egl): construction can fail late, so teardown must be structural
Three unwind holes in the EGL side, all of the same shape — fallible
construction over raw handles with no Drop to unwind through — and all
retried per frame, so a sustained failure (VRAM pressure is the realistic
one) leaked unboundedly:

- The GlBlit/Nv12Blit/Yuv444Blit constructors interleave GL-object creation
  with fallible steps (FBO completeness, CUDA registration, pool
  allocation). A GlNameGuard now owns every bare GL name until the final
  struct exists; on unwind it deletes them AFTER the RegisteredTexture
  locals unregister (declaration order), preserving the
  unregister-before-delete invariant. gl.rs gets the same treatment for its
  compile paths: the vertex shader on a fragment-compile failure, the
  program on a link failure. (R1)
- EglImporter::new leaked the DRM render-node fd and the whole gbm_device on
  every '?' after their creation (most realistically cuda::context() failing
  on a host where EGL comes up but CUDA does not). Both now live in a
  GbmDevice with its own Drop: destroy the device, then close the fd. (R2)
- EglImporter's manual Drop destroyed the gbm device and closed the fd
  BEFORE field drops ran the blit destructors, which then called
  cuGraphicsUnregisterResource/glDeleteTextures against a dead native
  display — the stale-driver-state class this path once crashed on. The
  Drop impl is gone; teardown is now purely field order (blits and bridge
  first, GbmDevice last), and the blit SAFETY comments cite that order
  instead of the previously-false claim. (R4)

(R1/R2/R4 from design/pf-zerocopy-sweep-handoff.md.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:47:54 +02:00
enricobuehlerandClaude Fable 5 8de5ba4092 test(zerocopy): the fd must actually cross the socket, not just claim to
SCM_RIGHTS descriptor passing is the mechanism the whole worker isolation
design rests on, and no test verified it end to end: both suites asserted
only the has_fd boolean parsed from the JSON body. Setting the send-site's
descriptor to None — zero-copy broken in production — stayed green; so did
dropping the received fd in the worker's dispatch loop.

Now the client-side scripted peer records the st_ino of every descriptor
that arrives and the tests assert the sequence against dmabuf_key() of the
sent plane (SCM_RIGHTS re-numbers the fd but preserves the open file
description, so the inode is the identity the worker keys its cache on);
the worker dispatch test sends one import with a live fd and asserts the
backend received a descriptor with the sender's identity. (T1 from
design/pf-zerocopy-sweep-handoff.md)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:43:39 +02:00
enricobuehlerandClaude Fable 5 143a707f76 fix(zerocopy): the mechanical sweep batch — truthful fence waits, forgiving env flags, no leaked planes
From the pf-zerocopy review sweep (design/pf-zerocopy-sweep-handoff.md), the
compile-verifiable batch:

- dmabuf_fence: the blocking poll's result was discarded — EINTR silently
  skipped the wait (reopening the stale-frame race a SIGCHLD away) and a
  timeout reported as waited. Now EINTR retries with the remaining budget
  and the caller gets Signaled/TimedOut/NoFence, so the one diagnostic
  operators have about implicit fencing stops lying. (C2)
- env flags: PUNKTFUNK_ZEROCOPY=TRUE meant *off* — values are case-folded
  now, and an unrecognised spelling falls back to the flag's default with a
  one-shot warning instead of silently inverting the operator's intent. (C3)
- cuda: alloc_pitched_nv12 leaked the Y plane when the UV allocation failed
  (per-frame under VRAM pressure — the worst possible time to leak); a failed
  async-copy enqueue now drains the stream before returning, so a recycled
  pool buffer can't race an orphaned in-flight copy. (C4, C5)
- worker: --fd is validated (>= 3, fstat + S_ISSOCK) before OwnedFd adoption
  — 'zerocopy-worker --fd -1' was constructing OwnedFd's niche value. (V1)
- docs: all 15 rustdoc warnings fixed, including the link to the renamed
  note_raw_dmabuf_negotiation_failed. (D1)
- CI: a SPIR-V drift gate — the committed .spv blobs are include_bytes!'d and
  rebuilt by hand; the gate diffs disassembly (filtering only the
  shaderc/glslang generator difference) so a forgotten rebuild fails CI
  instead of shipping the old kernel. Both blobs verified in sync. (S1)
- tests: bt709_limited pinned to external BT.709 anchors (it is the sole
  oracle for the GPU colour self-test); blend_geometry gets its first tests —
  empty rect, CURSOR_MAX clamp, per-format group counts, and negative-ox
  floor alignment. (T2, T3)

Verified on .25: clippy -D warnings clean, 27/27 tests, cargo doc 0 warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:40:37 +02:00
enricobuehlerandClaude Sonnet 5 c4e80fd455 docs(release): v0.21.0 notes should only cover its own delta
apple / swift (push) Successful in 1m33s
android / android (push) Successful in 12m23s
arch / build-publish (push) Successful in 13m11s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m5s
ci / bench (push) Successful in 5m47s
ci / rust-arm64 (push) Successful in 9m23s
ci / rust (push) Successful in 23m7s
apple / screenshots (push) Successful in 21m43s
deb / build-publish (push) Successful in 8m56s
deb / build-publish-client-arm64 (push) Successful in 7m24s
decky / build-publish (push) Successful in 32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
deb / build-publish-host (push) Successful in 9m55s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m52s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10m55s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m32s
docker / build-push-arm64cross (push) Successful in 4m23s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m8s
Duplicated the entire v0.20.1 fix bundle verbatim instead of
following house convention (each vX.Y.Z.md covers only what changed
since the immediately preceding release file — see v0.19.1 -> v0.19.2,
neither restates the other). v0.20.1 is a real, already-tagged
release with its own notes file; 0.21.0 only needed to add the
monitor-streaming feature, the KDE registry fix found while building
it, and the CI-only winget fix, with a pointer back to v0.20.1 for
the rest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 09:08:47 +02:00
543 changed files with 58670 additions and 7674 deletions
+19 -17
View File
@@ -4,6 +4,14 @@
# `screenshots` job, gated to STABLE RELEASE tags only. Standalone + best-effort: a failure here # `screenshots` job, gated to STABLE RELEASE tags only. Standalone + best-effort: a failure here
# reds nothing else. PNGs land as a 30-day artifact; not committed or published. # reds nothing else. PNGs land as a 30-day artifact; not committed or published.
name: android-screenshots name: android-screenshots
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
@@ -14,33 +22,27 @@ jobs:
screenshots: screenshots:
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
# JDK 21 + SDK baked (AGP 9.3 + Robolectric's SDK-36 android-all jar both want 1721).
# The tests are pure JVM (no NDK), but sharing android.yml's image means one image to
# keep warm instead of a per-run setup-java + sdkmanager download pair.
container:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 45 timeout-minutes: 45
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: JDK 21 (AGP 9.2 + Robolectric's SDK-36 android-all jar both want 1721)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Android SDK
# SHA-pinned for parity with android.yml (third-party action). v3 = 9fc6c4e.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
# No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP
# if the platform channel lacks it (same note as android.yml).
- name: platform-tools + platform 36 + build-tools
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0"
- name: Cache (gradle) - name: Cache (gradle)
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
~/.gradle/wrapper ~/.gradle/wrapper
key: android-screenshots-${{ hashFiles('clients/android/**/*.gradle.kts') }} # gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the
restore-keys: android-screenshots- # Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise
# restore a key that can never hold the new one. Namespace shared with android.yml —
# it is the same content; two keys just stored it twice in the central cache.
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
# Roborazzi renders Compose on the JVM (Robolectric Native Graphics). `-PskipRustBuild` keeps # Roborazzi renders Compose on the JVM (Robolectric Native Graphics). `-PskipRustBuild` keeps
# the cargo-ndk native build out of the graph — the tests never load libpunktfunk_android.so. # the cargo-ndk native build out of the graph — the tests never load libpunktfunk_android.so.
+79 -49
View File
@@ -2,82 +2,112 @@
# cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml # cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed. # but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
# #
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle # Runs in the punktfunk-android-ci builder image (ci/android-ci.Dockerfile, content-keyed on
# distribution in-job). If android-actions/setup-android is not mirrored on this Gitea instance, # the LAN registry): JDK 21, the Android SDK/NDK/CMake pins, cargo-ndk and sccache are all
# replace that step with a manual cmdline-tools download, or bake an `android-ci` image like # baked, so the multi-GB per-run Google downloads this job used to make are gone. Emulator
# ci/rust-ci.Dockerfile. Emulator instrumentation tests are deferred until a KVM-capable runner # instrumentation tests are deferred until a KVM-capable runner exists (they self-skip
# exists (they self-skip otherwise, like apple.yml's RemoteFirstLightTests). # otherwise, like apple.yml's RemoteFirstLightTests).
name: android name: android
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
branches: [main] branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'clients/android/**'
# The builder image is part of what this artifact is built from — an image
# change must exercise its consumer.
- 'ci/android-ci.Dockerfile'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/android.yml'
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed # Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main # track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
# push is canary (Play `internal`). # push is canary (Play `internal`).
tags: ['v*'] tags: ['v*']
pull_request: pull_request:
paths:
- 'crates/**'
- 'clients/android/**'
# The builder image is part of what this artifact is built from — an image
# change must exercise its consumer.
- 'ci/android-ci.Dockerfile'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/android.yml'
workflow_dispatch: workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). The NDK clang targets get their own key universes automatically (keys embed
# compiler hash + target), so the three ABI builds share the bucket with everything else.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs: jobs:
android: android:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: JDK 21 (AGP 9.2 runs on JDK 1721, not the host default) # Everything below the checkout used to be four download steps (JDK, SDK,
uses: actions/setup-java@v4 # NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked
with: # into the image now. This guard only re-asserts the Android targets so a
distribution: temurin # rust-toolchain.toml pin bump keeps working against an older image (:latest lags
java-version: "21" # one image rebuild, same bootstrap note as ci.yml's dep steps).
- name: Rust Android targets (no-op unless the toolchain pin outran the image)
run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
- name: Rust toolchain + Android targets (self-healing on a fresh runner) # Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical
run: | # CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then # the rest of the fleet in the central cache. target/ is deliberately NOT cached
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ # anymore — sccache covers recompilation without shipping multi-GB tars per run.
| sh -s -- -y --no-modify-path --profile minimal - name: Cache (cargo registry)
fi uses: actions/cache@v4
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
- name: Android SDK
# SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with: with:
# Only platform-tools — NOT the action's default legacy `tools`, whose dependency chain path: |
# drags in the ~250 MB emulator nobody here runs (instrumentation tests are deferred). /usr/local/cargo/registry
# That download was the single flakiest piece of this job: the shared runner fleet drops /usr/local/cargo/git
# packets under parallel-job load and sdkmanager's streamed unzip turns a truncated key: cargo-home-${{ hashFiles('Cargo.lock') }}
# stream into "Error on ZipFile unknown archive" (observed 2026-07-22, twice). restore-keys: cargo-home-
packages: platform-tools
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
# auto-download it if needed during the build.
# retry.sh: sdkmanager is a single-shot multi-hundred-MB fetch, exactly the class the
# helper exists for (fleet-load packet drops truncate the stream mid-unzip); a failed
# attempt leaves no partial package behind, so a plain re-invoke is safe.
run: bash scripts/ci/retry.sh 4 sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
- name: Caches (cargo + gradle) - name: Cache (gradle)
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.cargo/registry
~/.cargo/git
~/.gradle/caches ~/.gradle/caches
~/.gradle/wrapper ~/.gradle/wrapper
target # gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the
key: android-${{ hashFiles('Cargo.lock', 'clients/android/**/*.gradle.kts') }} # Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise
restore-keys: android- # restore a key that can never hold the new one. Namespace shared with
# android-screenshots.yml — same content, one copy in the central store.
- name: cargo-ndk key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
run: command -v cargo-ndk >/dev/null || cargo install cargo-ndk restore-keys: gradle-
- name: assembleDebug (cargo-ndk → jniLibs → APK) - name: assembleDebug (cargo-ndk → jniLibs → APK)
working-directory: clients/android working-directory: clients/android
+86
View File
@@ -8,13 +8,57 @@
# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated # them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated
# from the build/test job and best-effort, so a capture gap never reds the core signal. # from the build/test job and best-effort, so a capture gap never reds the core signal.
name: apple name: apple
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
branches: [main] branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'clients/apple/**'
- 'scripts/build-xcframework.sh'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/apple.yml'
pull_request: pull_request:
paths:
- 'crates/**'
- 'clients/apple/**'
- 'scripts/build-xcframework.sh'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/apple.yml'
workflow_dispatch: workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift
# side stays on DerivedData (sccache doesn't cache swiftc).
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; the shared cache makes the
# runner's persistent target/ disposable instead of precious.
CARGO_INCREMENTAL: "0"
jobs: jobs:
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared # SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR # with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
@@ -41,6 +85,18 @@ jobs:
dirname "$RUSTUP" >> "$GITHUB_PATH" dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin "$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling # `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a # `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
# system Opus — so the xcframework is self-contained (no runtime libopus.dylib on end-user Macs). # system Opus — so the xcframework is self-contained (no runtime libopus.dylib on end-user Macs).
@@ -99,6 +155,18 @@ jobs:
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \ "$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake. # See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
- name: CMake (for the vendored libopus audiopus_sys builds) - name: CMake (for the vendored libopus audiopus_sys builds)
run: | run: |
@@ -115,6 +183,20 @@ jobs:
# inherits this from the env during the xcframework build). # inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV" echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
- name: Pin + prune DerivedData (same disease release.yml already cures)
# screenshots.sh builds into a throwaway mktemp DerivedData per invocation — two
# fresh ~1 GB trees per run, zero reuse. Pin one stable root (PF_SHOT_DERIVED_DATA,
# honored by the script) so repeat runs are incremental, and GC anything a week old
# in the default DerivedData root that no pin owns.
run: |
DD="$HOME/ci/derived-data/screenshots"
mkdir -p "$DD"
echo "PF_SHOT_DERIVED_DATA=$DD" >> "$GITHUB_ENV"
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
- name: Build PunktfunkCore.xcframework (mac + iOS slices) - name: Build PunktfunkCore.xcframework (mac + iOS slices)
run: BUILD_IOS=1 bash scripts/build-xcframework.sh run: BUILD_IOS=1 bash scripts/build-xcframework.sh
@@ -128,6 +210,10 @@ jobs:
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped" bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
echo "Produced:"; ls -la screenshots || true echo "Produced:"; ls -la screenshots || true
- name: Shut the Simulators down (leaked booted sims once piled up 846 deep)
if: always()
run: xcrun simctl shutdown all || true
- name: Upload screenshots (zip artifact) - name: Upload screenshots (zip artifact)
if: always() if: always()
# v3, not v4: Gitea's artifact backend identifies as GHES, which @actions/artifact v2+ # v3, not v4: Gitea's artifact backend identifies as GHES, which @actions/artifact v2+
+92 -7
View File
@@ -14,10 +14,36 @@
# NOTE: this token + the registry-held private key are the trust root — a token holder can # NOTE: this token + the registry-held private key are the trust root — a token holder can
# publish a validly-signed package (the signature attests "via the registry", not "built by CI"). # publish a validly-signed package (the signature attests "via the registry", not "built by CI").
name: arch name: arch
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
branches: [main] branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'clients/linux/**'
- 'clients/session/**'
- 'clients/shared/**'
- 'clients/cli/**'
- 'web/**'
- 'sdk/**'
- 'packaging/arch/**'
- 'packaging/gamescope/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/arch.yml'
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the # Single project version: a `vX.Y.Z` tag is THE release. main publishes to the
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1), # `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
# tags to `punktfunk` — separate repos, so neither channel can shadow the other. # tags to `punktfunk` — separate repos, so neither channel can shadow the other.
@@ -27,27 +53,41 @@ on:
env: env:
REGISTRY: git.unom.io REGISTRY: git.unom.io
OWNER: unom OWNER: unom
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io). NOTE: makepkg runs
# behind `sudo -u builder env ...`, which strips ambient env — the makepkg step
# re-exports these explicitly.
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
CARGO_INCREMENTAL: "0"
jobs: jobs:
build-publish: build-publish:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
container: container:
image: docker.io/library/archlinux:base-devel # Everything the two pacman steps below used to download (~1 GB/run) is baked in,
# plus bun, sccache and node (ci/arch-ci.Dockerfile). The steps stay as --needed
# no-op guards for the one push where :latest lags an image-content change.
image: 192.168.1.58:5010/punktfunk-arch-ci:latest
timeout-minutes: 90 timeout-minutes: 90
env: env:
CARGO_HOME: /usr/local/cargo CARGO_HOME: /usr/local/cargo
steps: steps:
# git + nodejs must exist before actions/checkout — base-devel ships neither, and # git + nodejs must exist before actions/checkout — base-devel ships neither, and
# act_runner runs the action's JS with the CONTAINER's node, it does not inject one. # act_runner runs the action's JS with the CONTAINER's node, it does not inject one.
- name: Install build + runtime-dev deps - name: Build + runtime-dev deps (no-op guard — baked into arch-ci)
# No -Syu: the image's snapshot IS the build environment (see the Dockerfile's
# rolling-release note); with everything installed this resolves locally and
# does nothing. It only matters on the push that adds a dep before the image
# rebuild lands — same bootstrap note as ci.yml's GTK4 step.
run: | run: |
pacman -Syu --noconfirm --needed \ pacman -S --noconfirm --needed \
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \ git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \ gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive mesa libglvnd unzip libarchive || echo "::warning::pacman guard failed (stale image db?) — proceeding with baked packages"
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so bootstrap
# the official binary.
command -v bun >/dev/null || { command -v bun >/dev/null || {
curl -fsSL https://bun.sh/install | bash curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
@@ -106,16 +146,61 @@ jobs:
sudo -u builder git config --global --add safe.directory "$PWD" sudo -u builder git config --global --add safe.directory "$PWD"
mkdir -p dist && chown builder: dist mkdir -p dist && chown builder: dist
cd packaging/arch cd packaging/arch
# sudo env_reset strips the ambient env, so the sccache wiring must cross the
# boundary explicitly (same values as the workflow env block).
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \ sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \ PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \ CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
RUSTC_WRAPPER="$RUSTC_WRAPPER" CARGO_INCREMENTAL="$CARGO_INCREMENTAL" \
SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \
SCCACHE_REGION="$SCCACHE_REGION" \
AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \
makepkg -f -d --holdver makepkg -f -d --holdver
ls -lh "$GITHUB_WORKSPACE/dist" ls -lh "$GITHUB_WORKSPACE/dist"
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
# completely different dependency set, published into the same repo so `pacman -S
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
#
# CACHED on `packaging/gamescope/**`: it depends on nothing else in this repo, so a normal
# push restores the built package instead of spending ~10 minutes on someone else's C++ tree.
# Arch is rolling, so the cache is invalidated by our own patch changes only — a stale binary
# against newer system libs is the same risk the distro's own package carries between rebuilds.
- uses: actions/cache@v4
id: gamescope
with:
path: dist-gamescope
key: punktfunk-gamescope-arch-${{ hashFiles('packaging/gamescope/**') }}
- name: Build punktfunk-gamescope (makepkg)
if: steps.gamescope.outputs.cache-hit != 'true'
# Best-effort: punktfunk-host works without it (SDR on the gamescope backend), and a
# failure building gamescope must not cost the packages this workflow exists to publish.
run: |
set -x
# Baked into arch-ci — a no-op guard, like the dep step above.
pacman -S --noconfirm --needed \
glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \
libxkbcommon libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \
hwdata luajit pipewire seatd sdl2-compat vulkan-icd-loader wayland \
xcb-util-errors xcb-util-wm xorg-xwayland \
meson cmake glm wayland-protocols benchmark libxcursor || true
mkdir -p dist-gamescope && chown builder: dist-gamescope
chown -R builder: packaging/gamescope
if sudo -u builder env PKGDEST="$GITHUB_WORKSPACE/dist-gamescope" \
bash -c 'cd packaging/gamescope && makepkg -f -d --holdver'; then
ls -lh dist-gamescope
else
echo "::warning::punktfunk-gamescope failed to build — Arch boxes stay SDR on the gamescope backend this run"
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
fi
- name: Publish to the Gitea Arch registry - name: Publish to the Gitea Arch registry
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
# The gamescope companion rides the same loop (same repo, same channel).
cp -f dist-gamescope/*.pkg.tar.zst dist/ 2>/dev/null || true
for pkg in dist/*.pkg.tar.zst; do for pkg in dist/*.pkg.tar.zst; do
echo "uploading $pkg" echo "uploading $pkg"
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p') NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
+115 -13
View File
@@ -1,32 +1,64 @@
# Supply-chain advisory scan for BOTH dependency trees the project ships to users: # Supply-chain advisory scan for EVERY dependency tree the project ships or publishes, plus the
# license-allowlist gate (CRA Annex I Part II: know your components; catch a bad dep the moment
# it lands).
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB. # * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
# * bun audit → the web management console (Nitro/Bun BFF) — the component that holds the login # * bun audit → each Bun-managed tree that ships or publishes: web (the mgmt console BFF —
# gate, session sealing, and the mgmt bearer token, so its deps matter too. # login gate, session sealing, mgmt bearer token), sdk (@punktfunk/host),
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile change (catch a bad # plugin-kit (@punktfunk/plugin-kit).
# dep the moment it lands), and on demand. # * pnpm audit → clients/decky (the Steam Deck plugin).
# * docs-site → scanned NON-blocking (continue-on-error): known transitive advisories ride in
# via the CMS/UI chain (@unom/ui → payload → dompurify/monaco) and the nitropack
# build chain (node-tar, brace-expansion); clearing them needs coordinated bumps
# verified against the LIVE site (the docs don't build standalone) — tracked in
# punktfunk-planning design/cra-readiness.md. Flip to blocking once clean.
# * cargo-about → license-allowlist gate over BOTH Rust workspaces (about.toml `accepted`);
# fails if any crate carries a license outside the allowlist — the regression
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
# nothing scans it — see the CRA roadmap.)
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
# change, and on demand.
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]). # To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
name: audit name: audit
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
schedule: schedule:
- cron: '0 6 * * 1' # Mondays 06:00 UTC - cron: '0 6 * * 1' # Mondays 06:00 UTC
push: push:
branches: [main] branches: [main]
paths: ['Cargo.lock', 'web/bun.lock', '.gitea/workflows/audit.yml'] paths:
- 'Cargo.lock'
- 'packaging/windows/drivers/Cargo.lock'
- 'web/bun.lock'
- 'docs-site/bun.lock'
- 'sdk/bun.lock'
- 'plugin-kit/bun.lock'
- 'clients/decky/pnpm-lock.yaml'
- 'about.toml'
- '.gitea/workflows/audit.yml'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
cargo-audit: cargo-audit:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
container: container:
image: git.unom.io/unom/punktfunk-rust-ci:latest image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Cache /usr/local/cargo so the cargo-audit binary (and the advisory DB clone) persist. # Cache /usr/local/cargo so the cargo-audit binary (and the advisory DB clone) persist.
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: /usr/local/cargo path: |
/usr/local/cargo/bin
/usr/local/cargo/registry
key: cargo-audit-${{ hashFiles('Cargo.lock') }} key: cargo-audit-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-audit- restore-keys: cargo-audit-
- name: cargo audit - name: cargo audit
@@ -36,13 +68,17 @@ jobs:
cargo audit cargo audit
bun-audit: bun-audit:
strategy:
fail-fast: false
matrix:
tree: [web, sdk, plugin-kit]
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
container: container:
image: oven/bun:1 image: oven/bun:1
timeout-minutes: 15 timeout-minutes: 15
defaults: defaults:
run: run:
working-directory: web working-directory: ${{ matrix.tree }}
steps: steps:
# oven/bun's slim base lacks a CA bundle + git — actions/checkout's HTTPS fetch needs them # oven/bun's slim base lacks a CA bundle + git — actions/checkout's HTTPS fetch needs them
# (same preamble as web-screenshots.yml / ci.yml's web job). # (same preamble as web-screenshots.yml / ci.yml's web job).
@@ -50,9 +86,75 @@ jobs:
working-directory: / working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# `bun audit` queries the registry advisory DB for the versions pinned in web/bun.lock. No # `bun audit` queries the registry advisory DB for the versions pinned in the tree's
# install/build needed — it reads the manifest + lockfile. Fails the job on any advisory, the # bun.lock. No install/build needed — it reads the manifest + lockfile. Fails the job on any
# same fail-on-vulnerability stance as cargo-audit above; triage a finding by bumping the dep # advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
# (or, if genuinely unfixable + inapplicable, pinning a resolution and noting why here). # bumping the dep (or, if genuinely unfixable + inapplicable, pinning a resolution and
# noting why here).
- name: bun audit - name: bun audit
run: bun audit run: bun audit
# Kept OUT of the bun-audit matrix so this tree's known-advisory state can't normalize failure
# in a shipping tree. Non-blocking via a step-level `||` (NOT job-level continue-on-error, which
# act_runner does not reliably honor — a red job here would take the whole run red). The full
# advisory list still lands in the log; the warning marks it wasn't clean.
docs-site-audit:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
defaults:
run:
working-directory: docs-site
steps:
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
- name: bun audit (non-blocking)
run: bun audit || echo "::warning::docs-site has known advisories (CMS/UI + nitropack chains) — tracked in punktfunk-planning design/cra-readiness.md"
pnpm-audit:
runs-on: ubuntu-24.04
container:
image: node:22-bookworm
timeout-minutes: 15
defaults:
run:
working-directory: clients/decky
steps:
- uses: actions/checkout@v4
# decky is pnpm-managed (pnpm-lock.yaml lockfileVersion 9.0 → pnpm 10 reads it). Like
# bun audit, `pnpm audit` needs no install/build — lockfile + registry advisory DB only.
# --prod: rollup bundles only the prod deps into the shipped plugin; devDependencies are
# build tooling that never leaves CI (auditing them fails on toolchain advisories that
# can't reach a user — the docs-site problem in miniature).
- name: pnpm audit
run: |
npm install -g pnpm@10
pnpm audit --prod
# The regression guard about.toml documents: fail if any crate in either Rust workspace carries
# a license outside the `accepted` allowlist (e.g. a copyleft dep silently entering the linked
# set). cargo-about is version-pinned: the config uses the per-crate `accepted` syntax
# validated against exactly this version.
license-gate:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/bin
/usr/local/cargo/registry
key: cargo-about-0.9.1
restore-keys: cargo-about-
- name: cargo about license gate (host + driver workspaces)
run: |
git config --global --add safe.directory "$PWD"
command -v cargo-about >/dev/null 2>&1 || cargo install --locked cargo-about --version 0.9.1 --features cli
cargo about generate about.hbs --fail -o /dev/null
cargo about generate -m packaging/windows/drivers/Cargo.toml -c about.toml about.hbs --fail -o /dev/null
+59
View File
@@ -0,0 +1,59 @@
# Report-only CPU benchmarks, moved out of ci.yml: they never fail the build (shared CI
# hardware is too noisy to gate on), so running them per-push only occupied a fleet slot
# during fan-out storms. Nightly + on demand is exactly as much signal at none of the
# queue cost. The tight regression gate + the real encode/stream path live on the
# self-hosted GPU runner (Tier 3, bench-gpu.yml).
name: bench
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
schedule:
- cron: '30 4 * * *'
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
bench:
# Tier-1 (criterion microbenchmarks) + Tier-2 (FEC loss recovery) — GPU-free, so they run here.
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
- name: Prep
run: |
git config --global --add safe.directory "$PWD"
command -v python3 >/dev/null || { apt-get update && apt-get install -y --no-install-recommends python3; }
- name: Tier-1 microbenchmarks (criterion)
run: cargo bench -p punktfunk-core --bench pipeline -- --warm-up-time 1 --measurement-time 3
- name: Tier-2 FEC loss recovery (loss-harness)
run: cargo run -q -p loss-harness
- name: Compare vs baseline (report-only)
run: python3 scripts/bench/compare.py --threshold 0.5
+77 -27
View File
@@ -1,23 +1,58 @@
# CI for punktfunk (Gitea Actions). Linux jobs run on the `ubuntu-latest` runner; the Rust # CI for punktfunk (Gitea Actions). Linux jobs run on the `ubuntu-24.04` fleet label; the
# job runs inside the prebuilt builder image (ci/rust-ci.Dockerfile — system FFmpeg 8, # Rust job runs inside the prebuilt builder image (ci/rust-ci.Dockerfile — system FFmpeg 8,
# PipeWire, GL/GBM, libcuda link stub, pinned-channel rustup) so the workspace links the # PipeWire, GL/GBM, libcuda link stub, pinned-channel rustup) so the workspace links the
# same libs as the dev boxes. Apple client CI lives in apple.yml (macOS runner). # same libs as the dev boxes. Builder images come from the LAN registry on home-ci-core
# (content-keyed, docker.yml) — never the WAN. Apple client CI lives in apple.yml (macOS
# runner). The report-only benchmarks moved to bench.yml (nightly + dispatch) so they stop
# occupying a fleet slot on every push.
name: ci name: ci
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
branches: [main] branches: [main]
pull_request: pull_request:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs: jobs:
rust: rust:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
container: container:
image: git.unom.io/unom/punktfunk-rust-ci:latest image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 90 timeout-minutes: 90
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
# punktfunk-client-linux link deps. Also baked into rust-ci.Dockerfile — but ci.yml # punktfunk-client-linux link deps. Also baked into rust-ci.Dockerfile — but ci.yml
# runs against the image from the PREVIOUS push (docker.yml bootstrap note), so this # runs against the image from the PREVIOUS push (docker.yml bootstrap note), so this
# keeps the job green across image-content changes; a no-op once the image has them. # keeps the job green across image-content changes; a no-op once the image has them.
@@ -26,6 +61,30 @@ jobs:
apt-get update apt-get update
apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libsdl3-dev apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libsdl3-dev
# The committed pf-zerocopy SPIR-V blobs are pulled in with include_bytes! and rebuilt only
# by hand — edit a .comp, forget the rebuild, and the OLD kernel ships with no compile error
# or failing test. Recompile each shader and diff the disassembly. Filtering OpSourceExtension
# (+ --no-header) is exactly what absorbs the shaderc-vs-glslang generator difference; every
# instruction, ID and constant must match.
#
# Disassemble to FILES rather than `diff <(…) <(…)`: Gitea's runner executes a step's `run:`
# under `sh -e`, not bash, and dash has no process substitution — the shell rejected the
# script at PARSE time, so the gate never compared anything. Worse, it took the whole `rust`
# job with it: Format, Clippy, Build, Test and every gate below were skipped on each of the
# 35 commits between the gate landing (143a707f) and this fix. A gate that cannot run is
# indistinguishable from one that passes, which is exactly the failure it exists to prevent.
- name: Shader SPIR-V drift gate (pf-zerocopy)
run: |
apt-get install -y --no-install-recommends glslang-tools spirv-tools
for s in rgb2nv12_buf cursor_blend; do
d=crates/pf-zerocopy/src/imp
glslangValidator -V "$d/$s.comp" -o "/tmp/$s.spv" >/dev/null
spirv-dis --no-header "$d/$s.spv" | grep -v OpSourceExtension > "/tmp/$s.committed"
spirv-dis --no-header "/tmp/$s.spv" | grep -v OpSourceExtension > "/tmp/$s.rebuilt"
diff "/tmp/$s.committed" "/tmp/$s.rebuilt" \
|| { echo "::error::$d/$s.spv is stale — rebuild it from $s.comp"; exit 1; }
done
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock: # Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
# registry/git are download caches, target/ the incremental build. The target key # registry/git are download caches, target/ the incremental build. The target key
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed # carries the rustc version — resolved via `rustc --version` (below) rather than parsed
@@ -101,6 +160,9 @@ jobs:
- name: C ABI harness (standalone link proof) - name: C ABI harness (standalone link proof)
run: bash crates/punktfunk-core/tests/c/run.sh run: bash crates/punktfunk-core/tests/c/run.sh
- name: sccache stats (visibility only)
run: sccache --show-stats
- name: Verify generated header is committed & up to date - name: Verify generated header is committed & up to date
run: | run: |
cargo build -p punktfunk-core --locked cargo build -p punktfunk-core --locked
@@ -120,11 +182,21 @@ jobs:
rust-arm64: rust-arm64:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
container: container:
image: git.unom.io/unom/punktfunk-rust-ci-arm64cross:latest image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
- name: Cache keys - name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV" run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4 - uses: actions/cache@v4
@@ -203,25 +275,3 @@ jobs:
run: bun run build run: bun run build
- name: Typecheck - name: Typecheck
run: bun run lint run: bun run lint
bench:
# Tier-1 (criterion microbenchmarks) + Tier-2 (FEC loss recovery) — GPU-free, so they run here.
# Report-only: prints the numbers + a diff vs the committed baseline to the job summary and never
# fails the build (shared CI hardware is too noisy to gate on). The tight regression gate + the
# real encode/stream path live on the self-hosted GPU runner (Tier 3, bench-gpu.yml).
runs-on: ubuntu-24.04
container:
image: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Prep
run: |
git config --global --add safe.directory "$PWD"
command -v python3 >/dev/null || { apt-get update && apt-get install -y --no-install-recommends python3; }
- name: Tier-1 microbenchmarks (criterion)
run: cargo bench -p punktfunk-core --bench pipeline -- --warm-up-time 1 --measurement-time 3
- name: Tier-2 FEC loss recovery (loss-harness)
run: cargo run -q -p loss-harness
- name: Compare vs baseline (report-only)
run: python3 scripts/bench/compare.py --threshold 0.5
+88 -11
View File
@@ -23,10 +23,36 @@
# #
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml). # REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
name: deb name: deb
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
branches: [main] branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'clients/linux/**'
- 'clients/session/**'
- 'clients/shared/**'
- 'clients/cli/**'
- 'web/**'
- 'sdk/**'
- 'packaging/debian/**'
- 'packaging/linux/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/deb.yml'
# Single project version: a `vX.Y.Z` tag is THE release for every platform (see # Single project version: a `vX.Y.Z` tag is THE release for every platform (see
# docs-site channels.md). The old version-shadow (a client tag shipping a host package # docs-site channels.md). The old version-shadow (a client tag shipping a host package
# that outranked rolling builds) is now structurally impossible — main publishes to the # that outranked rolling builds) is now structurally impossible — main publishes to the
@@ -38,16 +64,35 @@ env:
REGISTRY: git.unom.io REGISTRY: git.unom.io
OWNER: unom OWNER: unom
COMPONENT: main COMPONENT: main
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs: jobs:
build-publish: build-publish:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
container: container:
image: git.unom.io/unom/punktfunk-rust-ci:latest image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 90 timeout-minutes: 90
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
- name: Version + channel - name: Version + channel
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release). # vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
# A main push -> <next-minor>~ciN.g<sha>, published to the `canary` distribution: the '~' sorts # A main push -> <next-minor>~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
@@ -102,10 +147,15 @@ jobs:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs) PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
run: | run: |
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect — # THREE binaries ship in the client .deb, so all three are built here: the GTK shell,
# both client binaries must ship (build-client-deb.sh installs both). The HOST is built # punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect), and
# separately in the build-publish-host job (Ubuntu 24.04 image + bundled FFmpeg 8). # punktfunk-cli (the headless `punktfunk` front-end). build-client-deb.sh installs all
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session # three; leaving punktfunk-cli out here made it fall over on `install: No such file or
# directory`, because its build-if-missing guard only tested the first two and so decided
# everything was already built. The HOST is built separately in the build-publish-host
# job (Ubuntu 24.04 image + bundled FFmpeg 8).
cargo build --release --locked \
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli
- name: Build + smoke-boot web console (bun preset) - name: Build + smoke-boot web console (bun preset)
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset # Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
@@ -184,11 +234,21 @@ jobs:
build-publish-host: build-publish-host:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
container: container:
image: git.unom.io/unom/punktfunk-rust-ci-noble:latest image: 192.168.1.58:5010/punktfunk-rust-ci-noble:latest
timeout-minutes: 90 timeout-minutes: 90
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
- name: Version + channel - name: Version + channel
run: | run: |
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
@@ -234,11 +294,18 @@ jobs:
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA; # Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode # NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host # (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). ffmpeg-sys-next links the image's
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8 # bundled FFmpeg 8 via PKG_CONFIG_PATH (set in rust-ci-noble).
# via PKG_CONFIG_PATH (set in rust-ci-noble). #
# punktfunk-tray is deliberately NOT in this invocation — build-deb.sh builds it separately,
# and that split is load-bearing (see the identical note in the RPM spec / Arch PKGBUILD):
# cargo unifies features across one build, so co-building the tray with the host pulls the
# host's ashpd -> zbus/tokio onto the tray's shared zbus and the tray panics at every launch
# with "there is no reactor running, must be called from the context of a Tokio 1.x runtime".
# It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and
# Arch packages — which already split it — were fine.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \ cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-tray -p punktfunk-host
- name: Build host .deb (FFmpeg bundled) - name: Build host .deb (FFmpeg bundled)
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the # BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
@@ -284,11 +351,21 @@ jobs:
build-publish-client-arm64: build-publish-client-arm64:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
container: container:
image: git.unom.io/unom/punktfunk-rust-ci-arm64cross:latest image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
timeout-minutes: 90 timeout-minutes: 90
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
# Byte-identical to build-publish's version step (pf-version.sh is deterministic per # Byte-identical to build-publish's version step (pf-version.sh is deterministic per
# commit), so the arm64 package always shares the amd64 version line. # commit), so the arm64 package always shares the amd64 version line.
- name: Version + channel - name: Version + channel
+15
View File
@@ -20,10 +20,25 @@
# #
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker). # REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker).
name: decky name: decky
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
branches: [main] branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'clients/decky/**'
- 'scripts/ci/**'
- '.gitea/workflows/decky.yml'
tags: ['v*'] tags: ['v*']
workflow_dispatch: workflow_dispatch:
+175 -66
View File
@@ -1,18 +1,37 @@
# Build + push the dockerized pieces to the Gitea container registry: # Build + push the dockerized pieces.
# punktfunk-web — management console (web/Dockerfile, repo-root context) #
# punktfunk-docs — documentation site (docs-site/Dockerfile) # Two very different image families now:
# punktfunk-rust-ci — Rust CI builder image consumed by ci.yml #
# punktfunk-rust-ci-arm64cross — the above + an arm64 sysroot, for the aarch64 client legs # BUILDER images (punktfunk-rust-ci{,-noble,-arm64cross}, punktfunk-fedora{,44}-rpm)
# punktfunk-fedora-rpm — Fedora 43 builder image consumed by rpm.yml (Bazzite RPM) # live on the LAN registry (home-ci-core, 192.168.1.58:5010 — unom/infra
# runners/ci-core/) and are CONTENT-KEYED: the tag is a hash of what they are built
# from (the ci/ tree, + rust-toolchain.toml for the cross image), and a build only
# happens when that key has no manifest yet. A push that doesn't touch ci/ costs one
# curl per image (~seconds), pushes nothing over the WAN, and mints no per-SHA tag
# debris on the runners — the failure mode that filled the fleet's disks. `:latest`
# is re-pushed alongside every new key and is what the consuming workflows pin.
#
# APP images (punktfunk-web, punktfunk-docs) are deployables: they keep going to the
# Gitea registry (git.unom.io) with :latest + :sha-<8> (+ :vX.Y.Z on tags), because
# unom-1 deploys pull from there and releases pin them.
#
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left"). # Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
# #
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope. # REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
# the LAN registry is unauthenticated inside the LAN).
# #
# Bootstrap note: ci.yml's rust job pulls punktfunk-rust-ci:latest from the registry, so # Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
# this workflow (or a manual push) must have succeeded once before that job can run; on # registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
# the same push, ci.yml builds against the PREVIOUS image. All three were seeded manually # images); after that, this workflow keeps :latest current whenever ci/ changes.
# on 2026-06-12.
name: docker name: docker
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
@@ -23,9 +42,151 @@ on:
env: env:
REGISTRY: git.unom.io REGISTRY: git.unom.io
OWNER: unom OWNER: unom
CI_REGISTRY: 192.168.1.58:5010
jobs: jobs:
build-push: builders:
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
matrix:
include:
- image: punktfunk-rust-ci
dockerfile: ci/rust-ci.Dockerfile
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
- image: punktfunk-rust-ci-noble
dockerfile: ci/rust-ci-noble.Dockerfile
- image: punktfunk-fedora-rpm
dockerfile: ci/fedora-rpm.Dockerfile
# Fedora 44 builder (Fedora KDE spin): same Dockerfile, newer base → libavcodec.so.62.
- image: punktfunk-fedora44-rpm
dockerfile: ci/fedora-rpm.Dockerfile
buildargs: --build-arg FEDORA_VERSION=44
keysuffix: -f44
# Android builder (JDK + SDK/NDK + cargo-ndk + sccache) — android.yml and
# android-screenshots.yml run in it; ~3 GB of per-run Google downloads became
# image layers.
- image: punktfunk-android-ci
dockerfile: ci/android-ci.Dockerfile
# Arch builder (base-devel + both makepkg legs' deps + bun + sccache) —
# arch.yml runs in it; ~1 GB of per-run pacman traffic became image layers.
- image: punktfunk-arch-ci
dockerfile: ci/arch-ci.Dockerfile
steps:
- uses: actions/checkout@v4
# The key is the git TREE HASH of ci/ — every byte any of these Dockerfiles can see
# (they all use ci/ as build context). One key for the whole family on purpose: a
# change to any of them re-keys all four, and a spurious rebuild of a sibling is
# cheap, rare, and infinitely better than a stale one.
- name: Content key
run: |
git config --global --add safe.directory "$PWD"
echo "KEY=ck-$(git rev-parse HEAD:ci | cut -c1-12)${{ matrix.keysuffix }}" >> "$GITHUB_ENV"
- name: Check whether this key already exists
id: exists
run: |
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
if curl -sf -o /dev/null -H "$ACCEPT" \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"; then
echo "hit=true" >> "$GITHUB_OUTPUT"
echo "::notice::${{ matrix.image }}:$KEY already in the LAN registry — nothing to build"
else
echo "hit=false" >> "$GITHUB_OUTPUT"
fi
- name: Build
if: steps.exists.outputs.hit == 'false'
# --pull is cheap now: base images come through the ci-core pull-through mirror.
run: |
docker build --pull ${{ matrix.buildargs }} \
-f "${{ matrix.dockerfile }}" \
-t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY/${{ matrix.image }}:latest" \
ci
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
# A release pins reproducible builder images without any rebuild: copy the key's
# manifest to a vX.Y.Z tag via the registry API (no image bytes move).
- name: Tag for release
if: startsWith(github.ref, 'refs/tags/v')
run: |
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
MT=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY" \
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
# (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed
# by the arm64 client legs in ci.yml/deb.yml. Its key also folds in rust-toolchain.toml:
# the Dockerfile installs the aarch64 target against the toolchain the workspace pins.
builders-arm64cross:
runs-on: ubuntu-24.04
needs: builders
timeout-minutes: 60
env:
IMAGE: punktfunk-rust-ci-arm64cross
steps:
- uses: actions/checkout@v4
- name: Content key
run: |
git config --global --add safe.directory "$PWD"
echo "KEY=ck-$(printf '%s%s' "$(git rev-parse HEAD:ci)" "$(git rev-parse HEAD:rust-toolchain.toml)" | sha256sum | cut -c1-12)" >> "$GITHUB_ENV"
- name: Check whether this key already exists
id: exists
run: |
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
if curl -sf -o /dev/null -H "$ACCEPT" \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"; then
echo "hit=true" >> "$GITHUB_OUTPUT"
echo "::notice::$IMAGE:$KEY already in the LAN registry — nothing to build"
else
echo "hit=false" >> "$GITHUB_OUTPUT"
fi
- name: Build
if: steps.exists.outputs.hit == 'false'
# Root context: it needs rust-toolchain.toml to install the target against the
# toolchain the workspace actually pins.
run: |
docker build --pull \
-f ci/rust-ci-arm64cross.Dockerfile \
-t "$CI_REGISTRY/$IMAGE:$KEY" \
-t "$CI_REGISTRY/$IMAGE:latest" \
.
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY/$IMAGE:$KEY"
docker push "$CI_REGISTRY/$IMAGE:latest"
- name: Tag for release
if: startsWith(github.ref, 'refs/tags/v')
run: |
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
MT=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
apps:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 45 timeout-minutes: 45
strategy: strategy:
@@ -37,23 +198,6 @@ jobs:
- image: punktfunk-docs - image: punktfunk-docs
dockerfile: docs-site/Dockerfile dockerfile: docs-site/Dockerfile
context: docs-site context: docs-site
- image: punktfunk-rust-ci
dockerfile: ci/rust-ci.Dockerfile
context: ci
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
- image: punktfunk-rust-ci-noble
dockerfile: ci/rust-ci-noble.Dockerfile
context: ci
- image: punktfunk-fedora-rpm
dockerfile: ci/fedora-rpm.Dockerfile
context: ci
# Fedora 44 builder (Fedora KDE spin): same Dockerfile, newer base → libavcodec.so.62.
- image: punktfunk-fedora44-rpm
dockerfile: ci/fedora-rpm.Dockerfile
context: ci
buildargs: --build-arg FEDORA_VERSION=44
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -68,7 +212,7 @@ jobs:
# On a release tag, also tag the image vX.Y.Z so a release pins reproducible web/docs images. # On a release tag, also tag the image vX.Y.Z so a release pins reproducible web/docs images.
EXTRA="" EXTRA=""
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
docker build --pull ${{ matrix.buildargs }} \ docker build --pull \
-f "${{ matrix.dockerfile }}" \ -f "${{ matrix.dockerfile }}" \
-t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \ -t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \
-t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \ -t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \
@@ -81,48 +225,13 @@ jobs:
docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest" docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest"
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
# and so must not race the matrix entry that publishes that base. Consumed by the arm64
# client legs in deb.yml/rpm.yml/arch.yml. Root context: it needs rust-toolchain.toml to
# install the target against the toolchain the workspace actually pins.
build-push-arm64cross:
runs-on: ubuntu-24.04
needs: build-push
timeout-minutes: 45
env:
IMAGE: punktfunk-rust-ci-arm64cross
steps:
- uses: actions/checkout@v4
- name: Login to registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" \
| docker login "$REGISTRY" -u enricobuehler --password-stdin
- name: Build
run: |
EXTRA=""
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/$IMAGE:${GITHUB_REF_NAME}" ;; esac
docker build --pull \
-f ci/rust-ci-arm64cross.Dockerfile \
-t "$REGISTRY/$OWNER/$IMAGE:latest" \
-t "$REGISTRY/$OWNER/$IMAGE:sha-${GITHUB_SHA::8}" \
$EXTRA \
.
- name: Push
run: |
docker push "$REGISTRY/$OWNER/$IMAGE:sha-${GITHUB_SHA::8}"
docker push "$REGISTRY/$OWNER/$IMAGE:latest"
case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/$IMAGE:${GITHUB_REF_NAME}" ;; esac
# Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to # Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to
# (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set # (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set
# as unom/website's deploy: DEPLOY_HOST/DEPLOY_USER/DEPLOY_PORT/DEPLOY_SSH_KEY (the # as unom/website's deploy: DEPLOY_HOST/DEPLOY_USER/DEPLOY_PORT/DEPLOY_SSH_KEY (the
# unom-ci-deploy key). # unom-ci-deploy key).
deploy-docs: deploy-docs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: build-push needs: apps
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+27
View File
@@ -19,6 +19,14 @@
# #
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker). # REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker).
name: flatpak name: flatpak
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
@@ -127,6 +135,25 @@ jobs:
https://dl.flathub.org/repo/flathub.flatpakrepo https://dl.flathub.org/repo/flathub.flatpakrepo
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
# This job was the fleet's single heaviest network consumer: every run re-downloaded
# the GNOME runtime + SDK + llvm/rust/ffmpeg extensions (multi-GB from Flathub) and
# every crate source. Both live in well-defined directories, both are idempotently
# verified/extended by the steps below, and the central cache server restores them
# at LAN speed — so cache them. Keyed on what actually pins them: the manifest tree
# (runtimes/extensions) and manifest+Cargo.lock (crate sources + builder state).
- name: Cache Flathub runtimes
uses: actions/cache@v4
with:
path: ~/.local/share/flatpak
key: flatpak-runtimes-${{ hashFiles('packaging/flatpak/**') }}
restore-keys: flatpak-runtimes-
- name: Cache flatpak-builder state (crate sources, ccache)
uses: actions/cache@v4
with:
path: .flatpak-builder
key: flatpak-builder-state-${{ hashFiles('Cargo.lock', 'packaging/flatpak/**') }}
restore-keys: flatpak-builder-state-
- name: Version + channel - name: Version + channel
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push -> # Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
# <next-minor>-ciN.g<sha> on the `canary` branch (base one minor ahead of the latest stable # <next-minor>-ciN.g<sha> on the `canary` branch (base one minor ahead of the latest stable
+33 -1
View File
@@ -5,23 +5,55 @@
# Standalone + best-effort: a failure here reds nothing else. PNGs land as a 30-day # Standalone + best-effort: a failure here reds nothing else. PNGs land as a 30-day
# artifact; they are not committed or published. # artifact; they are not committed or published.
name: linux-client-screenshots name: linux-client-screenshots
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
tags: ["v*"] tags: ["v*"]
workflow_dispatch: workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs: jobs:
screenshots: screenshots:
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
# Same image as ci.yml/deb.yml — already carries the Rust toolchain + GTK/SDL build deps. # Same image as ci.yml/deb.yml — already carries the Rust toolchain + GTK/SDL build deps.
container: container:
image: git.unom.io/unom/punktfunk-rust-ci:latest image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 90 timeout-minutes: 90
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
# Client link deps (baked into the image; kept here so the job is green across image # Client link deps (baked into the image; kept here so the job is green across image
# rebuilds — a no-op once present) PLUS the headless-render extras: a virtual X server, # rebuilds — a no-op once present) PLUS the headless-render extras: a virtual X server,
# software GL+Vulkan (llvmpipe/lavapipe), the icon theme + fonts the UI draws with, and a # software GL+Vulkan (llvmpipe/lavapipe), the icon theme + fonts the UI draws with, and a
+8
View File
@@ -9,6 +9,14 @@
# #
# Auth: REGISTRY_TOKEN — the same repo Actions secret sdk-publish.yml uses. # Auth: REGISTRY_TOKEN — the same repo Actions secret sdk-publish.yml uses.
name: plugin-kit-publish name: plugin-kit-publish
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
+35
View File
@@ -56,6 +56,14 @@
# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a # picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a
# loud warning. # loud warning.
name: release name: release
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
@@ -80,6 +88,21 @@ on:
required: false required: false
default: "true" default: "true"
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift
# side stays on DerivedData (sccache doesn't cache swiftc).
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; the shared cache makes the
# runner's persistent target/ disposable instead of precious.
CARGO_INCREMENTAL: "0"
jobs: jobs:
apple: apple:
runs-on: macos-arm64 runs-on: macos-arm64
@@ -149,6 +172,18 @@ jobs:
# inherits this from the env during the xcframework build). # inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV" echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
- name: Pin + prune Xcode DerivedData - name: Pin + prune Xcode DerivedData
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the # Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace # PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
+115 -6
View File
@@ -9,10 +9,33 @@
# #
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml). # REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
name: rpm name: rpm
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
branches: [main] branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'web/**'
- 'sdk/**'
- 'packaging/rpm/**'
- 'packaging/gamescope/**'
- 'packaging/bazzite/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/rpm.yml'
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm # Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
# groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old # groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
# version-shadow (a release outranking rolling builds in one group) is structurally gone. # version-shadow (a release outranking rolling builds in one group) is structurally gone.
@@ -22,6 +45,15 @@ on:
env: env:
REGISTRY: git.unom.io REGISTRY: git.unom.io
OWNER: unom OWNER: unom
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs: jobs:
build-publish: build-publish:
@@ -40,13 +72,23 @@ jobs:
group: fedora-44 group: fedora-44
fedver: 44 fedver: 44
container: container:
image: git.unom.io/unom/${{ matrix.image }}:latest image: 192.168.1.58:5010/${{ matrix.image }}:latest
timeout-minutes: 90 timeout-minutes: 90
env: env:
CARGO_HOME: /usr/local/cargo CARGO_HOME: /usr/local/cargo
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
# rpmbuild + git archive need the checkout trusted; cache the crates download. # rpmbuild + git archive need the checkout trusted; cache the crates download.
# The client link deps are also baked into the fedora-rpm image, but this job runs # The client link deps are also baked into the fedora-rpm image, but this job runs
# against the image from the PREVIOUS push (docker.yml bootstrap note) — keep it # against the image from the PREVIOUS push (docker.yml bootstrap note) — keep it
@@ -59,6 +101,11 @@ jobs:
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling. # sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
# sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and
# on a cache hit (the common case) nothing else in this job would have pulled libavif /
# luajit / seatd / SDL2 in. Cheap, and it tracks gamescope's dep list for us.
dnf -y install gamescope || true
# bun builds the punktfunk-web console (--with web). Baked into the image; install it # bun builds the punktfunk-web console (--with web). Baked into the image; install it
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note). # here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
command -v bun >/dev/null || { command -v bun >/dev/null || {
@@ -70,8 +117,8 @@ jobs:
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: /usr/local/cargo/registry path: /usr/local/cargo/registry
key: cargo-home-${{ hashFiles('Cargo.lock') }} key: cargo-home-fedora-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home- restore-keys: cargo-home-fedora-
- name: Version + channel - name: Version + channel
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> <next-minor>-0.ciN.g<sha> # vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> <next-minor>-0.ciN.g<sha>
@@ -97,7 +144,9 @@ jobs:
# Recommends both). Both need bun (ensured in Prep). # Recommends both). Both need bun (ensured in Prep).
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
- name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md) # Signs with packages@unom.io (org secret) and self-verifies before publish. On a v* tag a
# missing key FAILS the build rather than publishing unsigned RPMs into a gpgcheck=1 repo.
- name: Sign RPMs
env: env:
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }} RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }} RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
@@ -124,27 +173,87 @@ jobs:
done done
echo "published to $OWNER/rpm/$GROUP" echo "published to $OWNER/rpm/$GROUP"
# The HDR-capable gamescope the sysext carries (packaging/gamescope) — what lets the
# gamescope backend stream 10-bit BT.2020 PQ instead of 8-bit SDR.
#
# CACHED, and that is the whole reason this is affordable: it is a ~10-minute C++ meson build
# of an entirely separate tree that depends on NOTHING in this repo except
# `packaging/gamescope/**` (the patches and the upstream pin, which lives in the build
# script). So the key is that directory's hash and a normal push restores a binary instead of
# building one. Per-Fedora-major, because the binary is soname-coupled to its base exactly
# like the RPM is — an f43 build does not start on f44 (libavutil.so.59 vs .60).
- uses: actions/cache@v4
id: gamescope
with:
path: gs-cache
key: punktfunk-gamescope-f${{ matrix.fedver }}-${{ hashFiles('packaging/gamescope/**') }}
- name: Build the HDR gamescope
if: steps.gamescope.outputs.cache-hit != 'true'
# Best-effort ON PURPOSE. The sysext is the primary Bazzite delivery path and works without
# this binary (the host just stays SDR on the gamescope backend, which is what every
# release before this one did) — so a hiccup building someone else's tree must not cost the
# whole image. It is loud, though: the warning below, and `--gamescope` silently absent
# downstream is impossible because build-sysext.sh verifies the +pfhdr marker itself.
run: |
set -x
# `dnf builddep` resolves Fedora's PACKAGED gamescope, which is older than the master we
# pin, so it can come up short — xorg-x11-server-Xwayland-devel is the one that actually
# bites (wlroots' configure dies on a missing xserver.wrap several minutes in).
dnf -y install dnf-plugins-core meson ninja-build glslc || true
dnf builddep -y gamescope || true
dnf -y install xorg-x11-server-Xwayland-devel || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext # The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that # image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree. # `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
- name: Build the sysext image - name: Build the sysext image
run: | run: |
# Execute it here rather than only handing it over: build-sysext.sh treats an unusable
# --version as fatal (rightly — it is how the +pfhdr marker is read), and a cached binary
# whose runtime libs are missing from this container must cost the image its HDR, not the
# image itself.
gs=()
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
gs=(--gamescope gs-cache/punktfunk-gamescope)
echo "folding in $(gs-cache/punktfunk-gamescope --version 2>&1 | head -1)"
else
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \ bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \ --out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
"${gs[@]}" \
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \ dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \ dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
# The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and
# punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved
# anything, sitting on the same registry as the images they describe.
- name: Publish the sysext feed - name: Publish the sysext feed
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
run: | run: |
case "$GROUP" in case "$GROUP" in
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up *-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6; OTHER="f${{ matrix.fedver }}" ;;
*) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release *) FEED="f${{ matrix.fedver }}"; KEEP=0; OTHER="f${{ matrix.fedver }}-canary" ;;
esac esac
KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \ KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
# Re-seal this Fedora major's OTHER channel too. Stable feeds only publish on a tag, so
# without this a stable box would sit in front of an unsigned (hence refused) feed until
# the next release; canary pushes are frequent, so every live feed gets sealed within a
# day of this landing, and a key rotation propagates without rebuilding any image.
# Best-effort: a channel that has never published yet has no manifest to seal.
bash packaging/bazzite/publish-sysext-feed.sh --seal "$OTHER" \
|| echo "::warning::could not seal the $OTHER feed (no manifest yet?)"
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases # On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep # (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
+68
View File
@@ -0,0 +1,68 @@
# Per-release SBOM (CRA Annex I Part II §1: identify and document the components in the product,
# in a commonly used machine-readable format — we emit CycloneDX JSON).
#
# Tag push → the SBOM is attached to the Gitea release, next to the artifacts it describes.
# Release assets are never pruned (security updates must stay available ≥10 years, CRA Art. 13),
# so the SBOM's retention rides on the release's.
# workflow_dispatch on a non-tag ref → generated and uploaded as a workflow artifact only
# (pipeline validation / an on-demand snapshot); no release is touched.
#
# What goes in: scripts/ci/gen-sbom.sh = syft over the checkout (every lockfile-pinned dep in
# both Rust workspaces + the JS trees + Swift Package.resolved) merged with
# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, VB-CABLE, gamescope).
name: sbom
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
tags: ['v*']
workflow_dispatch:
jobs:
sbom:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 20
steps:
# fetch-depth 0: the dispatch path derives the canary base from the tag history
# (scripts/ci/pf-version.sh), which a shallow clone cannot see.
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
- name: Install syft
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin v1.49.0
- name: Generate SBOM
run: |
git config --global --add safe.directory "$PWD"
case "$GITHUB_REF" in
refs/tags/v*) VERSION="${GITHUB_REF_NAME#v}" ;;
*) eval "$(bash scripts/ci/pf-version.sh)"; VERSION="${PF_BASE}-snapshot" ;;
esac
sh scripts/ci/gen-sbom.sh "$VERSION" "punktfunk-${VERSION}.cdx.json"
echo "SBOM_FILE=punktfunk-${VERSION}.cdx.json" >> "$GITHUB_ENV"
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$SBOM_FILE"
# v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see release.yml).
- name: Upload artifact (non-tag runs)
if: "!startsWith(github.ref, 'refs/tags/')"
uses: actions/upload-artifact@v3
with:
name: sbom
path: punktfunk-*.cdx.json
+8
View File
@@ -7,6 +7,14 @@
# Auth: REGISTRY_TOKEN — the same repo Actions secret docker.yml uses (a Gitea PAT with # Auth: REGISTRY_TOKEN — the same repo Actions secret docker.yml uses (a Gitea PAT with
# write:package scope). No new secret needed. # write:package scope). No new secret needed.
name: sdk-publish name: sdk-publish
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
+8
View File
@@ -6,6 +6,14 @@
# host packaging). Best-effort: a standalone workflow, so a failure here reds # host packaging). Best-effort: a standalone workflow, so a failure here reds
# nothing else. PNGs land as a 30-day artifact; they are not committed or published. # nothing else. PNGs land as a 30-day artifact; they are not committed or published.
name: web-screenshots name: web-screenshots
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
+10 -2
View File
@@ -11,6 +11,14 @@
# shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the # shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the
# first GITHUB_ENV line — see windows.yml). # first GITHUB_ENV line — see windows.yml).
name: windows-drivers name: windows-drivers
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
workflow_dispatch: workflow_dispatch:
@@ -153,9 +161,9 @@ jobs:
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` + # `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a # `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
# toolchain-only probe crate and is excluded.) # toolchain-only probe crate and is excluded.)
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers - name: cargo fmt --check the safe-layer + gamepad/mouse drivers
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build - name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
run: | run: |
# explicit --target (.cargo/config.toml) -> output under the triple subdir. # explicit --target (.cargo/config.toml) -> output under the triple subdir.
+62 -7
View File
@@ -22,7 +22,9 @@
# #
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them # Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
# an ephemeral self-signed cert is generated and its public .cer published next to the installer # an ephemeral self-signed cert is generated and its public .cer published next to the installer
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1. # (import once to LocalMachine\TrustedPublisher). That fallback is for canary/CI ONLY — on a v* tag
# the pack script FAILS CLOSED rather than ship a release signed by a per-build throwaway cert.
# See packaging/windows/pack-host-installer.ps1.
# #
# GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer. # GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer.
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at # - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
@@ -38,6 +40,14 @@
# 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).
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only. # CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
name: windows-host name: windows-host
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
@@ -81,6 +91,15 @@ env:
REGISTRY: git.unom.io REGISTRY: git.unom.io
OWNER: unom OWNER: unom
PKG: punktfunk-host-windows PKG: punktfunk-host-windows
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs: jobs:
package: package:
@@ -113,10 +132,9 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
# CARGO_TARGET_DIR=C:\t dodges the MAX_PATH wall in the CMake-from-source crates (aws-lc, # CARGO_TARGET_DIR=C:\t dodges the MAX_PATH wall in the CMake-from-source crates (aws-lc,
# opus) the host pulls; CARGO_WORKSPACE_DIR mirrors the client workflows. Both via GITHUB_ENV # opus) the host pulls; via GITHUB_ENV (pwsh Out-File utf8 = no BOM, unlike Windows
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean). # PowerShell 5.1 — keeps the first line clean).
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# audiopus_sys' vendored opus declares cmake_minimum_required < 3.5, which CMake 4.x # audiopus_sys' vendored opus declares cmake_minimum_required < 3.5, which CMake 4.x
# refuses outright. Green runs today only survive on the cached configure output — a # refuses outright. Green runs today only survive on the cached configure output — a
# target-dir purge (the runner's disk-cleanup task) would fail the fresh configure, as # target-dir purge (the runner's disk-cleanup task) would fail the fresh configure, as
@@ -172,8 +190,8 @@ jobs:
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason # openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
# pf-vkhdr-layer's clippy below runs --release. # pf-vkhdr-layer's clippy below runs --release.
# #
# pf-encode and pf-capture are linted SEPARATELY with --all-targets so their Windows # pf-encode, pf-capture and pf-vdisplay are linted SEPARATELY with --all-targets so their
# `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions # Windows `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions
# (`variant_layout_matches_c` and friends, which are the only guard on a hand-mirrored # (`variant_layout_matches_c` and friends, which are the only guard on a hand-mirrored
# vtable ABI), the QSV tests, the PyroWave-Windows smoke test; pf-capture's `StallWatch` # vtable ABI), the QSV tests, the PyroWave-Windows smoke test; pf-capture's `StallWatch`
# tests, the DXGI HDR self-tests and the cursor-conversion tables. The host lint above # tests, the DXGI HDR self-tests and the cursor-conversion tables. The host lint above
@@ -199,6 +217,7 @@ jobs:
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" } cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" } cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
cargo clippy --release -p pf-capture --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-capture clippy" } cargo clippy --release -p pf-capture --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-capture clippy" }
cargo clippy --release -p pf-vdisplay --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-vdisplay clippy" }
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" } cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
- name: Test (pf-capture, Windows) - name: Test (pf-capture, Windows)
@@ -223,6 +242,28 @@ jobs:
run: | run: |
cargo test --release -p pf-capture; if ($LASTEXITCODE) { throw "pf-capture tests" } cargo test --release -p pf-capture; if ($LASTEXITCODE) { throw "pf-capture tests" }
- name: Test (pf-vdisplay, Windows)
shell: pwsh
# pf-vdisplay's Windows half is ~3,400 lines (manager.rs, pf_vdisplay.rs, ddc.rs, the three
# manager/ submodules) that NO other job compiles — the host lint above builds the crate as
# a dependency, so its test targets were reaching no compiler anywhere. Worse, the only two
# Windows `#[test]`s were `if env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() { return; }`
# early-returns, so an unrun hardware test reported `ok`. They are `#[ignore]`d now and this
# step reports them as `ignored`, which is the truth.
#
# The link objection recorded for the host and pf-encode above does NOT apply here, for the
# same reason it does not apply to pf-capture: `pf-encode` is `default = []`, and nothing in
# `-p pf-vdisplay`'s graph turns on `nvenc`/`amf-qsv`/`qsv`, so no nvidia/ffmpeg/libvpl
# import libs are ever asked for. Settled empirically before this step was added, to the
# same standard as pf-capture's: the exact two commands were run on this runner against a
# checkout at C:\temp\pf-vd-check — clippy clean, then 46 passed / 2 ignored in 0.19 s.
#
# --release to reuse C:\t\release rather than spawning a debug tree (the C1069 trigger).
# Note this DOES build a second, featureless pf-encode; it is small precisely because none
# of the encoder features are on.
run: |
cargo test --release -p pf-vdisplay; if ($LASTEXITCODE) { throw "pf-vdisplay tests" }
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer) - name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
shell: pwsh shell: pwsh
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games # Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
@@ -271,7 +312,15 @@ jobs:
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN" Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
} }
Push-Location web Push-Location web
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" } # `--ignore-scripts` like every other web install in CI (ci.yml, web-screenshots.yml,
# sdk/plugin-kit-publish, and the SDK install further down this same file). This step was
# the one site that ran lifecycle scripts, and web's `postinstall` is `bun2nix -o bun.nix`
# — a NIX codegen step that shells out to `bun` on PATH. CI runs a fetched PORTABLE bun by
# absolute path (`$env:BUN_EXE`), so PATH has none, and bun2nix aborted the install:
# error: bun is not installed in %PATH% ... postinstall script exited with 255
# Nothing here needs those scripts — `build` re-runs its own `prebuild` codegen — and
# bun.nix is a Nix artifact this job neither consumes nor commits.
& $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" } & $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) { if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
throw "web build is not a bun bundle - need the 'bun' preset + custom entry" throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
@@ -311,6 +360,12 @@ jobs:
env: env:
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }} MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }} MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
# The DRIVER cert is separate from the host/MSIX one and reaches the two driver build
# scripts through the environment (pack-host-installer.ps1 invokes them, they read
# $env:DRIVER_CERT_PFX_B64 themselves). Without it they sign with a per-build throwaway,
# which the installer then trusts as a machine root — see packaging/windows/README.md.
DRIVER_CERT_PFX_B64: ${{ secrets.DRIVER_CERT_PFX_B64 }}
DRIVER_CERT_PASSWORD: ${{ secrets.DRIVER_CERT_PASSWORD }}
run: | run: |
& packaging/windows/pack-host-installer.ps1 ` & packaging/windows/pack-host-installer.ps1 `
-Version $env:HOST_VERSION -TargetDir C:\t\release -OutDir C:\t\out -Version $env:HOST_VERSION -TargetDir C:\t\release -OutDir C:\t\out
+32 -11
View File
@@ -21,12 +21,23 @@
# Published to the generic registry + the `canary/` alias. # Published to the generic registry + the `canary/` alias.
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix). # Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
# #
# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets # Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD
# are set (a real or shared code-signing .pfx whose subject DN == Publisher), the package is signed # Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the
# with them. Otherwise an ephemeral self-signed cert is generated and its public .cer is published # package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public
# next to the .msix (users import it to Trusted People before install). Drop in a real cert later # .cer is published next to the .msix (users import it to Trusted People before install).
# with no workflow change — just add the secrets (+ pass -Publisher if its subject differs). #
# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that
# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself.
name: windows-msix name: windows-msix
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
@@ -49,6 +60,15 @@ env:
REGISTRY: git.unom.io REGISTRY: git.unom.io
OWNER: unom OWNER: unom
PKG: punktfunk-client-windows PKG: punktfunk-client-windows
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs: jobs:
package: package:
@@ -81,11 +101,9 @@ jobs:
- name: Configure + version - name: Configure + version
shell: pwsh shell: pwsh
run: | run: |
# windows-reactor's build.rs unwraps CARGO_WORKSPACE_DIR; CARGO_TARGET_DIR (per-arch, short) # CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
# dodges the MAX_PATH wall in the CMake-from-source crates (see windows.yml). FFMPEG_DIR # crates (see windows.yml). FFMPEG_DIR selects the arch's import libs + is read by
# selects the arch's import libs + is read by pack-msix.ps1 for the runtime DLLs. All via # pack-msix.ps1 for the runtime DLLs. All via GITHUB_ENV.
# GITHUB_ENV.
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg). # pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
@@ -109,7 +127,10 @@ jobs:
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell. # hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
- name: Build (release) - name: Build (release)
shell: pwsh shell: pwsh
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }} # punktfunk-cli builds the `punktfunk.exe` the manifest aliases and pack-msix.ps1
# requires (bf981027 added the requirement without the build — same gap 90c84ef4
# closed for deb).
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }}
- name: Pack + sign MSIX - name: Pack + sign MSIX
shell: pwsh shell: pwsh
+24 -5
View File
@@ -22,8 +22,6 @@
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, the x64 FFmpeg, # The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, the x64 FFmpeg,
# CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout # CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
# / per-arch vars are set in a step: # / per-arch vars are set in a step:
# - CARGO_WORKSPACE_DIR windows-reactor's build.rs unwraps it + stages the Win App SDK
# NuGets/winmd under it (from GITHUB_WORKSPACE).
# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under # - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\, # C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
# so the default target\ path blows past Windows' MAX_PATH (260) inside the # so the default target\ path blows past Windows' MAX_PATH (260) inside the
@@ -34,10 +32,18 @@
# - FFMPEG_DIR per-arch FFmpeg import libs (x64 vs arm64 tree). # - FFMPEG_DIR per-arch FFmpeg import libs (x64 vs arm64 tree).
# #
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's # Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (the # `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that
# CARGO_WORKSPACE_DIR var silently never gets set -> reactor build.rs panics). pwsh writes no BOM. # var silently never gets set). pwsh writes no BOM.
# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh. # The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh.
name: windows name: windows
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
push: push:
@@ -67,6 +73,20 @@ on:
- '.gitea/workflows/windows.yml' - '.gitea/workflows/windows.yml'
workflow_dispatch: workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs: jobs:
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the # SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
# host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml / # host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml /
@@ -97,7 +117,6 @@ jobs:
- name: Configure + toolchain versions - name: Configure + toolchain versions
shell: pwsh shell: pwsh
run: | run: |
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\). # Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' } $td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Generated
+125 -43
View File
@@ -945,6 +945,19 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "cursor-probe"
version = "0.21.0"
dependencies = [
"anyhow",
"pf-capture",
"pf-inject",
"pf-vdisplay",
"punktfunk-core",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "curve25519-dalek" name = "curve25519-dalek"
version = "4.1.3" version = "4.1.3"
@@ -1585,6 +1598,12 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "glib-build-tools"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9871f38b67853c358b8190f77b9f878eb27d933a950f1045b244c4559a9f5f0"
[[package]] [[package]]
name = "glib-macros" name = "glib-macros"
version = "0.22.6" version = "0.22.6"
@@ -2863,6 +2882,7 @@ dependencies = [
"pipewire", "pipewire",
"punktfunk-core", "punktfunk-core",
"pyrowave-sys", "pyrowave-sys",
"rand 0.9.4",
"rustls", "rustls",
"sdl3", "sdl3",
"serde", "serde",
@@ -2870,7 +2890,7 @@ dependencies = [
"tracing", "tracing",
"ureq", "ureq",
"wasapi", "wasapi",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
] ]
[[package]] [[package]]
@@ -3050,6 +3070,7 @@ dependencies = [
"sha2", "sha2",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber",
"utoipa", "utoipa",
"wayland-backend", "wayland-backend",
"wayland-client", "wayland-client",
@@ -3278,6 +3299,17 @@ dependencies = [
"unarray", "unarray",
] ]
[[package]]
name = "punktfunk-cli"
version = "0.21.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
"serde_json",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.21.0" version = "0.21.0"
@@ -3300,6 +3332,7 @@ version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
"glib-build-tools",
"gtk4", "gtk4",
"libadwaita", "libadwaita",
"pf-client-core", "pf-client-core",
@@ -3315,10 +3348,15 @@ name = "punktfunk-client-session"
version = "0.21.0" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel",
"glib-build-tools",
"gtk4",
"libadwaita",
"pf-client-core", "pf-client-core",
"pf-console-ui", "pf-console-ui",
"pf-presenter", "pf-presenter",
"punktfunk-core", "punktfunk-core",
"relm4",
"serde_json", "serde_json",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -3336,9 +3374,10 @@ dependencies = [
"punktfunk-core", "punktfunk-core",
"serde", "serde",
"serde_json", "serde_json",
"test_reactor",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-reactor", "windows-reactor",
"windows-reactor-setup", "windows-reactor-setup",
"winresource", "winresource",
@@ -4538,6 +4577,18 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "test_reactor"
version = "0.0.0"
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [
"rustc-hash",
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-reactor",
"windows-reactor-setup",
"windows-reference",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -5382,16 +5433,26 @@ dependencies = [
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.62.2" version = "0.62.2"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-future 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-future 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-reference", "windows-reference",
"windows-time", "windows-time",
] ]
[[package]]
name = "windows-canvas"
version = "0.0.0"
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-window",
]
[[package]] [[package]]
name = "windows-collections" name = "windows-collections"
version = "0.3.2" version = "0.3.2"
@@ -5404,9 +5465,20 @@ dependencies = [
[[package]] [[package]]
name = "windows-collections" name = "windows-collections"
version = "0.3.2" version = "0.3.2"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
]
[[package]]
name = "windows-composition"
version = "0.0.0"
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [
"windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-time",
] ]
[[package]] [[package]]
@@ -5425,13 +5497,13 @@ dependencies = [
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"windows-implement 0.60.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-implement 0.60.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-interface 0.59.3 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-interface 0.59.3 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-result 0.4.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-result 0.4.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-strings 0.5.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-strings 0.5.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
] ]
[[package]] [[package]]
@@ -5448,11 +5520,11 @@ dependencies = [
[[package]] [[package]]
name = "windows-future" name = "windows-future"
version = "0.3.2" version = "0.3.2"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-threading 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-threading 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
] ]
[[package]] [[package]]
@@ -5469,7 +5541,7 @@ dependencies = [
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.2" version = "0.60.2"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -5490,7 +5562,7 @@ dependencies = [
[[package]] [[package]]
name = "windows-interface" name = "windows-interface"
version = "0.59.3" version = "0.59.3"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -5506,7 +5578,7 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
[[package]] [[package]]
name = "windows-numerics" name = "windows-numerics"
@@ -5521,38 +5593,40 @@ dependencies = [
[[package]] [[package]]
name = "windows-numerics" name = "windows-numerics"
version = "0.3.1" version = "0.3.1"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
] ]
[[package]] [[package]]
name = "windows-reactor" name = "windows-reactor"
version = "0.0.0" version = "0.0.0"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"rustc-hash", "rustc-hash",
"windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-canvas",
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-collections 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-future 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-composition",
"windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-future 0.3.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-numerics 0.3.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-reference", "windows-reference",
"windows-threading 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-threading 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-time", "windows-time",
] ]
[[package]] [[package]]
name = "windows-reactor-setup" name = "windows-reactor-setup"
version = "0.0.0" version = "0.0.0"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
[[package]] [[package]]
name = "windows-reference" name = "windows-reference"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-time", "windows-time",
] ]
@@ -5568,9 +5642,9 @@ dependencies = [
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
] ]
[[package]] [[package]]
@@ -5596,9 +5670,9 @@ dependencies = [
[[package]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.5.1" version = "0.5.1"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
] ]
[[package]] [[package]]
@@ -5706,18 +5780,26 @@ dependencies = [
[[package]] [[package]]
name = "windows-threading" name = "windows-threading"
version = "0.2.1" version = "0.2.1"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
] ]
[[package]] [[package]]
name = "windows-time" name = "windows-time"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f#a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [ dependencies = [
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)", "windows-link 0.2.1 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
]
[[package]]
name = "windows-window"
version = "0.0.0"
source = "git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc#acb5a1a7441033d9312b16842af02eb0c2b403dc"
dependencies = [
"windows-core 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
] ]
[[package]] [[package]]
+31
View File
@@ -24,10 +24,12 @@ members = [
"crates/pyrowave-sys", "crates/pyrowave-sys",
"crates/libvpl-sys", "crates/libvpl-sys",
"clients/probe", "clients/probe",
"clients/cli",
"clients/linux", "clients/linux",
"clients/session", "clients/session",
"clients/windows", "clients/windows",
"clients/android/native", "clients/android/native",
"tools/cursor-probe",
"tools/display-disturb", "tools/display-disturb",
"tools/latency-probe", "tools/latency-probe",
"tools/loss-harness", "tools/loss-harness",
@@ -56,6 +58,35 @@ license = "MIT OR Apache-2.0"
authors = ["unom"] authors = ["unom"]
repository = "https://git.unom.io/unom/punktfunk" repository = "https://git.unom.io/unom/punktfunk"
# The `unsafe` discipline the `packaging/windows/drivers/*` crates already run, extended to the
# workspace. `unsafe fn` marks a CONTRACT the caller must uphold; it is not a licence for the whole
# body to skip checking. Without this lint an `unsafe fn` body is unchecked end to end, so a 600-line
# function hides which handful of lines are actually the unsafe ones — exactly the reviewer-hostile
# shape we are working down. (This is the Rust 2024 default; adopting it early also pays off the
# edition migration.)
#
# `deny`, not `warn`. `warn` was never actually a softer setting: CI runs `cargo clippy … -D
# warnings`, which promotes it to a hard error anyway — that is how adopting this lint turned main
# red on every platform for a day without the level in this file ever saying `deny`. A level that
# lies about its own severity is worse than a strict one, so this now states what CI already does,
# and the exemptions are written down per file instead of hiding in a 689-warning wall nobody reads.
#
# THE EXEMPTIONS. Fourteen GPU/FFI backend files carry `#![allow(unsafe_op_in_unsafe_fn)]` with a
# one-line reason each. They are not "not done yet" — they are where this lint stops paying:
# their bodies are ash/CUDA/AMF/libav calls almost line for line (measured: 64% of the sites are a
# single third-party FFI call, and of the 44 `unsafe fn`s in them only 4 have a body containing no
# unsafe operation at all). Narrowing them means one `unsafe {}` per line plus, since pf-encode also
# denies `clippy::undocumented_unsafe_blocks`, one hand-written SAFETY comment per line that could
# only ever restate "an ash call on a live device" — the precise noise that made `unsafe` stop
# meaning anything here before (see the header of `pf-win-display/src/win_display.rs`).
#
# Everything else in the workspace is at zero and enforced. Removing one of those allows, file by
# file, is real work with a real payoff; blanket-narrowing all fourteen is not. Prefer DELETING an
# `unsafe fn` marker over wrapping its body: keep the marker only where a caller can actually break
# something (a raw pointer, a borrowed HANDLE, a GPU object that must not be in flight).
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = "thin" lto = "thin"
+32
View File
@@ -56,6 +56,38 @@ https://docs.punktfunk.unom.io/docs/security):
If you're unsure whether something is in scope, report it anyway — we'd rather hear about it. If you're unsure whether something is in scope, report it anyway — we'd rather hear about it.
## Verifying what you downloaded
Every distribution path is authenticated. Nothing below needs an account or a network round trip to
us beyond the download itself.
- **Release-page downloads** (DMG, MSIX, setup.exe, APK, decky zip, .deb/.rpm) each ship a
`<file>.sha256` next to them. In your download directory:
`sha256sum -c punktfunk-1.2.3.dmg.sha256` (macOS: `shasum -a 256 -c …`).
- **RPMs** from the dnf repo are OpenPGP-signed with `packages@unom.io` (`AF245C506F4E4763`); the
repo file in [`packaging/rpm/README.md`](packaging/rpm/README.md) sets `gpgcheck=1`, so dnf
checks every package for you. `rpmkeys --checksig` on a downloaded RPM verifies it by hand.
- **The Bazzite sysext feed** carries a detached signature over its `SHA256SUMS`, from that same
key. `punktfunk-sysext` verifies it before installing and refuses a feed it cannot verify — the
public key is baked into the script rather than fetched from the feed.
- **Windows installers and MSIX packages** are Authenticode-signed; a release build that cannot
reach its code-signing certificate fails to build rather than falling back to a self-signed one.
Check with `Get-AuthenticodeSignature punktfunk-host-setup-1.2.3.exe`.
- **The Windows drivers** (virtual display, virtual gamepads) are signed with a stable self-signed
certificate, `CN=punktfunk-driver`
(SHA-1 `4B8493E7CD565758D335F8F4F05C5A7261A13E02`), also published in
[`packaging/windows/README.md`](packaging/windows/README.md). The installer has to add it to the
machine's trusted roots for a self-signed driver to install at all, so — unlike the cases above —
this signature does **not** authenticate the download: it gives the drivers a stable publisher
identity you can compare against the published fingerprint, and it is removed again on uninstall.
Verify with `Get-AuthenticodeSignature` on the installed `pf_vdisplay.dll`, or list what is
trusted with `Get-ChildItem Cert:\LocalMachine\Root | ? Subject -like '*punktfunk*'`.
A checksum on its own only tells you the download wasn't corrupted in transit — it says nothing
about who produced the file, since anyone able to replace an artifact can replace its checksum.
Where that distinction matters (the update feeds, the package repos), the checksums are covered by
a signature. If a signature check fails, please don't work around it; report it.
## Safe harbor ## Safe harbor
We consider good-faith security research that follows this policy to be authorized, and we won't We consider good-faith security research that follows this policy to be authorized, and we won't
+692 -799
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -37,13 +37,15 @@ accepted = [
ignore-build-dependencies = true ignore-build-dependencies = true
ignore-dev-dependencies = true ignore-dev-dependencies = true
# r-efi offers an LGPL-2.1-or-later arm but is tri-licensed; take a permissive arm. (It is also # Per-crate license-acceptance additions (cargo-about ≥0.6 syntax; the old `[crate.clarify]`
# UEFI-target-gated out of every shipped build.) # license-only form fails to deserialize under cargo-about 0.9, which now wants checksummed file
[r-efi.clarify] # clarifications — per-crate `accepted` extensions express the same intent without checksums).
license = "MIT OR Apache-2.0" #
# r-efi is tri-licensed with an LGPL-2.1-or-later arm; cargo-about resolves OR-expressions to an
[ring.clarify] # accepted arm on its own (MIT/Apache-2.0 are globally accepted), so it needs no entry. (It is
license = "MIT AND ISC AND OpenSSL" # also UEFI-target-gated out of every shipped build.)
#
[aws-lc-sys.clarify] # ring's license is an AND of permissive terms including the OpenSSL license; accept the
license = "ISC AND Apache-2.0 AND MIT AND BSD-3-Clause AND OpenSSL" # OpenSSL/ISC parts for this crate only, not globally.
[ring]
accepted = ["OpenSSL", "ISC"]
+44 -5
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.20.0" "version": "0.21.0"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
@@ -1558,7 +1558,7 @@
"host" "host"
], ],
"summary": "Local status summary for the tray icon", "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.", "description": "Non-sensitive status (counts, booleans, and the streaming client's display name — no PIN\nvalues, no fingerprints). Unauthenticated, but served to loopback peers only.",
"operationId": "getLocalSummary", "operationId": "getLocalSummary",
"responses": { "responses": {
"200": { "200": {
@@ -3912,11 +3912,19 @@
"format": "int64", "format": "int64",
"minimum": 0 "minimum": 0
}, },
"encoder_backend": {
"type": "string",
"description": "The encode backend that ACTUALLY opened for this session — `\"nvenc\"`, `\"vaapi\"`,\n`\"vulkan\"`, `\"amf\"`, `\"qsv\"`, `\"software\"`, … — and the GPU it runs on.\n\nRecorded because the stage split alone can't be read without them. A p50 `submit` of 10 ms\nmeans \"the GPU's CSC+encode throughput is the ceiling\" on one backend and something else\nentirely on another, and every fps-shortfall report so far has cost a round-trip asking\nwhich one it was. Both come from `pf_gpu::active()`, the record the encoder open itself\nwrites, so they name the branch that really opened rather than a re-derived guess.\n\n`\"\"` when nothing was streaming at registration (or on a build without the record)."
},
"fps": { "fps": {
"type": "integer", "type": "integer",
"format": "int32", "format": "int32",
"minimum": 0 "minimum": 0
}, },
"gpu": {
"type": "string",
"description": "Human-readable GPU name (`\"NVIDIA GeForce RTX 4090\"`, `\"CPU (openh264)\"`), or `\"\"`."
},
"height": { "height": {
"type": "integer", "type": "integer",
"format": "int32", "format": "int32",
@@ -5327,6 +5335,8 @@
"abi_version", "abi_version",
"app_version", "app_version",
"gfe_version", "gfe_version",
"os",
"os_name",
"codecs", "codecs",
"gamestream", "gamestream",
"ports" "ports"
@@ -5364,6 +5374,16 @@
"type": "string", "type": "string",
"description": "Best-effort primary LAN IP." "description": "Best-effort primary LAN IP."
}, },
"os": {
"type": "string",
"description": "OS identity chain, generic → most specific, slash-separated (`windows` | `macos` |\n`linux[/<family>][/<id>]`). A client walks it most-specific-first and shows the first\ntoken it has an icon for, so an unknown distro still degrades to its family's mark.",
"example": "linux/fedora/bazzite"
},
"os_name": {
"type": "string",
"description": "Human-readable OS name (os-release `PRETTY_NAME`; `\"Windows\"`/`\"macOS\"` elsewhere).",
"example": "Bazzite 42 (Kinoite)"
},
"ports": { "ports": {
"$ref": "#/components/schemas/PortMap" "$ref": "#/components/schemas/PortMap"
}, },
@@ -5666,7 +5686,7 @@
}, },
"LocalSummary": { "LocalSummary": {
"type": "object", "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.", "description": "Non-sensitive host status for the local tray icon: counts and booleans — no PIN values, no\nfingerprints. The ONE name exposed is `client_name`, the streaming client's display label\n(deliberate loosening for the tray's \"client connected\" toast: it tells the local user who is\non their machine, which is disclosure in the user's favor — and any local process could\nalready infer a session exists from the booleans here). Served unauthenticated to LOOPBACK\npeers only (see `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on\nWindows, so the per-user tray process cannot authenticate — this narrow read-only route is\nits status source.",
"required": [ "required": [
"version", "version",
"video_streaming", "video_streaming",
@@ -5682,6 +5702,13 @@
"type": "boolean", "type": "boolean",
"description": "True while audio is streaming on either plane (same rule as `video_streaming`)." "description": "True while audio is streaming on either plane (same rule as `video_streaming`)."
}, },
"client_name": {
"type": [
"string",
"null"
],
"description": "Display name of the (first) streaming native client — the trust store's name for it, else\nthe name the device sent at connect. `null` when idle, for a nameless client, or for a\nGameStream session (that plane carries no device name)."
},
"conflicts": { "conflicts": {
"type": "array", "type": "array",
"items": { "items": {
@@ -5823,7 +5850,8 @@
"type": "object", "type": "object",
"description": "The host's physical monitors + which one capture is pinned to.", "description": "The host's physical monitors + which one capture is pinned to.",
"required": [ "required": [
"monitors" "monitors",
"pin_supported"
], ],
"properties": { "properties": {
"compositor": { "compositor": {
@@ -5847,6 +5875,10 @@
}, },
"description": "The heads, ordered left-to-right by desktop position." "description": "The heads, ordered left-to-right by desktop position."
}, },
"pin_supported": {
"type": "boolean",
"description": "Whether this build can actually STREAM one of these monitors.\n\nEnumeration and capture are separate capabilities, and on Windows only the first exists: the\nheads below are real and worth showing (they explain the topology, and `/display/state`\ncross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a\nframe channel pushed by our OWN IddCx virtual display. There is no desktop-duplication\ncapturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so\n`vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.\n\nThe console renders the picker read-only on `false`. Reported as a capability rather than\nsniffed client-side from the OS so the answer comes from the build that would have to honor\nit — when a Windows mirror backend lands, this flips and the UI needs no change."
},
"pinned": { "pinned": {
"type": [ "type": [
"string", "string",
@@ -6355,6 +6387,7 @@
"audio_streaming", "audio_streaming",
"pin_pending", "pin_pending",
"paired_clients", "paired_clients",
"native_paired_clients",
"active_sessions", "active_sessions",
"games" "games"
], ],
@@ -6376,10 +6409,16 @@
}, },
"description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game." "description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game."
}, },
"native_paired_clients": {
"type": "integer",
"format": "int32",
"description": "Number of paired native (punktfunk/1) devices — the default plane, so on a host that has\nnever been touched by Moonlight this is the only non-zero one of the pair.",
"minimum": 0
},
"paired_clients": { "paired_clients": {
"type": "integer", "type": "integer",
"format": "int32", "format": "int32",
"description": "Number of pinned (paired) client certificates.", "description": "Number of pinned (paired) GameStream client certificates. Native (punktfunk/1) devices pair\nagainst a separate store and are counted in `native_paired_clients` — sum the two for\n\"how many clients are paired with this host\".",
"minimum": 0 "minimum": 0
}, },
"pin_pending": { "pin_pending": {
@@ -0,0 +1,17 @@
Font Awesome Free — brand icons (windows, apple, linux, steam, ubuntu, fedora,
opensuse in assets/os-icons/) are from Font Awesome Free.
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
Font Awesome Free icons are licensed under the Creative Commons Attribution 4.0
International license (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/.
The icons are redistributed here as monochrome SVG path data with no
modifications beyond color normalization (fill="currentColor").
Per the Font Awesome Free license (https://fontawesome.com/license/free):
"Font Awesome Free is free, open source, and GPL friendly. You can use it for
commercial projects, open source projects, or really almost whatever you want.
Attribution is required by MIT, SIL OFL, and CC BY licenses."
Brand icons are trademarks of their respective owners and are used for
identification purposes only; their use does not imply endorsement.
+10
View File
@@ -0,0 +1,10 @@
Simple Icons — brand icons (arch, nixos, debian in assets/os-icons/) are from
Simple Icons (https://simpleicons.org, https://github.com/simple-icons/simple-icons).
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
required; this notice is provided for provenance.
Brand icons are trademarks of their respective owners and are used for
identification purposes only; their use does not imply endorsement. See
https://github.com/simple-icons/simple-icons/blob/develop/DISCLAIMER.md.
+30
View File
@@ -0,0 +1,30 @@
# OS icon masters
The canonical OS/distro brand marks every client derives its host-card OS icon from
(web console inline SVGs, GTK symbolic icons, Windows PNGs, Apple template imagesets,
Android `ImageVector`s). One file per **icon token** of the host's OS-identity chain
(see `crates/punktfunk-host/src/osinfo.rs` and `crates/pf-client-core/src/os.rs`):
| token | mark | source |
|---|---|---|
| `windows` | Windows | Font Awesome Free brands (CC BY 4.0) |
| `apple` | Apple (also `macos` via alias) | Font Awesome Free brands (CC BY 4.0) |
| `linux` | Tux | Font Awesome Free brands (CC BY 4.0) |
| `steam` | Steam (also `steamos` via alias) | Font Awesome Free brands (CC BY 4.0) |
| `ubuntu` | Ubuntu | Font Awesome Free brands (CC BY 4.0) |
| `fedora` | Fedora | Font Awesome Free brands (CC BY 4.0) |
| `opensuse` | SUSE | Font Awesome Free brands (CC BY 4.0) |
| `arch` | Arch Linux | Simple Icons (CC0 1.0) |
| `nixos` | NixOS | Simple Icons (CC0 1.0) |
| `debian` | Debian | Simple Icons (CC0 1.0) |
Distros with no file here (Bazzite, CachyOS, Nobara, Pop!_OS, Mint, …) are intentional:
the host advertises the full chain (`linux/fedora/bazzite`), and clients walk it
most-specific-first, so they degrade to the family mark and finally to Tux.
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved.
Licensing: attribution notices live in `LICENSES/` and are folded into
`THIRD-PARTY-NOTICES.txt` by `scripts/gen-third-party-notices.py`. The marks are
trademarks of their respective owners; they are used here nominatively — to *identify*
the operating system a host runs, the standard practice in this ecosystem — and imply no
affiliation or endorsement.
+2
View File
@@ -0,0 +1,2 @@
<!-- apple — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaApple. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>

After

Width:  |  Height:  |  Size: 640 B

+2
View File
@@ -0,0 +1,2 @@
<!-- arch — from Simple Icons (CC0 1.0), via react-icons SiArchlinux. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091"/></svg>

After

Width:  |  Height:  |  Size: 841 B

+2
View File
@@ -0,0 +1,2 @@
<!-- debian — from Simple Icons (CC0 1.0), via react-icons SiDebian. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- fedora — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaFedora. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><path d="M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- linux — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaLinux. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><path d="M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z"/></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- nixos — from Simple Icons (CC0 1.0), via react-icons SiNixos. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z"/></svg>

After

Width:  |  Height:  |  Size: 880 B

+2
View File
@@ -0,0 +1,2 @@
<!-- opensuse — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSuse. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" fill="currentColor"><path d="M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSteam. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>

After

Width:  |  Height:  |  Size: 937 B

+2
View File
@@ -0,0 +1,2 @@
<!-- ubuntu — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaUbuntu. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- windows — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaWindows. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>

After

Width:  |  Height:  |  Size: 344 B

+67
View File
@@ -0,0 +1,67 @@
# Android CI builder: JDK 21 + Android SDK/NDK/CMake + pinned Rust with the three shipping
# Android targets + cargo-ndk + sccache. Everything android.yml used to download per run
# (~3 GB of NDK + SDK packages from Google, plus a from-source cargo-ndk build) is baked
# here instead; the image is content-keyed and rebuilt only when the ci/ tree changes
# (docker.yml `builders`).
#
# docker build -f ci/android-ci.Dockerfile -t punktfunk-android-ci ci
#
# Version pins mirror what android.yml installed via sdkmanager: AGP 9.3 wants JDK 1721;
# cmake;3.22.1 because kit/build.gradle.kts prepends $ANDROID_SDK/cmake/3.22.1/bin to PATH
# for cargo-ndk's audiopus_sys (libopus) CMake build; platforms;android-37 is deliberately
# absent (AGP auto-downloads it if a build ever needs it — same note as the old workflow).
FROM ubuntu:26.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git unzip zip python3 openjdk-21-jdk-headless \
build-essential pkg-config \
&& rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
# Android SDK: cmdline-tools must land under cmdline-tools/latest for sdkmanager to
# find its own root.
ENV ANDROID_HOME=/opt/android-sdk \
ANDROID_SDK_ROOT=/opt/android-sdk
ARG CMDLINE_TOOLS=13114758
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
&& curl -fsSL -o /tmp/clt.zip "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip" \
&& unzip -q /tmp/clt.zip -d "$ANDROID_HOME/cmdline-tools" \
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
&& rm /tmp/clt.zip
ENV PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH
RUN yes | sdkmanager --licenses >/dev/null \
&& sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" \
"ndk;30.0.14904198" "cmake;3.22.1" \
&& chmod -R a+rX "$ANDROID_HOME"
# Toolchain shared across CI users (jobs may run as different uids) — same shape as
# rust-ci.Dockerfile, plus the Android cross targets and cargo-ndk. The registry/git
# download caches are stripped after the cargo-ndk install: jobs restore those from the
# shared actions cache, and baking them would only bloat every pull.
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
&& rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \
&& cargo install cargo-ndk --locked \
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo ndk --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
# actions/checkout (and every other JS action: cache, upload-artifact) execs `node` INSIDE
# the job container — no node, no checkout (exit 127; same lesson flatpak.yml documents for
# fedora:43). A separate trailing layer on purpose: appending here keeps the fat SDK/NDK
# layers above cache-valid instead of invalidating the whole build.
RUN apt-get update && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& node --version
+42
View File
@@ -0,0 +1,42 @@
# Arch CI builder: base-devel + every dependency arch.yml's two makepkg legs used to
# pacman-install per run (~1 GB of mirror traffic each time) + bun + sccache + nodejs
# (JS actions exec node INSIDE the job container — the same lesson as android-ci).
# Content-keyed and rebuilt only when the ci/ tree changes (docker.yml `builders`).
#
# docker build -f ci/arch-ci.Dockerfile -t punktfunk-arch-ci ci
#
# ROLLING-RELEASE TRADEOFF, on purpose: packages now build against the Arch snapshot
# from the last image rebuild instead of a fresh -Syu per run. That is the same staleness
# the gamescope cache already embraces ("a stale binary against newer system libs is the
# same risk the distro's own package carries between rebuilds"), and any ci/ edit — or
# bumping the date in this line (refreshed: 2026-07-29) — re-keys and re-snapshots it.
FROM docker.io/library/archlinux:base-devel
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
# deps (second list) — both copied verbatim from what arch.yml installed in-job, where
# they now no-op as `--needed` guards.
RUN pacman -Syu --noconfirm --needed \
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive \
glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \
libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \
hwdata luajit seatd sdl2-compat vulkan-icd-loader \
xcb-util-errors xcb-util-wm xorg-xwayland \
meson glm wayland-protocols benchmark libxcursor \
&& pacman -Scc --noconfirm
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
# as their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so
# bootstrap the official binary — once, here, instead of per run.
RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
&& rm -rf /root/.bun \
&& bun --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
+8
View File
@@ -66,3 +66,11 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \ | sh -s -- -y --no-modify-path --profile minimal \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \ && chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo --version && rustc --version && cargo --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
+1 -1
View File
@@ -16,7 +16,7 @@
# explicit `Architectures:` or apt tries to fetch arm64 from the amd64 mirror and 404s. # explicit `Architectures:` or apt tries to fetch arm64 from the amd64 mirror and 404s.
# #
# Built from the REPO ROOT context (not ci/) — see the rust-toolchain.toml copy below. # Built from the REPO ROOT context (not ci/) — see the rust-toolchain.toml copy below.
FROM git.unom.io/unom/punktfunk-rust-ci:latest FROM 192.168.1.58:5010/punktfunk-rust-ci:latest
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
+8
View File
@@ -83,3 +83,11 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
--component rustfmt,clippy \ --component rustfmt,clippy \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \ && chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo clippy --version && cargo fmt --version && rustc --version && cargo clippy --version && cargo fmt --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
+8
View File
@@ -50,3 +50,11 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
--component rustfmt,clippy \ --component rustfmt,clippy \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \ && chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo clippy --version && cargo fmt --version && rustc --version && cargo clippy --version && cargo fmt --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
@@ -90,6 +90,21 @@
<!-- TV launcher entry. --> <!-- TV launcher entry. -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<!-- punktfunk:// deep links (design/client-deep-links.md §2): an external tool, an OS
shortcut or a wiki page opens a stream on a host this device already trusts. The
URL carries only REFERENCES to things that exist here (a host record, a settings
profile, a library id) — never resolution/bitrate/codec values, and never a
pairing route; MainActivity's router enforces the rest. BROWSABLE is what lets a
browser hand it over (behind its own "Open Punktfunk?" prompt).
NOTE: launchMode deliberately stays `standard` and the configChanges set above is
untouched — its `keyboard` entry is what keeps an SC2 claim from killing a running
stream, and neither has anything to gain from this filter. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="punktfunk" />
</intent-filter>
</activity> </activity>
</application> </application>
</manifest> </manifest>
@@ -31,8 +31,9 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -42,14 +43,23 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import android.widget.Toast
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.Tab import io.unom.punktfunk.models.Tab
@Composable @Composable
fun App(forceGamepadUi: Boolean = false) { fun App(forceGamepadUi: Boolean = false) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as? MainActivity
val settingsStore = remember { SettingsStore(context) } val settingsStore = remember { SettingsStore(context) }
var settings by remember { mutableStateOf(settingsStore.load()) } var settings by remember { mutableStateOf(settingsStore.load()) }
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming // The active session (null = not streaming). It carries the settings the connect resolved,
// so the stream screen never re-reads the store behind its own connect's back.
var session by remember { mutableStateOf<ActiveSession?>(null) }
var tab by remember { mutableStateOf(Tab.Connect) } var tab by remember { mutableStateOf(Tab.Connect) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is // Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
@@ -58,21 +68,53 @@ fun App(forceGamepadUi: Boolean = false) {
val controllerConnected by rememberControllerConnected() val controllerConnected by rememberControllerConnected()
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi) val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
// instance is ever resumed — see MainActivity.onCreate. Cleared on dispose, so an activity
// destroyed mid-stream doesn't leave a ghost that blocks every future link.
DisposableEffect(session) {
MainActivity.liveStream = session?.let { MainActivity.LiveStream(it.hostId) }
onDispose { MainActivity.liveStream = null }
}
// The same rule for the rare in-instance case (a caller that set FLAG_ACTIVITY_SINGLE_TOP, so
// the link reached `onNewIntent` on the streaming activity itself). Pointing at the host
// already being streamed is the one exception, and its right answer is to do nothing — the
// intent has already brought the app forward, which is exactly what "focus it" means here.
val pendingLink = activity?.pendingDeepLink
LaunchedEffect(pendingLink, session) {
val url = pendingLink ?: return@LaunchedEffect
val live = session ?: return@LaunchedEffect // not streaming: ConnectScreen routes it
activity.pendingDeepLink = null
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return@LaunchedEffect
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(context).all())
val sameHost = target is HostResolution.Known && target.host.id == live.hostId
if (!sameHost) {
Toast.makeText(
context,
"Already streaming — end this session first.",
Toast.LENGTH_LONG,
).show()
}
}
AnimatedContent( AnimatedContent(
targetState = streamHandle != 0L, targetState = session,
transitionSpec = { transitionSpec = {
fadeIn() togetherWith fadeOut() fadeIn() togetherWith fadeOut()
}, },
label = "StreamTransition" label = "StreamTransition"
) { isStreaming -> ) { active ->
if (isStreaming) { if (active != null) {
// Immersive: the stream takes the whole screen, no bottom bar. // Immersive: the stream takes the whole screen, no bottom bar.
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L }) StreamScreen(active, onDisconnect = { session = null })
} else if (gamepadUi) { } else if (gamepadUi) {
GamepadShell( GamepadShell(
settings = settings, settings = settings,
onSettingsChange = { settings = it; settingsStore.save(it) }, onSettingsChange = { settings = it; settingsStore.save(it) },
onConnected = { streamHandle = it }, onConnected = { session = it },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
) )
} else { } else {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail // Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
@@ -103,7 +145,13 @@ fun App(forceGamepadUi: Boolean = false) {
label = "TabTransition" label = "TabTransition"
) { targetTab -> ) { targetTab ->
when (targetTab) { when (targetTab) {
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it }) Tab.Connect -> ConnectScreen(
settings = settings,
onConnected = { session = it },
onSettingsChange = { settings = it; settingsStore.save(it) },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
)
Tab.Settings -> SettingsScreen( Tab.Settings -> SettingsScreen(
initial = settings, initial = settings,
onChange = { settings = it; settingsStore.save(it) }, onChange = { settings = it; settingsStore.save(it) },
@@ -167,7 +215,9 @@ private enum class GamepadScreen { Home, Settings, Library }
fun GamepadShell( fun GamepadShell(
settings: Settings, settings: Settings,
onSettingsChange: (Settings) -> Unit, onSettingsChange: (Settings) -> Unit,
onConnected: (Long) -> Unit, onConnected: (ActiveSession) -> Unit,
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) } var screen by remember { mutableStateOf(GamepadScreen.Home) }
@@ -194,6 +244,9 @@ fun GamepadShell(
GamepadScreen.Home -> ConnectScreen( GamepadScreen.Home -> ConnectScreen(
settings = settings, settings = settings,
onConnected = onConnected, onConnected = onConnected,
onSettingsChange = onSettingsChange,
deepLink = deepLink,
onDeepLinkHandled = onDeepLinkHandled,
gamepadUi = true, gamepadUi = true,
onOpenSettings = { screen = GamepadScreen.Settings }, onOpenSettings = { screen = GamepadScreen.Settings },
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library }, onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
@@ -19,6 +19,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
@@ -353,16 +354,18 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
} }
/** /**
* Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the * Edit a saved host: name, address, port, the Wake-on-LAN MAC, and the per-host settings the record
* host's mDNS advert while it's online, but this is where you can enter or correct it (e.g. to wake a * owns — shared clipboard (a trust decision about THIS machine, so it was never really a global).
* host you've only ever reached by address). [suggestedMacs] prefills the field from the live advert * The MAC is auto-learned from the host's mDNS advert while it's online, but this is where you can
* when nothing's been learned yet. Keyed by the host so reopening resets the fields. Mirrors the * enter or correct it (e.g. to wake a host you've only ever reached by address). [suggestedMacs]
* Apple client's edit form. * prefills the field from the live advert when nothing's been learned yet. Keyed by the host so
* reopening resets the fields. Mirrors the Apple client's edit form.
*/ */
@Composable @Composable
internal fun EditHostDialog( internal fun EditHostDialog(
target: KnownHost, target: KnownHost,
suggestedMacs: List<String>, suggestedMacs: List<String>,
profiles: List<StreamProfile>,
onSave: (KnownHost) -> Unit, onSave: (KnownHost) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
@@ -372,6 +375,13 @@ internal fun EditHostDialog(
var mac by remember(target) { var mac by remember(target) {
mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", ")) mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", "))
} }
var clipboard by remember(target) { mutableStateOf(target.clipboardSync) }
// A binding whose profile was deleted reads as "Default settings" (which is what it already
// resolves to) and is cleaned off the record on the next save — never an error state.
var boundId by remember(target, profiles) {
mutableStateOf(target.profileId?.takeIf { id -> profiles.any { it.id == id } })
}
var pins by remember(target) { mutableStateOf(target.pinnedProfileIds) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Edit host") }, title = { Text("Edit host") },
@@ -407,6 +417,31 @@ internal fun EditHostDialog(
placeholder = { Text("auto-filled when the host is seen") }, placeholder = { Text("auto-filled when the host is seen") },
singleLine = true, singleLine = true,
) )
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("Shared clipboard", style = MaterialTheme.typography.bodyLarge)
Text(
"Text copied here pastes on this host and vice versa",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = clipboard, onCheckedChange = { clipboard = it })
}
if (profiles.isNotEmpty()) {
HostProfileBinding(
profiles = profiles,
boundId = boundId,
onBind = { boundId = it },
pins = pins,
onTogglePin = { id ->
pins = if (id in pins) pins - id else pins + id
},
)
}
} }
}, },
confirmButton = { confirmButton = {
@@ -419,6 +454,9 @@ internal fun EditHostDialog(
address = address.trim(), address = address.trim(),
port = port.toIntOrNull() ?: target.port, port = port.toIntOrNull() ?: target.port,
mac = KnownHostStore.parseMacs(mac), mac = KnownHostStore.parseMacs(mac),
clipboardSync = clipboard,
profileId = boundId,
pinnedProfileIds = pins,
), ),
) )
}, },
@@ -429,3 +467,103 @@ internal fun EditHostDialog(
}, },
) )
} }
/**
* The network speed test, as a dialog: it narrates while it measures, then offers to apply the
* recommendation to the layer the tested host actually reads bitrate from — see [SpeedTestTarget]
* for why that is the interesting part. The apply buttons name their destination, so the write is
* never a surprise.
*/
@Composable
internal fun SpeedTestDialog(
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val done = phase as? SpeedTestPhase.Done
AlertDialog(
// Measuring can't be cancelled mid-burst (the host is already sending), so a stray tap
// outside shouldn't look like it did something.
onDismissRequest = { if (done != null || phase is SpeedTestPhase.Failed) onDismiss() },
title = { Text("Network speed test") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(hostName, style = MaterialTheme.typography.titleMedium)
when (phase) {
SpeedTestPhase.Connecting, SpeedTestPhase.Measuring -> Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text(
if (phase == SpeedTestPhase.Connecting) {
"Connecting…"
} else {
"Measuring — the host is bursting test traffic for two seconds."
},
)
}
is SpeedTestPhase.Failed -> Text(
phase.message,
color = MaterialTheme.colorScheme.error,
)
is SpeedTestPhase.Done -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"%.0f Mbit/s measured · %.1f %% loss".format(
phase.measuredMbps,
phase.lossPct,
),
style = MaterialTheme.typography.bodyLarge,
)
Text(
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
style = MaterialTheme.typography.bodyLarge,
)
Text(
speedTestTargetNote(target),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
confirmButton = {
if (done != null) {
TextButton(onClick = { onApply(true) }) {
Text(
when (target) {
SpeedTestTarget.Global -> "Apply"
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}"
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}"
},
)
}
}
},
dismissButton = {
Row {
// The both-are-defensible case: the user picks the layer, we don't guess.
if (done != null && target is SpeedTestTarget.Ask) {
TextButton(onClick = { onApply(false) }) { Text("Set as default") }
}
TextButton(onClick = onDismiss) { Text("Close") }
}
},
)
}
/** One line saying which layer an Apply will write to, and why that one. */
private fun speedTestTargetNote(target: SpeedTestTarget): String = when (target) {
SpeedTestTarget.Global ->
"This host uses the default settings, so the bitrate goes there."
is SpeedTestTarget.Profile ->
"This host streams with “${target.profile.name}”, which sets its own bitrate — " +
"that override is what it actually reads."
is SpeedTestTarget.Ask ->
"This host streams with “${target.profile.name}”, which currently inherits the default " +
"bitrate. Setting it in the profile affects only this host's profile; setting it as " +
"the default affects everything that inherits it."
}
@@ -53,16 +53,23 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import io.unom.punktfunk.components.EmptyHostsState import io.unom.punktfunk.components.EmptyHostsState
import io.unom.punktfunk.components.HostCard import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.HostMenuItem
import io.unom.punktfunk.components.SectionLabel import io.unom.punktfunk.components.SectionLabel
import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.discovery.DiscoveredHost import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.discovery.HostDiscovery import io.unom.punktfunk.kit.discovery.HostDiscovery
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.link.LinkError
import io.unom.punktfunk.kit.link.LinkRoute
import io.unom.punktfunk.kit.security.ClientIdentity import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.IdentityStore import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.kit.security.obtainIdentity import io.unom.punktfunk.kit.security.obtainIdentity
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.HostStatus import io.unom.punktfunk.models.HostStatus
import io.unom.punktfunk.models.PendingTrust import io.unom.punktfunk.models.PendingTrust
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@@ -101,7 +108,10 @@ private class ConnectAttempt(val hostName: String) {
@Composable @Composable
fun ConnectScreen( fun ConnectScreen(
settings: Settings, settings: Settings,
onConnected: (Long) -> Unit, onConnected: (ActiveSession) -> Unit,
// Writes the global defaults back. Only the speed test uses it — that is the one action on this
// screen that can land in the defaults layer (design/client-settings-profiles.md §5.3).
onSettingsChange: (Settings) -> Unit = {},
// Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this // Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this
// screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the // screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button). // gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
@@ -109,6 +119,11 @@ fun ConnectScreen(
onOpenSettings: () -> Unit = {}, onOpenSettings: () -> Unit = {},
onOpenLibrary: (KnownHost) -> Unit = {}, onOpenLibrary: (KnownHost) -> Unit = {},
navGate: Boolean = true, // false while the console home is cross-fading out navGate: Boolean = true, // false while the console home is cross-fading out
// A `punktfunk://` URL to route (design/client-deep-links.md §3). This screen owns it because
// it owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a
// link must go through all of them, not around them.
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
@@ -117,6 +132,10 @@ fun ConnectScreen(
var port by remember { mutableStateOf("9777") } var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) } var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) } var status by remember { mutableStateOf<String?>(null) }
// A confirmation, as opposed to [status]'s failures — "75 Mbit/s set in “Travel”". Separate
// state because the two read completely differently: an error banner is red on purpose, and a
// successful write dressed as one is a small lie every time it appears.
var notice by remember { mutableStateOf<String?>(null) }
// A plain dial in flight (drives the "Connecting…" phase of the full-screen ConnectOverlay); null // A plain dial in flight (drives the "Connecting…" phase of the full-screen ConnectOverlay); null
// when idle or when the request-access / wake flows own the screen instead. // when idle or when the request-access / wake flows own the screen instead.
var attempt by remember { mutableStateOf<ConnectAttempt?>(null) } var attempt by remember { mutableStateOf<ConnectAttempt?>(null) }
@@ -195,6 +214,11 @@ fun ConnectScreen(
val identityStore = remember { IdentityStore(context) } val identityStore = remember { IdentityStore(context) }
val knownHostStore = remember { KnownHostStore(context) } val knownHostStore = remember { KnownHostStore(context) }
var savedHosts by remember { mutableStateOf(knownHostStore.all()) } var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
// The settings-profile catalog. Read here (not in the settings screen's copy) because this is
// where profiles are USED: to resolve what a tap connects with, to offer the one-offs, and to
// render the pinned cards. Re-read on entry, since Settings may have changed it in between.
val profileStore = remember { ProfileStore(context) }
var profiles by remember { mutableStateOf(profileStore.all()) }
// Wakes a sleeping saved host and waits for it to reappear on mDNS before dialing (its overlay // Wakes a sleeping saved host and waits for it to reappear on mDNS before dialing (its overlay
// rides over both the touch and console home). Fire-and-forget WoL isn't enough — a cold boot can // rides over both the touch and console home). Fire-and-forget WoL isn't enough — a cold boot can
// take a minute-plus to advertise again. // take a minute-plus to advertise again.
@@ -213,6 +237,13 @@ fun ConnectScreen(
knownHostStore.learnMac(dh.host, dh.port, dh.mac) knownHostStore.learnMac(dh.host, dh.port, dh.mac)
any = true any = true
} }
// Same for the OS-identity chain, so the card's icon survives the host sleeping.
if (dh.os.isNotEmpty() &&
knownHostStore.get(dh.host, dh.port)?.let { it.os != dh.os } == true
) {
knownHostStore.learnOs(dh.host, dh.port, dh.os)
any = true
}
} }
any any
} }
@@ -258,7 +289,7 @@ fun ConnectScreen(
var editTarget by remember { mutableStateOf<KnownHost?>(null) } var editTarget by remember { mutableStateOf<KnownHost?>(null) }
// A saved host whose console options menu (Wake / Edit / Forget) is open — reached with Up on the // A saved host whose console options menu (Wake / Edit / Forget) is open — reached with Up on the
// carousel (the console counterpart of the touch host card's overflow menu). // carousel (the console counterpart of the touch host card's overflow menu).
var optionsTarget by remember { mutableStateOf<KnownHost?>(null) } var optionsTarget by remember { mutableStateOf<HostCardEntry?>(null) }
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts", // Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so // not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
@@ -267,15 +298,44 @@ fun ConnectScreen(
// Issue the native connect (shared by the normal connect and the request-access path). A plain // Issue the native connect (shared by the normal connect and the request-access path). A plain
// desktop connect (no library launch) — the library launcher calls [connectToHost] with an id. // desktop connect (no library launch) — the library launcher calls [connectToHost] with an id.
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long = suspend fun connectNative(
connectToHost(context, settings, id, targetHost, targetPort, pinHex, launch = null, timeoutMs = timeoutMs) id: ClientIdentity,
targetHost: String,
targetPort: Int,
pinHex: String,
timeoutMs: Int,
profile: StreamProfile?,
launch: String?,
): Long = connectToHost(
context, settings.effectiveFor(profile), id, targetHost, targetPort, pinHex,
launch = launch, timeoutMs = timeoutMs,
)
// What the stream screen is handed: the settings this connect actually used, plus the HOST's
// clipboard decision (a property of the record, not a global). A host we never saved — a
// connect that failed to pin — falls back to the on default the setting always had.
fun session(handle: Long, record: KnownHost?, profile: StreamProfile?) = ActiveSession(
handle,
settings.effectiveFor(profile),
clipboardSync = record?.clipboardSync ?: true,
profileName = profile?.name,
hostId = record?.id,
)
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint // The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
// the host presented (as an unpaired known host) so the next connect goes straight through and it // the host presented (as an unpaired known host) so the next connect goes straight through and it
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait // appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
// fallback) instead of the error status line — discovery is already restarted when it runs, so // fallback) instead of the error status line — discovery is already restarted when it runs, so
// the wait can observe the host reappear. // the wait can observe the host reappear.
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) { fun doConnectDirect(
targetHost: String,
targetPort: Int,
name: String,
pinHex: String?,
profile: StreamProfile?,
launch: String? = null,
onFailure: (() -> Unit)? = null,
) {
val id = identity ?: run { val id = identity ?: run {
status = "Identity not ready yet — try again in a moment" status = "Identity not ready yet — try again in a moment"
return return
@@ -284,9 +344,11 @@ fun ConnectScreen(
attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately
connecting = true connecting = true
status = null status = null
notice = null
discovery.stop() // free the Wi-Fi radio before the stream session discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch { scope.launch {
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS) val handle =
connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS, profile, launch)
// Cancelled mid-dial: the UI's already been returned (and discovery restarted) by // Cancelled mid-dial: the UI's already been returned (and discovery restarted) by
// cancelConnect — drop the just-opened session silently rather than navigating into it. // cancelConnect — drop the just-opened session silently rather than navigating into it.
if (thisAttempt.cancelled.get()) { if (thisAttempt.cancelled.get()) {
@@ -296,13 +358,14 @@ fun ConnectScreen(
attempt = null attempt = null
connecting = false connecting = false
if (handle != 0L) { if (handle != 0L) {
var record = knownHostStore.get(targetHost, targetPort)
if (pinHex == null) { // TOFU: pin what we observed (unpaired) if (pinHex == null) { // TOFU: pin what we observed (unpaired)
val fp = NativeBridge.nativeHostFingerprint(handle) val fp = NativeBridge.nativeHostFingerprint(handle)
if (fp.isNotEmpty()) { if (fp.isNotEmpty()) {
knownHostStore.save(KnownHost(targetHost, targetPort, name, fp, paired = false)) record = knownHostStore.trust(targetHost, targetPort, name, fp, paired = false)
} }
} }
onConnected(handle) onConnected(session(handle, record, profile))
} else { } else {
discovery.start() discovery.start()
val token = NativeBridge.nativeTakeLastError() val token = NativeBridge.nativeTakeLastError()
@@ -339,12 +402,22 @@ fun ConnectScreen(
// only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…" // only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…"
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already // overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
// seen live) dial straight through. // seen live) dial straight through.
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) { fun doConnect(
targetHost: String,
targetPort: Int,
name: String,
pinHex: String?,
oneOffProfile: String?,
launch: String? = null,
) {
if (identity == null) { if (identity == null) {
status = "Identity not ready yet — try again in a moment" status = "Identity not ready yet — try again in a moment"
return return
} }
val kh = knownHostStore.get(targetHost, targetPort) val kh = knownHostStore.get(targetHost, targetPort)
// Latched here, not per dial attempt: a wake-and-redial must stream with the same profile
// the user asked for, and the "applies from the next session" footers stay truthful.
val profile = profileStore.resolveFor(kh, oneOffProfile)
val macs = kh?.mac ?: emptyList() val macs = kh?.mac ?: emptyList()
// "Up" = a live advert that is THIS host — matched by fingerprint first (so it survives a DHCP // "Up" = a live advert that is THIS host — matched by fingerprint first (so it survives a DHCP
// address change on a cold boot), else by address:port. Returns the CURRENT advert so we can // address change on a cold boot), else by address:port. Returns the CURRENT advert so we can
@@ -355,7 +428,7 @@ fun ConnectScreen(
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) { if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
// Fire-and-forget first packet (harmless if it's awake), then dial-first. // Fire-and-forget first packet (harmless if it's awake), then dial-first.
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) } scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = { doConnectDirect(targetHost, targetPort, name, pinHex, profile, launch, onFailure = {
waker.start( waker.start(
hostName = name, hostName = name,
connectsAfter = true, connectsAfter = true,
@@ -368,15 +441,18 @@ fun ConnectScreen(
// connects) point at the live one, then dial there (no fallback on this // connects) point at the live one, then dial there (no fallback on this
// redial — a second failure surfaces as the plain error). // redial — a second failure surfaces as the plain error).
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) { if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port)) knownHostStore.save(kh.copy(address = live.host, port = live.port))
savedHosts = knownHostStore.all() savedHosts = knownHostStore.all()
} }
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex) doConnectDirect(
live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex,
profile, launch,
)
}, },
) )
}) })
} else { } else {
doConnectDirect(targetHost, targetPort, name, pinHex) doConnectDirect(targetHost, targetPort, name, pinHex, profile, launch)
} }
} }
@@ -401,7 +477,12 @@ fun ConnectScreen(
// Pin the advertised fingerprint for a discovered host (defence against an impostor while // Pin the advertised fingerprint for a discovered host (defence against an impostor while
// we wait); a manually-typed host has none, so trust-on-first-use. // we wait); a manually-typed host has none, so trust-on-first-use.
val pinHex = target.advertisedFp ?: "" val pinHex = target.advertisedFp ?: ""
val handle = connectNative(id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS) // A host being trusted for the first time can't have a binding yet, so this is always
// the plain defaults — a profile only ever enters via a later, deliberate choice.
val handle = connectNative(
id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS,
profile = null, launch = target.launch,
)
// Cancelled while we were parked: tear the (possibly just-approved) session down and // Cancelled while we were parked: tear the (possibly just-approved) session down and
// don't touch UI a fresh action may now own. // don't touch UI a fresh action may now own.
if (req.cancelled.get()) { if (req.cancelled.get()) {
@@ -414,11 +495,12 @@ fun ConnectScreen(
// Approved — save the host as PAIRED, pinning the fingerprint it presented, so // Approved — save the host as PAIRED, pinning the fingerprint it presented, so
// future connects are silent (exactly like after a PIN ceremony). // future connects are silent (exactly like after a PIN ceremony).
val fp = NativeBridge.nativeHostFingerprint(handle) val fp = NativeBridge.nativeHostFingerprint(handle)
var record = knownHostStore.get(target.host, target.port)
if (fp.isNotEmpty()) { if (fp.isNotEmpty()) {
knownHostStore.save(KnownHost(target.host, target.port, target.name, fp, paired = true)) record = knownHostStore.trust(target.host, target.port, target.name, fp, paired = true)
savedHosts = knownHostStore.all() savedHosts = knownHostStore.all()
} }
onConnected(handle) onConnected(session(handle, record, profile = null))
} else { } else {
// Cause-specific: an operator denial, an approval timeout, and a request that // Cause-specific: an operator denial, an approval timeout, and a request that
// never reached the host are different problems with different fixes. // never reached the host are different problems with different fixes.
@@ -441,6 +523,12 @@ fun ConnectScreen(
targetPort: Int, targetPort: Int,
dh: DiscoveredHost? = null, dh: DiscoveredHost? = null,
manualName: String? = null, manualName: String? = null,
// A one-off "Connect with ▸" pick. `null` = follow the host's binding (a plain tap);
// `""` = force the global defaults, which is a real choice on a bound host and must
// therefore survive as a value rather than collapsing into "unset". NEVER rebinds.
oneOffProfile: String? = null,
// A library id the host should boot straight into (`launch=` on a link).
launch: String? = null,
) { ) {
// Every dial/pair path funnels through here — with local network access denied the connect // Every dial/pair path funnels through here — with local network access denied the connect
// can only EPERM its way to a 10 s timeout, so ask instead of pretending to try. // can only EPERM its way to a 10 s timeout, so ask instead of pretending to try.
@@ -456,18 +544,195 @@ fun ConnectScreen(
when { when {
// Known host whose advertised fp still matches the pin → silent pinned reconnect. // Known host whose advertised fp still matches the pin → silent pinned reconnect.
known != null && (adv == null || adv == known.fpHex) -> known != null && (adv == null || adv == known.fpHex) ->
doConnect(targetHost, targetPort, known.name, known.fpHex) doConnect(targetHost, targetPort, known.name, known.fpHex, oneOffProfile, launch)
// Known host whose fp changed → force re-pairing (no silent re-trust shortcut). // Known host whose fp changed → force re-pairing (no silent re-trust shortcut).
known != null -> pendingTrust = known != null -> pendingTrust = PendingTrust(
PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED) targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED,
oneOffProfile, launch,
)
// Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it, // Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it,
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null. // clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
dh?.pairingRequired == false -> pendingTrust = dh?.pairingRequired == false -> pendingTrust = PendingTrust(
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW) targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW,
oneOffProfile, launch,
)
// pair=required, or a manual/unknown-policy host → offer the two ways in: a no-PIN // pair=required, or a manual/unknown-policy host → offer the two ways in: a no-PIN
// "request access" (approve in the console) or the SPAKE2 PIN ceremony. // "request access" (approve in the console) or the SPAKE2 PIN ceremony.
else -> pendingTrust = else -> pendingTrust = PendingTrust(
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS) targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS,
oneOffProfile, launch,
)
}
}
// A speed test in flight: which host+profile it is measuring, and how far it has got. The
// measurement is over a real connect, so it takes the same `connecting` gate every dial does.
var speedTest by remember { mutableStateOf<HostCardEntry?>(null) }
var speedTestPhase by remember { mutableStateOf<SpeedTestPhase>(SpeedTestPhase.Connecting) }
fun startSpeedTest(entry: HostCardEntry) {
val id = identity ?: run {
status = "Identity not ready yet — try again in a moment"
return
}
// The magic packet isn't the only thing LNP blocks: without the grant this would EPERM its
// way to a timeout and report a dead link on a perfectly good one.
if (!lnpGranted) {
lnpPrompt = true
return
}
speedTest = entry
speedTestPhase = SpeedTestPhase.Connecting
notice = null
connecting = true
discovery.stop() // a browse running through the burst would measure itself
scope.launch {
runSpeedTest(context, id, entry.host.address, entry.host.port, entry.host.fpHex) { p ->
// A dismissed dialog abandons the run; don't drag it back onto the screen.
if (speedTest != null) speedTestPhase = p
}
connecting = false
discovery.start()
}
}
// Toggle a host+profile pin. Presentation only: it never touches the profile itself and never
// changes the host's default binding.
fun togglePin(kh: KnownHost, profile: StreamProfile) {
val pins = if (profile.id in kh.pinnedProfileIds) {
kh.pinnedProfileIds - profile.id
} else {
kh.pinnedProfileIds + profile.id
}
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
savedHosts = knownHostStore.all()
}
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
// lives in the Edit sheet instead.
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
if (pin == null) {
add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) })
}
if (profiles.isEmpty()) return@buildList
if (pin != null) {
add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) })
}
add(
HostMenuItem("Connect with: Default settings", startsSection = true) {
// The empty reference is "force the defaults", not "unset" — on a bound host that
// is a real, different action from a plain tap.
connect(kh.address, kh.port, oneOffProfile = "")
},
)
profiles.forEach { p ->
add(HostMenuItem("Connect with: ${p.name}") { connect(kh.address, kh.port, oneOffProfile = p.id) })
}
if (pin == null) {
profiles.forEachIndexed { i, p ->
val pinned = p.id in kh.pinnedProfileIds
add(
HostMenuItem(
if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}",
startsSection = i == 0,
) { togglePin(kh, p) },
)
}
}
}
// The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a
// pinned combination is a plain one-click connect instead of a trip through a menu.
val savedCards = savedHosts.flatMap { kh ->
listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) }
}
// Cards in one grid row must be the same height (the grid won't stretch them), so as soon as
// ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use
// profiles ever sees the gap.
val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null }
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
//
// The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust
// decisions. So it never pairs, never trusts on its own, and carries references rather than
// values. Everything below is either "do exactly what the card does" or "refuse and say why" —
// a shortcut that can't honour its reference must say so, because streaming with the wrong
// settings is worse than an explanatory notice.
LaunchedEffect(deepLink, identity, savedHosts) {
val url = deepLink ?: return@LaunchedEffect
// Wait for the identity rather than refusing: it arrives a beat after first composition and
// the effect re-runs when it does.
if (identity == null) return@LaunchedEffect
onDeepLinkHandled()
val parsed = DeepLinks.parse(url)
if (parsed is DeepLinkResult.Refused) {
// A link for someone else's scheme is not our business to complain about.
if (parsed.error != LinkError.NOT_OUR_SCHEME) status = parsed.message()
return@LaunchedEffect
}
val link = (parsed as DeepLinkResult.Parsed).link
if (link.route != LinkRoute.CONNECT) {
// `wake` and `browse` are reserved in the grammar and parse today; a front-end that
// hasn't implemented them refuses with a notice rather than silently connecting.
status = "Punktfunk on Android can't do “${link.route.word}” links yet."
return@LaunchedEffect
}
// A profile reference that can't be honoured refuses: a "Work" shortcut streaming with the
// wrong settings is worse than an error naming what failed.
val profileRef = link.profile
if (profileRef != null) {
val (_, resolution) = profileStore.resolve(profileRef)
if (resolution != ProfileResolution.FOUND) {
status = if (resolution == ProfileResolution.AMBIGUOUS) {
"More than one profile is called “$profileRef” — rename one and try again."
} else {
"That link asks for a profile called “$profileRef”, which isn't on this device."
}
return@LaunchedEffect
}
}
when (val resolved = DeepLinks.resolveHost(link, savedHosts)) {
// Known AND pinned is the one-click contract: do exactly what tapping its card does.
is HostResolution.Known -> {
// A pin that contradicts the stored one is the link being stale or lying. Hard
// refusal: this is the one case where doing what the card does would be wrong.
if (link.pinConflict(resolved.host)) {
status = "That link's fingerprint doesn't match the one pinned for " +
"${resolved.host.name} — it's out of date, or it isn't that host."
return@LaunchedEffect
}
if (resolved.host.fpHex.isEmpty()) {
// Saved but never pinned (nothing writes such a record today, but the rule is
// absolute): a link may not establish trust, so this is a confirmation.
pendingTrust = PendingTrust(
resolved.host.address, resolved.host.port, resolved.host.name,
link.fp, PendingTrust.Kind.REQUEST_ACCESS, profileRef, link.launch,
)
return@LaunchedEffect
}
connect(
resolved.host.address, resolved.host.port,
oneOffProfile = profileRef, launch = link.launch,
)
}
// Unknown, or known only by address: the confirmation sheet, from which the normal
// pairing flow proceeds under the user's eyes. Never a silent trust.
is HostResolution.Unknown -> pendingTrust = PendingTrust(
resolved.address,
resolved.port,
link.name ?: resolved.address,
resolved.fp,
PendingTrust.Kind.REQUEST_ACCESS,
profileRef,
link.launch,
)
HostResolution.Ambiguous ->
status = "More than one saved host is called “${link.hostRef}” — " +
"rename one, or use its address."
HostResolution.Unresolvable ->
status = "That link points at a host this device doesn't know."
} }
} }
@@ -478,11 +743,15 @@ fun ConnectScreen(
// every action above; the trailing Add Host tile opens the same manual-entry sheet. // every action above; the trailing Add Host tile opens the same manual-entry sheet.
val tiles = buildList { val tiles = buildList {
savedHosts.forEach { kh -> savedHosts.forEach { kh ->
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
add( add(
HomeTile( HomeTile(
id = "saved-${kh.address}:${kh.port}", id = "saved-${kh.id}",
title = kh.name, title = kh.name,
subtitle = "${kh.address}:${kh.port}", // The binding is what a press will actually do, so the tile says so — the
// console can't edit profiles, but it must never lie about which one it uses.
subtitle = bound?.let { "${kh.address}:${kh.port} · ${it.name}" }
?: "${kh.address}:${kh.port}",
filled = true, filled = true,
online = kh.isOnline(discovered, reachable), online = kh.isOnline(discovered, reachable),
paired = kh.paired, paired = kh.paired,
@@ -490,6 +759,23 @@ fun ConnectScreen(
activate = { connect(kh.address, kh.port) }, activate = { connect(kh.address, kh.port) },
), ),
) )
// Pinned host+profile combinations, right after their host: one focus-and-press
// each, which is the affordance a controller surface does well (menus are not).
profileStore.pinsFor(kh).forEach { p ->
add(
HomeTile(
id = "pin-${kh.id}-${p.id}",
title = kh.name,
subtitle = p.name,
filled = true,
online = kh.isOnline(discovered, reachable),
paired = kh.paired,
knownHost = kh,
pinnedProfileId = p.id,
activate = { connect(kh.address, kh.port, oneOffProfile = p.id) },
),
)
}
} }
discoveredUnsaved.forEach { dh -> discoveredUnsaved.forEach { dh ->
add( add(
@@ -526,7 +812,11 @@ fun ConnectScreen(
onActivate = { it.activate() }, onActivate = { it.activate() },
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) }, onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onOptions = { it.knownHost?.let { kh -> optionsTarget = kh } }, onOptions = { tile ->
tile.knownHost?.let { kh ->
optionsTarget = HostCardEntry(kh, tile.pinnedProfileId?.let(profileStore::byId))
}
},
) )
} else { } else {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@@ -548,6 +838,23 @@ fun ConnectScreen(
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
notice?.let {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(16.dp))
}
status?.let { status?.let {
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's // In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
// job now, so `status` only ever carries a result/error here — a filled error // job now, so `status` only ever carries a result/error here — a filled error
@@ -612,24 +919,45 @@ fun ConnectScreen(
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
SectionLabel("Saved hosts") SectionLabel("Saved hosts")
} }
items(savedHosts, key = { "saved-${it.address}-${it.port}" }) { kh -> items(savedCards, key = { it.key }) { entry ->
val kh = entry.host
val pin = entry.pin
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
HostCard( HostCard(
name = kh.name, name = kh.name,
address = "${kh.address}:${kh.port}", address = "${kh.address}:${kh.port}",
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU, status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
online = kh.isOnline(discovered, reachable), online = kh.isOnline(discovered, reachable),
// Live advert preferred (the store lags a discovery tick), else stored.
os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os
?: kh.os,
enabled = !connecting, enabled = !connecting,
onConnect = { connect(kh.address, kh.port) }, // A pinned card connects with ITS profile; the host's own card follows the
onForget = { // binding, which is exactly what its chip says it will do.
knownHostStore.remove(kh.address, kh.port) onConnect = {
savedHosts = knownHostStore.all() if (pin != null) {
connect(kh.address, kh.port, oneOffProfile = pin.id)
} else {
connect(kh.address, kh.port)
}
}, },
onEdit = { editTarget = kh }, // Edit / Forget / Wake live on the host's own card only: a pinned card is a
// shortcut, not a second host, and offering destructive host actions on it
// would blur exactly that.
onForget = if (pin != null) {
null
} else {
{
knownHostStore.remove(kh)
savedHosts = knownHostStore.all()
}
},
onEdit = if (pin != null) null else ({ editTarget = kh }),
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs // Explicit wake-only: offered when the host is offline and we have a MAC. Runs
// through the WakeController so it shows the "Waking…" overlay and waits for // through the WakeController so it shows the "Waking…" overlay and waits for
// the host to come online (matched by fingerprint, so a new DHCP address on a // the host to come online (matched by fingerprint, so a new DHCP address on a
// cold boot still counts as "up") rather than firing a single silent packet. // cold boot still counts as "up") rather than firing a single silent packet.
onWake = if (kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) { onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
{ {
// The magic packet is UDP broadcast — LNP-blocked like everything else. // The magic packet is UDP broadcast — LNP-blocked like everything else.
if (!lnpGranted) { if (!lnpGranted) {
@@ -648,6 +976,11 @@ fun ConnectScreen(
} else { } else {
null null
}, },
profileLabel = pin?.name ?: bound?.name,
profileProminent = pin != null,
accent = accentColor(pin?.accent ?: bound?.accent),
menuItems = hostMenu(kh, pin),
reserveProfileSlot = anyProfileChip,
) )
} }
} }
@@ -663,6 +996,7 @@ fun ConnectScreen(
address = "${dh.host}:${dh.port}", address = "${dh.host}:${dh.port}",
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU, status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
online = true, // in the discovered list ⇒ live on mDNS right now online = true, // in the discovered list ⇒ live on mDNS right now
os = dh.os,
enabled = !connecting, enabled = !connecting,
onConnect = { connect(dh.host, dh.port, dh) }, onConnect = { connect(dh.host, dh.port, dh) },
onForget = null, onForget = null,
@@ -741,15 +1075,15 @@ fun ConnectScreen(
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode. // Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) } val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
val onSavePaired = { fp: String -> val onSavePaired = { fp: String ->
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true)) knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true)
savedHosts = knownHostStore.all() savedHosts = knownHostStore.all()
pendingTrust = null pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp) doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
} }
when (pt.kind) { when (pt.kind) {
PendingTrust.Kind.TRUST_NEW -> PendingTrust.Kind.TRUST_NEW ->
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null }) if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null }) else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
PendingTrust.Kind.FP_CHANGED -> PendingTrust.Kind.FP_CHANGED ->
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null }) if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null }) else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
@@ -774,7 +1108,9 @@ fun ConnectScreen(
} }
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget. // Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
optionsTarget?.let { kh -> optionsTarget?.let { entry ->
val kh = entry.host
val pin = entry.pin
val offline = !kh.isOnline(discovered, reachable) val offline = !kh.isOnline(discovered, reachable)
GamepadHostOptionsDialog( GamepadHostOptionsDialog(
hostName = kh.name, hostName = kh.name,
@@ -794,27 +1130,56 @@ fun ConnectScreen(
}, },
// A saved host always has a library (it's a knownHost) → offer it when the setting's on, // A saved host always has a library (it's a knownHost) → offer it when the setting's on,
// so a TV remote reaches the library here instead of via the Y face button. // so a TV remote reaches the library here instead of via the Y face button.
onLibrary = if (settings.libraryEnabled) { onLibrary = if (settings.libraryEnabled && pin == null) {
{ optionsTarget = null; onOpenLibrary(kh) } { optionsTarget = null; onOpenLibrary(kh) }
} else { } else {
null null
}, },
onSpeedTest = if (pin == null) {
{ optionsTarget = null; startSpeedTest(HostCardEntry(kh, null)) }
} else {
null
},
onEdit = { optionsTarget = null; editTarget = kh }, onEdit = { optionsTarget = null; editTarget = kh },
onForget = { onForget = {
knownHostStore.remove(kh.address, kh.port) knownHostStore.remove(kh)
savedHosts = knownHostStore.all() savedHosts = knownHostStore.all()
optionsTarget = null optionsTarget = null
}, },
onDismiss = { optionsTarget = null }, onDismiss = { optionsTarget = null },
// A pin's only action: unpinning touches neither the host nor the profile.
onUnpin = pin?.let { p -> { togglePin(kh, p); optionsTarget = null } },
profileName = pin?.name,
) )
} }
speedTest?.let { entry ->
val target = SpeedTestTarget.resolve(entry.host, entry.pin?.id, profileStore)
val dismiss = { speedTest = null }
val apply: (Boolean) -> Unit = { toProfile ->
val done = speedTestPhase as? SpeedTestPhase.Done
if (done != null) {
val where = applySpeedTestResult(
done.recommendedKbps, target, toProfile, profileStore, settings, onSettingsChange,
)
profiles = profileStore.all()
notice = "%.0f Mbit/s set in %s".format(done.recommendedMbps, where)
}
speedTest = null
}
if (gamepadUi) {
GamepadSpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
} else {
SpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
}
}
editTarget?.let { kh -> editTarget?.let { kh ->
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's // Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
// `discovery.hosts.first { host.matches($0) }?.macAddresses`. // `discovery.hosts.first { host.matches($0) }?.macAddresses`.
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList() val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
val onSaveHost: (KnownHost) -> Unit = { updated -> val onSaveHost: (KnownHost) -> Unit = { updated ->
knownHostStore.update(kh.address, kh.port, updated) knownHostStore.save(updated)
savedHosts = knownHostStore.all() savedHosts = knownHostStore.all()
editTarget = null editTarget = null
} }
@@ -832,6 +1197,7 @@ fun ConnectScreen(
EditHostDialog( EditHostDialog(
target = kh, target = kh,
suggestedMacs = suggested, suggestedMacs = suggested,
profiles = profiles,
onSave = onSaveHost, onSave = onSaveHost,
onDismiss = { editTarget = null }, onDismiss = { editTarget = null },
) )
@@ -872,6 +1238,15 @@ fun ConnectScreen(
) )
} }
/**
* One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned
* host+profile cards. Pins are additive presentation state on the host record — never duplicated
* host entries, which would fork pairing, trust and renames (design §5.2a).
*/
private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}"
}
/** /**
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically * Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via * as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
@@ -213,19 +213,92 @@ fun GamepadHostOptionsDialog(
onEdit: () -> Unit, onEdit: () -> Unit,
onForget: () -> Unit, onForget: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSpeedTest: (() -> Unit)? = null,
/**
* Non-null when this is a PINNED host+profile tile, whose only action is to unpin. A pin is a
* shortcut, not a second host — offering the host's destructive actions on it would blur
* exactly that, and the touch grid withholds them for the same reason.
*/
onUnpin: (() -> Unit)? = null,
profileName: String? = null,
) { ) {
GamepadDialog( GamepadDialog(
title = hostName, title = if (profileName != null) "$hostName · $profileName" else hostName,
onDismiss = onDismiss, onDismiss = onDismiss,
actions = buildList { actions = buildList {
if (onUnpin != null) {
add(DialogAction("Unpin card", primary = true, onClick = onUnpin))
add(DialogAction("Cancel", onClick = onDismiss))
return@buildList
}
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary)) if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
if (canWake) add(DialogAction("Wake host", onClick = onWake)) if (canWake) add(DialogAction("Wake host", onClick = onWake))
if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest))
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit)) add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
add(DialogAction("Forget", onClick = onForget)) add(DialogAction("Forget", onClick = onForget))
add(DialogAction("Cancel", onClick = onDismiss)) add(DialogAction("Cancel", onClick = onDismiss))
}, },
) { ) {
DialogText("Manage this saved host.") DialogText(
if (onUnpin != null) {
"This card is a shortcut to this host with one profile. Unpinning it changes " +
"nothing about the host or the profile."
} else {
"Manage this saved host."
},
)
}
}
/**
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
* couch surface too, even though profile EDITING doesn't.
*/
@Composable
fun GamepadSpeedTestDialog(
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val done = phase as? SpeedTestPhase.Done
GamepadDialog(
title = "Network speed test",
onDismiss = onDismiss,
actions = buildList {
if (done != null) {
add(
DialogAction(
when (target) {
SpeedTestTarget.Global -> "Apply"
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}"
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}"
},
primary = true,
) { onApply(true) },
)
if (target is SpeedTestTarget.Ask) {
add(DialogAction("Set as default") { onApply(false) })
}
}
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
},
) {
DialogText(hostName)
when (phase) {
SpeedTestPhase.Connecting -> DialogText("Connecting…")
SpeedTestPhase.Measuring ->
DialogText("Measuring — the host is bursting test traffic for two seconds.")
is SpeedTestPhase.Failed -> DialogText(phase.message)
is SpeedTestPhase.Done -> {
DialogText(
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
)
DialogText("Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps))
}
}
} }
} }
@@ -73,11 +73,17 @@ class HomeTile(
val connecting: Boolean = false, val connecting: Boolean = false,
val isAdd: Boolean = false, // the trailing Add Host tile (plus icon, not a monogram) val isAdd: Boolean = false, // the trailing Add Host tile (plus icon, not a monogram)
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y) val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
/**
* Set when this tile is a PINNED host+profile combination rather than the host's own tile.
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget, library)
* belong to the host's own tile, and this one offers only Unpin.
*/
val pinnedProfileId: String? = null,
val activate: () -> Unit, val activate: () -> Unit,
) { ) {
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair // Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
// first" message if the host hasn't authorized this device for its management API. // first" message if the host hasn't authorized this device for its management API.
val hasLibrary: Boolean get() = knownHost != null val hasLibrary: Boolean get() = knownHost != null && pinnedProfileId == null
} }
/** /**
@@ -87,7 +87,9 @@ fun GamepadSettingsScreen(
val context = LocalContext.current val context = LocalContext.current
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto. // Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null } val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
val rows = buildSettingsRows(s, hasBodyVibrator, ::update) // Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update)
var focus by remember { mutableIntStateOf(0) } var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
@@ -139,7 +141,10 @@ fun GamepadSettingsScreen(
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
item(key = "__title") { item(key = "__title") {
ConsoleHeader("Settings", horizontalInset = false) // "Default settings", not "Settings": this screen edits the base layer only. The
// console honours a host's profile but doesn't edit profiles (design §5.4), so a
// bare "Settings" would quietly imply it changes whatever that host streams with.
ConsoleHeader("Default settings", horizontalInset = false)
} }
itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = { SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
@@ -263,10 +268,12 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
} }
/** Build the console settings rows from the current [Settings], writing through [update]. /** Build the console settings rows from the current [Settings], writing through [update].
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */ * [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
* AV1 codec entry (see `codecOptionsFor`). */
private fun buildSettingsRows( private fun buildSettingsRows(
s: Settings, s: Settings,
hasBodyVibrator: Boolean, hasBodyVibrator: Boolean,
av1Capable: Boolean,
update: (Settings) -> Unit, update: (Settings) -> Unit,
): List<GpRow> { ): List<GpRow> {
fun <T> choice( fun <T> choice(
@@ -304,9 +311,36 @@ private fun buildSettingsRows(
toggled = value, toggled = value,
) )
// Grouped and ordered by the cross-client category map (General / Display / Audio /
// Controllers), with the same sub-section names the touch settings and the desktop clients use,
// so a setting sits in the same place whichever surface you found it on. The ROWS stay the
// couch-relevant subset: a pad can't drive a touch-input picker, and adding one for the sake of
// symmetry would be parity in name only.
return listOf( return listOf(
choice( choice(
"resolution", "Stream", "Resolution", "hud", "General · Statistics", "Statistics overlay",
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
"A 3-finger tap cycles the tiers live.",
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
) { update(s.copy(statsVerbosity = it)) },
toggle(
"autoWake", "General · Session", "Auto-wake on connect",
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
s.autoWakeEnabled,
) { update(s.copy(autoWakeEnabled = it)) },
toggle(
"library", "General · Library", "Game library",
"Browse a paired host's games with Y (experimental).",
s.libraryEnabled,
) { update(s.copy(libraryEnabled = it)) },
toggle(
"gamepadUI", "General · Interface", "Controller-optimized UI",
"Turn off to use the touch interface even with a controller connected.",
s.gamepadUiEnabled,
) { update(s.copy(gamepadUiEnabled = it)) },
choice(
"resolution", "Display · Resolution", "Resolution",
"The host creates a virtual display at exactly this size — no scaling. " + "The host creates a virtual display at exactly this size — no scaling. " +
"Custom sizes are typed in the touch settings.", "Custom sizes are typed in the touch settings.",
// A custom size (typed in the touch settings) leads the list so it stays visible and // A custom size (typed in the touch settings) leads the list so it stays visible and
@@ -323,33 +357,36 @@ private fun buildSettingsRows(
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.", "refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
REFRESH_OPTIONS, s.hz, REFRESH_OPTIONS, s.hz,
) { update(s.copy(hz = it)) }, ) { update(s.copy(hz = it)) },
choice( choice(
"bitrate", null, "Bitrate", "bitrate", "Display · Quality", "Bitrate",
"Automatic uses the host's default. Run a speed test from the touch UI for an informed value.", "Automatic uses the host's default. A host's options (Up on its tile) can measure the " +
"link and set an informed value.",
BITRATE_OPTIONS, s.bitrateKbps, BITRATE_OPTIONS, s.bitrateKbps,
) { update(s.copy(bitrateKbps = it)) }, ) { update(s.copy(bitrateKbps = it)) },
choice( choice(
"compositor", null, "Compositor", "codec", null, "Video codec",
"Which compositor drives the virtual output — honored only if available on the host.",
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
) { update(s.copy(compositor = it)) },
choice(
"codec", "Video", "Video codec",
"A preference — the host falls back if it can't encode this one.", "A preference — the host falls back if it can't encode this one.",
CODEC_OPTIONS, s.codec, codecOptionsFor(s.codec, av1Capable), s.codec,
) { update(s.copy(codec = it)) }, ) { update(s.copy(codec = it)) },
toggle( toggle(
"hdr", null, "10-bit HDR", "hdr", null, "10-bit HDR",
"HDR10 — engages when the host sends HDR content and this display supports it.", "HDR10 — engages when the host sends HDR content and this display supports it.",
s.hdrEnabled, s.hdrEnabled,
) { update(s.copy(hdrEnabled = it)) }, ) { update(s.copy(hdrEnabled = it)) },
toggle( toggle(
"lowLatency", null, "Low-latency mode", "lowLatency", "Display · Decoding", "Low-latency mode",
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.", "The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
s.lowLatencyMode, s.lowLatencyMode,
) { update(s.copy(lowLatencyMode = it)) }, ) { update(s.copy(lowLatencyMode = it)) },
choice(
"compositor", "Display · Host output", "Compositor",
"Which compositor drives the virtual output — honored only if available on the host.",
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
) { update(s.copy(compositor = it)) },
choice( choice(
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.", "audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
AUDIO_CHANNEL_OPTIONS, s.audioChannels, AUDIO_CHANNEL_OPTIONS, s.audioChannels,
@@ -360,9 +397,9 @@ private fun buildSettingsRows(
) { update(s.copy(micEnabled = it)) }, ) { update(s.copy(micEnabled = it)) },
choice( choice(
"padType", "Controller", "Controller type", "padType", "Controllers", "Controller type",
"The virtual pad the host creates — Automatic matches this controller.", "The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad, GAMEPAD_OPTIONS, s.gamepad,
) { update(s.copy(gamepad = it)) }, ) { update(s.copy(gamepad = it)) },
) + listOfNotNull( ) + listOfNotNull(
if (hasBodyVibrator) { if (hasBodyVibrator) {
@@ -376,26 +413,13 @@ private fun buildSettingsRows(
null null
}, },
) + listOf( ) + listOf(
choice( // NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
"hud", "Interface", "Statistics overlay", // nothing to do with this device's motor, and a TV box is where it matters most.
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
"A 3-finger tap cycles the tiers live.",
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
) { update(s.copy(statsVerbosity = it)) },
toggle( toggle(
"library", null, "Game library", "sc2", null, "Steam Controller 2 passthrough",
"Browse a paired host's games with Y (experimental).", "Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
s.libraryEnabled, "it as-is — Steam on the host drives it like the physical pad.",
) { update(s.copy(libraryEnabled = it)) }, s.sc2Capture,
toggle( ) { update(s.copy(sc2Capture = it)) },
"autoWake", null, "Auto-wake on connect",
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
s.autoWakeEnabled,
) { update(s.copy(autoWakeEnabled = it)) },
toggle(
"gamepadUI", null, "Controller-optimized UI",
"Turn off to use the touch interface even with a controller connected.",
s.gamepadUiEnabled,
) { update(s.copy(gamepadUiEnabled = it)) },
) )
} }
@@ -1,6 +1,7 @@
package io.unom.punktfunk package io.unom.punktfunk
import android.content.Context import android.content.Context
import android.os.Build
import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.kit.VideoDecoders
@@ -53,6 +54,9 @@ suspend fun connectToHost(
// the user's soft codec preference — the host resolves the emitted codec from both. // the user's soft codec preference — the host resolves the emitted codec from both.
VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs, VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
launch, launch,
// The host's approval-list / trust-store label for this device — the same
// Build.MODEL convention the pairing dialogs use for nativePair.
Build.MODEL ?: "Android",
) )
} }
} }
@@ -63,6 +63,7 @@ import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.IdentityStore import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.obtainIdentity import io.unom.punktfunk.kit.security.obtainIdentity
import io.unom.punktfunk.models.ActiveSession
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.cos import kotlin.math.cos
@@ -85,7 +86,7 @@ private sealed class LibState {
fun LibraryScreen( fun LibraryScreen(
host: KnownHost, host: KnownHost,
settings: Settings, settings: Settings,
onLaunched: (Long) -> Unit, onLaunched: (ActiveSession) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
navActive: Boolean = true, navActive: Boolean = true,
) { ) {
@@ -142,7 +143,11 @@ fun LibraryScreen(
host.address, host.port, host.fpHex, launch = game.id, host.address, host.port, host.fpHex, launch = game.id,
) )
launching = false launching = false
if (handle != 0L) onLaunched(handle) if (handle != 0L) {
onLaunched(
ActiveSession(handle, settings, host.clipboardSync),
)
}
else Toast.makeText( else Toast.makeText(
context, context,
"Launch failed — check the host and try again.", "Launch failed — check the host and try again.",
@@ -13,6 +13,7 @@ import android.view.InputDevice
import android.view.KeyCharacterMap import android.view.KeyCharacterMap
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -27,6 +28,10 @@ import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.Keymap import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.security.KnownHostStore
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */ /** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION" private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
@@ -96,6 +101,17 @@ class MainActivity : ComponentActivity() {
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC) var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
private set private set
/**
* A `punktfunk://` URL waiting to be routed — set from the VIEW intent that started (or
* re-entered) this activity, cleared by whoever handles it. Compose observes it.
*
* Read in BOTH [onCreate] and [onNewIntent] on purpose: `launchMode` is `standard`, so a second
* link usually arrives as a fresh activity instance (onCreate) and only sometimes as a new
* intent on this one (a caller that set `FLAG_ACTIVITY_SINGLE_TOP`). A link arriving while an
* earlier one is still unhandled replaces it — the user's latest intent is the live one.
*/
var pendingDeepLink by mutableStateOf<String?>(null)
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */ /** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
private var highRefreshModeId = 0 private var highRefreshModeId = 0
@@ -125,6 +141,28 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// A URL may never preempt a live session (design/client-deep-links.md §3.2). With
// `launchMode = standard` a link normally arrives as a NEW activity instance in a new task
// — the streaming one gets backgrounded, and backgrounding ends a session — so the refusal
// has to happen HERE, before this instance is resumed, not inside the composition (which
// only ever sees the rare `onNewIntent` case). Finishing now leaves the streaming task in
// front, untouched.
val live = liveStream
if (live != null && deepLinkFrom(intent) != null) {
// Pointing at the host already being streamed is the one exception, and its right
// answer is to do nothing: the intent has already brought the app forward, which is
// what "focus it" means here.
if (!targetsHost(intent, live)) {
Toast.makeText(
this,
"Already streaming — end this session first.",
Toast.LENGTH_LONG,
).show()
}
finish()
return
}
pendingDeepLink = deepLinkFrom(intent)
lastPadIsGamepad = !isTvDevice(this) lastPadIsGamepad = !isTvDevice(this)
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad()) lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
resolveHighRefreshMode() resolveHighRefreshMode()
@@ -185,6 +223,20 @@ class MainActivity : ComponentActivity() {
} }
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Keep `getIntent()` truthful for anything that reads it later (the gamepad-UI dev flag).
setIntent(intent)
deepLinkFrom(intent)?.let { pendingDeepLink = it }
}
/**
* The `punktfunk://` URL of a VIEW intent, or null. Only VIEW: the launcher's MAIN intent
* carries no data, and nothing else may inject a URL into the router.
*/
private fun deepLinkFrom(intent: Intent?): String? =
intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data?.toString()
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
startSc2MenuNav() startSc2MenuNav()
@@ -339,6 +391,17 @@ class MainActivity : ComponentActivity() {
return true // consumed return true // consumed
} }
} }
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
// Resolved before the remote-pointer hook so pointer mode can't eat them as its own
// BACK. See [mouseSideButton] for how a mouse's BACK is told from a remote's.
mouseSideButton(event)?.let { back ->
when (event.action) {
KeyEvent.ACTION_DOWN ->
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
}
return true
}
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it; // TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK). // while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) { if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
@@ -355,13 +418,12 @@ class MainActivity : ComponentActivity() {
return true return true
} }
when (event.keyCode) { when (event.keyCode) {
// A mouse's back/forward buttons already go over the wire as X1/X2 via their // Whatever [mouseSideButton] didn't claim. A view-level FALLBACK BACK appears when
// BUTTON_* motion edges — but Android ALSO delivers them as key events: the input // a BUTTON_* press goes unconsumed, and an air-mouse remote stamps its own BACK
// reader synthesizes KEYCODE_BACK/FORWARD (stamped SOURCE_MOUSE) unconditionally, // SOURCE_MOUSE; both are duplicates of something already handled, and letting
// and a view-level FALLBACK BACK appears when the BUTTON_* press goes unconsumed. // either through doubles as Android navigation and yanks the user out of the
// Swallow every such duplicate or it doubles as Android navigation and yanks the // stream. A remote/keyboard BACK is never mouse-sourced, so it still falls through
// user out of the stream. A remote/keyboard BACK is never mouse-sourced, so it // to the BackHandler and exits.
// still falls through to the BackHandler and exits.
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD -> KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD ->
if (event.isFromSource(InputDevice.SOURCE_MOUSE) || if (event.isFromSource(InputDevice.SOURCE_MOUSE) ||
event.flags and KeyEvent.FLAG_FALLBACK != 0 event.flags and KeyEvent.FLAG_FALLBACK != 0
@@ -431,6 +493,34 @@ class MainActivity : ComponentActivity() {
return super.dispatchKeyEvent(event) return super.dispatchKeyEvent(event)
} }
/**
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
* anything else — including a remote's or keyboard's BACK, which must keep exiting the stream.
*
* A mouse that carries its side buttons on the HID consumer page (AC Back / AC Forward) reaches
* us only as `KEYCODE_BACK`/`KEYCODE_FORWARD`, with no `BUTTON_BACK`/`BUTTON_FORWARD` motion
* edge behind it — on those, the motion path alone leaves the side buttons dead. The event may
* even be stamped SOURCE_KEYBOARD rather than SOURCE_MOUSE, because the consumer-page collection
* is a separate sub-device, so the DEVICE is what we ask: it has to be able to be a mouse.
*
* A D-pad-capable device is excluded even when it also reports a pointer: that is an air-mouse
* remote, whose BACK is the couch user's way out of the stream and must stay navigation.
* FLAG_FALLBACK events are excluded too — those are a duplicate the framework raises after an
* unconsumed BUTTON_* press, i.e. one the motion path already forwarded.
*/
private fun mouseSideButton(event: KeyEvent): Boolean? {
val back = when (event.keyCode) {
KeyEvent.KEYCODE_BACK -> true
KeyEvent.KEYCODE_FORWARD -> false
else -> return null
}
if (event.flags and KeyEvent.FLAG_FALLBACK != 0) return null
val device = event.device ?: return null
if (!device.supportsSource(InputDevice.SOURCE_MOUSE)) return null
if (device.supportsSource(InputDevice.SOURCE_DPAD)) return null
return back
}
/** Last D-pad direction synthesised from a stick/HAT — edge detection (one focus move per push). */ /** Last D-pad direction synthesised from a stick/HAT — edge detection (one focus move per push). */
private var lastNavDir = 0 private var lastNavDir = 0
@@ -503,4 +593,29 @@ class MainActivity : ComponentActivity() {
-> true -> true
else -> KeyEvent.isGamepadButton(kc) else -> KeyEvent.isGamepadButton(kc)
} }
/** Does [intent]'s link resolve to the host [live] is already streaming? */
private fun targetsHost(intent: Intent?, live: LiveStream): Boolean {
val url = deepLinkFrom(intent) ?: return false
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return false
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(this).all())
return target is HostResolution.Known && target.host.id == live.hostId
}
/** The host a live stream is on — see [liveStream]. */
data class LiveStream(val hostId: String?)
companion object {
/**
* The live stream, PROCESS-wide (null = not streaming), published by the composition that
* owns it.
*
* Deliberately not per-instance state: `launchMode` is `standard`, so a `punktfunk://`
* link arrives as a second activity instance that knows nothing about the first — and the
* one thing it must know is that a session is already running. Static state is what
* crosses that gap; the process dying resets it, which is also correct.
*/
@Volatile
var liveStream: LiveStream? = null
}
} }
@@ -180,6 +180,22 @@ class MouseForwarder(
} }
} }
/**
* A mouse side button that arrived as a KEY event rather than a BUTTON_* motion edge.
*
* Not every mouse reports its side buttons the same way. One that puts them on the HID button
* page (BTN_SIDE/BTN_EXTRA) gets `BUTTON_BACK`/`BUTTON_FORWARD` in the motion button state and
* lands in [button]. One that puts them on the consumer page (AC Back / AC Forward — common on
* Bluetooth mice, and the shape Android TV boxes tend to see) produces ONLY synthesized
* `KEYCODE_BACK`/`KEYCODE_FORWARD` key events, so [button] never fires and the side buttons are
* dead on the wire. This is the key-shaped entry point for those.
*
* Devices that report BOTH send the key first and the motion edge second (that is the order the
* input reader synthesizes them in), so both paths funnel into the same held-set and the
* add/remove guard collapses the pair into a single wire press.
*/
fun sideButtonKey(back: Boolean, down: Boolean) = press(if (back) 4 else 5, down)
private fun button(actionButton: Int, down: Boolean) { private fun button(actionButton: Int, down: Boolean) {
val b = when (actionButton) { val b = when (actionButton) {
MotionEvent.BUTTON_PRIMARY -> 1 MotionEvent.BUTTON_PRIMARY -> 1
@@ -189,9 +205,15 @@ class MouseForwarder(
MotionEvent.BUTTON_FORWARD -> 5 MotionEvent.BUTTON_FORWARD -> 5
else -> return else -> return
} }
press(b, down)
}
private fun press(b: Int, down: Boolean) {
if (down) { if (down) {
heldButtons.add(b) // add() is false when the button is already held — the second delivery of a button
NativeBridge.nativeSendPointerButton(handle, b, true) // this device reports on two paths at once. Sending the down again would double-press
// it on the host.
if (heldButtons.add(b)) NativeBridge.nativeSendPointerButton(handle, b, true)
} else if (heldButtons.remove(b)) { } else if (heldButtons.remove(b)) {
// Only release what we pressed — drops the release of a swallowed engaging click // Only release what we pressed — drops the release of a swallowed engaging click
// and anything that raced a capture transition. // and anything that raced a capture transition.
@@ -0,0 +1,495 @@
package io.unom.punktfunk
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
/**
* The scope switcher: the one new settings concept. Selecting a profile puts the WHOLE settings
* surface into that profile's scope — there is one settings UI, never a second parallel editor
* that drifts from it. "Default settings" is the base layer every profile inherits from.
*
* A chips row rather than a menu, because on touch the scopes are worth seeing at a glance and
* there are rarely more than a handful. Managing a profile lives ON its chip: the selected one
* grows a chevron, and tapping it again opens Edit / Duplicate / Delete anchored under it. That
* replaced a lone overflow button parked after the LAST chip — which meant scrolling past every
* profile to reach an action that applied to one of them, with nothing on screen saying which.
*
* With no profiles at all the row is just "Default settings" and a "New profile" chip, which is
* all the clutter a user who never wants this feature ever sees.
*/
@Composable
internal fun ProfileScopeChips(
profiles: List<StreamProfile>,
selectedId: String?,
onSelect: (String?) -> Unit,
onNew: () -> Unit,
onEdit: (StreamProfile) -> Unit,
onDuplicate: (StreamProfile) -> Unit,
onDelete: (StreamProfile) -> Unit,
modifier: Modifier = Modifier,
) {
// Which chip's menu is open — one at a time, and it closes itself when the scope changes.
var menuFor by remember { mutableStateOf<String?>(null) }
Row(
modifier = modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FilterChip(
selected = selectedId == null,
onClick = { onSelect(null) },
label = { Text("Default settings") },
)
profiles.forEach { p ->
val isSelected = selectedId == p.id
Box {
FilterChip(
selected = isSelected,
// Tap to select; tap the selected one — the one wearing the chevron — to manage
// it. The action is on the object it acts on, which is the whole point.
onClick = { if (isSelected) menuFor = p.id else onSelect(p.id) },
// The dot and the chevron ride INSIDE the label, not in the `leadingIcon` /
// `trailingIcon` slots: those reserve an 18dp icon and shrink the chip's padding
// to suit, so a chip with an accent (or with the chevron) would sit differently
// from "Default settings" beside it. In the label every chip keeps the same
// padding and the spacing is ours to set.
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
accentColor(p.accent)?.let { dot ->
AccentDot(dot, size = 8)
Spacer(Modifier.width(8.dp))
}
Text(p.name)
if (isSelected) {
Spacer(Modifier.width(2.dp))
Icon(
Icons.Filled.ArrowDropDown,
contentDescription = "Manage “${p.name}",
modifier = Modifier.size(18.dp),
)
}
}
},
)
DropdownMenu(expanded = menuFor == p.id, onDismissRequest = { menuFor = null }) {
DropdownMenuItem(text = { Text("Edit…") }, onClick = { menuFor = null; onEdit(p) })
DropdownMenuItem(
text = { Text("Duplicate") },
onClick = { menuFor = null; onDuplicate(p) },
)
DropdownMenuItem(text = { Text("Delete…") }, onClick = { menuFor = null; onDelete(p) })
}
}
}
AssistChip(
onClick = onNew,
label = { Text("New profile") },
leadingIcon = {
Icon(Icons.Filled.Add, contentDescription = null, Modifier.size(AssistChipDefaults.IconSize))
},
)
}
}
/**
* Create or edit a profile: its name and its colour, decided together. They were two flows —
* a name dialog at creation, "Change colour…" afterwards — which meant every profile started
* colourless-looking until the user went hunting for a menu item, and the accent is exactly the
* signal that has to be there from the first moment (it is all a bound host card's chip and a
* pinned card's tint have to go on).
*
* Names must be unique case-insensitively: two "Work" chips in a menu are ambiguous, and a
* `punktfunk://…?profile=Work` link would have to refuse rather than guess. [taken] is the live
* duplicate check, which lets an edit keep its own name (and change only its case).
*/
@Composable
internal fun ProfileEditorDialog(
title: String,
confirmLabel: String,
initialName: String,
initialAccent: String?,
creating: Boolean,
taken: (String) -> Boolean,
onConfirm: (name: String, accent: String?) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember { mutableStateOf(initialName) }
var accent by remember { mutableStateOf(initialAccent) }
val trimmed = name.trim()
val duplicate = trimmed.isNotEmpty() && taken(trimmed)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
ProfileEditorFields(
name = name,
accent = accent,
duplicate = duplicate,
creating = creating,
onNameChange = { name = it },
onAccentChange = { accent = it },
)
},
confirmButton = {
TextButton(
enabled = trimmed.isNotEmpty() && !duplicate,
onClick = { onConfirm(trimmed, accent) },
) { Text(confirmLabel) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
/**
* The editor's body. Extracted from [ProfileEditorDialog] so the screenshot harness can render
* exactly these — a focused text field inside a Dialog window never reaches idle under Robolectric,
* so the dialog itself is uncapturable, and an eyeballed-only layout is how this shipped once with
* the field and its caption touching.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun ProfileEditorFields(
name: String,
accent: String?,
duplicate: Boolean,
creating: Boolean,
onNameChange: (String) -> Unit,
onAccentChange: (String?) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name") },
placeholder = { Text("e.g. Game, Work, Travel") },
singleLine = true,
isError = duplicate,
)
Text(
when {
duplicate -> "A profile called “${name.trim()}” already exists."
creating -> "A profile starts out inheriting every default setting. Whatever you " +
"change while it's selected becomes an override."
else -> "The colour marks this profile on host cards, where its name doesn't fit."
},
style = MaterialTheme.typography.bodySmall,
color = if (duplicate) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
Text(
"Colour",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(top = 4.dp),
)
// A fixed 4×2 grid rather than a flow: eight colours wrapping to whatever fits the dialog
// landed 6-then-2, which reads as a mistake. Two even rows read as a palette. The order is
// the hue sweep from PROFILE_ACCENTS, so it looks like a spectrum rather than a bag.
//
// Each row FILLS the width, its swatches sharing it equally, so the palette's edges line up
// with the name field above it and every row is the same length. A fixed swatch size left
// the rows short of the dialog's edge and wrapped unevenly, which read as the grid having
// run out rather than as a deliberate block.
Column(
verticalArrangement = Arrangement.spacedBy(SWATCH_GAP),
modifier = Modifier.fillMaxWidth(),
) {
PROFILE_ACCENTS.chunked(SWATCHES_PER_ROW).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(SWATCH_GAP),
) {
row.forEach { hex ->
Swatch(
colour = accentColor(hex),
selected = accent?.equals(hex, ignoreCase = true) == true,
onClick = { onAccentChange(hex) },
modifier = Modifier.weight(1f),
)
}
}
}
}
// "No colour" is a real choice, not only an initial state — the chip then falls back to the
// theme's own accent, which is what a profile made before colours existed shows. It sits
// apart from the grid and says so in words, rather than hiding as a ninth, colourless
// circle that breaks the palette's rhythm.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clickable { onAccentChange(null) }
.padding(vertical = 4.dp),
) {
Swatch(colour = null, selected = accent == null, onClick = { onAccentChange(null) })
Text(
"No colour",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
/**
* One colour choice. The selected one keeps its size and grows a ring OUTSIDE the disc with a gap
* between the two, plus a check — a border drawn on the disc's own edge reads as a heavier circle
* rather than as a selection, and colour-plus-check survives a reader who can't tell two of these
* hues apart. The ring's space is always reserved, so picking never nudges the grid.
*
* `null` is "no colour": the surface's own variant, outlined so it reads as an empty slot rather
* than a dark swatch.
*/
@Composable
internal fun Swatch(
colour: Color?,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier.size(SWATCH_TOTAL),
) {
val fill = colour ?: MaterialTheme.colorScheme.surfaceVariant
Box(
modifier = modifier
.aspectRatio(1f)
.clip(CircleShape)
.then(
if (selected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
} else {
Modifier
},
)
.clickable(onClick = onClick)
.semantics { contentDescription = if (colour == null) "No colour" else "Colour" },
contentAlignment = Alignment.Center,
) {
Box(
Modifier
// Padding, not a fixed size: the disc has to scale with a swatch that shares its
// row's width, while the gap that makes the selection ring read stays constant.
.fillMaxSize()
.padding(RING_GAP)
.clip(CircleShape)
.background(fill)
.then(
if (colour == null) {
Modifier.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
} else {
Modifier
},
),
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
// These hues are all light enough that a near-black check is the readable one;
// the empty slot is dark, so it takes the surface's foreground instead.
tint = if (colour == null) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
Color(0xFF1B1633)
},
)
}
}
}
}
/**
* The palette's geometry. [SWATCHES_PER_ROW] divides [PROFILE_ACCENTS] exactly — that is the whole
* reason the palette has ten colours — so both rows are full. [SWATCH_TOTAL] is only the fallback
* footprint for a swatch outside the grid (the "no colour" one); in the grid a swatch takes an
* equal share of the row instead.
*/
private const val SWATCHES_PER_ROW = 5
private val SWATCH_TOTAL = 44.dp
private val RING_GAP = 5.dp
private val SWATCH_GAP = 10.dp
/**
* Deleting a profile is not destructive to anything but the profile — a host bound to it falls
* back to the default settings and a card pinned to it disappears, neither of which is an error.
* The warning counts both so the consequence is stated rather than discovered.
*/
@Composable
internal fun DeleteProfileDialog(
profile: StreamProfile,
boundHosts: Int,
pinnedCards: Int,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete “${profile.name}”?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
val consequences = buildList {
if (boundHosts > 0) {
add("$boundHosts ${plural(boundHosts, "host", "hosts")} will fall back to the default settings")
}
if (pinnedCards > 0) {
add("$pinnedCards pinned ${plural(pinnedCards, "card", "cards")} will disappear")
}
}
Text(
if (consequences.isEmpty()) {
"Nothing uses this profile."
} else {
consequences.joinToString(", and ") + "."
},
)
Text(
"The settings it overrides aren't lost anywhere else — the defaults stay " +
"exactly as they are.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = { TextButton(onClick = onConfirm) { Text("Delete") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
private fun plural(n: Int, one: String, many: String) = if (n == 1) one else many
/**
* The per-host half of profiles, inside the host's Edit sheet: which profile a plain tap uses
* (the binding — the one thing that IS sticky; "Connect with ▸" on a card never rebinds), and which
* profiles get their own card in the host list.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun HostProfileBinding(
profiles: List<StreamProfile>,
boundId: String?,
onBind: (String?) -> Unit,
pins: List<String>,
onTogglePin: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val bound = profiles.firstOrNull { it.id == boundId }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = bound?.name ?: "Default settings",
onValueChange = {},
readOnly = true,
label = { Text("Profile") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("Default settings") },
onClick = { onBind(null); expanded = false },
)
profiles.forEach { p ->
DropdownMenuItem(
text = { Text(p.name) },
onClick = { onBind(p.id); expanded = false },
)
}
}
}
Text(
"What a tap on this host connects with.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"Pinned cards",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(top = 8.dp),
)
Text(
"A pinned profile gets its own card beside this host — one tap instead of a menu. " +
"Pinning changes nothing about which profile is the default.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
profiles.forEach { p ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(p.name, modifier = Modifier.weight(1f))
Checkbox(checked = p.id in pins, onCheckedChange = { onTogglePin(p.id) })
}
}
}
}
/** The accent marker a profile's chip and its pinned cards wear. */
@Composable
internal fun AccentDot(color: Color, size: Int = 10) {
Box(Modifier.size(size.dp).clip(CircleShape).background(color))
}
/** `#RRGGBB` → a Compose colour, or null when the stored string isn't one (never a crash). */
internal fun accentColor(hex: String?): Color? {
val h = hex?.removePrefix("#") ?: return null
if (h.length != 6 || !h.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) return null
return runCatching { Color(h.toLong(16) or 0xFF000000L) }.getOrNull()
}
@@ -0,0 +1,391 @@
package io.unom.punktfunk
import android.content.Context
import io.unom.punktfunk.kit.security.KnownHost
import java.security.SecureRandom
import org.json.JSONObject
/**
* Client settings profiles — named bundles of setting overrides applied on top of the global
* [Settings] (design/client-settings-profiles.md §4). The Kotlin mirror of
* `crates/pf-client-core/src/profiles.rs`; the model is the same on every client, so get it right
* here rather than re-deciding it.
*
* A profile overrides only the fields the user touched; everything else keeps following the global
* defaults **live**, so fixing a global once fixes it everywhere. That is why an overlay is sparse
* nullable fields rather than a snapshot copy, and why a value is written on touch and cleared only
* on an explicit "reset to default" — never by diffing against the current global at save time. A
* stored value that happens to equal today's global is a legitimate *pin*: the profile keeps it
* when the global later moves.
*
* Only tier-P settings are here. Device facts (which pad this device forwards, whether its console
* UI is on) and host facts (clipboard sync, which lives on the host record) are deliberately absent
* — see the design's §3 curation.
*
* Values are stored exactly as [SettingsStore] persists them — ints for the compositor/gamepad wire
* bytes, enum names for the rest — so there is one encoding of a setting on this platform rather
* than two. The catalog is client-local (v1 has no profile sync or export), so nothing else reads
* it.
*/
data class SettingsOverlay(
val width: Int? = null,
val height: Int? = null,
val hz: Int? = null,
val bitrateKbps: Int? = null,
val renderScale: Double? = null,
val codec: String? = null,
val hdrEnabled: Boolean? = null,
val compositor: Int? = null,
val audioChannels: Int? = null,
val micEnabled: Boolean? = null,
val touchMode: TouchMode? = null,
val mouseMode: MouseMode? = null,
val invertScroll: Boolean? = null,
val gamepad: Int? = null,
val statsVerbosity: StatsVerbosity? = null,
/**
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
* else, but here it is the one knob a marginal link wants turned off per host.
*/
val lowLatencyMode: Boolean? = null,
/**
* Overlay keys a newer build wrote and this one doesn't model — carried through a load→save
* round-trip untouched. The don't-clobber rule: opening and saving a profile on an older client
* must not erase what a newer one stored.
*/
val extra: Map<String, Any> = emptyMap(),
) {
/** The one resolution seam: this overlay on top of [base]. Pure, so it is fully testable. */
fun apply(base: Settings): Settings = base.copy(
width = width ?: base.width,
height = height ?: base.height,
hz = hz ?: base.hz,
bitrateKbps = bitrateKbps ?: base.bitrateKbps,
renderScale = renderScale ?: base.renderScale,
codec = codec ?: base.codec,
hdrEnabled = hdrEnabled ?: base.hdrEnabled,
compositor = compositor ?: base.compositor,
audioChannels = audioChannels ?: base.audioChannels,
micEnabled = micEnabled ?: base.micEnabled,
touchMode = touchMode ?: base.touchMode,
mouseMode = mouseMode ?: base.mouseMode,
invertScroll = invertScroll ?: base.invertScroll,
gamepad = gamepad ?: base.gamepad,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
)
/**
* Record, as overrides, every tier-P field that differs between two settings snapshots.
*
* The settings UI commits a whole `Settings` per control (`update(s.copy(codec = …))`), so it
* can't hand over a list of touched fields — it hands over "what the control was showing" and
* "what it shows now", and the only field that can differ is the one the user just touched.
*
* This is NOT the diff-on-save the design rejects: the comparison is against the EFFECTIVE
* settings the control was displaying, not against the globals, so setting a value back to
* whatever the global happens to be still records an override — the pin. It only ever adds
* overrides; removing one is [clear], a different, explicit operation.
*/
fun absorb(before: Settings, after: Settings): SettingsOverlay = copy(
width = if (after.width != before.width) after.width else width,
height = if (after.height != before.height) after.height else height,
hz = if (after.hz != before.hz) after.hz else hz,
bitrateKbps = if (after.bitrateKbps != before.bitrateKbps) after.bitrateKbps else bitrateKbps,
renderScale = if (after.renderScale != before.renderScale) after.renderScale else renderScale,
codec = if (after.codec != before.codec) after.codec else codec,
hdrEnabled = if (after.hdrEnabled != before.hdrEnabled) after.hdrEnabled else hdrEnabled,
compositor = if (after.compositor != before.compositor) after.compositor else compositor,
audioChannels = if (after.audioChannels != before.audioChannels) after.audioChannels else audioChannels,
micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled,
touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode,
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
)
/**
* Drop one override by its field name, putting the row back to inheriting. [FIELD_RESOLUTION]
* is the one alias, covering the width/height pair a single control drives. An unknown name is
* a no-op.
*/
fun clear(field: String): SettingsOverlay = when (field) {
FIELD_RESOLUTION -> copy(width = null, height = null)
"refresh_hz" -> copy(hz = null)
"bitrate_kbps" -> copy(bitrateKbps = null)
"render_scale" -> copy(renderScale = null)
"codec" -> copy(codec = null)
"hdr_enabled" -> copy(hdrEnabled = null)
"compositor" -> copy(compositor = null)
"audio_channels" -> copy(audioChannels = null)
"mic_enabled" -> copy(micEnabled = null)
"touch_mode" -> copy(touchMode = null)
"mouse_mode" -> copy(mouseMode = null)
"invert_scroll" -> copy(invertScroll = null)
"gamepad" -> copy(gamepad = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
else -> this
}
/** The field names this overlay overrides — what the settings rows draw their markers from. */
fun overridden(): Set<String> = buildSet {
if (width != null || height != null) add(FIELD_RESOLUTION)
if (hz != null) add("refresh_hz")
if (bitrateKbps != null) add("bitrate_kbps")
if (renderScale != null) add("render_scale")
if (codec != null) add("codec")
if (hdrEnabled != null) add("hdr_enabled")
if (compositor != null) add("compositor")
if (audioChannels != null) add("audio_channels")
if (micEnabled != null) add("mic_enabled")
if (touchMode != null) add("touch_mode")
if (mouseMode != null) add("mouse_mode")
if (invertScroll != null) add("invert_scroll")
if (gamepad != null) add("gamepad")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
}
/**
* True when the profile overrides nothing — "inherits everything", the state a freshly created
* profile starts in. A profile holding only a newer build's field is NOT empty.
*/
fun isEmpty(): Boolean = overridden().isEmpty() && extra.isEmpty()
internal fun toJson(): JSONObject {
val j = JSONObject()
// Unknown keys first, so a modelled field always wins over a stale carried-through one.
extra.forEach { (k, v) -> j.put(k, v) }
width?.let { j.put("width", it) }
height?.let { j.put("height", it) }
hz?.let { j.put("refresh_hz", it) }
bitrateKbps?.let { j.put("bitrate_kbps", it) }
renderScale?.let { j.put("render_scale", it) }
codec?.let { j.put("codec", it) }
hdrEnabled?.let { j.put("hdr_enabled", it) }
compositor?.let { j.put("compositor", it) }
audioChannels?.let { j.put("audio_channels", it) }
micEnabled?.let { j.put("mic_enabled", it) }
touchMode?.let { j.put("touch_mode", it.name) }
mouseMode?.let { j.put("mouse_mode", it.storedName) }
invertScroll?.let { j.put("invert_scroll", it) }
gamepad?.let { j.put("gamepad", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
return j
}
companion object {
/** The width/height pair, which one control drives — the reset alias, as on every client. */
const val FIELD_RESOLUTION = "resolution"
/** Keys this build models; everything else in a stored overlay is carried through. */
private val KNOWN = setOf(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "touch_mode",
"mouse_mode", "invert_scroll", "gamepad", "stats_verbosity", "low_latency_mode",
)
internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay(
width = j.optIntOrNull("width"),
height = j.optIntOrNull("height"),
hz = j.optIntOrNull("refresh_hz"),
bitrateKbps = j.optIntOrNull("bitrate_kbps"),
renderScale = if (j.has("render_scale")) j.optDouble("render_scale") else null,
codec = j.optStringOrNull("codec"),
hdrEnabled = j.optBooleanOrNull("hdr_enabled"),
compositor = j.optIntOrNull("compositor"),
audioChannels = j.optIntOrNull("audio_channels"),
micEnabled = j.optBooleanOrNull("mic_enabled"),
touchMode = j.optStringOrNull("touch_mode")
?.let { n -> TouchMode.entries.firstOrNull { it.name == n } },
mouseMode = j.optStringOrNull("mouse_mode")
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
invertScroll = j.optBooleanOrNull("invert_scroll"),
gamepad = j.optIntOrNull("gamepad"),
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) },
)
}
}
/**
* One named bundle of overrides. [id] is stable across renames — host bindings, pinned cards and
* `punktfunk://` links all point at it, never at the name.
*/
data class StreamProfile(
val id: String,
/** User-facing and editable; unique case-insensitively (menus are ambiguous otherwise). */
val name: String,
/** `#RRGGBB` chip colour. Reserved by the schema; pinned cards tint their subtitle with it. */
val accent: String? = null,
val overrides: SettingsOverlay = SettingsOverlay(),
/** Profile keys a newer build wrote — preserved across a load→save round-trip. */
val extra: Map<String, Any> = emptyMap(),
)
/** What a `profile=` / one-off reference resolved to. Ambiguity is reported, never guessed. */
enum class ProfileResolution { FOUND, NOT_FOUND, AMBIGUOUS }
/**
* The profile catalog — client-wide, not per host: "Work" applied to three hosts is one profile,
* and the per-host part is only the binding on the host record ([KnownHost.profileId]).
*
* Stored one JSON string per profile keyed by id in its own `punktfunk_profiles` prefs file — the
* `KnownHostStore` pattern, and deliberately not inside the settings file, which is rewritten
* wholesale by several writers.
*/
class ProfileStore(context: Context) {
private val prefs =
context.applicationContext.getSharedPreferences("punktfunk_profiles", Context.MODE_PRIVATE)
/** Every profile, name-sorted — the order the scope switcher and the menus show. */
fun all(): List<StreamProfile> = prefs.all.values
.mapNotNull { (it as? String)?.let(::parse) }
.sortedBy { it.name.lowercase() }
fun byId(id: String): StreamProfile? = prefs.getString(id, null)?.let(::parse)
fun save(profile: StreamProfile) {
prefs.edit().putString(profile.id, encode(profile)).apply()
}
fun delete(id: String) {
prefs.edit().remove(id).apply()
}
/**
* Resolve a reference the way every surface must: exact id first, then a unique
* case-insensitive name. Two profiles sharing a name resolve to [ProfileResolution.AMBIGUOUS]
* — a link or a flag naming two profiles must refuse, not pick whichever came first.
*/
fun resolve(reference: String): Pair<StreamProfile?, ProfileResolution> {
if (reference.isEmpty()) return null to ProfileResolution.NOT_FOUND
byId(reference)?.let { return it to ProfileResolution.FOUND }
val hits = all().filter { it.name.equals(reference, ignoreCase = true) }
return when (hits.size) {
1 -> hits[0] to ProfileResolution.FOUND
0 -> null to ProfileResolution.NOT_FOUND
else -> null to ProfileResolution.AMBIGUOUS
}
}
/**
* Is this name already used (case-insensitively) by a *different* profile? The create/rename
* guard — [except] is the profile being renamed, so renaming "Work" to "work" is allowed.
*/
fun nameTaken(name: String, except: String? = null): Boolean =
all().any { it.name.equals(name, ignoreCase = true) && it.id != except }
/**
* The profile a connect to [host] should use: the one-off pick, else the host's binding, else
* none. [oneOff] is a reference (id or unique name); the empty string means "force the global
* defaults" — a real choice ("Connect with ▸ Default settings" on a bound host), not "unset",
* which is why it must survive as a value all the way down here. A binding whose profile was
* deleted resolves as none: never an error, never a blocked connect.
*/
fun resolveFor(host: KnownHost?, oneOff: String?): StreamProfile? = when {
oneOff != null -> resolve(oneOff).first
else -> host?.profileId?.let(::byId)
}
/** [host]'s pinned profiles, in card order, with duplicates and deleted profiles dropped. */
fun pinsFor(host: KnownHost): List<StreamProfile> =
host.pinnedProfileIds.distinct().mapNotNull(::byId)
private fun parse(s: String): StreamProfile? = runCatching {
val j = JSONObject(s)
StreamProfile(
id = j.getString("id"),
name = j.getString("name"),
accent = j.optStringOrNull("accent"),
overrides = SettingsOverlay.fromJson(j.optJSONObject("overrides") ?: JSONObject()),
extra = j.keys().asSequence()
.filter { it !in setOf("id", "name", "accent", "overrides") }
.associateWith { j.get(it) },
)
}.getOrNull()
private fun encode(p: StreamProfile): String {
val j = JSONObject()
p.extra.forEach { (k, v) -> j.put(k, v) }
j.put("id", p.id)
j.put("name", p.name)
p.accent?.let { j.put("accent", it) }
j.put("overrides", p.overrides.toJson())
return j.toString()
}
}
/**
* Chip colours a profile can wear. Chosen to stay legible on a dark surface and to be
* distinguishable from each other at the size they are actually used — a 6dp dot on a chip and a
* tint on a pinned card — and held at one saturation and lightness so no single swatch shouts
* over its neighbours. Deliberately NOT the presence green ([HostCard]'s online dot), which means
* something else entirely.
*
* **Ordered by hue**, so the picker reads as one sweep of the colour wheel rather than a bag of
* colours; the degrees are in the comments to keep it that way when one is swapped out. That order
* is also the order [nextAccent] hands them out in, so a user creating profiles one after another
* walks the spectrum instead of getting an arbitrary sequence.
*/
val PROFILE_ACCENTS = listOf(
"#FF8A4C", // orange 21°
"#FBBF24", // amber 45°
"#A3E635", // lime 82°
"#34D399", // green 160°
"#22D3EE", // cyan 187°
"#60A5FA", // blue 213°
"#818CF8", // indigo 239°
"#A78BFA", // violet 258°
"#F472B6", // pink 330°
"#FB7185", // rose 350°
)
/** The first accent no existing profile is using, so two profiles don't look alike by accident. */
fun nextAccent(existing: List<StreamProfile>): String {
val taken = existing.mapNotNull { it.accent?.lowercase() }.toSet()
return PROFILE_ACCENTS.firstOrNull { it.lowercase() !in taken } ?: PROFILE_ACCENTS.first()
}
/**
* A new, empty profile: it inherits everything, which is the right creation default under
* inherit-by-exception (Duplicate covers "start from that other profile"). The id is 12 lowercase
* hex characters — the shape the Rust `new_profile_id` mints.
*
* [accent] is presentation, not a setting, so it does NOT inherit — a profile with no colour would
* be indistinguishable from the defaults everywhere the accent is the whole signal (a bound card's
* chip, a pinned card's tint). Callers creating a profile from the UI pass [nextAccent].
*/
fun newProfile(name: String, accent: String? = null): StreamProfile =
StreamProfile(id = newProfileId(), name = name, accent = accent)
private val PROFILE_ID_RNG = SecureRandom()
fun newProfileId(): String {
val b = ByteArray(6)
PROFILE_ID_RNG.nextBytes(b)
return b.joinToString("") { "%02x".format(it) }
}
/**
* The settings a connect to [host] should use: the resolved profile's overrides on top of these
* globals, resolved ONCE per connect (matching the latch-at-connect model the "applies from the
* next session" footers promise). See [ProfileStore.resolveFor] for the precedence.
*/
fun Settings.effectiveFor(profile: StreamProfile?): Settings =
profile?.overrides?.apply(this) ?: this
// ---- org.json null-vs-absent helpers (optInt and friends can't tell 0 from "not there") ---------
private fun JSONObject.optIntOrNull(key: String): Int? = if (has(key)) optInt(key) else null
private fun JSONObject.optBooleanOrNull(key: String): Boolean? =
if (has(key)) optBoolean(key) else null
private fun JSONObject.optStringOrNull(key: String): String? =
if (has(key)) optString(key).ifEmpty { null } else null
@@ -111,30 +111,41 @@ data class Settings(
val sc2Capture: Boolean = true, val sc2Capture: Boolean = true,
/** /**
* Lock a physical mouse to the stream ([android.view.View.requestPointerCapture]) and forward * How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
* raw relative motion — FPS mouse-look, the iPad "Capture pointer for games" twin. Engages at * [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
* stream start and on a click into the stream; Ctrl+Alt+Shift+Q toggles it live (the chord * to the stream ([android.view.View.requestPointerCapture]) and forwards raw relative motion.
* works even with this off). Off (default): a mouse points absolutely, desktop-style. * Read once per session by StreamScreen; Ctrl+Alt+Shift+Q flips the capture live either way.
*/ */
val pointerCapture: Boolean = false, val mouseMode: MouseMode = MouseMode.DESKTOP,
/** /**
* Flip scroll direction — the mouse wheel and the two-finger touch scroll both. Parity with * Flip scroll direction — the mouse wheel and the two-finger touch scroll both. Parity with
* the Apple/GTK clients' "Invert scroll direction". * the Apple/GTK clients' "Invert scroll direction".
*/ */
val invertScroll: Boolean = false, val invertScroll: Boolean = false,
// NOTE: clipboard sync is NOT here. It is a decision about a HOST, not about this device or
/** // this stream (design/client-settings-profiles.md §3, tier H), so it lives on the host record
* Sync text copied on this device to the host and vice versa while streaming (the desktop // — see `KnownHost.clipboardSync`. It used to be a global here; `KnownHostStore.migrate`
* clients' shared clipboard, text-only here). Only effective when the host advertises the // copied that value onto every saved host and retired the key.
* clipboard capability; the protocol is opt-in per session either way.
*/
val clipboardSync: Boolean = true,
) )
/** [Settings.touchMode] values; persisted by name. */ /** [Settings.touchMode] values; persisted by name. */
enum class TouchMode { TRACKPAD, POINTER, TOUCH } enum class TouchMode { TRACKPAD, POINTER, TOUCH }
/**
* How a physical mouse drives the host — the cross-client mouse model (the Rust `MouseMode`,
* persisted as the same lowercase names). Only meaningful with a mouse attached.
* - [CAPTURE] — pointer lock: relative deltas, the local cursor hidden, the host's cursor the only
* one you see. The game model, and the desktop clients' default.
* - [DESKTOP] — uncaptured absolute pointing: the cursor enters and leaves the stream freely. The
* remote-desktop model, and Android's default (a phone/TV is far more often driven by touch or a
* pad than by a locked mouse, and this is what the platform did before the setting existed).
*/
enum class MouseMode(val storedName: String, val label: String) {
CAPTURE("capture", "Capture (games)"),
DESKTOP("desktop", "Desktop (absolute)"),
}
/** /**
* Stats-overlay detail tiers, in cycling order (persisted by name). Each tier is a strict superset * Stats-overlay detail tiers, in cycling order (persisted by name). Each tier is a strict superset
* of the previous one, so toning down never hides a number a lower tier keeps: * of the previous one, so toning down never hides a number a lower tier keeps:
@@ -193,9 +204,13 @@ class SettingsStore(context: Context) {
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true), autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false), rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true), sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
pointerCapture = prefs.getBoolean(K_POINTER_CAPTURE, false), mouseMode = prefs.getString(K_MOUSE_MODE, null)
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
// default was false, which IS `desktop` — so an install that never touched the toggle
// lands where it already was.
?: if (prefs.getBoolean(K_POINTER_CAPTURE, false)) MouseMode.CAPTURE else MouseMode.DESKTOP,
invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false), invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false),
clipboardSync = prefs.getBoolean(K_CLIPBOARD_SYNC, true),
) )
fun save(s: Settings) { fun save(s: Settings) {
@@ -219,9 +234,8 @@ class SettingsStore(context: Context) {
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled) .putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone) .putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture) .putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_POINTER_CAPTURE, s.pointerCapture) .putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll) .putBoolean(K_INVERT_SCROLL, s.invertScroll)
.putBoolean(K_CLIPBOARD_SYNC, s.clipboardSync)
.apply() .apply()
} }
@@ -260,9 +274,11 @@ class SettingsStore(context: Context) {
const val K_AUTO_WAKE = "auto_wake_enabled" const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone" const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture" const val K_SC2_CAPTURE = "sc2_capture"
const val K_MOUSE_MODE = "mouse_mode"
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
const val K_POINTER_CAPTURE = "pointer_capture" const val K_POINTER_CAPTURE = "pointer_capture"
const val K_INVERT_SCROLL = "invert_scroll" const val K_INVERT_SCROLL = "invert_scroll"
const val K_CLIPBOARD_SYNC = "clipboard_sync"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */ /** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode" const val K_TRACKPAD = "trackpad_mode"
@@ -406,21 +422,47 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
8 to "7.1 Surround", 8 to "7.1 Surround",
) )
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. The `"av1"` row /**
* only makes sense on a device with a real AV1 decoderSettingsScreen filters it out otherwise. */ * (stored value, label) for the preferred video codecthe cross-client table (the Rust
* `CODECS`), so a value another client or a profile stored is always representable here.
* `"auto"` = host decides.
*
* Two rows are capability-gated by [codecOptionsFor] rather than dropped from the table: `"av1"`
* needs a real `video/av01` decoder on this device, and `"pyrowave"` needs a PyroWave decoder,
* which this platform does not have at all (it is a Vulkan-compute codec living in `pf-presenter`;
* the JNI client decodes through MediaCodec and never advertises the bit, so preferring it would
* be a dead setting that silently resolves to HEVC).
*/
val CODEC_OPTIONS = listOf( val CODEC_OPTIONS = listOf(
"auto" to "Automatic", "auto" to "Automatic",
"hevc" to "HEVC (H.265)", "hevc" to "HEVC (H.265)",
"h264" to "H.264 (AVC)", "h264" to "H.264 (AVC)",
"av1" to "AV1", "av1" to "AV1",
"pyrowave" to "PyroWave (wired LAN)",
) )
/**
* [CODEC_OPTIONS] minus the rows this device can't decode — a preference the client never
* advertises is a setting that does nothing. [stored] is the currently persisted value, which is
* always kept selectable so the selection can be rendered (the don't-clobber rule: a codec chosen
* on another device, or by a newer build, must survive being looked at here).
*/
fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, String>> =
CODEC_OPTIONS.filter { (v, _) ->
when (v) {
"av1" -> av1Capable || stored == "av1"
"pyrowave" -> stored == "pyrowave" // no PyroWave decoder on Android — see above
else -> true
}
}
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2, /** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
* AV1=4. */ * AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
fun Settings.preferredCodec(): Int = when (codec) { fun Settings.preferredCodec(): Int = when (codec) {
"h264" -> 1 "h264" -> 1
"hevc" -> 2 "hevc" -> 2
"av1" -> 4 "av1" -> 4
"pyrowave" -> 8
else -> 0 else -> 0
} }
@@ -456,11 +498,20 @@ val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TOUCH to "Touch passthrough", TouchMode.TOUCH to "Touch passthrough",
) )
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */ /** (mode, label) for the physical-mouse model. */
val MOUSE_MODE_OPTIONS = MouseMode.entries.map { it to it.label }
/**
* (GamepadPref wire byte, label) for the emulated pad the host creates. NOT positional: the wire
* bytes are `punktfunk_core::config::GamepadPref` (see `Gamepad.PREF_*`), and Steam Deck is `6`
* with `5` (the classic Steam Controller) deliberately not offered — the same subset the desktop
* clients' picker shows.
*/
val GAMEPAD_OPTIONS = listOf( val GAMEPAD_OPTIONS = listOf(
"Automatic", io.unom.punktfunk.kit.Gamepad.PREF_AUTO to "Automatic",
"Xbox 360", io.unom.punktfunk.kit.Gamepad.PREF_XBOX360 to "Xbox 360",
"DualSense", io.unom.punktfunk.kit.Gamepad.PREF_DUALSENSE to "DualSense",
"Xbox One", io.unom.punktfunk.kit.Gamepad.PREF_XBOXONE to "Xbox One",
"DualShock 4", io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
) )
@@ -20,9 +20,11 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -33,16 +35,18 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.SportsEsports import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Tv import androidx.compose.material.icons.filled.Tv
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -50,10 +54,14 @@ import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@@ -67,29 +75,102 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.kit.deviceBodyVibrator import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.security.KnownHostStore
/** /**
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category * Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
* subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen * subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen
* it becomes a two-pane list-detail (the list stays on the left, the detail on the right). Edits * it becomes a two-pane list-detail (the list stays on the left, the detail on the right). Edits
* persist immediately via [onChange]; [onBack] returns to the connect screen. * persist immediately; [onBack] returns to the connect screen.
*
* **Structure mirrors the desktop/Apple settings revamp** ([SettingsCategory], and the Windows
* client's `app/settings.rs`), so every client reads the same way: General = session/app behaviour,
* Display = everything about the picture, Input = touch/keyboard/mouse, Audio, Controllers, About.
* Each field carries its explanation DIRECTLY under it (the `described()` idiom — see
* [SettingDropdown]'s `caption` and [ToggleRow]'s `subtitle`) rather than as loose paragraphs
* floating between controls; the only form-level notes are the "applies from the next session"
* footers, one per affected category.
*
* **One settings UI, not two.** The scope switcher on top edits either the global defaults
* ([onChange], the base layer every profile inherits from) or one profile's overrides — the same
* rows either way, so a profile can never drift from the surface it overrides. In profile scope
* only profileable settings render, every row shows the EFFECTIVE value, and a row the profile
* overrides carries a marker and a reset. See [SettingsOverlay] for the model.
*/ */
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
initial: Settings, initial: Settings,
onChange: (Settings) -> Unit, onChange: (Settings) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
/**
* Seeds the pushed detail page. The live app always starts on the category list (null); the
* screenshot harness passes a category to capture one, the way the GTK client's
* `PUNKTFUNK_SHOT_SETTINGS_SCOPE` seeds its scope.
*/
initialCategory: SettingsCategory? = null,
/** Seeds the scope the same way, for a screenshot of a profile being edited. */
initialProfileId: String? = null,
) { ) {
var s by remember { mutableStateOf(initial) } var globals by remember { mutableStateOf(initial) }
val context = LocalContext.current val context = LocalContext.current
val profileStore = remember { ProfileStore(context) }
val hostStore = remember { KnownHostStore(context) }
var profiles by remember { mutableStateOf(profileStore.all()) }
// Which layer is being edited: null = the global defaults, else a profile id. Survives rotation
// but not a trip out of Settings — coming back should land on the defaults, which is what the
// host cards actually connect with unless they say otherwise.
var scopeId by rememberSaveable { mutableStateOf(initialProfileId) }
// A profile deleted from under the scope (or a store that never had it) falls back to defaults.
val active = scopeId?.let { id -> profiles.firstOrNull { it.id == id } }
if (scopeId != null && active == null) scopeId = null
var showLicenses by remember { mutableStateOf(false) } var showLicenses by remember { mutableStateOf(false) }
var showControllers by remember { mutableStateOf(false) } var showControllers by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<EditIntent?>(null) }
var deleting by remember { mutableStateOf<StreamProfile?>(null) }
// Every row renders the EFFECTIVE value: the globals with this profile's overrides on top, so a
// row the profile doesn't override reads as the live global — and keeps following it.
val s = globals.effectiveFor(active)
/**
* The scope an edit writes to, resolved AT THE EDIT rather than closed over at composition.
*
* [update] and [resetField] reach the rows as `::update` / `::resetField` — callable references,
* and two of those compare EQUAL however different the scope they captured. Compose therefore
* sees an unchanged callback and skips [CategoryDetail] on a scope switch that doesn't move any
* value on screen — which is the ordinary case, since a profile inherits the globals until it
* overrides something. The row then kept calling the reference it was first handed and wrote
* into the scope the user had just LEFT: edit a default, switch to a profile, edit the same row
* — the globals move again and the profile records nothing; switch back and the next edit lands
* on the profile. Reading the live state here is what makes the write follow the chips.
*/
fun scopeProfile(): StreamProfile? = scopeId?.let { id -> profiles.firstOrNull { it.id == id } }
fun update(next: Settings) { fun update(next: Settings) {
s = next val profile = scopeProfile()
if (profile == null) {
globals = next
onChange(next) onChange(next)
} else {
// `absorb` against what the control was SHOWING, so picking today's global value is
// still recorded as an override (the pin) — see [SettingsOverlay.absorb]. A skipped row
// shows what a fresh one would (that is WHY it skipped), so the effective settings
// recomputed here are the same ones it rendered.
val shown = globals.effectiveFor(profile)
profileStore.save(profile.copy(overrides = profile.overrides.absorb(shown, next)))
profiles = profileStore.all()
}
}
fun resetField(field: String) {
val profile = scopeProfile() ?: return
profileStore.save(profile.copy(overrides = profile.overrides.clear(field)))
profiles = profileStore.all()
} }
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off. // Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
@@ -116,17 +197,63 @@ fun SettingsScreen(
} }
// Selected category persists across rotation (stored by name — null = the bare list on a phone). // Selected category persists across rotation (stored by name — null = the bare list on a phone).
var selectedName by rememberSaveable { mutableStateOf<String?>(null) } var selectedName by rememberSaveable { mutableStateOf(initialCategory?.name) }
val selected = selectedName?.let { n -> SettingsCategory.entries.firstOrNull { it.name == n } } val categories = SettingsCategory.entries.filter { active == null || it.profileable }
val selected = selectedName
?.let { n -> categories.firstOrNull { it.name == n } }
Column(Modifier.fillMaxSize()) {
ProfileScopeChips(
profiles = profiles,
selectedId = active?.id,
onSelect = { id ->
scopeId = id
// About has no profileable rows at all; don't strand the pane on it.
if (id != null && selected?.profileable == false) selectedName = null
},
onNew = { editing = EditIntent.New(nextAccent(profiles)) },
onEdit = { p -> editing = EditIntent.Existing(p) },
onDuplicate = { p ->
val copy = newProfile(uniqueName(profileStore, p.name), p.accent)
.copy(overrides = p.overrides)
profileStore.save(copy)
profiles = profileStore.all()
scopeId = copy.id
},
onDelete = { p -> deleting = p },
modifier = Modifier.padding(top = 12.dp),
)
if (active != null) {
Text(
"Overrides the default settings for hosts that use “${active.name}”.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 10.dp),
)
}
Spacer(Modifier.height(12.dp))
HorizontalDivider()
CompositionLocalProvider(
LocalSettingsScope provides SettingsScopeState(
profileScope = active != null,
overridden = active?.overrides?.overridden() ?: emptySet(),
onReset = ::resetField,
),
) {
BoxWithConstraints(Modifier.fillMaxSize()) { BoxWithConstraints(Modifier.fillMaxSize()) {
val twoPane = maxWidth >= 640.dp val twoPane = maxWidth >= 640.dp
// A two-column layout must never show an empty detail — land on the first category. // A two-column layout must never show an empty detail — land on the first category.
LaunchedEffect(twoPane) { LaunchedEffect(twoPane, categories) {
if (twoPane && selected == null) selectedName = SettingsCategory.Display.name if (twoPane && selected == null) selectedName = categories.first().name
} }
val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back -> val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back ->
// Keyed on the scope: switching chips rebuilds the page rather than recomposing
// it in place. Correctness no longer depends on it (see [scopeProfile]), but a
// row's own `remember` is per-scope state too — "Custom…" picked while editing
// a profile has no business still being picked over on the defaults.
key(active?.id) {
CategoryDetail( CategoryDetail(
category = cat, category = cat,
settings = s, settings = s,
@@ -138,11 +265,13 @@ fun SettingsScreen(
onBack = back, onBack = back,
) )
} }
}
if (twoPane) { if (twoPane) {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
CategoryList( CategoryList(
categories = categories,
selected = selected, selected = selected,
twoPane = true, twoPane = true,
onSelect = { selectedName = it.name }, onSelect = { selectedName = it.name },
@@ -152,15 +281,15 @@ fun SettingsScreen(
Box(Modifier.weight(1f).fillMaxHeight()) { Box(Modifier.weight(1f).fillMaxHeight()) {
// Cross-fade the detail pane as the selected category changes. // Cross-fade the detail pane as the selected category changes.
AnimatedContent( AnimatedContent(
targetState = selected ?: SettingsCategory.Display, targetState = selected ?: categories.first(),
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) }, transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
label = "SettingsPane", label = "SettingsPane",
) { cat -> detail(cat, null) } ) { cat -> detail(cat, null) }
} }
} }
} else { } else {
// Compact: the category list pushes to a full-screen detail and back, like the iOS / // Compact: the category list pushes to a full-screen detail and back, like the
// Android system settings — a horizontal slide that tracks the drill-in direction. // iOS / Android system settings — a horizontal slide tracking the drill-in.
BackHandler { if (selected != null) selectedName = null else onBack() } BackHandler { if (selected != null) selectedName = null else onBack() }
AnimatedContent( AnimatedContent(
targetState = selected, targetState = selected,
@@ -177,6 +306,7 @@ fun SettingsScreen(
) { sel -> ) { sel ->
if (sel == null) { if (sel == null) {
CategoryList( CategoryList(
categories = categories,
selected = null, selected = null,
twoPane = false, twoPane = false,
onSelect = { selectedName = it.name }, onSelect = { selectedName = it.name },
@@ -189,19 +319,159 @@ fun SettingsScreen(
} }
} }
} }
}
/** The top-level settings groups — each opens its own subpage (list on phone, split on tablet). */ editing?.let { intent ->
enum class SettingsCategory(val title: String, val icon: ImageVector) { val existing = (intent as? EditIntent.Existing)?.profile
ProfileEditorDialog(
title = if (existing == null) "New profile" else "Edit profile",
confirmLabel = if (existing == null) "Create" else "Save",
initialName = existing?.name.orEmpty(),
initialAccent = existing?.accent ?: (intent as? EditIntent.New)?.accent,
creating = existing == null,
taken = { profileStore.nameTaken(it, except = existing?.id) },
onConfirm = { name, accent ->
val saved = existing?.copy(name = name, accent = accent)
?: newProfile(name, accent)
profileStore.save(saved)
profiles = profileStore.all()
scopeId = saved.id
editing = null
},
onDismiss = { editing = null },
)
}
deleting?.let { profile ->
val hosts = hostStore.all()
DeleteProfileDialog(
profile = profile,
boundHosts = hosts.count { it.profileId == profile.id },
pinnedCards = hosts.count { profile.id in it.pinnedProfileIds },
onConfirm = {
profileStore.delete(profile.id)
profiles = profileStore.all()
scopeId = null
deleting = null
// Bindings and pins are left dangling on purpose: they resolve to "no profile" and
// to "no card", which is exactly right, and rewriting every host record here would
// be a second write pass over data the user didn't ask us to touch.
},
onDismiss = { deleting = null },
)
}
}
/** What the profile editor is for — a fresh profile (with the colour creation picked out for it),
* or one that already exists. */
private sealed interface EditIntent {
data class New(val accent: String) : EditIntent
data class Existing(val profile: StreamProfile) : EditIntent
}
/** "Work" → "Work copy" → "Work copy 2" — the first name Duplicate can actually save. */
private fun uniqueName(store: ProfileStore, base: String): String {
val first = "$base copy"
if (!store.nameTaken(first)) return first
var n = 2
while (store.nameTaken("$first $n")) n++
return "$first $n"
}
// ---- Scope plumbing ----------------------------------------------------------------------------
/**
* What the rows need to know about the layer being edited. Carried as a composition local rather
* than threaded through every category and row: the rows are the same rows in both scopes, and the
* scope only decides whether tier-G rows render at all and whether a row wears an override marker.
*/
private class SettingsScopeState(
val profileScope: Boolean,
val overridden: Set<String>,
val onReset: (String) -> Unit,
)
private val LocalSettingsScope = compositionLocalOf {
SettingsScopeState(profileScope = false, overridden = emptySet(), onReset = {})
}
/**
* Wraps rows that are facts about THIS DEVICE or this app rather than about a stream — the console
* UI toggle, the library browser, auto-wake, the controller diagnostics, rumble mirroring, SC2
* capture (design §3, tiers G and H). They never belong to a profile, so in profile scope they
* simply don't render.
*/
@Composable
private fun DeviceScopeOnly(content: @Composable () -> Unit) {
if (!LocalSettingsScope.current.profileScope) content()
}
/**
* The accent marker and reset a row wears when the selected profile overrides it. Nothing renders
* in the defaults scope, or on a row the profile inherits — an inherited row shows the live global
* value in the ordinary quiet style, which is the whole point of inherit-by-default.
*/
@Composable
private fun OverrideBadge(field: String?) {
val scope = LocalSettingsScope.current
if (!scope.profileScope || field == null || field !in scope.overridden) return
// One compact line. A `TextButton` here carried its own 48dp touch target and dwarfed both the
// control it annotates and the caption under it; the reset keeps a generous padded hit area
// instead, which is the right trade for a secondary action inside a dense settings list.
Row(
verticalAlignment = Alignment.CenterVertically,
// No vertical padding of its own: this row is the FIRST thing in a card whose column
// already pads 16dp, and anything added here reads as double the gap every other row has.
// The bottom padding is the badge's tie to the control it annotates — deliberately tighter
// than the gap down to the caption, so the marker groups upward with its own field.
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
) {
AccentDot(MaterialTheme.colorScheme.primary, size = 6)
Text(
"Overridden",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 6.dp).weight(1f),
)
Text(
"Reset",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.clickable { scope.onReset(field) }
.padding(horizontal = 10.dp, vertical = 2.dp),
)
}
}
// ---- Categories --------------------------------------------------------------------------------
/**
* The top-level settings groups — each opens its own subpage (list on phone, split on tablet).
* The map and its order are the cross-client one (Apple's `SettingsCategory`, the Windows
* NavigationView, the GTK pages): General, Display, Input, Audio, Controllers, About.
*
* [profileable] is false for a category with no profileable rows at all — it isn't offered in
* profile scope, rather than opening onto an empty page.
*/
enum class SettingsCategory(
val title: String,
val icon: ImageVector,
internal val profileable: Boolean = true,
) {
General("General", Icons.Filled.Tune),
Display("Display", Icons.Filled.Tv), Display("Display", Icons.Filled.Tv),
Audio("Audio", Icons.Filled.VolumeUp), Input("Input", Icons.Filled.TouchApp),
Controls("Controls", Icons.Filled.SportsEsports), Audio("Audio", Icons.AutoMirrored.Filled.VolumeUp),
Interface("Interface", Icons.Filled.Tune), Controllers("Controllers", Icons.Filled.SportsEsports),
About("About", Icons.Filled.Info), About("About", Icons.Filled.Info, profileable = false),
} }
/** The category list — the settings root. Highlights the [selected] row when it drives a detail pane. */ /** The category list — the settings root. Highlights the [selected] row when it drives a detail pane. */
@Composable @Composable
private fun CategoryList( private fun CategoryList(
categories: List<SettingsCategory>,
selected: SettingsCategory?, selected: SettingsCategory?,
twoPane: Boolean, twoPane: Boolean,
onSelect: (SettingsCategory) -> Unit, onSelect: (SettingsCategory) -> Unit,
@@ -218,7 +488,7 @@ private fun CategoryList(
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(start = 8.dp, bottom = 12.dp), modifier = Modifier.padding(start = 8.dp, bottom = 12.dp),
) )
SettingsCategory.entries.forEach { cat -> categories.forEach { cat ->
val highlighted = twoPane && selected == cat val highlighted = twoPane && selected == cat
Row( Row(
modifier = Modifier modifier = Modifier
@@ -276,11 +546,56 @@ private fun CategoryDetail(
Text(category.title, style = MaterialTheme.typography.headlineMedium) Text(category.title, style = MaterialTheme.typography.headlineMedium)
} }
when (category) { when (category) {
SettingsCategory.General -> GeneralSettings(settings, onChange)
SettingsCategory.Display -> DisplaySettings(settings, onChange, context) SettingsCategory.Display -> DisplaySettings(settings, onChange, context)
SettingsCategory.Input -> InputSettings(settings, onChange)
SettingsCategory.Audio -> AudioSettings(settings, onChange, onMicChange) SettingsCategory.Audio -> AudioSettings(settings, onChange, onMicChange)
SettingsCategory.Controls -> ControlsSettings(settings, onChange, onOpenControllers) SettingsCategory.Controllers -> ControllerSettings(settings, onChange, onOpenControllers)
SettingsCategory.Interface -> InterfaceSettings(settings, onChange) SettingsCategory.About -> AboutSettings(context, onOpenLicenses)
SettingsCategory.About -> AboutSettings(onOpenLicenses) }
}
}
@Composable
private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
DeviceScopeOnly {
SettingsGroup("Session") {
ToggleRow(
title = "Auto-wake on connect",
subtitle = "Wake a sleeping saved host before connecting. Turn off if hosts " +
"behind a VPN look offline when they aren't.",
checked = s.autoWakeEnabled,
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
)
}
}
SettingsGroup("Statistics") {
SettingDropdown(
label = "Stats overlay",
options = STATS_VERBOSITY_OPTIONS,
selected = s.statsVerbosity,
field = "stats_verbosity",
caption = "Compact is one line; Detailed adds the decoder and latency breakdown. " +
"A 3-finger tap cycles the tiers in-stream.",
) { v -> update(s.copy(statsVerbosity = v)) }
}
DeviceScopeOnly {
SettingsGroup("Library") {
ToggleRow(
title = "Game library",
subtitle = "Browse a paired host's games and launch one directly.",
checked = s.libraryEnabled,
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
)
}
SettingsGroup("Interface") {
ToggleRow(
title = "Controller-optimized UI",
subtitle = "Switch to the console home when a controller is connected. A TV " +
"always uses it.",
checked = s.gamepadUiEnabled,
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
)
} }
} }
} }
@@ -293,7 +608,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
// from the stored size, never flagged (see [isCustomResolution]), so nothing new persists. // from the stored size, never flagged (see [isCustomResolution]), so nothing new persists.
var customPicked by remember { mutableStateOf(false) } var customPicked by remember { mutableStateOf(false) }
val showCustom = customPicked || s.isCustomResolution() val showCustom = customPicked || s.isCustomResolution()
SettingsCard { SettingsGroup("Resolution") {
SettingDropdown( SettingDropdown(
label = "Resolution", label = "Resolution",
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } + options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } +
@@ -301,6 +616,9 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
// stored its label carries the live value, like the native row carries ($nw × $nh). // stored its label carries the live value, like the native row carries ($nw × $nh).
((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"), ((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"),
selected = if (showCustom) -1 to -1 else s.width to s.height, selected = if (showCustom) -1 to -1 else s.width to s.height,
field = SettingsOverlay.FIELD_RESOLUTION,
caption = "The host makes a display exactly this size — no scaling. Native follows " +
"this device's panel.",
) { (w, h) -> ) { (w, h) ->
if (w < 0) { if (w < 0) {
// Seed from the current *effective* size so the fields start from something // Seed from the current *effective* size so the fields start from something
@@ -327,29 +645,41 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
label = "Refresh rate", label = "Refresh rate",
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) }, options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
selected = s.hz, selected = s.hz,
field = "refresh_hz",
caption = "Native follows this device's refresh rate.",
) { hz -> update(s.copy(hz = hz)) } ) { hz -> update(s.copy(hz = hz)) }
SettingDropdown(label = "Bitrate", options = BITRATE_OPTIONS, selected = s.bitrateKbps) { kbps ->
update(s.copy(bitrateKbps = kbps))
} }
SettingsGroup("Quality") {
SettingDropdown( SettingDropdown(
label = "Render scale", label = "Render scale",
options = RENDER_SCALE_OPTIONS, options = RENDER_SCALE_OPTIONS,
// Snap the stored value (a Float round-tripped to Double) to the nearest preset so the // Snap the stored value (a Float round-tripped to Double) to the nearest preset so the
// exact Double keys match. > 1 supersamples for sharpness (more bandwidth AND decode); // exact Double keys match.
// < 1 renders under native for a lighter host — this device resamples to the display.
selected = RenderScale.PRESETS.minByOrNull { kotlin.math.abs(it - s.renderScale) } ?: 1.0, selected = RenderScale.PRESETS.minByOrNull { kotlin.math.abs(it - s.renderScale) } ?: 1.0,
field = "render_scale",
caption = "Above native is sharper but costs bandwidth and decode; below is " +
"lighter on the host.",
) { scale -> update(s.copy(renderScale = scale)) } ) { scale -> update(s.copy(renderScale = scale)) }
// AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the SettingDropdown(
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable label = "Bitrate",
// device stays visible so the selection is always representable. options = BITRATE_OPTIONS,
selected = s.bitrateKbps,
field = "bitrate_kbps",
caption = "Automatic lets the host decide.",
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
// Only codecs this device can actually decode are offered — a preference the client never
// advertises would be a dead setting (see [codecOptionsFor]).
val av1Capable = remember { VideoDecoders.pickDecoder("video/av01") != null } val av1Capable = remember { VideoDecoders.pickDecoder("video/av01") != null }
val codecOptions = CODEC_OPTIONS.filter { (v, _) -> v != "av1" || av1Capable || s.codec == "av1" } SettingDropdown(
SettingDropdown(label = "Video codec", options = codecOptions, selected = s.codec) { c -> label = "Video codec",
update(s.copy(codec = c)) options = codecOptionsFor(s.codec, av1Capable),
} selected = s.codec,
field = "codec",
caption = "A preference — the host falls back if it can't encode this one.",
) { c -> update(s.copy(codec = c)) }
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is // HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is
// disabled (and HDR is never advertised) so the host doesn't send PQ the panel mis-tone-maps. // disabled (and HDR is never advertised) so the host doesn't send PQ the panel mis-tone-maps.
@@ -357,110 +687,132 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
ToggleRow( ToggleRow(
title = "HDR", title = "HDR",
subtitle = if (hdrCapable) { subtitle = if (hdrCapable) {
"Stream 10-bit HDR (BT.2020 PQ) when the host supports it" "10-bit HDR10, when the host has HDR content to send."
} else { } else {
"This display can't present HDR10 — streams stay SDR" "This display can't present HDR10 — streams stay SDR"
}, },
checked = s.hdrEnabled && hdrCapable, checked = s.hdrEnabled && hdrCapable,
enabled = hdrCapable, enabled = hdrCapable,
field = "hdr_enabled",
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) }, onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
) )
}
// The desktop clients group their decoder and GPU pickers here, and keep them out of profiles —
// they are facts about that machine's hardware. Android has neither choice (MediaCodec resolves
// both), and the one knob it does have IS worth varying per host: a marginal link is exactly
// where you want the plain decode path back (design §3 lists it as an Android-only profileable).
SettingsGroup("Decoding") {
ToggleRow(
title = "Low-latency mode",
subtitle = "The fast decode pipeline. Turn it off if the stream stutters or " +
"glitches on this device.",
checked = s.lowLatencyMode,
field = "low_latency_mode",
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
)
}
SettingsGroup("Host output", footer = "Display changes apply from the next session.") {
SettingDropdown( SettingDropdown(
label = "Compositor", label = "Compositor",
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
selected = s.compositor, selected = s.compositor,
field = "compositor",
caption = "Linux hosts only; falls back to auto-detection when unavailable.",
) { c -> update(s.copy(compositor = c)) } ) { c -> update(s.copy(compositor = c)) }
}
}
@Composable
private fun InputSettings(s: Settings, update: (Settings) -> Unit) {
SettingsGroup("Touch & pointer") {
SettingDropdown(
label = "Touch input",
options = TOUCH_MODE_OPTIONS,
selected = s.touchMode,
field = "touch_mode",
caption = "Trackpad moves the cursor by relative swipes; Direct pointer jumps it " +
"to your finger; Passthrough sends real multi-touch.",
) { mode -> update(s.copy(touchMode = mode)) }
}
SettingsGroup("Keyboard & mouse") {
SettingDropdown(
label = "Mouse input",
options = MOUSE_MODE_OPTIONS,
selected = s.mouseMode,
field = "mouse_mode",
caption = "Capture locks the pointer to the stream for mouse-look; Desktop leaves " +
"it free. Ctrl+Alt+Shift+Q flips it live.",
) { mode -> update(s.copy(mouseMode = mode)) }
ToggleRow( ToggleRow(
title = "Low-latency mode", title = "Invert scroll direction",
subtitle = "The fast pipeline (async decode, per-device decoder selection, HDMI game " + subtitle = "Reverses wheel and two-finger scrolling",
"mode). On by default — turn off to fall back to the plain decode path if the stream " + checked = s.invertScroll,
"stutters or glitches on this device.", field = "invert_scroll",
checked = s.lowLatencyMode, onCheckedChange = { on -> update(s.copy(invertScroll = on)) },
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
) )
// "Shared clipboard" is NOT here: it is a trust decision about one host, so it lives on the
// host record and is edited from that host's Edit sheet.
} }
} }
@Composable @Composable
private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange: (Boolean) -> Unit) { private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange: (Boolean) -> Unit) {
SettingsCard { SettingsGroup(footer = "Applies from the next session.") {
SettingDropdown(label = "Audio channels", options = AUDIO_CHANNEL_OPTIONS, selected = s.audioChannels) { ch -> SettingDropdown(
update(s.copy(audioChannels = ch)) label = "Audio channels",
} options = AUDIO_CHANNEL_OPTIONS,
selected = s.audioChannels,
field = "audio_channels",
caption = "Requested from the host; it downmixes if it has fewer.",
) { ch -> update(s.copy(audioChannels = ch)) }
ToggleRow( ToggleRow(
title = "Microphone", title = "Microphone",
subtitle = "Send your mic to the host's virtual microphone", subtitle = "Feeds this device's microphone to the host",
checked = s.micEnabled, checked = s.micEnabled,
field = "mic_enabled",
onCheckedChange = onMicChange, onCheckedChange = onMicChange,
) )
} }
} }
@Composable @Composable
private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) { private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
SettingsCard { SettingsGroup(footer = "Applies from the next session.") {
SettingDropdown(label = "Touch input", options = TOUCH_MODE_OPTIONS, selected = s.touchMode) { mode ->
update(s.copy(touchMode = mode))
}
Text(
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger tap " +
"right-clicks, two fingers scroll, tap-then-drag holds the button. Direct pointer: " +
"the cursor jumps to your finger. Touch passthrough: real multi-touch reaches the " +
"host, for apps that understand touch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ToggleRow(
title = "Capture pointer for games",
subtitle = "Lock a connected mouse to the stream and send raw relative motion " +
"(mouse-look). Ctrl+Alt+Shift+Q toggles it live; click the stream to re-capture. " +
"Off: the mouse points at the desktop directly",
checked = s.pointerCapture,
onCheckedChange = { on -> update(s.copy(pointerCapture = on)) },
)
ToggleRow(
title = "Invert scroll direction",
subtitle = "Flip the mouse wheel and two-finger touch scrolling",
checked = s.invertScroll,
onCheckedChange = { on -> update(s.copy(invertScroll = on)) },
)
ToggleRow(
title = "Shared clipboard",
subtitle = "Text copied here pastes on the host and vice versa (hosts with " +
"clipboard sharing enabled)",
checked = s.clipboardSync,
onCheckedChange = { on -> update(s.copy(clipboardSync = on)) },
)
}
SettingsCard {
SettingDropdown( SettingDropdown(
label = "Controller type", label = "Controller type",
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, options = GAMEPAD_OPTIONS,
selected = s.gamepad, selected = s.gamepad,
field = "gamepad",
caption = "The virtual pad the host creates. Automatic matches your controller; " +
"every connected one is forwarded as its own player.",
) { g -> update(s.copy(gamepad = g)) } ) { g -> update(s.copy(gamepad = g)) }
DeviceScopeOnly {
ClickableRow( ClickableRow(
title = "Connected controllers", title = "Connected controllers",
subtitle = "What the app detects, with a live input test", subtitle = "What the app detects, with a live input test",
onClick = onOpenControllers, onClick = onOpenControllers,
) )
// Only where the device has a body vibrator to mirror onto (a TV box doesn't). // Rumble mirroring needs a body vibrator to mirror ONTO — a TV box has none, so the row
// would be a silent no-op there.
val context = LocalContext.current val context = LocalContext.current
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null } val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
if (hasBodyVibrator) { if (hasBodyVibrator) {
ToggleRow( ToggleRow(
title = "Rumble on this phone", title = "Rumble on this phone",
subtitle = "Also play controller 1's rumble on this phone's own vibration " + subtitle = "Also play controller 1's rumble on this phone's motor",
"motor — for clip-on pads without rumble motors",
checked = s.rumbleOnPhone, checked = s.rumbleOnPhone,
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) }, onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
) )
}
// NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do
// with rumbling this device's body, and the gate hid the toggle on exactly the machines
// that most want it — TV boxes, where a Steam Controller 2 is the whole input story.
ToggleRow( ToggleRow(
title = "Steam Controller 2 passthrough", title = "Steam Controller 2 passthrough",
subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " + subtitle = "Stream a Steam Controller 2 as-is — Steam on the host drives its " +
"Bluetooth): it navigates these menus and streams as-is — Steam on the " + "trackpads, gyro and haptics directly",
"host drives it like the physical pad (trackpads, gyro, haptics)",
checked = s.sc2Capture, checked = s.sc2Capture,
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) }, onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
) )
@@ -469,46 +821,26 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont
} }
@Composable @Composable
private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) { private fun AboutSettings(context: android.content.Context, onOpenLicenses: () -> Unit) {
SettingsCard { // The app's own version, read from the installed package (the WinUI/Apple About convention:
ToggleRow( // identity first, then the legal rows). Empty on a harness with no real package info.
title = "Controller-optimized UI", val version = remember {
subtitle = "Switch to the console home (host carousel) when a controller is connected", runCatching {
checked = s.gamepadUiEnabled, @Suppress("DEPRECATION")
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) }, context.packageManager.getPackageInfo(context.packageName, 0).versionName
) }.getOrNull().orEmpty()
ToggleRow( }
title = "Game library", SettingsGroup {
subtitle = "Browse a paired host's game library (press Y on a saved host)", Column {
checked = s.libraryEnabled, Text("Punktfunk", style = MaterialTheme.typography.titleLarge)
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) }, if (version.isNotEmpty()) {
)
ToggleRow(
title = "Auto-wake on connect",
subtitle = "Send Wake-on-LAN and wait for a saved host to reappear on mDNS before " +
"connecting. Turn off if a host that's already on isn't seen on mDNS, so connects " +
"go straight through instead of waiting out the wake timeout.",
checked = s.autoWakeEnabled,
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
)
SettingDropdown(
label = "Stats overlay",
options = STATS_VERBOSITY_OPTIONS,
selected = s.statsVerbosity,
) { v -> update(s.copy(statsVerbosity = v)) }
Text( Text(
"How much the in-stream overlay shows: Compact is a single fps · latency · bitrate " + "Version $version",
"line; Normal adds the resolution and reliability lines; Detailed adds the decoder, " +
"colour and latency-breakdown lines. A 3-finger tap cycles the tiers live.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@Composable
private fun AboutSettings(onOpenLicenses: () -> Unit) {
SettingsCard {
ClickableRow( ClickableRow(
title = "Open-source licenses", title = "Open-source licenses",
subtitle = "Third-party notices and credits", subtitle = "Third-party notices and credits",
@@ -517,9 +849,31 @@ private fun AboutSettings(onOpenLicenses: () -> Unit) {
} }
} }
/** A group of settings rendered inside an outlined card. */ // ---- Row / group primitives --------------------------------------------------------------------
/**
* A group of settings rendered inside an outlined card, with an optional sub-section [header]
* above it and an optional form-level [footer] beneath it. The header is what turns a long
* category into the scannable sub-sections the desktop clients have ("Resolution", "Quality",
* "Host output"); the footer carries the one "applies from the next session" note per category —
* per-field guidance lives on the fields themselves.
*/
@Composable @Composable
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) { private fun SettingsGroup(
header: String? = null,
footer: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (header != null) {
Text(
header.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
letterSpacing = 1.2.sp,
modifier = Modifier.padding(start = 4.dp),
)
}
OutlinedCard(modifier = Modifier.fillMaxWidth()) { OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@@ -527,9 +881,21 @@ private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
content = content, content = content,
) )
} }
if (footer != null) {
Text(
footer,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp),
)
}
}
} }
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */ /**
* A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row;
* [field] is the overlay field this row writes, which is what its override marker keys on.
*/
@Composable @Composable
private fun ToggleRow( private fun ToggleRow(
title: String, title: String,
@@ -537,9 +903,12 @@ private fun ToggleRow(
checked: Boolean, checked: Boolean,
onCheckedChange: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit,
enabled: Boolean = true, enabled: Boolean = true,
field: String? = null,
) { ) {
// Dim the labels when disabled so the row reads as inactive (the Switch dims itself). // Dim the labels when disabled so the row reads as inactive (the Switch dims itself).
val labelAlpha = if (enabled) 1f else 0.38f val labelAlpha = if (enabled) 1f else 0.38f
Column {
OverrideBadge(field)
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(
@@ -556,6 +925,7 @@ private fun ToggleRow(
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
} }
} }
}
/** A title + subtitle on the left; the whole row is clickable (opens a sub-screen). */ /** A title + subtitle on the left; the whole row is clickable (opens a sub-screen). */
@Composable @Composable
@@ -581,18 +951,28 @@ private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
} }
} }
/** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */ /**
* A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick.
* [caption] is the field's own explanation, rendered directly under the control — the `described()`
* idiom the other clients use, so a dropdown's guidance belongs to it instead of floating as a
* loose paragraph between rows. [field] is the overlay field this row writes, which is what its
* override marker keys on.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun <T> SettingDropdown( private fun <T> SettingDropdown(
label: String, label: String,
options: List<Pair<T, String>>, options: List<Pair<T, String>>,
selected: T, selected: T,
field: String? = null,
caption: String? = null,
onSelect: (T) -> Unit, onSelect: (T) -> Unit,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val selectedLabel = options.firstOrNull { it.first == selected }?.second val selectedLabel = options.firstOrNull { it.first == selected }?.second
?: options.firstOrNull()?.second.orEmpty() ?: options.firstOrNull()?.second.orEmpty()
Column {
OverrideBadge(field)
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField( OutlinedTextField(
value = selectedLabel, value = selectedLabel,
@@ -616,6 +996,15 @@ private fun <T> SettingDropdown(
} }
} }
} }
if (caption != null) {
Text(
caption,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp),
)
}
}
} }
/** One side of a custom resolution. Digits only; every usable keystroke commits — coerced even /** One side of a custom resolution. Digits only; every usable keystroke commits — coerced even
@@ -0,0 +1,187 @@
package io.unom.punktfunk
import android.content.Context
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/**
* The network speed test: measure the path to a host **over the real data plane** — connect, ask
* the host to burst filler for two seconds, report goodput and loss, and offer to apply a
* recommended bitrate in one tap.
*
* The measurement is the easy half. The half that was wrong everywhere for a long time is *where
* the answer goes*: a measured bitrate belongs in the layer the tested host actually resolves
* bitrate from (design/client-settings-profiles.md §5.3). Writing it to the global — the
* long-standing behaviour — meant measuring the slow retro box downstairs quietly re-tuned the
* desktop too. [SpeedTestTarget] is that decision, and because it depends only on the host it is
* known *before* the result lands, so the button can say where it will write.
*/
sealed interface SpeedTestTarget {
/** No profile in play — the global default, i.e. what has always happened. */
data object Global : SpeedTestTarget
/** The profile this host uses already overrides bitrate, so that override is what it reads. */
data class Profile(val profile: StreamProfile) : SpeedTestTarget
/**
* The host uses a profile, but that profile inherits bitrate. Writing either layer is
* defensible, so the user gets both buttons rather than us guessing which they meant.
*/
data class Ask(val profile: StreamProfile) : SpeedTestTarget
companion object {
/**
* Resolved exactly the way a connect resolves it (see [ProfileStore.resolveFor]): the
* one-off pick this test was started from — a pinned card carries one — else the host's
* binding. A dangling binding resolves as no profile here too.
*/
fun resolve(
host: KnownHost?,
oneOffProfile: String?,
profiles: ProfileStore,
): SpeedTestTarget {
val profile = profiles.resolveFor(host, oneOffProfile) ?: return Global
return if (profile.overrides.bitrateKbps != null) Profile(profile) else Ask(profile)
}
}
}
/** Where the speed test is: it connects, it measures, then it has an answer or a reason. */
sealed interface SpeedTestPhase {
data object Connecting : SpeedTestPhase
data object Measuring : SpeedTestPhase
data class Failed(val message: String) : SpeedTestPhase
/**
* [recommendedKbps] is 70 % of the measured throughput — headroom for the FEC overhead and for
* the loss a real stream will meet, the same margin the desktop clients apply.
*/
data class Done(
val throughputKbps: Int,
val lossPct: Double,
val recommendedKbps: Int,
) : SpeedTestPhase {
val measuredMbps: Double get() = throughputKbps / 1000.0
val recommendedMbps: Double get() = recommendedKbps / 1000.0
}
}
/**
* Connect to [host]:[port], run one burst, and report. Blocking-ish (it suspends on IO) — call
* from a coroutine; [onPhase] is invoked as it progresses so the dialog can narrate.
*
* The connect is deliberately minimal: 1280×720@60, no launch, host-default bitrate. Nothing here
* presents a frame, and asking a host to spin up a 4K encode for a three-second measurement would
* be rude to it and slower for us.
*/
suspend fun runSpeedTest(
context: Context,
identity: ClientIdentity,
host: String,
port: Int,
pinHex: String,
onPhase: (SpeedTestPhase) -> Unit,
) {
onPhase(SpeedTestPhase.Connecting)
val probeSettings = Settings(
width = 1280,
height = 720,
hz = 60,
bitrateKbps = 0, // the host's default: this measures the link, not an encoder setting
hdrEnabled = false,
audioChannels = 2,
)
val handle = connectToHost(
context, probeSettings, identity, host, port, pinHex,
launch = null, timeoutMs = SPEED_TEST_CONNECT_TIMEOUT_MS,
)
if (handle == 0L) {
onPhase(
SpeedTestPhase.Failed(
ConnectErrors.connectMessage(NativeBridge.nativeTakeLastError(), requestAccess = false),
),
)
return
}
try {
onPhase(SpeedTestPhase.Measuring)
if (!NativeBridge.nativeSpeedTest(handle, TARGET_KBPS, BURST_MS)) {
onPhase(SpeedTestPhase.Failed("The host wouldn't start a measurement."))
return
}
var waited = 0
while (waited < POLL_BUDGET_MS) {
delay(POLL_INTERVAL_MS.toLong())
waited += POLL_INTERVAL_MS
val r = NativeBridge.nativeProbeResult(handle)
if (r == null || r.size < 3) {
onPhase(SpeedTestPhase.Failed("The session ended before the measurement finished."))
return
}
if (r[0] == 0.0) continue
// Let the last UDP shards land before tearing the session down, or the tail of the
// burst is counted as loss that never happened.
delay(SETTLE_MS)
val settled = NativeBridge.nativeProbeResult(handle) ?: r
val kbps = settled[1].toInt()
onPhase(
SpeedTestPhase.Done(
throughputKbps = kbps,
lossPct = settled[2],
// Integer arithmetic in this order (not `* 0.7`) so the recommendation matches
// the desktop clients' to the kilobit.
recommendedKbps = kbps / 10 * 7,
),
)
return
}
onPhase(SpeedTestPhase.Failed("The measurement timed out."))
} finally {
withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
}
}
/**
* Write a measured bitrate into the layer [target] names. [toProfile] picks the side of a
* [SpeedTestTarget.Ask]; it is ignored for the other targets, which have only one answer. Returns
* a human phrase naming where it went, for the confirmation.
*/
fun applySpeedTestResult(
kbps: Int,
target: SpeedTestTarget,
toProfile: Boolean,
profiles: ProfileStore,
settings: Settings,
onGlobalChange: (Settings) -> Unit,
): String {
val profile = when (target) {
is SpeedTestTarget.Profile -> target.profile
is SpeedTestTarget.Ask -> target.profile.takeIf { toProfile }
SpeedTestTarget.Global -> null
}
return if (profile == null) {
onGlobalChange(settings.copy(bitrateKbps = kbps))
"the default bitrate"
} else {
// Only the bitrate moves — a speed test has nothing to say about the rest of the profile.
// Re-read rather than trusting the copy this dialog was opened with, so a rename or another
// edit in between isn't clobbered.
val live = profiles.byId(profile.id) ?: profile
profiles.save(live.copy(overrides = live.overrides.copy(bitrateKbps = kbps)))
"${live.name}"
}
}
/** Ask for far more than any real link can carry, so the link is what limits the answer. */
private const val TARGET_KBPS = 3_000_000
/** Long enough to fill the pipe and settle, short enough not to interrupt anyone for long. */
private const val BURST_MS = 2_000
private const val POLL_INTERVAL_MS = 250
private const val POLL_BUDGET_MS = 10_000
private const val SETTLE_MS = 400L
private const val SPEED_TEST_CONNECT_TIMEOUT_MS = 15_000
@@ -40,6 +40,12 @@ internal fun StatsOverlay(
verbosity: StatsVerbosity, verbosity: StatsVerbosity,
decoderLabel: String = "", decoderLabel: String = "",
codecLabel: String = "", codecLabel: String = "",
/**
* The settings profile this session resolved, appended to the first line when there is one —
* the in-stream answer to "which profile am I on?", as on the other clients. Absent (the
* common case: no profile) the line is exactly what it always was.
*/
profileName: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (verbosity == StatsVerbosity.OFF || s.size < 10) return if (verbosity == StatsVerbosity.OFF || s.size < 10) return
@@ -56,13 +62,17 @@ internal fun StatsOverlay(
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
) { ) {
val profileTag = profileName?.let { " · $it" }.orEmpty()
// Compact: everything the glance-value needs on one line, nothing else. // Compact: everything the glance-value needs on one line, nothing else.
if (verbosity == StatsVerbosity.COMPACT) { if (verbosity == StatsVerbosity.COMPACT) {
statLine(compactLine(s, latValid), Color.White) statLine(compactLine(s, latValid) + profileTag, Color.White)
return@Column return@Column
} }
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White) statLine(
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag",
Color.White,
)
if (detailed && decoderLabel.isNotEmpty()) { if (detailed && decoderLabel.isNotEmpty()) {
statLine(decoderLabel, Color(0xFFB0D0FF)) statLine(decoderLabel, Color(0xFFB0D0FF))
} }
@@ -59,11 +59,21 @@ import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.Sc2Capture import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
/**
* The immersive stream. Everything it reads about the session comes from [session] — the settings
* the connect actually resolved (globals, or a profile's overrides on top of them) and the HOST's
* clipboard decision — rather than from a fresh `SettingsStore` load, which could disagree with
* the connect that produced this handle.
*/
@Composable @Composable
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
val handle = session.handle
val initialSettings = session.settings
val micEnabled = initialSettings.micEnabled
val context = LocalContext.current val context = LocalContext.current
val activity = context as? MainActivity val activity = context as? MainActivity
val window = activity?.window val window = activity?.window
@@ -85,7 +95,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the // Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never // visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
// blanks the numbers for a poll interval. // blanks the numbers for a poll interval.
val initialSettings = remember { SettingsStore(context).load() }
var stats by remember { mutableStateOf<DoubleArray?>(null) } var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") } var decoderLabel by remember { mutableStateOf("") }
var codecLabel by remember { mutableStateOf("") } var codecLabel by remember { mutableStateOf("") }
@@ -246,7 +255,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val mouse = MouseForwarder( val mouse = MouseForwarder(
handle, handle,
invertScroll = initialSettings.invertScroll, invertScroll = initialSettings.invertScroll,
captureWanted = initialSettings.pointerCapture, captureWanted = initialSettings.mouseMode == MouseMode.CAPTURE,
surfaceSize = { (decor?.width ?: 0) to (decor?.height ?: 0) }, surfaceSize = { (decor?.width ?: 0) to (decor?.height ?: 0) },
) )
mouse.onRequestCapture = { mouse.onRequestCapture = {
@@ -276,7 +285,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.remotePointer = remote activity?.remotePointer = remote
// Shared clipboard (text v1): only when the user setting is on AND the host has a // Shared clipboard (text v1): only when the user setting is on AND the host has a
// working clipboard service. Protocol-level opt-in + the poll thread live in the sync. // working clipboard service. Protocol-level opt-in + the poll thread live in the sync.
val clip = if (initialSettings.clipboardSync && NativeBridge.nativeClipSupported(handle)) { val clip = if (session.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
ClipboardSync(context, handle).also { it.start() } ClipboardSync(context, handle).also { it.start() }
} else { } else {
null null
@@ -461,7 +470,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// BEFORE the transparent gesture layer below, so it shows through and never eats touches. // BEFORE the transparent gesture layer below, so it shows through and never eats touches.
if (statsOn) { if (statsOn) {
stats?.let { stats?.let {
StatsOverlay(it, statsVerbosity, decoderLabel, codecLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) StatsOverlay(
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
Modifier.align(Alignment.TopStart).padding(12.dp),
)
} }
} }
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s // "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
@@ -9,16 +9,22 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -32,6 +38,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -48,9 +57,26 @@ fun SectionLabel(text: String) {
) )
} }
/**
* One row of a host card's overflow menu. [startsSection] draws a divider above it, which is how
* the profile actions ("Connect with: …", "Pin as card: …") stay legible next to the host actions
* in one flat menu Compose has no submenus, and the Windows client made the same call.
*/
data class HostMenuItem(
val label: String,
val startsSection: Boolean = false,
val onClick: () -> Unit,
)
/** /**
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for * A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
* saved hosts) an overflow menu with Rename / Forget. Tapping the card connects. * saved hosts) an overflow menu with Wake / Edit / Forget plus whatever [menuItems] adds. Tapping
* the card connects.
*
* [profileLabel] names the settings profile this card connects with. On a host's own card that is
* its default binding, drawn as a quiet chip the card says what a tap will do. On a **pinned
* card** ([profileProminent]) the host name is still the title, but the profile is the loud part,
* because the pin exists to make that one combination a single tap.
*/ */
@Composable @Composable
fun HostCard( fun HostCard(
@@ -58,11 +84,25 @@ fun HostCard(
address: String, address: String,
status: HostStatus, status: HostStatus,
online: Boolean = false, online: Boolean = false,
/** OS-identity chain (mDNS `os` TXT / stored), for the address line's OS mark. "" = none. */
os: String = "",
enabled: Boolean, enabled: Boolean,
onConnect: () -> Unit, onConnect: () -> Unit,
onForget: (() -> Unit)?, onForget: (() -> Unit)?,
onEdit: (() -> Unit)? = null, onEdit: (() -> Unit)? = null,
onWake: (() -> Unit)? = null, onWake: (() -> Unit)? = null,
profileLabel: String? = null,
profileProminent: Boolean = false,
accent: Color? = null,
menuItems: List<HostMenuItem> = emptyList(),
/**
* Keep the profile chip's space even on a card that has no profile. `LazyVerticalGrid` sizes a
* row to its tallest item but does NOT stretch the others, so a card that grew a chip would
* leave its neighbour visibly short a row of cards stepping up and down reads as broken
* layout. The caller passes true when ANY card in that section carries a chip, so a user with
* no profiles never pays for the slot.
*/
reserveProfileSlot: Boolean = false,
) { ) {
// D-pad / controller focus highlight: a clickable card is focusable, but the default state // D-pad / controller focus highlight: a clickable card is focusable, but the default state
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused. // layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
@@ -89,8 +129,8 @@ fun HostCard(
.padding(16.dp), .padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
HostAvatar(name) HostAvatar(name, online)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(10.dp))
Text( Text(
name, name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -98,6 +138,19 @@ fun HostCard(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Row(verticalAlignment = Alignment.CenterVertically) {
// The OS mark leads the address line; absent entirely for a host that
// doesn't advertise one, so those cards render exactly as they always did.
val osIcon = resolveOsIcon(os)
if (osIcon != null) {
Icon(
osIcon,
contentDescription = os,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(4.dp))
}
Text( Text(
address, address,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -106,17 +159,28 @@ fun HostCard(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(12.dp)) }
Row( if (profileLabel != null || reserveProfileSlot) {
verticalAlignment = Alignment.CenterVertically, Spacer(Modifier.height(10.dp))
horizontalArrangement = Arrangement.spacedBy(12.dp), Box(
Modifier.heightIn(min = PROFILE_CHIP_SLOT),
contentAlignment = Alignment.Center,
) { ) {
PresencePill(online) if (profileLabel != null) {
StatusPill(status) ProfileChip(profileLabel, accent, prominent = profileProminent)
}
}
} }
} }
if (onForget != null || onEdit != null || onWake != null) { // Trust state lives in the free top-left corner, mirroring the overflow on the right —
// it costs no height, and it is a state you glance at rather than read. The label is
// still there for TalkBack, and the trust DECISION is made in a dialog that spells all
// of this out; on the card it only has to say "this one is settled" vs "this one will
// ask something of you".
TrustBadge(status, Modifier.align(Alignment.TopStart))
if (onForget != null || onEdit != null || onWake != null || menuItems.isNotEmpty()) {
var menu by remember { mutableStateOf(false) } var menu by remember { mutableStateOf(false) }
Box(modifier = Modifier.align(Alignment.TopEnd)) { Box(modifier = Modifier.align(Alignment.TopEnd)) {
IconButton(enabled = enabled, onClick = { menu = true }) { IconButton(enabled = enabled, onClick = { menu = true }) {
@@ -155,6 +219,16 @@ fun HostCard(
}, },
) )
} }
menuItems.forEach { item ->
if (item.startsSection) HorizontalDivider()
DropdownMenuItem(
text = { Text(item.label) },
onClick = {
menu = false
item.onClick()
},
)
}
} }
} }
} }
@@ -162,10 +236,66 @@ fun HostCard(
} }
} }
/** A circular avatar with the host's first letter (Apple-contact style). */ /**
* The profile a card connects with. Quiet on a bound host's own card (it is a note about what a tap
* does); filled and tinted on a pinned card, where the profile IS the reason the card exists the
* accent field the schema reserves earns its keep here.
*/
@Composable @Composable
fun HostAvatar(name: String) { private fun ProfileChip(label: String, accent: Color?, prominent: Boolean) {
val tint = accent ?: MaterialTheme.colorScheme.primary
Row(
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(tint.copy(alpha = if (prominent) 0.24f else 0.12f))
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
Spacer(Modifier.width(6.dp))
Text(
label,
style = if (prominent) {
MaterialTheme.typography.labelLarge
} else {
MaterialTheme.typography.labelMedium
},
color = tint,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/**
* Reserved height for the profile chip the one part of a card that varies. `LazyVerticalGrid`
* sizes a row to its tallest item and does NOT stretch the others, so a card that grew a chip its
* neighbour lacks would leave the row stepping up and down.
*
* `heightIn(min =)`, not a fixed height: at a large accessibility font scale the chip must be
* allowed to grow rather than clip, and the reservation is sized with room to spare because the
* equal-height guarantee only holds while every card fits INSIDE it.
*/
private val PROFILE_CHIP_SLOT = 26.dp
/** Live presence, on any dynamic scheme: green reads as "up" to everyone, and Material You's
* primary might be any hue at all including a green that would then mean nothing. */
private val PRESENCE_ONLINE = Color(0xFF4ADE80)
/**
* The host's letter avatar (Apple-contact style) with its presence as a dot on the corner the
* idiom every contact list already uses, and one fewer labelled badge on a small card.
*
* [online] is true when the host advertises on mDNS OR answers the reachability probe, so a
* routed/VPN host that never advertises still reads as up. Online is a FILLED green dot, offline a
* hollow grey ring: the difference is a shape as well as a colour, so it survives both a
* colour-blind reader and a screenshot in greyscale. TalkBack gets the word either way.
*/
@Composable
fun HostAvatar(name: String, online: Boolean = false) {
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?" val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val cardColor = CardDefaults.elevatedCardColors().containerColor
Box {
Box( Box(
modifier = Modifier modifier = Modifier
.size(44.dp) .size(44.dp)
@@ -179,42 +309,49 @@ fun HostAvatar(name: String) {
color = MaterialTheme.colorScheme.onPrimaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer,
) )
} }
} Box(
modifier = Modifier
/** .align(Alignment.BottomEnd)
* A small dot + label for live presence: green Online when the host advertises on mDNS OR answers .size(13.dp)
* the reachability probe (so a routed/VPN host that never advertises still reads Online), dimmed .clip(CircleShape)
* Offline otherwise. // A ring in the card's own colour is what makes the dot read as sitting ON the
*/ // avatar rather than beside it.
@Composable .background(cardColor)
fun PresencePill(online: Boolean) { .padding(2.dp)
val color = .clip(CircleShape)
if (online) MaterialTheme.colorScheme.primary .then(
else MaterialTheme.colorScheme.onSurfaceVariant if (online) {
Row(verticalAlignment = Alignment.CenterVertically) { Modifier.background(PRESENCE_ONLINE)
Box(Modifier.size(8.dp).clip(CircleShape).background(color)) } else {
Spacer(Modifier.width(6.dp)) Modifier
Text( .background(cardColor)
if (online) "Online" else "Offline", .border(1.5.dp, MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
style = MaterialTheme.typography.labelMedium, },
color = color, )
.semantics { contentDescription = if (online) "Online" else "Offline" },
) )
} }
} }
/** A small colored dot + label for the host's trust state. */ /**
* The host's trust state as a corner glyph: locked (paired nothing more to do), a key (this host
* will ask for a PIN), or an open lock (trust-on-first-use, the weakest of the three). The full
* label rides along as the content description, and the dialogs that actually make the decision
* spell it out in sentences.
*/
@Composable @Composable
fun StatusPill(status: HostStatus) { private fun TrustBadge(status: HostStatus, modifier: Modifier = Modifier) {
val color = when (status) { val (icon, tint) = when (status) {
HostStatus.PAIRED -> MaterialTheme.colorScheme.primary HostStatus.PAIRED -> Icons.Filled.Lock to MaterialTheme.colorScheme.primary
HostStatus.PAIRING -> MaterialTheme.colorScheme.tertiary HostStatus.PAIRING -> Icons.Filled.Key to MaterialTheme.colorScheme.tertiary
HostStatus.TOFU -> MaterialTheme.colorScheme.onSurfaceVariant HostStatus.TOFU -> Icons.Filled.LockOpen to MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
Spacer(Modifier.width(6.dp))
Text(status.label, style = MaterialTheme.typography.labelMedium, color = color)
} }
Icon(
icon,
contentDescription = status.label,
tint = tint.copy(alpha = 0.85f),
modifier = modifier.padding(14.dp).size(18.dp),
)
} }
/** Shown when there are no saved or discovered hosts. */ /** Shown when there are no saved or discovered hosts. */
@@ -0,0 +1,98 @@
package io.unom.punktfunk.components
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.discovery.osIconTokens
/**
* The host card's OS marks, resolved from the host's OS-identity chain (mDNS `os` TXT,
* e.g. "linux/fedora/bazzite"): [resolveOsIcon] walks the chain most-specific-first
* (kit's [osIconTokens] the shared order + brand aliases) and returns the first mark we
* ship, so an unknown distro degrades to its family's mark and finally to Tux; null means
* "no icon", rendering the card exactly as before the field existed.
*
* Path data is vendored from the assets/os-icons masters (Font Awesome Free brands
* CC BY 4.0 + Simple Icons CC0 provenance in that directory's README); Material ships
* no brand icons. Hand-kept as raw SVG path strings (one line each) rather than
* transcribed ImageVector DSL [PathParser] builds the vector once, then it's cached.
*/
private class OsGlyph(val viewportWidth: Float, val viewportHeight: Float, val d: String)
private val GLYPHS: Map<String, OsGlyph> = mapOf(
"windows" to OsGlyph(
viewportWidth = 448f,
viewportHeight = 512f,
d = "M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z",
),
"apple" to OsGlyph(
viewportWidth = 384f,
viewportHeight = 512f,
d = "M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z",
),
"linux" to OsGlyph(
viewportWidth = 448f,
viewportHeight = 512f,
d = "M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z",
),
"steam" to OsGlyph(
viewportWidth = 496f,
viewportHeight = 512f,
d = "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z",
),
"ubuntu" to OsGlyph(
viewportWidth = 496f,
viewportHeight = 512f,
d = "M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z",
),
"fedora" to OsGlyph(
viewportWidth = 448f,
viewportHeight = 512f,
d = "M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z",
),
"arch" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091",
),
"debian" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83",
),
"nixos" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z",
),
"opensuse" to OsGlyph(
viewportWidth = 640f,
viewportHeight = 512f,
d = "M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z",
),
)
private val built = mutableMapOf<String, ImageVector>()
/** The mark for a chain, or null (no icon). Vectors build lazily and cache per token. */
fun resolveOsIcon(chain: String): ImageVector? =
osIconTokens(chain).firstNotNullOfOrNull { token ->
GLYPHS[token]?.let { glyph -> built.getOrPut(token) { glyph.build(token) } }
}
private fun OsGlyph.build(token: String): ImageVector =
ImageVector.Builder(
name = "OsIcon.$token",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = viewportWidth,
viewportHeight = viewportHeight,
).apply {
// Fill colour is irrelevant — Icon() tints via LocalContentColor, like Material icons.
addPath(
pathData = PathParser().parsePathString(d).toNodes(),
fill = SolidColor(Color.Black),
)
}.build()
@@ -25,10 +25,44 @@ data class PendingTrust(
val name: String, val name: String,
val advertisedFp: String?, val advertisedFp: String?,
val kind: Kind, val kind: Kind,
/**
* What the connect on the far side of this decision should carry a `punktfunk://` link's
* one-off profile and library id. A link to an unknown host goes through the confirmation
* first, and the user's stated intent must survive that detour rather than being silently
* dropped on the way to a plain desktop session.
*/
val profile: String? = null,
val launch: String? = null,
) { ) {
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS } enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
} }
/**
* A stream session that just opened, and the state the stream screen needs about it.
*
* [settings] is the settings the connect ACTUALLY used, resolved once at connect time not
* "whatever the settings store says now". Every post-connect read (the stats tier, the touch and
* mouse models, the low-latency pipeline, rumble, SC2 capture) takes it, so the stream can never
* disagree with the connect that produced it. [clipboardSync] comes from the host record, because
* clipboard sync is a decision about that host rather than about this device.
*/
data class ActiveSession(
val handle: Long,
val settings: io.unom.punktfunk.Settings,
val clipboardSync: Boolean,
/**
* The settings profile this session resolved, if any shown on the stats overlay's first line
* so "which profile am I on?" is answerable from inside the stream, as on the other clients.
*/
val profileName: String? = null,
/**
* The stable id of the host being streamed, when it is a saved one so a `punktfunk://` link
* that arrives mid-stream can tell "this same host" (a no-op; the intent already focused us)
* from "a different host" (a notice; a URL may never preempt a live session).
*/
val hostId: String? = null,
)
/** Trust state of a host, shown as a colored pill on its card. */ /** Trust state of a host, shown as a colored pill on its card. */
enum class HostStatus(val label: String) { enum class HostStatus(val label: String) {
PAIRED("Paired"), PAIRED("Paired"),
@@ -0,0 +1,247 @@
package io.unom.punktfunk
import io.unom.punktfunk.kit.security.KnownHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/**
* The profile model the part of this feature that is wrong-or-right rather than pretty-or-ugly.
* A profile is a named bundle of OVERRIDES, not a snapshot: an untouched field keeps following the
* global live, a touched one is recorded even when it equals today's global (a pin), and the only
* way back to inheriting is an explicit reset. These tests are the Kotlin twin of the Rust
* `profiles.rs` suite, so the two can't drift.
*
* `sdk = [36]` for the same reason the screenshot tests pin it: Robolectric ships android-all jars
* only up to API 36 while the app compiles against 37.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class ProfilesTest {
private val base = Settings(
width = 1920,
height = 1080,
bitrateKbps = 20_000,
codec = "hevc",
touchMode = TouchMode.TRACKPAD,
mouseMode = MouseMode.DESKTOP,
)
@Test
fun overlayAppliesOnlyWhatItOverrides() {
val empty = SettingsOverlay()
assertTrue(empty.isEmpty())
assertEquals(base, empty.apply(base))
val overlay = SettingsOverlay(
width = 3840,
height = 2160,
hz = 120,
bitrateKbps = 80_000,
renderScale = 1.5,
codec = "av1",
hdrEnabled = false,
compositor = 4,
audioChannels = 6,
micEnabled = true,
touchMode = TouchMode.POINTER,
mouseMode = MouseMode.CAPTURE,
invertScroll = true,
gamepad = 6,
statsVerbosity = StatsVerbosity.DETAILED,
lowLatencyMode = false,
)
assertFalse(overlay.isEmpty())
val out = overlay.apply(base)
assertEquals(Triple(3840, 2160, 120), Triple(out.width, out.height, out.hz))
assertEquals(80_000, out.bitrateKbps)
assertEquals(1.5, out.renderScale, 0.0)
assertEquals("av1", out.codec)
assertFalse(out.hdrEnabled)
assertEquals(4, out.compositor)
assertEquals(6, out.audioChannels)
assertTrue(out.micEnabled)
assertEquals(TouchMode.POINTER, out.touchMode)
assertEquals(MouseMode.CAPTURE, out.mouseMode)
assertTrue(out.invertScroll)
assertEquals(6, out.gamepad)
assertEquals(StatsVerbosity.DETAILED, out.statsVerbosity)
assertFalse(out.lowLatencyMode)
// Device-scope settings are not in the overlay at all, so no profile can move them.
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
assertEquals(base.libraryEnabled, out.libraryEnabled)
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
assertEquals(base.sc2Capture, out.sc2Capture)
}
@Test
fun anOverrideEqualToTheGlobalIsAPinThatSurvivesTheGlobalMoving() {
val pin = SettingsOverlay(bitrateKbps = 20_000) // exactly what `base` says today
assertFalse(pin.isEmpty())
assertEquals(20_000, pin.apply(base.copy(bitrateKbps = 50_000)).bitrateKbps)
}
@Test
fun absorbRecordsTheTouchedFieldOnly() {
var o = SettingsOverlay()
// One control fires: before = what it was showing, after = what the user picked.
var before = o.apply(base)
o = o.absorb(before, before.copy(codec = "av1"))
assertEquals("av1", o.codec)
assertNull("nothing else may be recorded", o.bitrateKbps)
// Setting it BACK to the global's value is still an override — the pin case, and the whole
// difference between this and diffing against the globals at save time.
before = o.apply(base)
o = o.absorb(before, before.copy(codec = "hevc"))
assertEquals("hevc", o.codec)
assertEquals("hevc", o.apply(base.copy(codec = "h264")).codec)
// Identical snapshots record nothing.
before = o.apply(base)
assertEquals(o, o.absorb(before, before))
}
@Test
fun clearDropsOneOverride() {
val o = SettingsOverlay(width = 3840, height = 2160, codec = "av1")
assertEquals(setOf(SettingsOverlay.FIELD_RESOLUTION, "codec"), o.overridden())
assertNull(o.clear("codec").codec)
// Width and height are one control, so they reset together.
val reset = o.clear(SettingsOverlay.FIELD_RESOLUTION)
assertNull(reset.width)
assertNull(reset.height)
assertEquals(o, o.clear("no_such_field")) // unknown names are a no-op, never a crash
}
@Test
fun catalogRoundTripsAndPreservesWhatItCannotRepresent() {
val store = ProfileStore(RuntimeEnvironment.getApplication())
val game = newProfile("Game").copy(
accent = "#ff8800",
overrides = SettingsOverlay(
width = 3840,
height = 2160,
hz = 120,
// A codec string this build's picker can't show is still stored and still applied:
// the host is the component that decides what it can encode.
codec = "vvc-from-the-future",
extra = mapOf("some_new_axis" to 7),
),
extra = mapOf("future_profile_key" to "kept"),
)
store.save(game)
store.save(newProfile("Work"))
val loaded = store.byId(game.id)!!
assertEquals("Game", loaded.name)
assertEquals("#ff8800", loaded.accent)
assertEquals("vvc-from-the-future", loaded.overrides.codec)
assertEquals(3840, loaded.overrides.width)
// The don't-clobber rule: an older build must not erase a newer one's keys by opening it.
assertEquals(mapOf<String, Any>("some_new_axis" to 7), loaded.overrides.extra)
assertEquals(mapOf<String, Any>("future_profile_key" to "kept"), loaded.extra)
assertEquals("vvc-from-the-future", loaded.overrides.apply(base).codec)
// A profile that overrides nothing is the "inherits everything" one a create starts at.
assertTrue(store.all().first { it.name == "Work" }.overrides.isEmpty())
assertEquals(listOf("Game", "Work"), store.all().map { it.name })
}
@Test
fun resolvePrefersIdsAndRefusesAmbiguity() {
val store = ProfileStore(RuntimeEnvironment.getApplication())
val work = newProfile("Work")
val work2 = newProfile("work") // saved directly: the UI's name guard is what prevents this
val game = newProfile("Game")
listOf(work, work2, game).forEach(store::save)
assertEquals(ProfileResolution.FOUND, store.resolve(work.id).second)
assertEquals(work.id, store.resolve(work.id).first!!.id)
// Two profiles carry this name — refuse rather than pick whichever came first.
assertEquals(ProfileResolution.AMBIGUOUS, store.resolve("Work").second)
assertNull(store.resolve("Work").first)
assertEquals(game.id, store.resolve("GAME").first!!.id) // names match case-insensitively
assertEquals(ProfileResolution.NOT_FOUND, store.resolve("nope").second)
assertEquals(ProfileResolution.NOT_FOUND, store.resolve("").second)
assertTrue(store.nameTaken("GAME"))
assertFalse(store.nameTaken("GAME", except = game.id)) // renaming in place is allowed
assertFalse(store.nameTaken("Travel"))
}
@Test
fun profilePrecedenceIsOneOffThenBindingThenNone() {
val store = ProfileStore(RuntimeEnvironment.getApplication())
val work = newProfile("Work")
val game = newProfile("Game")
listOf(work, game).forEach(store::save)
val bound = host().copy(profileId = work.id)
// A plain tap follows the binding…
assertEquals(work.id, store.resolveFor(bound, oneOff = null)!!.id)
// …a one-off wins over it, by id or by unique name, and never rebinds anything…
assertEquals(game.id, store.resolveFor(bound, oneOff = game.id)!!.id)
assertEquals(game.id, store.resolveFor(bound, oneOff = "game")!!.id)
assertEquals(work.id, store.resolveFor(bound, oneOff = null)!!.id)
// …and the empty reference is a real choice — "force the global defaults" — not "unset".
assertNull(store.resolveFor(bound, oneOff = ""))
// An unbound host is today's behaviour: the globals.
assertNull(store.resolveFor(host(), oneOff = null))
assertNull(store.resolveFor(null, oneOff = null))
}
@Test
fun aDeletedProfileLeavesNoErrorBehind() {
val store = ProfileStore(RuntimeEnvironment.getApplication())
val work = newProfile("Work")
store.save(work)
val h = host().copy(profileId = work.id, pinnedProfileIds = listOf(work.id, work.id))
assertEquals(1, store.pinsFor(h).size) // a duplicate pin is one card, not two
store.delete(work.id)
// A dangling binding resolves as "no profile" — never an error, never a blocked connect —
// and its pinned card simply stops rendering.
assertNull(store.resolveFor(h, oneOff = null))
assertTrue(store.pinsFor(h).isEmpty())
assertEquals(base, base.effectiveFor(store.resolveFor(h, oneOff = null)))
}
/**
* A profile created from the UI gets a colour, and a distinct one the accent is the WHOLE
* signal on a bound host card's chip and a pinned card's tint, so two profiles sharing it (or
* having none) makes those surfaces say less than they look like they're saying.
*/
@Test
fun creationHandsOutADistinctColour() {
val made = mutableListOf<StreamProfile>()
repeat(PROFILE_ACCENTS.size) { made += newProfile("p$it", nextAccent(made)) }
assertEquals(PROFILE_ACCENTS, made.map { it.accent })
// Past the palette it wraps rather than handing out nothing — a duplicate colour beats an
// invisible chip, and the picker is right there.
assertEquals(PROFILE_ACCENTS.first(), nextAccent(made))
// A gap is reused before wrapping.
assertEquals(PROFILE_ACCENTS[2], nextAccent(made.filter { it.accent != PROFILE_ACCENTS[2] }))
// The colour is presentation, so it never reaches the resolved settings.
assertEquals(base, made.first().overrides.apply(base))
}
@Test
fun mintedIdsAreWellFormed() {
val id = newProfileId()
assertEquals(12, id.length)
assertTrue(id.all { it.isDigit() || it in 'a'..'f' })
assertNotEquals(id, newProfileId())
}
private fun host() = KnownHost("192.168.1.42", 9777, "Desk", "a".repeat(64), paired = true)
}
@@ -0,0 +1,127 @@
package io.unom.punktfunk
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.GraphicsMode
import org.robolectric.annotation.Config
/**
* An edit must land in the scope the chips say is selected the one thing the two-layer settings
* surface can get wrong without looking wrong.
*
* The regression this pins: `update` reached the rows as `::update`, and two callable references
* compare EQUAL however different the scope they captured, so Compose skipped the whole detail page
* on a scope switch that moved nothing on screen (the ordinary case a profile inherits the globals
* until it overrides something). Each edit then wrote to the scope the user had just left: change a
* default, switch to a profile, change the same row the globals moved again and the profile
* recorded nothing and back on the defaults the next edit went into the profile, which reads as
* "the default settings can't be changed any more". It needs the real Compose runtime to catch, so
* this drives the actual screen rather than the model underneath it.
*
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36 while
* the app compiles against 37.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
class SettingsScopeTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
private val context: Context get() = ApplicationProvider.getApplicationContext()
/**
* "Invert scroll direction" is the row under test: Input is profileable end to end, and that
* toggle is the only toggleable node on the page, so a click needs no fragile lookup.
*/
private fun toggleTheRow() {
compose.onNode(isToggleable()).performClick()
compose.waitForIdle()
}
private fun selectScope(chip: String) {
compose.onNodeWithText(chip).performClick()
compose.waitForIdle()
}
@Test
fun editsFollowTheSelectedScope() {
val profiles = ProfileStore(context)
profiles.save(newProfile("Work", PROFILE_ACCENTS.first()))
// Mirrors App.kt: the screen is fed from state the host recomposes it with.
var saved = Settings()
compose.setContent {
var settings by remember { mutableStateOf(saved) }
SettingsScreen(
initial = settings,
onChange = { settings = it; saved = it },
onBack = {},
initialCategory = SettingsCategory.Input,
)
}
// 1. On the defaults, the globals move and no profile records anything.
toggleTheRow()
assertEquals(true, saved.invertScroll)
assertNull(profiles.all().single().overrides.invertScroll)
// 2. In profile scope the SAME row — untouched by the profile, so it still shows the global
// value and nothing on the page changed — must record an override and leave the globals
// alone. This is the step that used to write straight through to the globals.
selectScope("Work")
toggleTheRow()
assertEquals("the globals must not move while a profile is selected", true, saved.invertScroll)
assertEquals(false, profiles.all().single().overrides.invertScroll)
// 3. Back on the defaults the row is editable again, and the profile keeps its override.
selectScope("Default settings")
toggleTheRow()
assertEquals(false, saved.invertScroll)
assertEquals(
"the profile's override must survive an edit made on the defaults",
false,
profiles.all().single().overrides.invertScroll,
)
}
/** A reset puts the row back to inheriting — and, like an edit, it must obey the live scope. */
@Test
fun resetClearsTheSelectedProfilesOverride() {
val profiles = ProfileStore(context)
profiles.save(newProfile("Work", PROFILE_ACCENTS.first()))
compose.setContent {
var settings by remember { mutableStateOf(Settings()) }
SettingsScreen(
initial = settings,
onChange = { settings = it },
onBack = {},
initialCategory = SettingsCategory.Input,
initialProfileId = profiles.all().single().id,
)
}
toggleTheRow()
assertEquals(true, profiles.all().single().overrides.invertScroll)
compose.onNodeWithText("Reset").performClick()
compose.waitForIdle()
assertNull(profiles.all().single().overrides.invertScroll)
}
}
@@ -0,0 +1,131 @@
package io.unom.punktfunk
import io.unom.punktfunk.kit.security.KnownHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/**
* Where a measured bitrate lands. The measurement itself is the host's job; the decision this code
* makes is which layer to write and the long-standing wrong answer (always the global) is exactly
* what made measuring the slow box downstairs re-tune the desktop too
* (design/client-settings-profiles.md §5.3).
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class SpeedTestTest {
private val store get() = ProfileStore(RuntimeEnvironment.getApplication())
private fun host() = KnownHost("192.168.1.42", 9777, "Desk", "a".repeat(64), paired = true)
@Test
fun anUnboundHostTargetsTheGlobalDefault() {
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(host(), null, store))
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(null, null, store))
}
@Test
fun aProfileThatSetsBitrateIsTheLayerThatHostReads() {
val s = store
val game = newProfile("Game").copy(overrides = SettingsOverlay(bitrateKbps = 50_000))
s.save(game)
val target = SpeedTestTarget.resolve(host().copy(profileId = game.id), null, s)
assertEquals(game.id, (target as SpeedTestTarget.Profile).profile.id)
}
@Test
fun aProfileThatInheritsBitrateAsksWhichLayer() {
val s = store
val work = newProfile("Work") // overrides nothing
s.save(work)
val target = SpeedTestTarget.resolve(host().copy(profileId = work.id), null, s)
// Both layers are defensible here, so the user picks — we don't guess.
assertEquals(work.id, (target as SpeedTestTarget.Ask).profile.id)
}
@Test
fun theOneOffPickWinsAndTheEmptyOneForcesTheDefaults() {
val s = store
val game = newProfile("Game").copy(overrides = SettingsOverlay(bitrateKbps = 50_000))
val work = newProfile("Work")
listOf(game, work).forEach(s::save)
val bound = host().copy(profileId = work.id)
// Testing from a pinned card measures — and writes — that card's profile.
assertEquals(game.id, (SpeedTestTarget.resolve(bound, game.id, s) as SpeedTestTarget.Profile).profile.id)
// "Connect with: Default settings" is a real choice, so its speed test targets the global.
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(bound, "", s))
// A dangling binding resolves as no profile everywhere else; here too.
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(host().copy(profileId = "gone"), null, s))
}
@Test
fun applyingWritesOnlyTheBitrate_andOnlyToTheChosenLayer() {
val s = store
val game = newProfile("Game").copy(
overrides = SettingsOverlay(bitrateKbps = 50_000, width = 3840, height = 2160),
)
s.save(game)
val globals = Settings(bitrateKbps = 20_000, codec = "hevc")
var savedGlobals: Settings? = null
val where = applySpeedTestResult(
kbps = 84_000,
target = SpeedTestTarget.Profile(game),
toProfile = true,
profiles = s,
settings = globals,
onGlobalChange = { savedGlobals = it },
)
assertEquals("“Game”", where)
assertNull("the global must not move when a profile was the target", savedGlobals)
val after = s.byId(game.id)!!.overrides
assertEquals(84_000, after.bitrateKbps)
// Nothing else in the overlay is a speed test's business.
assertEquals(3840, after.width)
assertEquals(2160, after.height)
}
@Test
fun theAskCaseHonoursWhichButtonWasPressed() {
val s = store
val work = newProfile("Work")
s.save(work)
val globals = Settings(bitrateKbps = 20_000)
var savedGlobals: Settings? = null
// "Set as default" writes the global and leaves the profile inheriting.
val whereGlobal = applySpeedTestResult(
42_000, SpeedTestTarget.Ask(work), toProfile = false, profiles = s,
settings = globals, onGlobalChange = { savedGlobals = it },
)
assertEquals("the default bitrate", whereGlobal)
assertEquals(42_000, savedGlobals!!.bitrateKbps)
assertNull(s.byId(work.id)!!.overrides.bitrateKbps)
// "Set in Work" records the override instead — and now that profile stops inheriting.
savedGlobals = null
val whereProfile = applySpeedTestResult(
42_000, SpeedTestTarget.Ask(work), toProfile = true, profiles = s,
settings = globals, onGlobalChange = { savedGlobals = it },
)
assertEquals("“Work”", whereProfile)
assertNull(savedGlobals)
assertEquals(42_000, s.byId(work.id)!!.overrides.bitrateKbps)
}
@Test
fun theRecommendationLeavesHeadroom() {
// 70 % of measured, in the desktop clients' integer order — a stream needs room for the
// FEC overhead and for the loss a burst measurement doesn't see.
val done = SpeedTestPhase.Done(throughputKbps = 100_000, lossPct = 0.4, recommendedKbps = 100_000 / 10 * 7)
assertEquals(70_000, done.recommendedKbps)
assertEquals(100.0, done.measuredMbps, 0.001)
assertEquals(70.0, done.recommendedMbps, 0.001)
assertTrue(done.recommendedKbps < done.throughputKbps)
}
}
@@ -56,6 +56,21 @@ class ScreenshotTest {
@Test @Test
fun settings() = shootRoot("settings") { SettingsScene() } fun settings() = shootRoot("settings") { SettingsScene() }
// One category page per shot: the sub-section headers, the caption-under-control fields and
// the "applies from the next session" footers live inside a category, not on the root list.
@Test
fun settingsDisplay() = shootRoot("settings-display") {
SettingsCategoryScene(io.unom.punktfunk.SettingsCategory.Display)
}
@Test
fun settingsInput() = shootRoot("settings-input") {
SettingsCategoryScene(io.unom.punktfunk.SettingsCategory.Input)
}
@Test
fun settingsProfile() = shootRoot("settings-profile") { SettingsProfileScene() }
@Test @Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) } fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
@@ -97,6 +112,15 @@ class ScreenshotTest {
TrustDialog() TrustDialog()
} }
@Test
fun newProfile() = shootRoot("new-profile") { NewProfileScene() }
@Test
fun speedTest() = shootScreen("speed-test") {
HostsScene()
SpeedTestScene()
}
@Test @Test
fun pair() = shootScreen("pair") { fun pair() = shootScreen("pair") {
HostsScene() HostsScene()
@@ -19,10 +19,12 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.unom.punktfunk.BrandDark import io.unom.punktfunk.BrandDark
@@ -31,11 +33,20 @@ import io.unom.punktfunk.ConnectPhase
import io.unom.punktfunk.ConnectTakeover import io.unom.punktfunk.ConnectTakeover
import io.unom.punktfunk.Settings import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsCategory
import io.unom.punktfunk.SettingsScreen import io.unom.punktfunk.SettingsScreen
import io.unom.punktfunk.StatsOverlay import io.unom.punktfunk.StatsOverlay
import io.unom.punktfunk.StatsVerbosity import io.unom.punktfunk.StatsVerbosity
import io.unom.punktfunk.ProfileEditorFields
import io.unom.punktfunk.ProfileStore
import io.unom.punktfunk.SettingsOverlay
import io.unom.punktfunk.SpeedTestDialog
import io.unom.punktfunk.SpeedTestPhase
import io.unom.punktfunk.SpeedTestTarget
import io.unom.punktfunk.components.HostCard import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.HostMenuItem
import io.unom.punktfunk.components.SectionLabel import io.unom.punktfunk.components.SectionLabel
import io.unom.punktfunk.newProfile
import io.unom.punktfunk.models.HostStatus import io.unom.punktfunk.models.HostStatus
// The CI screenshot scenes: the REAL app composables, fed embedded mock state, under the forced // The CI screenshot scenes: the REAL app composables, fed embedded mock state, under the forced
@@ -48,15 +59,31 @@ internal fun ShotTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = BrandDark, content = content) MaterialTheme(colorScheme = BrandDark, content = content)
} }
private data class MockHost(val name: String, val address: String, val status: HostStatus) private data class MockHost(
val name: String,
val address: String,
val status: HostStatus,
val profile: String? = null,
val pin: String? = null,
val accent: Color? = null,
val online: Boolean = false,
)
// Ordered so an UNCHIPPED card sits beside a CHIPPED one in the same grid row, and a long trust
// label ("Trust on first use") beside a short one ("Paired"). Both are what used to make cards in a
// row step up and down — the grid sizes a row to its tallest item and doesn't stretch the rest — so
// this arrangement is the regression net for it.
private val SAVED = listOf( private val SAVED = listOf(
MockHost("Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED),
MockHost("Office", "192.168.1.50:9777", HostStatus.TOFU), MockHost("Office", "192.168.1.50:9777", HostStatus.TOFU),
MockHost(
"Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED,
profile = "Game", pin = "Work", accent = Color(0xFFFF8A4C), online = true,
),
) )
private val DISCOVERED = listOf( private val DISCOVERED = listOf(
MockHost("studio-deck", "192.168.1.61:9777", HostStatus.PAIRING), // Discovered ⇒ advertising right now, so both are online.
MockHost("HTPC", "192.168.1.70:9777", HostStatus.TOFU), MockHost("studio-deck", "192.168.1.61:9777", HostStatus.PAIRING, online = true),
MockHost("HTPC", "192.168.1.70:9777", HostStatus.TOFU, online = true),
) )
/** The connect screen's host grid, reconstructed from the real HostCard/SectionLabel components. */ /** The connect screen's host grid, reconstructed from the real HostCard/SectionLabel components. */
@@ -86,26 +113,53 @@ internal fun HostsScene() {
} }
} }
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") } item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
items(SAVED) { h -> // A pinned card is its OWN grid cell right after its host — the same flat list the
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onEdit = {}) // connect screen builds, not a second card crammed into the host's cell.
SAVED.forEach { h ->
item {
HostCard(
h.name, h.address, h.status, online = h.online, enabled = true,
onConnect = {}, onForget = {}, onEdit = {},
// The bound profile is a quiet chip: the card says what a tap will do.
profileLabel = h.profile,
accent = h.accent,
menuItems = listOf(
HostMenuItem("Connect with: Default settings", startsSection = true) {},
HostMenuItem("Connect with: Game") {},
),
// One card in this section has a chip, so every card reserves its space —
// the shot is here to catch a row that steps.
reserveProfileSlot = true,
)
}
if (h.pin != null) {
item {
HostCard(
h.name, h.address, h.status, online = h.online, enabled = true,
onConnect = {}, onForget = null,
profileLabel = h.pin, profileProminent = true, accent = h.accent,
menuItems = listOf(HostMenuItem("Unpin card", startsSection = true) {}),
reserveProfileSlot = true,
)
}
}
} }
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
SectionLabel("Discovered on the network") SectionLabel("Discovered on the network")
} }
items(DISCOVERED) { h -> items(DISCOVERED) { h ->
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = null) HostCard(
h.name, h.address, h.status, online = h.online,
enabled = true, onConnect = {}, onForget = null,
)
} }
} }
} }
} }
/** The real SettingsScreen, fed a representative non-default Settings. */ /** A representative non-default settings state, shared by the settings scenes. */
@Composable private val SHOT_SETTINGS = Settings(
internal fun SettingsScene() {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
SettingsScreen(
initial = Settings(
width = 1920, width = 1920,
height = 1080, height = 1080,
hz = 120, hz = 120,
@@ -115,13 +169,114 @@ internal fun SettingsScene() {
micEnabled = true, micEnabled = true,
statsVerbosity = StatsVerbosity.DETAILED, statsVerbosity = StatsVerbosity.DETAILED,
touchMode = TouchMode.TRACKPAD, touchMode = TouchMode.TRACKPAD,
), )
/**
* The real SettingsScreen at its root the shared category map (General / Display / Input /
* Audio / Controllers / About) every client now presents.
*/
@Composable
internal fun SettingsScene() {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
SettingsScreen(initial = SHOT_SETTINGS, onChange = {}, onBack = {})
}
}
/**
* One category page, seeded through `initialCategory` the sub-section headers, the
* caption-under-control fields and the "applies from the next session" footer only exist inside a
* category, so the root shot alone can't regress-catch them. Display is the richest page.
*/
@Composable
internal fun SettingsCategoryScene(category: SettingsCategory) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
SettingsScreen(
initial = SHOT_SETTINGS,
onChange = {}, onChange = {},
onBack = {}, onBack = {},
initialCategory = category,
) )
} }
} }
/**
* The same settings surface in a PROFILE's scope: the scope chips with "Game" selected, only
* profileable rows, every row showing the effective value, and the overridden ones carrying their
* marker and reset. One settings UI, two layers this shot is what proves it stayed one.
*/
@Composable
internal fun SettingsProfileScene() {
val store = ProfileStore(LocalContext.current)
val profile = remember {
val p = newProfile("Game").copy(
accent = "#FF8A4C",
// A representative mix: a resolution and refresh the profile pins, and a codec — the
// rest of the page keeps following the defaults, visibly unmarked.
overrides = SettingsOverlay(width = 3840, height = 2160, hz = 120, codec = "h264"),
)
store.save(p)
store.save(newProfile("Work"))
p
}
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
SettingsScreen(
initial = SHOT_SETTINGS,
onChange = {},
onBack = {},
initialCategory = SettingsCategory.Display,
initialProfileId = profile.id,
)
}
}
/**
* The speed test's result, in its most interesting shape: a host bound to a profile that INHERITS
* bitrate, so both layers are defensible and both buttons are offered. The note under the numbers
* is what stops "Apply" from being a write in an unknown direction.
*/
@Composable
internal fun SpeedTestScene() {
SpeedTestDialog(
hostName = "Living Room PC",
target = SpeedTestTarget.Ask(newProfile("Game")),
phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400),
onApply = {},
onDismiss = {},
)
}
/**
* Creating a profile. Small, but it is the first thing a user meets when they reach for this
* feature and dialogs only get a shot each because a layout slip inside one is invisible from
* every other scene (this one shipped with the field and its caption touching).
*/
@Composable
internal fun NewProfileScene() {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("New profile", style = MaterialTheme.typography.headlineSmall)
// The dialog's own body, not a rebuild of it — the layout under test is the real one.
ProfileEditorFields(
name = "Travel",
accent = "#60A5FA",
duplicate = false,
creating = true,
onNameChange = {},
onAccentChange = {},
)
Text("Duplicate name", style = MaterialTheme.typography.headlineSmall)
ProfileEditorFields(
name = "Game",
accent = "#FF8A4C",
duplicate = true,
creating = false,
onNameChange = {},
onAccentChange = {},
)
}
}
}
/** The real TOFU AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.TRUST_NEW), shown over the host grid. */ /** The real TOFU AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.TRUST_NEW), shown over the host grid. */
@Composable @Composable
internal fun TrustDialog() { internal fun TrustDialog() {
+3 -3
View File
@@ -2,10 +2,10 @@
// org.jetbrains.kotlin.android (it's an error under AGP 9). The Compose compiler plugin is declared // org.jetbrains.kotlin.android (it's an error under AGP 9). The Compose compiler plugin is declared
// here (version + apply false) so modules can apply it version-less; its version pins the build's // here (version + apply false) so modules can apply it version-less; its version pins the build's
// Kotlin (compose-compiler and Kotlin release in lockstep), keeping them matched. // Kotlin (compose-compiler and Kotlin release in lockstep), keeping them matched.
// Toolchain: AGP 9.2.0 · Gradle 9.4.1 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM // Toolchain: AGP 9.3.1 · Gradle 9.5.0 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM
// 2026.05.01 · compileSdk 37 · targetSdk 37 · minSdk 28. // 2026.05.01 · compileSdk 37 · targetSdk 37 · minSdk 28.
plugins { plugins {
id("com.android.application") version "9.2.1" apply false id("com.android.application") version "9.3.1" apply false
id("com.android.library") version "9.2.1" apply false id("com.android.library") version "9.3.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
} }
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 retryBackOffMs=500
+10 -2
View File
@@ -33,7 +33,11 @@ dependencies {
// mTLS HTTPS client for the host's management API (the game-library fetch + cover-art loads). // mTLS HTTPS client for the host's management API (the game-library fetch + cover-art loads).
// OkHttp lets us present the paired client cert and pin the host's self-signed cert by SHA-256. // OkHttp lets us present the paired client cert and pin the host's self-signed cert by SHA-256.
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser testImplementation("junit:junit:4.13.2") // JVM unit tests for the pure parsers/migrations
// A REAL org.json on the unit-test classpath. android.jar's org.json is stubs that throw
// "Stub!", so the host-store migration test — which asserts over the very JSON blobs the store
// reads and writes — cannot run without it. Explicit test deps precede the mockable android.jar.
testImplementation("org.json:json:20250107")
} }
// ------------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------------
@@ -43,7 +47,11 @@ dependencies {
// /README.md): `cargo install cargo-ndk` + `rustup target add aarch64-linux-android x86_64-linux-android`. // /README.md): `cargo install cargo-ndk` + `rustup target add aarch64-linux-android x86_64-linux-android`.
// ------------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------------
val repoRoot = rootDir.parentFile.parentFile // clients/android -> clients -> repo root val repoRoot = rootDir.parentFile.parentFile // clients/android -> clients -> repo root
val cargoBin = "${System.getProperty("user.home")}/.cargo/bin" // CARGO_HOME first: rustup puts every binary in $CARGO_HOME/bin, and the CI image
// (ci/android-ci.Dockerfile) installs the shared toolchain at /usr/local/cargo — the
// historical ~/.cargo fallback is what a GUI Android Studio launch (no env) still needs.
val cargoBin = System.getenv("CARGO_HOME")?.let { "$it/bin" }
?: "${System.getProperty("user.home")}/.cargo/bin"
// SDK location without depending on AGP's DSL (sdkDirectory isn't in AGP 9's library extension): // SDK location without depending on AGP's DSL (sdkDirectory isn't in AGP 9's library extension):
// env first (set by Android Studio and by our CLI shell), then local.properties, then the default. // env first (set by Android Studio and by our CLI shell), then local.properties, then the default.
@@ -57,6 +57,10 @@ object NativeBridge {
/** Store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game, /** Store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game,
* or `null`/empty for a plain desktop connect. Rides the Hello as `launch`. */ * or `null`/empty for a plain desktop connect. Rides the Hello as `launch`. */
launch: String?, launch: String?,
/** This device's display name (rides the Hello as `name`) what the host's pending-approval
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank
* the host falls back to a fingerprint-derived "device abcd1234" label. */
deviceName: String?,
): Long ): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */ /** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
@@ -145,6 +149,24 @@ object NativeBridge {
*/ */
external fun nativeProbe(host: String, port: Int, timeoutMs: Int): Boolean external fun nativeProbe(host: String, port: Int, timeoutMs: Int): Boolean
/**
* Start a bandwidth speed test on [handle]: the host bursts filler over the real data plane at
* [targetKbps] of goodput for [durationMs] (each clamped host-side to 3 Gbps / 5 s),
* **briefly pausing video**. Measuring over the stream's own path is the point the answer is
* about the link this host's stream will take, not about generic throughput.
*
* Non-blocking: poll [nativeProbeResult] until it reports done. Starting a probe resets any
* prior measurement. Returns false on a dead handle. Cheap; safe on the main thread.
*/
external fun nativeSpeedTest(handle: Long, targetKbps: Int, durationMs: Int): Boolean
/**
* The current speed-test measurement, partial until `[0] != 0.0`:
* `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`. Zeros before any
* probe, null on a dead handle. Cheap (one lock + a copy); safe to poll on the main thread.
*/
external fun nativeProbeResult(handle: Long): DoubleArray?
/** /**
* Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport * Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport
* defaults today just DSCP/QoS marking on the media sockets. Must be called BEFORE * defaults today just DSCP/QoS marking on the media sockets. Must be called BEFORE
@@ -18,16 +18,17 @@ data class DiscoveredHost(
val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies) val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
val pairingRequired: Boolean = false, val pairingRequired: Boolean = false,
val mac: List<String> = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN) val mac: List<String> = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN)
val os: String = "", // TXT "os" (OS-identity chain, e.g. "linux/fedora/bazzite"); "" on older hosts
) )
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */ /** Field separator the native browse uses inside one record (ASCII Unit Separator). */
private const val FIELD_SEP = '\u001F' private const val FIELD_SEP = '\u001F'
/** /**
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`keynameaddrportfppairmac`), or * Parse one record from [NativeBridge.nativeDiscoveryPoll] (`keynameaddrportfppairmacos`),
* null if it's malformed. `mac` (7th field) is optional an older host omits it. Pure * or null if it's malformed. Fields past the 6th are optional an older native lib omits them
* unit-tested without Android (see ParseRecordTest). The native side already applied the protocol * (`mac` 7th, `os` 8th). Pure unit-tested without Android (see ParseRecordTest). The native side
* gate and address selection, so this is just field marshaling. * already applied the protocol gate and address selection, so this is just field marshaling.
*/ */
fun parseHostRecord(record: String): DiscoveredHost? { fun parseHostRecord(record: String): DiscoveredHost? {
val f = record.split(FIELD_SEP) val f = record.split(FIELD_SEP)
@@ -44,9 +45,41 @@ fun parseHostRecord(record: String): DiscoveredHost? {
pairingRequired = f[5] == "required", pairingRequired = f[5] == "required",
mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() } mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() }
else emptyList(), else emptyList(),
os = if (f.size > 7) sanitizeOsChain(f[7]) else "",
) )
} }
/**
* Reduce a raw `os` TXT value to the trusted grammar (pf-client-core's `sanitize_os`, mirrored):
* lowercase slash-separated tokens of `[a-z0-9._-]`, each 32 chars, at most 5. mDNS is
* unauthenticated input; a value that sanitizes to nothing becomes "" (no icon, like an older host).
*/
fun sanitizeOsChain(raw: String): String =
raw.lowercase()
.split('/')
.map { token -> token.filter { it in 'a'..'z' || it in '0'..'9' || it in "._-" }.take(32) }
.filter { it.isNotEmpty() }
.take(5)
.joinToString("/")
/**
* The icon-lookup order for a chain: sanitized tokens most-specific-first, brand aliases applied
* (`macos` `apple` art, `steamos` `steam` art) pf-client-core's `os_icon_tokens`, mirrored.
* The UI takes the first token it has art for; empty means "no OS icon" (older host / garbage).
*/
fun osIconTokens(chain: String): List<String> =
sanitizeOsChain(chain)
.split('/')
.filter { it.isNotEmpty() }
.reversed()
.map {
when (it) {
"macos" -> "apple"
"steamos" -> "steam"
else -> it
}
}
/** /**
* Browses `_punktfunk._udp` for punktfunk/1 hosts via the native `mdns-sd` core (the same browse the * Browses `_punktfunk._udp` for punktfunk/1 hosts via the native `mdns-sd` core (the same browse the
* Linux/Windows clients use), exposed over JNI *not* `NsdManager`, whose per-OEM system daemon * Linux/Windows clients use), exposed over JNI *not* `NsdManager`, whose per-OEM system daemon
@@ -0,0 +1,451 @@
package io.unom.punktfunk.kit.link
import io.unom.punktfunk.kit.security.KnownHost
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.nio.charset.StandardCharsets
/**
* The `punktfunk://` URL grammar (design/client-deep-links.md §2). A **port**, not a new design:
* the Rust `crates/pf-client-core/src/deeplink.rs` is the reference, Swift keeps a third copy, and
* all three are held together by `clients/shared/deeplink-vectors.json`, which each language's test
* suite runs verbatim so the three parsers cannot drift into three different security postures.
*
* ```text
* punktfunk://connect/<host-ref>[?fp=<64-hex>][&host=<addr[:port]>][&launch=<id>]
* [&profile=<ref>][&name=<label>]
* ```
*
* The invariant the grammar exists to keep: **a URL may only ever do what a click on an existing
* card could do, minus trust decisions.** So it carries *references* to things that already exist
* on this device a host record, a settings profile, a library id and never values: no
* resolution, no bitrate, no codec. A web page must not be able to shape a session beyond picking
* among the user's own configurations. `pair` is deliberately not a route and never will be;
* pairing stays an interactive ceremony.
*
* `pf://` parses as an alias so a hand-typed or legacy link still works, but nothing ever *emits*
* or registers it.
*/
object DeepLinks {
/** Hostile-input caps. Generous for a real link, small enough that a pasted megabyte stops here. */
const val MAX_URL_LEN = 2048
const val MAX_HOST_REF_LEN = 128
const val MAX_LAUNCH_LEN = 128
const val MAX_PROFILE_LEN = 64
const val MAX_NAME_LEN = 64
/** The default native port, as everywhere else in the clients. */
const val DEFAULT_PORT = 9777
/**
* Parse a `punktfunk://` (or `pf://`) URL. Everything hostile is rejected here, once: over-long
* input, malformed escapes, control characters, out-of-charset launch ids and fingerprints that
* aren't fingerprints. What the caller still has to do is *resolve* the references may name
* things that don't exist on this device (see [resolveHost]).
*/
fun parse(url: String): DeepLinkResult {
if (url.length > MAX_URL_LEN) return DeepLinkResult.Refused(LinkError.TOO_LONG)
val sep = url.indexOf("://")
if (sep < 0) return DeepLinkResult.Refused(LinkError.NOT_OUR_SCHEME)
val scheme = url.substring(0, sep)
if (!scheme.equals("punktfunk", ignoreCase = true) && !scheme.equals("pf", ignoreCase = true)) {
return DeepLinkResult.Refused(LinkError.NOT_OUR_SCHEME)
}
// A fragment is never part of this grammar; drop it rather than folding it into the last
// parameter, where it would smuggle unvalidated text past the caps.
val rest = url.substring(sep + 3).substringBefore('#')
val path = rest.substringBefore('?').trimEnd('/')
val query = if (rest.contains('?')) rest.substringAfter('?') else ""
val slash = path.indexOf('/')
val routeWord: String
val hostRefRaw: String
when {
slash >= 0 -> {
routeWord = path.substring(0, slash)
hostRefRaw = path.substring(slash + 1)
}
// A single segment: Apple's shipped links are always `connect/<uuid>`, but a bare
// reference is unambiguous as long as it isn't one of the route words — those stay
// routes (with a missing reference), so `punktfunk://pair` refuses instead of hunting
// for a host called "pair".
isRouteWord(path) -> {
routeWord = path
hostRefRaw = ""
}
else -> {
routeWord = "connect"
hostRefRaw = path
}
}
val route = when (routeWord.lowercase()) {
"connect" -> LinkRoute.CONNECT
"wake" -> LinkRoute.WAKE
"browse" -> LinkRoute.BROWSE
"pair" -> return DeepLinkResult.Refused(LinkError.PAIR_REFUSED)
else -> return DeepLinkResult.Refused(LinkError.UNKNOWN_ROUTE, routeWord)
}
val hostRef = when (val d = decode(hostRefRaw)) {
is Decoded.Err -> return DeepLinkResult.Refused(d.error)
is Decoded.Ok -> d.text
}
if (hostRef.isEmpty()) return DeepLinkResult.Refused(LinkError.MISSING_HOST_REF)
if (scalarCount(hostRef) > MAX_HOST_REF_LEN) {
return DeepLinkResult.Refused(LinkError.PARAM_TOO_LONG, "host-ref")
}
var fp: String? = null
var host: Pair<String, Int>? = null
var launch: String? = null
var profile: String? = null
var name: String? = null
for (pair in query.split('&')) {
if (pair.isEmpty()) continue
val eq = pair.indexOf('=')
val rawKey = if (eq >= 0) pair.substring(0, eq) else pair
val rawValue = if (eq >= 0) pair.substring(eq + 1) else ""
val key = when (val d = decode(rawKey)) {
is Decoded.Err -> return DeepLinkResult.Refused(d.error)
is Decoded.Ok -> d.text.lowercase()
}
val value = when (val d = decode(rawValue)) {
is Decoded.Err -> return DeepLinkResult.Refused(d.error)
is Decoded.Ok -> d.text
}
// `?launch=` with nothing after it is "not given", not an error.
if (value.isEmpty()) continue
// First occurrence wins, and unknown keys are ignored: a newer emitter's parameter must
// not turn an otherwise valid link into a refusal, and appending a second `fp=` must
// not be able to override the first.
when {
key == "fp" && fp == null -> {
val hex = value.lowercase()
if (hex.length != 64 || !hex.all { it.isDigit() || it in 'a'..'f' }) {
return DeepLinkResult.Refused(LinkError.BAD_FINGERPRINT)
}
fp = hex
}
key == "host" && host == null ->
host = parseAddrPort(value) ?: return DeepLinkResult.Refused(LinkError.BAD_HOST_PARAM)
key == "launch" && launch == null -> {
if (value.toByteArray(StandardCharsets.UTF_8).size > MAX_LAUNCH_LEN) {
return DeepLinkResult.Refused(LinkError.PARAM_TOO_LONG, "launch")
}
if (!isSafeLaunchId(value)) return DeepLinkResult.Refused(LinkError.BAD_LAUNCH_ID)
launch = value
}
key == "profile" && profile == null -> {
if (scalarCount(value) > MAX_PROFILE_LEN) {
return DeepLinkResult.Refused(LinkError.PARAM_TOO_LONG, "profile")
}
profile = value
}
key == "name" && name == null -> {
if (scalarCount(value) > MAX_NAME_LEN) {
return DeepLinkResult.Refused(LinkError.PARAM_TOO_LONG, "name")
}
name = value
}
}
}
return DeepLinkResult.Parsed(DeepLink(route, hostRef, fp, host, launch, profile, name))
}
/**
* Resolve a link's host reference against the local store, in the documented order: stable
* record id unique case-insensitive name `addr[:port]` literal. The `host=` parameter is
* the recovery path a self-emitted shortcut that outlived the record it was written from
* still lands on the right box (degraded to the confirmation sheet).
*/
fun resolveHost(link: DeepLink, hosts: List<KnownHost>): HostResolution {
hosts.firstOrNull { it.id == link.hostRef }?.let { return HostResolution.Known(it) }
val byName = hosts.filter { it.name.equals(link.hostRef, ignoreCase = true) }
when (byName.size) {
1 -> return HostResolution.Known(byName[0])
0 -> Unit
else -> return HostResolution.Ambiguous
}
// `addr[:port]` literal, then the `host=` recovery parameter — both matched the way every
// other per-host lookup matches. The literal is only considered when the reference COULD be
// an address: a stale record id must fall through to `host=` (or to a refusal), never be
// offered as a box to dial.
val literal = if (looksLikeAddress(link.hostRef)) parseAddrPort(link.hostRef) else null
for ((addr, port) in listOfNotNull(literal, link.host)) {
hosts.firstOrNull { it.address == addr && it.port == port }
?.let { return HostResolution.Known(it) }
}
val fallback = literal ?: link.host ?: return HostResolution.Unresolvable
return HostResolution.Unknown(fallback.first, fallback.second, link.name, link.fp)
}
/**
* The self-emitted form for a saved host: id first (address-independent), with the address and
* pin alongside so the link degrades to a confirmation sheet instead of a dead click when the
* record is gone.
*/
fun forHost(host: KnownHost, launch: String? = null, profile: String? = null) = DeepLink(
route = LinkRoute.CONNECT,
hostRef = host.id,
fp = host.fpHex.ifEmpty { null },
host = host.address to host.port,
launch = launch,
profile = profile,
)
/** The reserved first path segments — plus `pair`, reserved precisely so it can be refused. */
private fun isRouteWord(s: String) = s.lowercase() in setOf("connect", "wake", "browse", "pair")
/**
* Could this reference be a network address (an IP literal or a host name) rather than a record
* id or a display name? Only then may an unmatched reference become "an unknown host at this
* address". A stale record id is NOT an address: offering to dial a UUID as a hostname would
* turn a wiped store into a confusing dead end instead of the `host=`-driven recovery.
*/
private fun looksLikeAddress(s: String): Boolean {
val uuidShaped = s.length == 36 && s.withIndex().all { (i, c) ->
if (i in setOf(8, 13, 18, 23)) c == '-' else isHex(c)
}
return !uuidShaped && s.isNotEmpty() &&
s.all { it.isLetterOrDigit() && it.code < 128 || it in ".-_:[]" }
}
/**
* `addr`, `addr:port`, `[v6]`, `[v6]:port` null when the port isn't a number. A bare IPv6
* literal (`::1`) keeps its colons and takes the default port; anything else splits at the last
* colon, like every other host-parsing site in the clients.
*/
private fun parseAddrPort(s: String): Pair<String, Int>? {
if (s.isEmpty()) return null
if (s.startsWith("[")) {
val close = s.indexOf(']', 1)
if (close < 0) return null
val addr = s.substring(1, close)
if (addr.isEmpty()) return null
val tail = s.substring(close + 1)
if (tail.isEmpty()) return addr to DEFAULT_PORT
if (!tail.startsWith(":")) return null
return addr to (tail.substring(1).toIntOrNull()?.takeIf { it in 1..65535 } ?: return null)
}
val lastColon = s.lastIndexOf(':')
if (lastColon < 0) return s to DEFAULT_PORT
val head = s.substring(0, lastColon)
// `::1` and friends: the head still has a colon, so this isn't a port separator.
if (head.contains(':')) return s to DEFAULT_PORT
if (head.isEmpty()) return null
val port = s.substring(lastColon + 1).toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
return head to port
}
/**
* The launch-id charset the whole product already agrees on: printable, non-space ASCII with no
* shell metacharacters (Decky rides ids through Steam launch options as an env token, so a
* quote or a backtick genuinely breaks something downstream). Validation only the id is
* opaque and the host matches it verbatim against its own library.
*/
private fun isSafeLaunchId(id: String): Boolean =
id.isNotEmpty() && id.toByteArray(StandardCharsets.UTF_8)
.all { it in 0x21..0x7e && it.toInt().toChar() !in "\"'\\$`" }
/**
* Strict percent-decoding: `%` must be followed by exactly two hex digits, the result must be
* UTF-8, and no control character may survive. Lenient decoders are how `%00`, a stray newline
* or a half-escape end up inside a filename or a log line.
*/
private fun decode(s: String): Decoded {
val bytes = s.toByteArray(StandardCharsets.UTF_8)
val out = java.io.ByteArrayOutputStream(bytes.size)
var i = 0
while (i < bytes.size) {
val b = bytes[i]
if (b == '%'.code.toByte()) {
if (i + 2 >= bytes.size) return Decoded.Err(LinkError.BAD_ESCAPE)
val hi = hexValue(bytes[i + 1].toInt().toChar())
val lo = hexValue(bytes[i + 2].toInt().toChar())
if (hi < 0 || lo < 0) return Decoded.Err(LinkError.BAD_ESCAPE)
out.write(hi * 16 + lo)
i += 3
} else {
out.write(b.toInt())
i += 1
}
}
// REPORT, not the default REPLACE: `%FF` must be a refusal, never a U+FFFD that survives.
val decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
val text = runCatching { decoder.decode(ByteBuffer.wrap(out.toByteArray())).toString() }
.getOrNull() ?: return Decoded.Err(LinkError.BAD_ESCAPE)
// `Char.isISOControl` is exactly Unicode's Cc category (C0 + DEL + C1), which is what the
// Rust side rejects.
if (text.any { it.isISOControl() }) return Decoded.Err(LinkError.CONTROL_CHAR)
return Decoded.Ok(text)
}
/** [decode]'s outcome — the decoded text, or which refusal it is. */
private sealed interface Decoded {
data class Ok(val text: String) : Decoded
data class Err(val error: LinkError) : Decoded
}
private fun hexValue(c: Char): Int = when (c) {
in '0'..'9' -> c - '0'
in 'a'..'f' -> c - 'a' + 10
in 'A'..'F' -> c - 'A' + 10
else -> -1
}
private fun isHex(c: Char) = hexValue(c) >= 0
/** Unicode scalars, matching the Rust caps (which count `chars()`, not UTF-16 units). */
private fun scalarCount(s: String) = s.codePointCount(0, s.length)
/**
* Percent-encode for emission: unreserved characters plus `:` (legal in a query value and left
* alone by Apple's `URLComponents`, so the three emitters agree on `steam:570`).
*/
internal fun encode(s: String): String {
val out = StringBuilder(s.length)
for (b in s.toByteArray(StandardCharsets.UTF_8)) {
val c = b.toInt().toChar()
if (c in 'A'..'Z' || c in 'a'..'z' || c in '0'..'9' || c in "-._~:") {
out.append(c)
} else {
out.append('%').append("%02X".format(b.toInt() and 0xFF))
}
}
return out.toString()
}
}
/** What the URL asks for. `WAKE`/`BROWSE` are reserved in the grammar and parse today. */
enum class LinkRoute(val word: String) {
CONNECT("connect"),
WAKE("wake"),
BROWSE("browse"),
}
/**
* Why a URL was rejected. The [code] strings are the cross-language contract the vector file names
* them, and Swift and Rust report the same code for the same input.
*/
enum class LinkError(val code: String) {
/** Not a `punktfunk://` (or `pf://`) URL at all — ignore it, don't warn. */
NOT_OUR_SCHEME("not-our-scheme"),
TOO_LONG("too-long"),
UNKNOWN_ROUTE("unknown-route"),
/** `punktfunk://pair/…` — pairing is an interactive ceremony, never a link. */
PAIR_REFUSED("pair-refused"),
MISSING_HOST_REF("missing-host-ref"),
/** A `%` escape that isn't two hex digits, or a decode that isn't UTF-8. */
BAD_ESCAPE("bad-escape"),
/** A control character survived decoding — no legitimate field contains one. */
CONTROL_CHAR("control-char"),
PARAM_TOO_LONG("param-too-long"),
BAD_FINGERPRINT("bad-fingerprint"),
BAD_HOST_PARAM("bad-host-param"),
BAD_LAUNCH_ID("bad-launch-id"),
}
/**
* A parsed, validated link. Every field is already length- and charset-checked, so a consumer never
* has to re-validate hostile input.
*/
data class DeepLink(
val route: LinkRoute = LinkRoute.CONNECT,
/** The host reference as written: a stable record id, a host name, or `addr[:port]`. */
val hostRef: String,
/** Expected host certificate fingerprint, lowercase hex (64 chars). */
val fp: String? = null,
/** Recovery address for a stable id that no longer resolves (store wiped, reinstall). */
val host: Pair<String, Int>? = null,
/** A store-qualified library id (`steam:570`) for the host to launch on arrival. */
val launch: String? = null,
/** A settings-profile reference (id, or a unique name) — one-off, never rebinding. */
val profile: String? = null,
/** Display label for the unknown-host confirmation sheet (external emitters). */
val name: String? = null,
) {
/** The canonical URL for this link — always `punktfunk://`, never the `pf://` alias. */
fun toUrl(): String {
val sb = StringBuilder("punktfunk://${route.word}/${DeepLinks.encode(hostRef)}")
var sep = '?'
fun push(key: String, value: String) {
sb.append(sep).append(key).append('=').append(DeepLinks.encode(value))
sep = '&'
}
fp?.let { push("fp", it) }
host?.let { (addr, port) ->
push(
"host",
when {
port == DeepLinks.DEFAULT_PORT -> addr
addr.contains(':') -> "[$addr]:$port" // literal IPv6 needs its brackets back
else -> "$addr:$port"
},
)
}
launch?.let { push("launch", it) }
profile?.let { push("profile", it) }
name?.let { push("name", it) }
return sb.toString()
}
/**
* True when this link's `fp` contradicts what we have pinned for that host the link is stale
* or lying, and the only safe answer is a hard refusal.
*/
fun pinConflict(host: KnownHost): Boolean =
fp != null && host.fpHex.isNotEmpty() && !fp.equals(host.fpHex, ignoreCase = true)
}
/** A parse outcome: a link, or a refusal carrying the shared code. */
sealed interface DeepLinkResult {
data class Parsed(val link: DeepLink) : DeepLinkResult
data class Refused(val error: LinkError, val detail: String? = null) : DeepLinkResult {
/**
* A sentence for the notice a refusing front-end shows. Deliberately names what failed: a
* shortcut that can't honour its reference says so instead of streaming with the wrong one.
*/
fun message(): String = when (error) {
LinkError.NOT_OUR_SCHEME -> "That isn't a Punktfunk link."
LinkError.TOO_LONG -> "That link is too long to be genuine."
LinkError.UNKNOWN_ROUTE -> "Punktfunk links can't do “$detail”."
LinkError.PAIR_REFUSED ->
"Pairing can't be done from a link — pair the host in Punktfunk first."
LinkError.MISSING_HOST_REF -> "That link doesn't say which host to use."
LinkError.BAD_ESCAPE, LinkError.CONTROL_CHAR -> "That link is malformed and was ignored."
LinkError.PARAM_TOO_LONG -> "That link's “$detail” value is too long."
LinkError.BAD_FINGERPRINT -> "That link's host fingerprint isn't a valid one."
LinkError.BAD_HOST_PARAM -> "That link's host address isn't valid."
LinkError.BAD_LAUNCH_ID -> "That link's game id isn't a valid one."
}
}
}
/** What the local host store made of a link's references. */
sealed interface HostResolution {
/** A record we already trust (subject to [DeepLink.pinConflict]). */
data class Known(val host: KnownHost) : HostResolution
/**
* No record, but the link says where to dial: the confirmation sheet's input, from which the
* normal pairing/TOFU flow proceeds under the user's eyes. Never an auto-connect.
*/
data class Unknown(
val address: String,
val port: Int,
val name: String?,
val fp: String?,
) : HostResolution
/** The name matched more than one saved host — refuse with a notice, never guess. */
data object Ambiguous : HostResolution
/** A reference that resolves to nothing and carries no address to fall back on. */
data object Unresolvable : HostResolution
}
@@ -1,11 +1,19 @@
package io.unom.punktfunk.kit.security package io.unom.punktfunk.kit.security
import android.content.Context import android.content.Context
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
/** /**
* A host the user has trusted (pinned). [fpHex] is the pinned host-cert SHA-256 (64-hex); [paired] * A host the user has trusted (pinned). [fpHex] is the pinned host-cert SHA-256 (64-hex); [paired]
* is true when trust was established via the SPAKE2 PIN ceremony (vs trust-on-first-use). * is true when trust was established via the SPAKE2 PIN ceremony (vs trust-on-first-use).
*
* [id] is the record's **stable identity** minted once, never changed, and the key this record is
* stored under. Everything that needs to point AT a host (a settings-profile binding, a pinned
* card, a `punktfunk://` link) points at the id, so renaming a host or moving it to a new address
* doesn't strand those references. Mirrors the Apple client's `StoredHost.id` and the Rust
* `KnownHost.id`; the shape is a lowercase UUID v4, one grammar on every platform.
*/ */
data class KnownHost( data class KnownHost(
val address: String, val address: String,
@@ -18,37 +26,85 @@ data class KnownHost(
* online, so the client can wake it once it sleeps. Empty until first learned. * online, so the client can wake it once it sleeps. Empty until first learned.
*/ */
val mac: List<String> = emptyList(), val mac: List<String> = emptyList(),
/**
* The host's OS-identity chain (`windows` | `linux/<family>/<id>`, ...) learned from its mDNS
* `os` TXT while online, so the card's OS icon survives the host going to sleep. Empty until
* first learned (or forever, against an older host).
*/
val os: String = "",
/** Stable record identity — see the class doc. Minted here for a genuinely new record. */
val id: String = newRecordId(),
/**
* Sync text copied on this device to this host and back while streaming. **A property of the
* host, not of the stream** (design/client-settings-profiles.md §3, tier H): it is a trust
* decision about that machine, so it is never in a settings profile and never global the
* work box and the couch box get their own answers. Only effective when the host advertises
* the clipboard capability; the protocol is opt-in per session either way.
*/
val clipboardSync: Boolean = true,
/**
* The settings profile a plain tap on this host connects with `null` (or an id whose profile
* was deleted) means the global defaults, i.e. today's behaviour. A dangling id is never an
* error and never blocks a connect.
*/
val profileId: String? = null,
/**
* Profiles pinned as their own cards for this host (design §5.2a). Presentation only: order is
* card order, and this is NOT the default binding ([profileId] is). Duplicates and profiles
* that no longer exist are dropped when the cards are rendered.
*/
val pinnedProfileIds: List<String> = emptyList(),
) )
/** /**
* Persists trusted hosts the pinned-fingerprint store *and* the saved-hosts list keyed by * Persists trusted hosts the pinned-fingerprint store *and* the saved-hosts list keyed by
* `address:port`. Replaces the old fp-only PinStore so a discovered and a manually-typed connection * [KnownHost.id]. Plain `SharedPreferences` in app-private storage: pinned fingerprints are public
* to the same host share one trust record (and so saved hosts can be listed + reconnected). Plain * host identities, not secrets; the property we need is integrity, which app sandboxing provides.
* `SharedPreferences` in app-private storage: pinned fingerprints are public host identities, not *
* secrets; the property we need is integrity, which app sandboxing provides. * Records used to be keyed by `"address:port"`, which meant editing a host's address had to
* re-key its record (delete + write) or leave a ghost behind, and meant nothing could hold a
* durable reference to a host. Keying by the minted stable id retires both. [migrate] moves an
* existing store over in one pass see its doc for what else rides along.
*/ */
class KnownHostStore(context: Context) { class KnownHostStore(context: Context) {
private val prefs = private val prefs =
context.applicationContext.getSharedPreferences("punktfunk_hosts", Context.MODE_PRIVATE) context.applicationContext.getSharedPreferences(PREFS_HOSTS, Context.MODE_PRIVATE)
// The pref key is just a unique id; address/port are also stored in the value so an IPv6 init {
// address (which contains colons) round-trips without parsing the key. migrateIfNeeded(context)
private fun key(address: String, port: Int) = "$address:$port" }
/** The trusted record for [address]:[port], or `null` if this host has never been trusted. */ /** The trusted record for [address]:[port], or `null` if this host has never been trusted. */
fun get(address: String, port: Int): KnownHost? = fun get(address: String, port: Int): KnownHost? =
prefs.getString(key(address, port), null)?.let(::parse) all().firstOrNull { it.address == address && it.port == port }
/** Pin (or update) a trusted host — upsert by `address:port`. */ /** The trusted record with this stable [id], or `null` — the lookup a binding or link uses. */
fun byId(id: String): KnownHost? = prefs.getString(id, null)?.let(::parse)
/**
* Pin (or update) a trusted host upsert by [KnownHost.id]. An edit that moves the address or
* port is a plain save now: the key is the identity, not the address.
*/
fun save(host: KnownHost) { fun save(host: KnownHost) {
val json = JSONObject() prefs.edit().putString(host.id, encode(host)).apply()
.put("addr", host.address) }
.put("port", host.port)
.put("name", host.name) /**
.put("fp", host.fpHex.lowercase()) * Trust (or re-trust) the host at [address]:[port] with the fingerprint it presented.
.put("paired", host.paired) *
.put("mac", host.mac.joinToString(",")) * When a record already exists there a re-pair after the host's identity changed, an
prefs.edit().putString(key(host.address, host.port), json.toString()).apply() * approval that upgrades a TOFU record to paired it keeps its identity and everything the
* user set on it: the stable [KnownHost.id] (so profile bindings, pinned cards and any
* `punktfunk://` shortcut still point at it), the per-host clipboard decision, the binding,
* the pins and the learned MACs. Only the name, pin and paired flag are refreshed. Returns the
* stored record.
*/
fun trust(address: String, port: Int, name: String, fpHex: String, paired: Boolean): KnownHost {
val existing = get(address, port)
val host = existing?.copy(name = name, fpHex = fpHex, paired = paired)
?: KnownHost(address, port, name, fpHex, paired)
save(host)
return host
} }
/** /**
@@ -63,30 +119,56 @@ class KnownHostStore(context: Context) {
save(h.copy(mac = mac)) save(h.copy(mac = mac))
} }
/** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */
fun remove(address: String, port: Int) {
prefs.edit().remove(key(address, port)).apply()
}
/** Set a saved host's display name, keeping its pin + paired flag. No-op if not saved. */
fun rename(address: String, port: Int, newName: String) {
val h = get(address, port) ?: return
save(h.copy(name = newName))
}
/** /**
* Edit a saved host, RE-KEYING if the address or port changed (the pref key IS `address:port`, so * Learn/refresh a saved host's OS-identity chain from its live advert same contract as
* a plain [save] would otherwise leave a stale record under the old key). The caller passes an * [learnMac]: no-op when unsaved, empty, or unchanged.
* [updated] copy that preserves `fpHex`/`paired` (and sets `mac` from the edit form).
*/ */
fun update(oldAddress: String, oldPort: Int, updated: KnownHost) { fun learnOs(address: String, port: Int, os: String) {
if (oldAddress != updated.address || oldPort != updated.port) remove(oldAddress, oldPort) if (os.isEmpty()) return
save(updated) val h = get(address, port) ?: return
if (h.os == os) return
save(h.copy(os = os))
}
/** Forget [host] (the next connect re-pairs / re-TOFUs). */
fun remove(host: KnownHost) {
prefs.edit().remove(host.id).apply()
} }
/** All trusted hosts, name-sorted — backs the saved-hosts list. */ /** All trusted hosts, name-sorted — backs the saved-hosts list. */
fun all(): List<KnownHost> = fun all(): List<KnownHost> = prefs.all
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() } .filterKeys { it != K_SCHEMA }
.values
.mapNotNull { (it as? String)?.let(::parse) }
.sortedBy { it.name.lowercase() }
/**
* One-time move from the `"address:port"`-keyed schema to id-keyed records, run on first
* construction after the upgrade and never again ([K_SCHEMA] records that it happened).
*
* It is deliberately ONE pass, not three: the store is being rewritten anyway, and every extra
* migration pass is another chance to strand somebody's hosts. So the same pass mints the
* stable id, re-keys the record onto it, and copies the retiring GLOBAL clipboard-sync setting
* onto every host behaviour-preserving, since every host was following that one value.
*/
private fun migrateIfNeeded(context: Context) {
if (prefs.getInt(K_SCHEMA, 0) >= SCHEMA_VERSION) return
val settings =
context.applicationContext.getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE)
val result = migrate(prefs.all, settings.getBoolean(K_GLOBAL_CLIPBOARD_SYNC, true))
// `commit`, not `apply`: the re-keyed records and the schema flag are one atomic write to
// disk, and the global below is only retired once that write has landed. With `apply` a
// process death in between could drop the old global while the hosts that were supposed to
// inherit it were still only in memory. Once, on one small file, on an upgrade.
val written = prefs.edit().apply {
result.removals.forEach(::remove)
result.writes.forEach { (k, v) -> putString(k, v) }
putInt(K_SCHEMA, SCHEMA_VERSION)
}.commit()
if (written && settings.contains(K_GLOBAL_CLIPBOARD_SYNC)) {
settings.edit().remove(K_GLOBAL_CLIPBOARD_SYNC).apply()
}
}
private fun parse(s: String): KnownHost? = runCatching { private fun parse(s: String): KnownHost? = runCatching {
val j = JSONObject(s) val j = JSONObject(s)
@@ -97,10 +179,67 @@ class KnownHostStore(context: Context) {
fpHex = j.getString("fp"), fpHex = j.getString("fp"),
paired = j.optBoolean("paired", false), paired = j.optBoolean("paired", false),
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() }, mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
os = j.optString("os", ""),
// A record without an id can only be one this build wrote before the migration ran, or
// a hand-edited file; minting here keeps the parse total rather than dropping a host.
id = j.optString("id", "").ifEmpty { newRecordId() },
clipboardSync = j.optBoolean("clip", true),
profileId = j.optString("profile", "").ifEmpty { null },
pinnedProfileIds = stringList(j.optJSONArray("pins")),
) )
}.getOrNull() }.getOrNull()
companion object { companion object {
/** The prefs file holding the host records. */
private const val PREFS_HOSTS = "punktfunk_hosts"
/** The app's settings file — read once by [migrate] for the retiring global. */
private const val PREFS_SETTINGS = "punktfunk_settings"
/**
* The global clipboard-sync key this migration retires. Clipboard sync is a decision about
* a HOST (design §3, tier H), so it lives on the record now; the global is read once, to
* seed every host, and then deleted.
*/
private const val K_GLOBAL_CLIPBOARD_SYNC = "clipboard_sync"
/** Schema marker inside the hosts file. Reserved — never a host record. */
private const val K_SCHEMA = "__schema"
/** 1 = id-keyed records with per-host clipboard sync, profile binding and pins. */
private const val SCHEMA_VERSION = 1
/** What [migrate] decided: entries to write, and (old) keys to drop. */
data class Migration(val writes: Map<String, String>, val removals: Set<String>)
/**
* The pure half of the store migration, over a raw prefs snapshot ([entries] as returned by
* `SharedPreferences.all`) so it can be tested against a real pre-migration blob without
* an Android runtime.
*
* Every host record survives with its address, port, name, pin, paired flag and MACs
* intact, gains a minted [KnownHost.id], moves to that key, and takes
* [globalClipboardSync] as its own [KnownHost.clipboardSync]. Entries that aren't parsable
* host records are left alone: they were already invisible (`all()` skipped them), and
* deleting things we don't understand is not this pass's job.
*/
fun migrate(entries: Map<String, Any?>, globalClipboardSync: Boolean): Migration {
val writes = mutableMapOf<String, String>()
val removals = mutableSetOf<String>()
for ((key, raw) in entries) {
if (key == K_SCHEMA) continue
val json = (raw as? String)?.let { runCatching { JSONObject(it) }.getOrNull() }
?: continue
if (!json.has("addr") || !json.has("port")) continue
val id = json.optString("id", "").ifEmpty { newRecordId() }
json.put("id", id)
json.put("clip", json.optBoolean("clip", globalClipboardSync))
writes[id] = json.toString()
if (key != id) removals += key
}
return Migration(writes, removals)
}
/** /**
* Parse a free-typed Wake-on-LAN field into normalized `aa:bb:cc:dd:ee:ff` entries (comma / * Parse a free-typed Wake-on-LAN field into normalized `aa:bb:cc:dd:ee:ff` entries (comma /
* space / newline separated). Anything that isn't six colon-separated hex octets is dropped; * space / newline separated). Anything that isn't six colon-separated hex octets is dropped;
@@ -116,5 +255,32 @@ class KnownHostStore(context: Context) {
o.size == 6 && o.all { it.length == 2 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } } o.size == 6 && o.all { it.length == 2 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } }
} }
} }
/** The stored JSON for one record — also the shape [migrate] upgrades into. */
internal fun encode(host: KnownHost): String = JSONObject()
.put("id", host.id)
.put("addr", host.address)
.put("port", host.port)
.put("name", host.name)
.put("fp", host.fpHex.lowercase())
.put("paired", host.paired)
.put("mac", host.mac.joinToString(","))
.put("os", host.os)
.put("clip", host.clipboardSync)
.put("profile", host.profileId ?: "")
.put("pins", JSONArray(host.pinnedProfileIds))
.toString()
private fun stringList(a: JSONArray?): List<String> {
if (a == null) return emptyList()
return (0 until a.length()).mapNotNull { a.optString(it, "").ifEmpty { null } }
} }
} }
}
/**
* A fresh stable record identity: a lowercase UUID v4, the shape the Apple client's `StoredHost.id`
* and the Rust `KnownHost.id` already use, so a `punktfunk://` host reference is one grammar
* everywhere.
*/
fun newRecordId(): String = UUID.randomUUID().toString()
@@ -33,6 +33,39 @@ class ParseRecordTest {
assertEquals(false, h.pairingRequired) assertEquals(false, h.pairingRequired)
} }
@Test
fun sevenFieldRecordHasNoOs() {
// A native lib predating the 8th field: `os` defaults empty, everything else parses.
val h = parseHostRecord(rec("k", "n", "10.0.0.5", "9777", "", "optional", "aa:bb:cc:dd:ee:ff"))!!
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), h.mac)
assertEquals("", h.os)
}
@Test
fun eighthFieldCarriesTheOsChain() {
val h = parseHostRecord(
rec("k", "n", "10.0.0.5", "9777", "", "optional", "", "linux/fedora/bazzite"),
)!!
assertEquals("linux/fedora/bazzite", h.os)
}
@Test
fun osChainIsSanitizedAsUntrustedInput() {
// mDNS is unauthenticated: junk is dropped, case folds, token/count caps apply.
val h = parseHostRecord(rec("k", "n", "10.0.0.5", "9777", "", "optional", "", "Linux/Fe do!ra"))!!
assertEquals("linux/fedora", h.os)
assertEquals("", sanitizeOsChain("///!!!"))
assertEquals("a/b/c/d/e", sanitizeOsChain("a/b/c/d/e/f/g"))
}
@Test
fun iconWalkIsMostSpecificFirstWithAliases() {
assertEquals(listOf("bazzite", "fedora", "linux"), osIconTokens("linux/fedora/bazzite"))
assertEquals(listOf("steam", "arch", "linux"), osIconTokens("linux/arch/steamos"))
assertEquals(listOf("apple"), osIconTokens("macos"))
assertTrue(osIconTokens("").isEmpty())
}
@Test @Test
fun emptyKeyFallsBackToAddrPort() { fun emptyKeyFallsBackToAddrPort() {
// Host advertised no `id` TXT → the native side leaves the key blank; we synthesize addr:port. // Host advertised no `id` TXT → the native side leaves the key blank; we synthesize addr:port.
@@ -0,0 +1,160 @@
package io.unom.punktfunk.kit.link
import io.unom.punktfunk.kit.security.KnownHost
import java.io.File
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* **The cross-language contract.** `clients/shared/deeplink-vectors.json` is consumed verbatim by
* the Rust, Swift and Kotlin suites, so the three parsers cannot drift into three different
* security postures a URL that Rust refuses as a control-character smuggle must not quietly
* parse here. Any new case belongs in that file, not in this one.
*/
class DeepLinkVectorTest {
private val vectors: JSONObject by lazy {
// Gradle runs a unit test with the module directory as its working directory, so the shared
// file is two levels up (clients/android/kit → clients/shared). Resolved rather than
// copied: a copy would be a fourth contract, free to go stale.
val file = File("../../shared/deeplink-vectors.json")
assertTrue(
"the shared vector file must be reachable at ${file.absolutePath}",
file.isFile,
)
JSONObject(file.readText())
}
@Test
fun everySharedVectorAgrees() {
val cases = vectors.getJSONArray("cases")
assertTrue("the vector file is the contract; keep it rich", cases.length() > 20)
for (i in 0 until cases.length()) {
val case = cases.getJSONObject(i)
val name = case.getString("name")
val result = DeepLinks.parse(case.getString("url"))
if (case.has("error")) {
val refusal = result as? DeepLinkResult.Refused
?: throw AssertionError("$name: expected ${case.getString("error")}, parsed ok")
assertEquals(name, case.getString("error"), refusal.error.code)
assertTrue("$name: a refusal must be explainable", refusal.message().isNotEmpty())
continue
}
val link = (result as? DeepLinkResult.Parsed)?.link
?: throw AssertionError("$name: refused, expected a parse — $result")
val want = case.getJSONObject("expect")
assertEquals(name, want.getString("route"), link.route.word)
assertEquals(name, want.getString("host_ref"), link.hostRef)
assertEquals("$name fp", want.optStringOrNull("fp"), link.fp)
assertEquals("$name launch", want.optStringOrNull("launch"), link.launch)
assertEquals("$name profile", want.optStringOrNull("profile"), link.profile)
assertEquals("$name name", want.optStringOrNull("name"), link.name)
assertEquals("$name host_addr", want.optStringOrNull("host_addr"), link.host?.first)
assertEquals(
"$name host_port",
if (want.has("host_port")) want.getInt("host_port") else null,
link.host?.second,
)
if (case.has("emit")) assertEquals("$name emit", case.getString("emit"), link.toUrl())
}
}
private fun JSONObject.optStringOrNull(key: String): String? =
if (has(key)) getString(key) else null
}
/**
* Resolution and emission the half the vector file can't cover, because it depends on what is in
* THIS device's host store. The rules are the one-click contract in resolution form: an id beats a
* name beats an address, an ambiguous name refuses rather than guesses, and a link whose record is
* gone still lands on the confirmation sheet via `host=`+`fp=` instead of dying.
*/
class DeepLinkResolutionTest {
private val fp = "a".repeat(64)
private val desk = host("Desk", "192.168.1.50", "11111111-2222-4333-8444-555555555555", fp)
private val hosts = listOf(
desk,
host("Couch", "192.168.1.60", "66666666-7777-4888-8999-aaaaaaaaaaaa", ""),
host("Couch", "192.168.1.61", "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", ""),
)
private fun resolve(url: String) =
DeepLinks.resolveHost((DeepLinks.parse(url) as DeepLinkResult.Parsed).link, hosts)
@Test
fun idBeatsNameBeatsAddress() {
assertEquals(desk, (resolve("punktfunk://connect/${desk.id}") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/desk") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50:9777") as HostResolution.Known).host)
// Two hosts answer to "Couch" — refuse with a notice, never pick one.
assertEquals(HostResolution.Ambiguous, resolve("punktfunk://connect/couch"))
}
@Test
fun aStaleIdRecoversThroughTheHostParameter() {
val stale = "00000000-0000-4000-8000-000000000000"
assertEquals(
desk,
(resolve("punktfunk://connect/$stale?host=192.168.1.50") as HostResolution.Known).host,
)
// …but a stale id is NOT a hostname: dialing "00000000-…" would be a confusing dead end
// rather than the recovery the grammar specifies.
assertEquals(HostResolution.Unresolvable, resolve("punktfunk://connect/$stale"))
// Neither is a display name that can't be an address.
assertEquals(HostResolution.Unresolvable, resolve("punktfunk://connect/Basement%20PC"))
}
@Test
fun anUnknownHostBecomesTheConfirmationSheetsInput() {
val r = resolve("punktfunk://connect/10.0.0.9:7000?name=Studio&fp=$fp")
assertEquals(HostResolution.Unknown("10.0.0.9", 7000, "Studio", fp), r)
// An mDNS/DNS name we've never saved is offered the same way — the sheet, never a connect.
assertEquals(
HostResolution.Unknown("nas.local", DeepLinks.DEFAULT_PORT, null, null),
resolve("punktfunk://connect/nas.local"),
)
}
@Test
fun aPinThatContradictsTheStoredOneIsTheLinkLying() {
fun link(url: String) = (DeepLinks.parse(url) as DeepLinkResult.Parsed).link
assertTrue(link("punktfunk://connect/desk?fp=${"b".repeat(64)}").pinConflict(desk))
assertFalse(link("punktfunk://connect/desk?fp=$fp").pinConflict(desk))
// No pin stored (an address-only record) → nothing to contradict; the trust flow runs.
assertFalse(link("punktfunk://connect/desk?fp=${"b".repeat(64)}").pinConflict(hosts[1]))
}
@Test
fun selfEmittedLinksRoundTripAndSurviveAWipedStore() {
val h = desk.copy(port = 7777)
val link = DeepLinks.forHost(h, launch = "steam:570", profile = "aaaaaaaaaaaa")
val url = link.toUrl()
assertEquals(
"punktfunk://connect/${h.id}?fp=$fp&host=192.168.1.50:7777" +
"&launch=steam:570&profile=aaaaaaaaaaaa",
url,
)
assertEquals(link, (DeepLinks.parse(url) as DeepLinkResult.Parsed).link)
// Names with spaces and non-ASCII survive the round trip.
val labelled = DeepLink(hostRef = "Wohnzimmer PC", name = "Büro · Mac")
assertTrue(labelled.toUrl().startsWith("punktfunk://connect/Wohnzimmer%20PC?"))
assertEquals(labelled, (DeepLinks.parse(labelled.toUrl()) as DeepLinkResult.Parsed).link)
// An emitted IPv6 host parameter comes back bracketed, so it parses again.
val v6 = DeepLink(hostRef = "x", host = "::1" to 1234)
assertEquals(v6.host, (DeepLinks.parse(v6.toUrl()) as DeepLinkResult.Parsed).link.host)
}
@Test
fun aHostWithNoPinEmitsNoFingerprint() {
assertNull(DeepLinks.forHost(hosts[1]).fp)
}
private fun host(name: String, addr: String, id: String, fp: String) =
KnownHost(addr, DeepLinks.DEFAULT_PORT, name, fp, paired = true, id = id)
}
@@ -1,6 +1,10 @@
package io.unom.punktfunk.kit.security package io.unom.punktfunk.kit.security
import org.json.JSONObject
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
/** Unit tests for the pure MAC-parsing helper backing the host edit form. */ /** Unit tests for the pure MAC-parsing helper backing the host edit form. */
@@ -30,4 +34,119 @@ class KnownHostStoreTest {
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("+a:-b:+c:-d:+e:-f")) // signed octets assertEquals(emptyList<String>(), KnownHostStore.parseMacs("+a:-b:+c:-d:+e:-f")) // signed octets
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("junk, aa:bb:cc:dd:ee:ff")) assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("junk, aa:bb:cc:dd:ee:ff"))
} }
@Test
fun encodedRecordCarriesTheOsChainAndLegacyRecordsReadBackEmpty() {
val h = KnownHost("10.0.0.5", 9777, "HTPC", "a".repeat(64), true, os = "linux/fedora/bazzite")
val j = JSONObject(KnownHostStore.encode(h))
assertEquals("linux/fedora/bazzite", j.getString("os"))
// A record written before the field existed has no "os" key; the parse contract
// (optString) reads it back as empty — same additive rule as every late field.
val legacy = JSONObject().put("addr", "10.0.0.5").put("port", 9777).toString()
assertEquals("", JSONObject(legacy).optString("os", ""))
}
}
/**
* The store migration, run against a REAL pre-migration prefs blob records exactly as the
* `"address:port"`-keyed store wrote them, IPv6 and all. It runs once against live user data on
* every upgraded install, so the property under test is blunt: **every host survives**, with its
* trust intact and the retiring global clipboard setting carried onto it.
*/
class KnownHostMigrationTest {
/** Verbatim shape of the old writer: no `id`, no `clip`, MACs as a comma-joined string. */
private fun legacy(addr: String, port: Int, name: String, fp: String, paired: Boolean, mac: String) =
JSONObject()
.put("addr", addr)
.put("port", port)
.put("name", name)
.put("fp", fp)
.put("paired", paired)
.put("mac", mac)
.toString()
private val preMigration: Map<String, Any?> = mapOf(
"192.168.1.42:9777" to legacy("192.168.1.42", 9777, "Living Room PC", "a".repeat(64), true, "aa:bb:cc:dd:ee:ff"),
"192.168.1.50:9777" to legacy("192.168.1.50", 9777, "Office", "b".repeat(64), false, ""),
// An IPv6 host: the old key contains colons of its own, which is exactly why the record
// always carried its address in the VALUE rather than parsing it back out of the key.
"fd00::1:9777" to legacy("fd00::1", 9777, "Basement", "c".repeat(64), true, ""),
)
private fun migrated(globalClipboard: Boolean): List<JSONObject> =
KnownHostStore.migrate(preMigration, globalClipboard).writes.values.map { JSONObject(it) }
@Test
fun everyHostSurvivesWithItsTrustIntact() {
val result = KnownHostStore.migrate(preMigration, globalClipboardSync = true)
assertEquals(3, result.writes.size)
// Every old key is dropped — none of them is a valid new key (they aren't ids).
assertEquals(preMigration.keys, result.removals)
val byName = result.writes.values.map { JSONObject(it) }.associateBy { it.getString("name") }
assertEquals(setOf("Living Room PC", "Office", "Basement"), byName.keys)
val living = byName.getValue("Living Room PC")
assertEquals("192.168.1.42", living.getString("addr"))
assertEquals(9777, living.getInt("port"))
assertEquals("a".repeat(64), living.getString("fp"))
assertTrue(living.getBoolean("paired"))
assertEquals("aa:bb:cc:dd:ee:ff", living.getString("mac"))
// The IPv6 address round-trips out of the value, untouched by the key rewrite.
assertEquals("fd00::1", byName.getValue("Basement").getString("addr"))
assertFalse(byName.getValue("Office").getBoolean("paired"))
}
@Test
fun eachRecordIsRekeyedOntoItsOwnMintedId() {
val result = KnownHostStore.migrate(preMigration, globalClipboardSync = true)
// The key IS the record's id, and the ids are distinct — two hosts must never collide onto
// one record (which would silently lose one of them).
result.writes.forEach { (key, json) -> assertEquals(key, JSONObject(json).getString("id")) }
assertEquals(3, result.writes.keys.size)
// The minted shape is the cross-platform one: a lowercase UUID.
result.writes.keys.forEach { id ->
assertEquals(36, id.length)
assertEquals(id.lowercase(), id)
assertEquals(listOf(8, 4, 4, 4, 12), id.split("-").map { it.length })
}
assertNotEquals(newRecordId(), newRecordId())
}
/**
* The behaviour-preserving half: clipboard sync was one global that every host followed, so
* after the migration every host must still be following the value that global held on or
* off. This is the assertion that would catch a migration silently defaulting everyone to on.
*/
@Test
fun theRetiringGlobalClipboardSettingLandsOnEveryHost() {
migrated(globalClipboard = true).forEach { assertTrue(it.getBoolean("clip")) }
migrated(globalClipboard = false).forEach { assertFalse(it.getBoolean("clip")) }
}
@Test
fun migrationIsIdempotentOverItsOwnOutput() {
val once = KnownHostStore.migrate(preMigration, globalClipboardSync = false)
val twice = KnownHostStore.migrate(once.writes, globalClipboardSync = true)
// Already-keyed records keep their ids and their per-host value: a second pass (a downgrade
// then re-upgrade, a schema flag lost) must not re-mint ids that bindings point at, and must
// not resurrect the global over a per-host answer the user has since changed.
assertEquals(once.writes, twice.writes)
assertTrue(twice.removals.isEmpty())
}
@Test
fun entriesThatArentHostRecordsAreLeftAlone() {
val junk = mapOf(
"some_unrelated_flag" to true,
"half_a_record" to JSONObject().put("name", "no address").toString(),
"not_even_json" to "{{{",
)
val result = KnownHostStore.migrate(junk, globalClipboardSync = true)
// They were already invisible to `all()`; deleting what we don't understand isn't this
// pass's job, and writing them as hosts would invent records out of nothing.
assertTrue(result.writes.isEmpty())
assertTrue(result.removals.isEmpty())
}
} }
+3
View File
@@ -64,3 +64,6 @@ libc = "0.2"
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake — # host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's. # the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
opus = "0.3" opus = "0.3"
[lints]
workspace = true
@@ -21,7 +21,7 @@ use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime, android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate, configure_low_latency, create_codec, try_set_frame_rate,
}; };
use super::{DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, PENDING_SPLIT_CAP}; use super::{DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP};
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed /// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the /// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
@@ -253,6 +253,12 @@ pub(super) fn run_async(
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop. // presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
let mut work_accum_ns: i64 = 0; let mut work_accum_ns: i64 = 0;
let mut fatal = false; let mut fatal = false;
// No-output backstop (see [`NO_OUTPUT_PATIENCE`]): the last time the decoder handed us a frame,
// and how many AUs it had been fed by then. Silence only counts while AUs are actually going in,
// so an idle stream never asks for anything. Seeded at start so a decoder that never produces a
// first frame — the missed opening IDR — is caught by the same window.
let mut last_output = Instant::now();
let mut fed_at_output: u64 = 0;
while !shutdown.load(Ordering::Relaxed) && !fatal { while !shutdown.load(Ordering::Relaxed) && !fatal {
// Block for the next event (idle wait — excluded from the work tally). The short timeout // Block for the next event (idle wait — excluded from the work tally). The short timeout
@@ -344,6 +350,12 @@ pub(super) fn run_async(
h.report_actual(work_accum_ns); h.report_actual(work_accum_ns);
} }
work_accum_ns = 0; work_accum_ns = 0;
// The one line that separates "the stream never reached glass" from "it reached glass
// and looked wrong" — the periodic tally below only starts at 300 frames, which is no
// help at all on a session that renders none.
if rendered == 1 {
log::info!("decode: first frame presented (fed={fed} discarded={discarded})");
}
if rendered > 0 && rendered % 300 == 0 { if rendered > 0 && rendered % 300 == 0 {
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}"); log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
} }
@@ -356,7 +368,27 @@ pub(super) fn run_async(
if aus_dropped > 0 { if aus_dropped > 0 {
gate.arm(now); gate.arm(now);
} }
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0) // Fed but silent: the decoder is holding nothing it can decode — the opening IDR never
// reached it, or its reference chain is gone. Ask for a fresh one and arm the freeze, so the
// concealment it may start emitting on the way back is withheld until a clean re-anchor
// (`gate.poll` keeps re-asking on the deadline until one arrives).
let starved = !had_output
&& fed > fed_at_output
&& now.duration_since(last_output) >= NO_OUTPUT_PATIENCE;
if had_output {
last_output = now;
fed_at_output = fed;
} else if starved {
log::warn!(
"decode: no output for {} ms with {} AU(s) fed — requesting a re-anchor keyframe",
now.duration_since(last_output).as_millis(),
fed - fed_at_output
);
gate.arm(now);
last_output = now; // one request per patience window, not per iteration
fed_at_output = fed;
}
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0 || starved)
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) && last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
{ {
last_kf_req = Some(now); last_kf_req = Some(now);
+7 -2
View File
@@ -128,7 +128,9 @@ pub(super) fn install_render_callback(
/// deleting the codec stops its internal threads, so no callback can still be running (or run /// deleting the codec stops its internal threads, so no callback can still be running (or run
/// later) against this pointer. /// later) against this pointer.
pub(super) unsafe fn release_render_callback(ud: *const DisplayTracker) { pub(super) unsafe fn release_render_callback(ud: *const DisplayTracker) {
drop(Arc::from_raw(ud)); // SAFETY: `ud` is the pointer `install_render_callback` leaked from `Arc::into_raw`, and this
// function's contract is that it is reclaimed exactly once, after the codec is gone.
unsafe { drop(Arc::from_raw(ud)) };
} }
/// The `AMediaCodecOnFrameRendered` trampoline: fires (possibly batched) on a codec-internal /// The `AMediaCodecOnFrameRendered` trampoline: fires (possibly batched) on a codec-internal
@@ -146,7 +148,10 @@ unsafe extern "C" fn on_frame_rendered(
media_time_us: i64, media_time_us: i64,
system_nano: i64, system_nano: i64,
) { ) {
let t = &*(userdata as *const DisplayTracker); // SAFETY: the platform hands back exactly the `userdata` registered with the callback — the
// `Arc::into_raw` pointer from `install_render_callback`, whose refcount is held for as long as
// the codec exists, and the codec is what delivers this call.
let t = unsafe { &*(userdata as *const DisplayTracker) };
if !t.stats.enabled() { if !t.stats.enabled() {
return; // HUD hidden — the ring is empty too (pushes are caller-gated) return; // HUD hidden — the ring is empty too (pushes are caller-gated)
} }
+21
View File
@@ -41,6 +41,27 @@ const PENDING_SPLIT_CAP: usize = 256;
/// gets evicted. /// gets evicted.
const RENDERED_CAP: usize = 64; const RENDERED_CAP: usize = 64;
/// How long the decoder may be FED while producing nothing before we treat it as un-anchored and
/// ask the host for a fresh IDR.
///
/// The shared gate's per-AU streak ([`punktfunk_core::reanchor::ReanchorGate::on_no_output`]) can't
/// be used verbatim here: it counts one-in/one-out decodes (the desktop clients' `LOW_DELAY`
/// libavcodec path and Apple's VideoToolbox), while MediaCodec is pipelined — inputs and outputs
/// don't pair up, so "this AU produced no output" isn't a thing this loop can observe. A wall-clock
/// silence window is the same signal in the shape Android can measure.
///
/// Why it matters: the host opens a stream with an IDR and, under infinite GOP, sends no other one
/// unless asked. Miss that one — the decode thread only starts at `surfaceCreated`, so a slow TV box
/// can be handed the stream mid-GOP — and every later AU references a picture the decoder never had.
/// A hardware decoder doesn't error on that; it simply emits nothing. Without this backstop the
/// session sat there forever: AUs arriving, a healthy HUD, and a black surface, because nothing in
/// the Android loops ever asked for the keyframe that would re-anchor it.
///
/// 500 ms because it must never fire on a decoder that is merely slow to spin up: even the pokiest
/// hardware decoder emits its first frame within a couple of frame periods, and a wedge that only
/// costs half a second before it self-heals is not a bug the user reports.
const NO_OUTPUT_PATIENCE: std::time::Duration = std::time::Duration::from_millis(500);
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous /// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a /// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when /// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
+34 -2
View File
@@ -24,7 +24,7 @@ use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime, android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate, configure_low_latency, create_codec, try_set_frame_rate,
}; };
use super::{DecodeOptions, IN_FLIGHT_CAP, PENDING_SPLIT_CAP}; use super::{DecodeOptions, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP};
/// The synchronous poll loop — the original decode path: the only one when low-latency mode is off, /// The synchronous poll loop — the original decode path: the only one when low-latency mode is off,
/// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the /// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the
@@ -144,6 +144,12 @@ pub(super) fn run_sync(
let mut fed: u64 = 0; let mut fed: u64 = 0;
let mut rendered: u64 = 0; let mut rendered: u64 = 0;
let mut discarded: u64 = 0; let mut discarded: u64 = 0;
// No-output backstop (see [`NO_OUTPUT_PATIENCE`]): when the decoder last handed us a frame, and
// how many AUs it had been fed by then. Silence only counts while AUs are going in, so an idle
// stream asks for nothing; seeded at start so a decoder that never produces a FIRST frame — the
// missed opening IDR — is caught by the same window.
let mut last_output = Instant::now();
let mut fed_at_output: u64 = 0;
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`). // AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
let mut oversized_dropped: u64 = 0; let mut oversized_dropped: u64 = 0;
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input // The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
@@ -313,6 +319,11 @@ pub(super) fn run_sync(
); );
rendered += r; rendered += r;
discarded += d; discarded += d;
// The one line that separates "the stream never reached glass" from "it reached glass and
// looked wrong"; the tally above counts AUs FED, which a black session racks up happily.
if r > 0 && rendered == r {
log::info!("decode: first frame presented (fed={fed} discarded={discarded})");
}
// ADPF: attribute this iteration's feed+drain time to the frame being produced, and report // ADPF: attribute this iteration's feed+drain time to the frame being produced, and report
// the accumulated per-frame work once one is actually presented (r > 0). Under back-pressure // the accumulated per-frame work once one is actually presented (r > 0). Under back-pressure
@@ -355,8 +366,29 @@ pub(super) fn run_sync(
// a decode-error trigger rarely fires — the gate arms the freeze on the drop-count climb // a decode-error trigger rarely fires — the gate arms the freeze on the drop-count climb
// instead. An overdue freeze (held REANCHOR_FREEZE_MAX with no clean re-anchor) re-asks while it // instead. An overdue freeze (held REANCHOR_FREEZE_MAX with no clean re-anchor) re-asks while it
// keeps holding: never resume to gray — a dead stream is the QUIC idle-timeout watchdog's job. // keeps holding: never resume to gray — a dead stream is the QUIC idle-timeout watchdog's job.
//
// Fed but silent is its own recovery trigger (see [`NO_OUTPUT_PATIENCE`]): a decoder that
// never got the opening IDR emits nothing at all and errors on nothing, so none of the
// signals above ever fire and the surface stays black for the life of the session.
let now = Instant::now(); let now = Instant::now();
if gate.poll(client.frames_dropped(), now) let had_output = r + d > 0;
let starved = !had_output
&& fed > fed_at_output
&& now.duration_since(last_output) >= NO_OUTPUT_PATIENCE;
if had_output {
last_output = now;
fed_at_output = fed;
} else if starved {
log::warn!(
"decode: no output for {} ms with {} AU(s) fed — requesting a re-anchor keyframe",
now.duration_since(last_output).as_millis(),
fed - fed_at_output
);
gate.arm(now);
last_output = now; // one request per patience window, not per iteration
fed_at_output = fed;
}
if (gate.poll(client.frames_dropped(), now) || starved)
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) && last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
{ {
last_kf_req = Some(now); last_kf_req = Some(now);
+18 -8
View File
@@ -31,9 +31,10 @@ const PROTO: &str = "punktfunk/1";
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value). /// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
const FIELD_SEP: char = '\u{1f}'; const FIELD_SEP: char = '\u{1f}';
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = [`FIELD_SEP`]). /// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac␟os`
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from /// (`␟` = [`FIELD_SEP`]). Records are newline-joined in a poll snapshot; [`Host::encode`] strips
/// every field so no value can break it. /// the framing bytes from every field so no value can break it. New fields append (the Kotlin
/// parser tolerates both arities), never reorder.
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
struct Host { struct Host {
key: String, key: String,
@@ -44,6 +45,9 @@ struct Host {
pair: String, pair: String,
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated), for later wake. Empty if absent. /// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated), for later wake. Empty if absent.
mac: String, mac: String,
/// OS-identity chain from the mDNS `os` TXT (`linux/fedora/bazzite`, ...), for the host
/// card's OS icon. Empty if absent (older host).
os: String,
} }
impl Host { impl Host {
@@ -56,7 +60,7 @@ impl Host {
s.replace(['\n', '\r', FIELD_SEP], "") s.replace(['\n', '\r', FIELD_SEP], "")
} }
format!( format!(
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}", "{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
clean(&self.key), clean(&self.key),
clean(&self.name), clean(&self.name),
clean(&self.addr), clean(&self.addr),
@@ -64,6 +68,7 @@ impl Host {
clean(&self.fp), clean(&self.fp),
clean(&self.pair), clean(&self.pair),
clean(&self.mac), clean(&self.mac),
clean(&self.os),
) )
} }
} }
@@ -186,6 +191,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
fp: val("fp"), fp: val("fp"),
pair: val("pair"), pair: val("pair"),
mac: val("mac"), mac: val("mac"),
os: val("os"),
}) })
} }
@@ -206,7 +212,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
} }
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot, /// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts / /// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os` (`␟` = U+001F). Empty string = no hosts /
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build). /// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
@@ -268,10 +274,11 @@ mod tests {
fp: "ab".repeat(32), fp: "ab".repeat(32),
pair: "required".into(), pair: "required".into(),
mac: "aa:bb:cc:dd:ee:ff".into(), mac: "aa:bb:cc:dd:ee:ff".into(),
os: "linux/fedora/bazzite".into(),
}; };
let encoded = h.encode(); let encoded = h.encode();
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect(); let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
assert_eq!(fields.len(), 7); assert_eq!(fields.len(), 8);
assert_eq!(fields[0], "host-123"); assert_eq!(fields[0], "host-123");
assert_eq!(fields[1], "home-worker-2"); assert_eq!(fields[1], "home-worker-2");
assert_eq!(fields[2], "192.168.1.70"); assert_eq!(fields[2], "192.168.1.70");
@@ -279,6 +286,7 @@ mod tests {
assert_eq!(fields[4], "ab".repeat(32)); assert_eq!(fields[4], "ab".repeat(32));
assert_eq!(fields[5], "required"); assert_eq!(fields[5], "required");
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff"); assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
assert_eq!(fields[7], "linux/fedora/bazzite");
assert!( assert!(
!encoded.contains('\n'), !encoded.contains('\n'),
"a record must never contain the record separator" "a record must never contain the record separator"
@@ -297,12 +305,13 @@ mod tests {
fp: "ab\u{1f}cd".into(), fp: "ab\u{1f}cd".into(),
pair: "required\n".into(), pair: "required\n".into(),
mac: "aa:bb\u{1f}cc".into(), mac: "aa:bb\u{1f}cc".into(),
os: "linux\u{1f}evil/arch".into(),
}; };
let encoded = h.encode(); let encoded = h.encode();
assert_eq!( assert_eq!(
encoded.matches(FIELD_SEP).count(), encoded.matches(FIELD_SEP).count(),
6, 7,
"exactly seven fields" "exactly eight fields"
); );
assert!(!encoded.contains('\n') && !encoded.contains('\r')); assert!(!encoded.contains('\n') && !encoded.contains('\r'));
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect(); let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
@@ -310,5 +319,6 @@ mod tests {
assert_eq!(fields[1], "evilhost"); assert_eq!(fields[1], "evilhost");
assert_eq!(fields[4], "abcd"); assert_eq!(fields[4], "abcd");
assert_eq!(fields[5], "required"); assert_eq!(fields[5], "required");
assert_eq!(fields[7], "linuxevil/arch");
} }
} }
+14 -1
View File
@@ -84,8 +84,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLaten
} }
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps, /// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): Long`. /// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch,
/// deviceName): Long`.
/// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game. /// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game.
/// `deviceName` (empty ⇒ none) rides the Hello as `name` — what the host's pending-approval list
/// and trust store show for this device (Kotlin passes `Build.MODEL`, its `nativePair` convention).
/// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty /// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty
/// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). /// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0).
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref` /// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
@@ -117,6 +120,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
preferred_codec: jint, preferred_codec: jint,
timeout_ms: jint, timeout_ms: jint,
launch: JString<'local>, launch: JString<'local>,
device_name: JString<'local>,
) -> jlong { ) -> jlong {
let host: String = match env.get_string(&host) { let host: String = match env.get_string(&host) {
Ok(s) => s.into(), Ok(s) => s.into(),
@@ -135,6 +139,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
.map(Into::into) .map(Into::into)
.ok() .ok()
.filter(|s: &String| !s.is_empty()); .filter(|s: &String| !s.is_empty());
// The host's approval-list / trust-store label for this device; null / blank ⇒ None (the host
// falls back to its fingerprint-derived "device abcd1234" placeholder).
let device_name: Option<String> = env
.get_string(&device_name)
.map(Into::into)
.ok()
.map(|s: String| s.trim().to_string())
.filter(|s| !s.is_empty());
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() { let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
None None
@@ -205,6 +217,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less. // planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less.
0, 0,
launch, // a store-qualified library id to boot into a game, or None for the desktop launch, // a store-qualified library id to boot into a game, or None for the desktop
device_name, // Kotlin's Build.MODEL — the host's approval-list / trust-store label
pin, // Some → Crypto on host-fp mismatch pin, // Some → Crypto on host-fp mismatch
identity, // owned (cert, key) PEM, or None (anonymous) identity, // owned (cert, key) PEM, or None (anonymous)
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access" // Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
+3 -2
View File
@@ -11,8 +11,8 @@
//! Kotlin side), `nativeConnect` with identity + pin (TOFU / pinned), and `nativePair` (SPAKE2 PIN). //! Kotlin side), `nativeConnect` with identity + pin (TOFU / pinned), and `nativePair` (SPAKE2 PIN).
//! //!
//! Split by concern: [`connect`] (identity + connect/close + the trust surface), [`planes`] //! Split by concern: [`connect`] (identity + connect/close + the trust surface), [`planes`]
//! (video/audio/mic start/stop + the stats drain), [`input`] (the input-plane shims). This module //! (video/audio/mic start/stop + the stats drain), [`input`] (the input-plane shims), [`probe`]
//! keeps the shared infrastructure they all deref through. //! (the bandwidth speed test). This module keeps the shared infrastructure they all deref through.
//! //!
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode //! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
//! renegotiation. Port the remaining orchestration from `clients/linux`. //! renegotiation. Port the remaining orchestration from `clients/linux`.
@@ -21,6 +21,7 @@ mod clipboard;
mod connect; mod connect;
mod input; mod input;
mod planes; mod planes;
mod probe;
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use std::panic::AssertUnwindSafe; use std::panic::AssertUnwindSafe;
@@ -0,0 +1,88 @@
//! The bandwidth speed test over an established session.
//!
//! The host bursts filler over the real data plane — the same path a stream uses, so the answer is
//! about the link the stream will actually take rather than about some generic throughput. It is
//! deliberately *measure-only*: which layer a measured bitrate belongs in (the global default, or a
//! host's bound profile) is a decision the UI makes with the user
//! (design/client-settings-profiles.md §5.3), never one this shim makes for them.
//!
//! Two calls: start, then poll. Both are cheap and non-blocking, so Kotlin can drive them from a
//! coroutine on the main thread the way it polls the stats HUD.
use super::{jni_guard, SessionHandle};
use jni::objects::JObject;
use jni::sys::{jboolean, jdoubleArray, jint, jlong};
use jni::JNIEnv;
/// The `DoubleArray` [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] returns. Kept in
/// one place because Kotlin indexes it positionally; see the Kotlin doc for the field order.
const PROBE_RESULT_LEN: usize = 6;
/// `NativeBridge.nativeSpeedTest(handle, targetKbps, durationMs): Boolean` — ask the host to burst
/// filler at `targetKbps` of goodput for `durationMs` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
/// **briefly pausing video**. Non-blocking: poll
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] until its `done` element is 1.
/// Starting a probe resets any prior measurement. `false` on a `0` handle or a closed session.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
_env: JNIEnv,
_this: JObject,
handle: jlong,
target_kbps: jint,
duration_ms: jint,
) -> jboolean {
jni_guard(0, || {
if handle == 0 {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let target = target_kbps.clamp(0, i32::MAX) as u32;
let duration = duration_ms.clamp(0, i32::MAX) as u32;
match h.client.request_probe(target, duration) {
Ok(()) => 1,
Err(e) => {
log::warn!("speed test: could not ask the host to probe: {e:?}");
0
}
}
})
}
/// `NativeBridge.nativeProbeResult(handle): DoubleArray?` — the current measurement, partial until
/// `[0] == 1`. Safe to poll; before any probe it reports zeros. `null` on a `0` handle.
///
/// Layout (doubles so one array carries both the counts and the percentages):
/// `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jdoubleArray {
jni_guard(JObject::null().into_raw(), || {
if handle == 0 {
return JObject::null().into_raw();
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let r = h.client.probe_result();
let values: [f64; PROBE_RESULT_LEN] = [
f64::from(u8::from(r.done)),
f64::from(r.throughput_kbps),
f64::from(r.loss_pct),
f64::from(r.host_drop_pct),
f64::from(r.elapsed_ms),
r.recv_bytes as f64,
];
match env.new_double_array(PROBE_RESULT_LEN as i32) {
Ok(arr) => {
if env.set_double_array_region(&arr, 0, &values).is_err() {
return JObject::null().into_raw();
}
arr.into_raw()
}
Err(_) => JObject::null().into_raw(),
}
})
}
+4
View File
@@ -34,6 +34,10 @@ let package = Package(
// Geist (SIL OFL 1.1) the brand typeface, shared with punktfunk-website. // Geist (SIL OFL 1.1) the brand typeface, shared with punktfunk-website.
// Registered with Core Text at first use; see BrandFont.swift. // Registered with Core Text at first use; see BrandFont.swift.
.copy("Resources/Fonts"), .copy("Resources/Fonts"),
// The host cards' OS marks (template vector imagesets derived from the
// assets/os-icons masters FA brands CC BY 4.0 + Simple Icons CC0, see that
// README). `.process` compiles the catalog; loaded via OsIcon.swift.
.process("Resources/OsIcons.xcassets"),
], ],
linkerSettings: [ linkerSettings: [
// Rust staticlib system deps. // Rust staticlib system deps.
@@ -20,36 +20,32 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@StateObject private var model = SessionModel() @StateObject private var model = SessionModel()
@StateObject private var store = HostStore() @StateObject private var store = HostStore()
/// The settings-profile catalog (design/client-settings-profiles.md §4.2) read at every
/// connect to resolve the session's `EffectiveSettings`, and edited by the settings surface.
@ObservedObject private var profiles = ProfileStore.shared
@StateObject private var discovery = HostDiscovery() @StateObject private var discovery = HostDiscovery()
// The dev auto-connect hook writes these three, so they stay observed here; every OTHER
// stream setting reaches a session through `EffectiveSettings`, resolved once per connect.
@AppStorage(DefaultsKey.streamWidth) private var width = 1920 @AppStorage(DefaultsKey.streamWidth) private var width = 1920
@AppStorage(DefaultsKey.streamHeight) private var height = 1080 @AppStorage(DefaultsKey.streamHeight) private var height = 1080
@AppStorage(DefaultsKey.streamHz) private var hz = 60 @AppStorage(DefaultsKey.streamHz) private var hz = 60
@AppStorage(DefaultsKey.renderScale) private var renderScale = 1.0
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.codec) private var codec = "auto"
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true @AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
// The raw string is what @AppStorage observes (so cycles from any surface re-render this // The raw string is what @AppStorage observes (so cycles from any surface re-render this
// view); the absent-key default runs the legacy-hudEnabled migration once per init. // view); the absent-key default runs the legacy-hudEnabled migration once per init.
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw @AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= StatsVerbosity.current.rawValue = StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
/// The persisted overlay tier (unknown raw falls back to .normal, like the migration). /// The tier the overlay actually shows: the live session's (its profile's, then whatever the
/// S/three-finger cycle moved it to) while streaming, the persisted global otherwise.
private var statsVerbosity: StatsVerbosity { private var statsVerbosity: StatsVerbosity {
StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal model.connection != nil
} ? model.statsVerbosity
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto). : (StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal)
private var preferredCodecByte: UInt8 {
switch codec {
case "h264": return PunktfunkConnection.codecH264
case "hevc": return PunktfunkConnection.codecHEVC
case "av1": return PunktfunkConnection.codecAV1
case "pyrowave": return PunktfunkConnection.codecPyroWave
default: return 0
} }
/// Fullscreen-while-streaming is profileable (a Game profile goes fullscreen, a Work one
/// doesn't), so a live session obeys ITS value and the host list obeys the global.
private var fullscreenForSession: Bool {
model.connection != nil ? model.settings.fullscreenWhileStreaming : fullscreenWhileStreaming
} }
@State private var showAddHost = false @State private var showAddHost = false
/// A `punktfunk://` deep link (widget / Siri / Shortcuts) couldn't be honored unknown host, or /// A `punktfunk://` deep link (widget / Siri / Shortcuts) couldn't be honored unknown host, or
@@ -120,7 +116,66 @@ struct ContentView: View {
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled) gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
} }
// The body is split in two `driven` (the screen plus its lifecycle drivers and sheets) and
// the prompt chain below. Not a style choice: as ONE expression this blew Swift's
// type-checker budget on the iOS slice ("unable to type-check in reasonable time"), which
// macOS builds never reveal. Keep new modifiers on whichever half is shorter.
var body: some View { var body: some View {
driven
// Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
// alert) so it never collides with the wait alert below. "Request Access" is the
// no-PIN delegated-approval path; "Pair with PIN" runs the SPAKE2 ceremony. The
// follow-on presentation is deferred a tick so this dialog is fully dismissed first.
.confirmationDialog(
"Pairing required",
isPresented: approvalChoicePresented,
titleVisibility: .visible,
presenting: approvalChoice
) { req in
Button("Request Access") {
DispatchQueue.main.async { requestAccess(req) }
}
Button("Pair with PIN…") {
DispatchQueue.main.async { pairingTarget = req.host }
}
Button("Cancel", role: .cancel) {}
} message: { req in
Text("\(req.host.displayName) requires pairing. Request access and approve this "
+ "device in the host's web console (port 47992 → Pairing) — no PIN needed. Or "
+ "pair with the 4-digit PIN it can display.")
}
// One "Connection failed" surface for every home screen (touch grid, gamepad launcher)
// and platform SessionModel funnels all connect/session errors into `errorMessage`.
.alert("Connection failed", isPresented: connectionErrorPresented) {
Button("OK", role: .cancel) {}
} message: {
Text(model.errorMessage ?? "")
}
// The delegated-approval wait: the host holds the connection open until the operator
// approves it. Cancel returns the UI at once; the in-flight connect is left to time out
// and its late result is discarded by SessionModel's connect guard (disconnect resets
// the phase/host it checks).
.alert(
"Waiting for approval",
isPresented: awaitingApprovalPresented,
presenting: awaitingApproval
) { _ in
Button("Cancel", role: .cancel) { model.disconnect() }
} message: { req in
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
+ "console (port 47992 → Pairing). This device connects automatically once you "
+ "approve it — no need to reconnect.")
}
// Informational deep-link outcome (unknown host, a refused profile, already
// streaming). Not an error.
.alert("Can't open", isPresented: deepLinkNoticePresented) {
Button("OK", role: .cancel) {}
} message: {
Text(deepLinkNotice ?? "")
}
}
private var driven: some View {
Group { Group {
// The stream view's structural identity MUST be stable across the // The stream view's structural identity MUST be stable across the
// awaiting-trust streaming transition: recreating it restarts the pump, // awaiting-trust streaming transition: recreating it restarts the pump,
@@ -144,6 +199,12 @@ struct ContentView: View {
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a // tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
// parallel session this drives the one `model` ContentView owns. // parallel session this drives the one `model` ContentView owns.
.onOpenURL { handleDeepLink($0) } .onOpenURL { handleDeepLink($0) }
// A live stats-overlay cycle from ANY surface (S, the three-finger tap, the Stream
// menu) writes the global; push it into the session so the overlay follows it from there
// on, whatever tier the session's profile started on.
.onChange(of: statsVerbosityRaw) { _, raw in
model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal)
}
#if os(iOS) || os(tvOS) #if os(iOS) || os(tvOS)
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is // Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
// ignored so neither branch fires for a Control-Center pull. // ignored so neither branch fires for a Control-Center pull.
@@ -198,16 +259,19 @@ struct ContentView: View {
} }
} }
// The Live Activity's / Shortcuts' End button runs EndStreamIntent in-process, which posts // The Live Activity's / Shortcuts' End button runs EndStreamIntent in-process, which posts
// this tear the session down deliberately (quit-close the host). // this tear the session down deliberately (quit-close the host). iOS-only along with
// the intent itself (LiveActivityIntent is ActivityKit's world).
.onReceive(NotificationCenter.default.publisher(for: .punktfunkEndActiveSession)) { _ in .onReceive(NotificationCenter.default.publisher(for: .punktfunkEndActiveSession)) { _ in
model.disconnect(deliberate: true) model.disconnect(deliberate: true)
} }
// Connect App Intent (Siri/Shortcuts): route its punktfunk:// URL through the same handler #endif
// as a widget tap. // Connect App Intent (Siri/Shortcuts/Spotlight): route its punktfunk:// URL through the
// same handler a widget tap uses. NOT iOS-gated the Connect intent compiles on macOS and
// tvOS too, and an intent that posts to nobody would be a shortcut that silently does
// nothing.
.onReceive(NotificationCenter.default.publisher(for: .punktfunkOpenDeepLink)) { note in .onReceive(NotificationCenter.default.publisher(for: .punktfunkOpenDeepLink)) { note in
if let url = note.object as? URL { handleDeepLink(url) } if let url = note.object as? URL { handleDeepLink(url) }
} }
#endif
.onChange(of: model.phase) { _, phase in .onChange(of: model.phase) { _, phase in
switch phase { switch phase {
case .streaming: case .streaming:
@@ -260,7 +324,7 @@ struct ContentView: View {
// `isFullscreen` (the user can toggle it manually), which drives the session view's // `isFullscreen` (the user can toggle it manually), which drives the session view's
// safe-area handling below. // safe-area handling below.
.background(FullscreenController( .background(FullscreenController(
active: fullscreenWhileStreaming && model.connection != nil, active: fullscreenForSession && model.connection != nil,
isFullscreen: $isFullscreen)) isFullscreen: $isFullscreen))
#endif #endif
// On the outer Group so the sheet survives the trust-prompt home transition // On the outer Group so the sheet survives the trust-prompt home transition
@@ -295,35 +359,26 @@ struct ContentView: View {
} }
#endif #endif
#endif #endif
// Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
// alert) so it never collides with the wait alert below. "Request Access" is the no-PIN
// delegated-approval path; "Pair with PIN" runs the SPAKE2 ceremony. The follow-on
// presentation is deferred a tick so this dialog is fully dismissed first.
.confirmationDialog(
"Pairing required",
isPresented: Binding(
get: { approvalChoice != nil },
set: { if !$0 { approvalChoice = nil } }),
titleVisibility: .visible,
presenting: approvalChoice
) { req in
Button("Request Access") {
DispatchQueue.main.async { requestAccess(req) }
} }
Button("Pair with PIN…") {
DispatchQueue.main.async { pairingTarget = req.host } // Presentation flags for the prompt chain, extracted from their `.alert`/`.confirmationDialog`
// calls so each manual get/set Binding type-checks on its own instead of inflating the body's
// budget (inline, they tip SwiftUI's per-expression limit see the split sections idiom).
private var deepLinkNoticePresented: Binding<Bool> {
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
} }
Button("Cancel", role: .cancel) {}
} message: { req in private var approvalChoicePresented: Binding<Bool> {
Text("\(req.host.displayName) requires pairing. Request access and approve this " Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
+ "device in the host's web console (port 47992 → Pairing) — no PIN needed. Or "
+ "pair with the 4-digit PIN it can display.")
} }
// One "Connection failed" surface for every home screen (touch grid, gamepad launcher) and
// platform SessionModel funnels all connect/session errors into `errorMessage`. private var awaitingApprovalPresented: Binding<Bool> {
.alert( Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } })
"Connection failed", }
isPresented: Binding(
private var connectionErrorPresented: Binding<Bool> {
Binding(
get: { get: {
guard model.errorMessage != nil else { return false } guard model.errorMessage != nil else { return false }
#if os(macOS) #if os(macOS)
@@ -335,46 +390,11 @@ struct ContentView: View {
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows // once the window leaves fullscreen and `isFullscreen` flips, the alert shows
// over the windowed home UI. Not gated when fullscreen is the user's own manual // over the windowed home UI. Not gated when fullscreen is the user's own manual
// choice (opt-out setting) nothing is auto-exiting there to conflict with. // choice (opt-out setting) nothing is auto-exiting there to conflict with.
if fullscreenWhileStreaming && isFullscreen { return false } if fullscreenForSession && isFullscreen { return false }
#endif #endif
return true return true
}, },
set: { if !$0 { model.errorMessage = nil } }) set: { if !$0 { model.errorMessage = nil } })
) {
Button("OK", role: .cancel) {}
} message: {
Text(model.errorMessage ?? "")
}
// The delegated-approval wait: the host holds the connection open until the operator
// approves it. Cancel returns the UI at once; the in-flight connect is left to time out
// and its late result is discarded by SessionModel's connect guard (disconnect resets the
// phase/host it checks).
.alert(
"Waiting for approval",
isPresented: Binding(
get: { awaitingApproval != nil },
set: { if !$0 { awaitingApproval = nil } }),
presenting: awaitingApproval
) { _ in
Button("Cancel", role: .cancel) { model.disconnect() }
} message: { req in
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
+ "console (port 47992 → Pairing). This device connects automatically once you "
+ "approve it — no need to reconnect.")
}
// Informational deep-link outcome (unknown host / already streaming). Not an error.
.alert("Can't open", isPresented: deepLinkNoticePresented) {
Button("OK", role: .cancel) {}
} message: {
Text(deepLinkNotice ?? "")
}
}
/// Presentation flag for the informational deep-link alert. Extracted from the `.alert` call so
/// the manual get/set Binding type-checks on its own instead of inflating the body chain's
/// budget (adding it inline tips SwiftUI's per-expression limit see the split sections idiom).
private var deepLinkNoticePresented: Binding<Bool> {
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
} }
#if os(iOS) #if os(iOS)
@@ -396,25 +416,81 @@ struct ContentView: View {
} }
#endif #endif
/// Route a `punktfunk://` deep link into the existing connect path. Rules (per design): /// Route a `punktfunk://` deep link into the existing connect path the whole §2 grammar
/// unknown host notice + no-op; a live session is up ignore if it's the same host, else /// (design/client-deep-links.md): a stable id, a unique host name or an `addr[:port]`, with
/// tell the user to end the current one first (NEVER tear down a live session on a background /// `fp`/`host` recovery parameters and a one-off `profile`.
/// tap); otherwise the normal `connect` trust policy, WoL and the approval sheet all apply. ///
/// The security posture is the parser's plus three rules that live here, and none of them
/// bends: a URL never pairs and never trusts on its own (an unknown host becomes a
/// confirmation, not a connect), never preempts a live session (same host focus, different
/// host say so; NEVER tear one down on a background tap), and carries only references a
/// profile it can't honor refuses with a notice rather than streaming with the wrong settings.
private func handleDeepLink(_ url: URL) { private func handleDeepLink(_ url: URL) {
guard case let .connect(hostID, launchID)? = DeepLink(url) else { return } let link: DeepLink
guard let host = store.hosts.first(where: { $0.id == hostID }) else { do {
deepLinkNotice = "That host isn't saved on this device." link = try DeepLink(url: url)
} catch DeepLinkError.notOurScheme {
return // not ours ignore it silently rather than warning about someone else's URL
} catch {
deepLinkNotice = (error as? DeepLinkError)?.message
?? "That link is malformed and was ignored."
return return
} }
if model.phase != .idle { guard link.route == .connect else {
guard model.activeHost?.id == hostID else { // `wake` and `browse` are reserved in the grammar and parse today; this build routes
// neither, and saying so beats silently connecting instead.
deepLinkNotice = "Punktfunk links can't do “\(link.route.rawValue)” yet."
return
}
// Resolve the one-off profile BEFORE anything happens: an unknown or ambiguous reference
// must refuse, not degrade to the host's binding (§10.6).
var selection = ProfileSelection.inherit
if let reference = link.profile {
let (profile, resolution) = profiles.catalog.resolve(reference)
switch resolution {
case .found:
selection = .profile(profile?.id ?? "")
case .notFound:
deepLinkNotice = "No settings profile called “\(reference)” on this device."
return
case .ambiguous:
deepLinkNotice = "More than one settings profile is called “\(reference)”. "
+ "Rename one, or link to it by its id."
return
}
}
switch link.resolveHost(in: store.hosts) {
case .known(let host):
guard !link.pinConflict(with: host) else {
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
return
}
guard model.phase == .idle else {
guard model.activeHost?.id == host.id else {
let current = model.activeHost?.displayName ?? "a host" let current = model.activeHost?.displayName ?? "a host"
deepLinkNotice = "Already streaming \(current). End that session first." deepLinkNotice = "Already streaming \(current). End that session first."
return return
} }
return // deep-linked to the host we're already on nothing to do return // deep-linked to the host we're already on nothing to do
} }
connect(host, launchID: launchID) connect(host, launchID: link.launch, profile: selection)
case .unknown(let address, let port, let name, let fp):
// Never a silent connect: hand the address, claimed name and pin to the add sheet so
// the user makes the trust decision with their eyes on it.
guard model.phase == .idle else {
deepLinkNotice = "Already streaming. End that session first."
return
}
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
+ "Add it with the + button — the link points at \(address):\(String(port))"
+ (fp == nil ? "." : ", and carries a fingerprint to verify it against.")
case .ambiguous:
deepLinkNotice = "More than one saved host is called “\(link.hostRef)”. "
+ "Rename one, or link to it by its address."
case .unresolvable:
deepLinkNotice = "That host isn't saved on this device."
}
} }
private var home: some View { private var home: some View {
@@ -447,13 +523,13 @@ struct ContentView: View {
GamepadHomeView( GamepadHomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker, libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0) }, connectDiscovered: connectDiscovered) connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
} else { } else {
HomeView( HomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
showAddHost: $showAddHost, pairingTarget: $pairingTarget, showAddHost: $showAddHost, pairingTarget: $pairingTarget,
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
connect: { connect($0) }, connectDiscovered: connectDiscovered, connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
} }
} }
@@ -463,7 +539,7 @@ struct ContentView: View {
GamepadHomeView( GamepadHomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker, libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0) }, connectDiscovered: connectDiscovered) connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
// On tvOS pairing/library normally present from HomeView's navigationDestinations // On tvOS pairing/library normally present from HomeView's navigationDestinations
// which aren't mounted while the gamepad launcher is up. Give the launcher its // which aren't mounted while the gamepad launcher is up. Give the launcher its
// own presenters (exactly one of the two homes is mounted at a time, so these can // own presenters (exactly one of the two homes is mounted at a time, so these can
@@ -487,7 +563,7 @@ struct ContentView: View {
showAddHost: $showAddHost, pairingTarget: $pairingTarget, showAddHost: $showAddHost, pairingTarget: $pairingTarget,
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
showSettings: $showSettings, showSettings: $showSettings,
connect: { connect($0) }, connectDiscovered: connectDiscovered, connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
} }
} }
@@ -731,7 +807,14 @@ struct ContentView: View {
// MARK: - Connect // MARK: - Connect
private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) { /// `profile` is this connect's one-off pick ("Connect with ", a pinned card, a link's
/// `profile=`). `.inherit` the default, and what a plain card tap passes falls through to
/// the host's binding. A one-off NEVER rebinds the host: rebinding is always an explicit act
/// in the edit sheet (design §5.2).
private func connect(
_ host: StoredHost, launchID: String? = nil,
profile: ProfileSelection = .inherit, allowTofu: Bool? = nil
) {
// A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when // A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when
// the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already // the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already
// know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set: // know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set:
@@ -750,20 +833,22 @@ struct ContentView: View {
return return
} }
} }
startSession(host, launchID: launchID, allowTofu: host.pinnedSHA256 == nil) startSession(
host, launchID: launchID, profile: profile, allowTofu: host.pinnedSHA256 == nil)
} }
/// Resolve the @AppStorage stream mode + input prefs and hand off to the session model. The /// Resolve the stream mode + input prefs and hand off to the session model. The gamepad-type
/// gamepad-type setting resolves NOW (Automatic match the active physical controller): the /// setting resolves NOW (Automatic match the active physical controller): the host's virtual
/// host's virtual pad backend is fixed per session. `requestAccess` opens the no-PIN /// pad backend is fixed per session. `requestAccess` opens the no-PIN delegated-approval
/// delegated-approval connect (host parks it until the operator approves). /// connect (host parks it until the operator approves).
private func startSession( private func startSession(
_ host: StoredHost, launchID: String? = nil, _ host: StoredHost, launchID: String? = nil,
profile: ProfileSelection = .inherit,
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
) { ) {
let go = { let go = {
startSessionDirect( startSessionDirect(
host, launchID: launchID, allowTofu: allowTofu, host, launchID: launchID, profile: profile, allowTofu: allowTofu,
requestAccess: requestAccess, approvalReq: approvalReq) requestAccess: requestAccess, approvalReq: approvalReq)
} }
// Not advertising and we can wake it? DIAL FIRST anyway no mDNS advert does NOT mean // Not advertising and we can wake it? DIAL FIRST anyway no mDNS advert does NOT mean
@@ -778,7 +863,7 @@ struct ContentView: View {
!host.wakeMacs.isEmpty, !discovery.advertises(host) { !host.wakeMacs.isEmpty, !discovery.advertises(host) {
discovery.start() // so the wake-wait can observe it reappear discovery.start() // so the wake-wait can observe it reappear
startSessionDirect( startSessionDirect(
host, launchID: launchID, allowTofu: allowTofu, host, launchID: launchID, profile: profile, allowTofu: allowTofu,
requestAccess: requestAccess, approvalReq: approvalReq, requestAccess: requestAccess, approvalReq: approvalReq,
onUnreachable: { onUnreachable: {
waker.start( waker.start(
@@ -794,19 +879,9 @@ struct ContentView: View {
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host /// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
/// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect /// is advertising (and is a harmless no-op otherwise). `onUnreachable` hands a plain connect
/// failure back to the caller (the wake-wait fallback) instead of the error alert. /// failure back to the caller (the wake-wait fallback) instead of the error alert.
/// The stream mode to request = the chosen resolution × the render scale, aspect-preserved,
/// even, and clamped to the codec's max dimension. > 1 supersamples for sharpness (the presenter
/// downscales the larger decoded frame to this display); < 1 renders under native and upscales.
/// The match-window path applies the SAME scale to the live window size in `MatchWindowFollower`.
private func scaledMode() -> (width: UInt32, height: UInt32) {
RenderScale.apply(
baseWidth: width, baseHeight: height,
scale: renderScale,
maxDimension: RenderScale.maxDimension(codec: codec))
}
private func startSessionDirect( private func startSessionDirect(
_ host: StoredHost, launchID: String? = nil, _ host: StoredHost, launchID: String? = nil,
profile: ProfileSelection = .inherit,
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil, allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil,
onUnreachable: (@MainActor () -> Void)? = nil onUnreachable: (@MainActor () -> Void)? = nil
) { ) {
@@ -814,19 +889,17 @@ struct ContentView: View {
// The delegated-approval wait prompt only makes sense once we're actually dialing set it // The delegated-approval wait prompt only makes sense once we're actually dialing set it
// here (after any wake), not before, so it never stacks under the "Waking" overlay. // here (after any wake), not before, so it never stacks under the "Waking" overlay.
if let approvalReq { awaitingApproval = approvalReq } if let approvalReq { awaitingApproval = approvalReq }
// THE resolution point (design §4.4): the globals plus this connect's profile, once, here.
// The model latches the result for the whole session, so nothing downstream can end up
// applying a profile to half of it.
let effective = EffectiveSettings.resolve(
host: host, selection: profile, catalog: profiles.catalog)
model.connect( model.connect(
to: host, to: host,
width: scaledMode().width, height: scaledMode().height, effective: effective,
hz: UInt32(clamping: hz),
compositor: PunktfunkConnection.Compositor(
rawValue: UInt32(clamping: compositor)) ?? .auto,
gamepad: GamepadManager.shared.resolveType( gamepad: GamepadManager.shared.resolveType(
setting: PunktfunkConnection.GamepadType( setting: PunktfunkConnection.GamepadType(
rawValue: UInt32(clamping: gamepadType)) ?? .auto), rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto),
bitrateKbps: UInt32(clamping: bitrateKbps),
audioChannels: UInt8(clamping: audioChannels),
hdrEnabled: hdrEnabled,
preferredCodec: preferredCodecByte,
launchID: launchID, launchID: launchID,
allowTofu: allowTofu, allowTofu: allowTofu,
requestAccess: requestAccess, requestAccess: requestAccess,
@@ -843,6 +916,7 @@ struct ContentView: View {
private func prepareWake(for host: StoredHost) { private func prepareWake(for host: StoredHost) {
if let live = discovery.hosts.first(where: { host.matches($0) }) { if let live = discovery.hosts.first(where: { host.matches($0) }) {
store.updateMacs(host.id, macs: live.macAddresses) // learn on every platform store.updateMacs(host.id, macs: live.macAddresses) // learn on every platform
store.updateOsChain(host.id, chain: live.osChain) // ditto for the card's OS mark
} else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty { } else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
// Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the // Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the
// dial times out. With auto-wake off, connects go straight through (no packet). // dial times out. With auto-wake off, connects go straight through (no packet).
@@ -897,7 +971,8 @@ struct ContentView: View {
guard !model.isBusy else { return } guard !model.isBusy else { return }
let host = StoredHost( let host = StoredHost(
name: d.name, address: d.host, port: d.port, name: d.name, address: d.host, port: d.port,
macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses) macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses,
osChain: d.osChain.isEmpty ? nil : d.osChain)
store.add(host) store.add(host)
if d.allowsTofu { if d.allowsTofu {
connect(host, allowTofu: true) connect(host, allowTofu: true)
@@ -979,36 +1054,25 @@ struct ContentView: View {
hz = dims[2] hz = dims[2]
} }
} }
var pref = PunktfunkConnection.Compositor( // The dev levers layer over the globals (no host record, so no binding to resolve).
rawValue: UInt32(clamping: compositor)) ?? .auto var effective = EffectiveSettings(defaults: .standard)
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_COMPOSITOR"], if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_COMPOSITOR"],
let c = PunktfunkConnection.Compositor(name: name) { let c = PunktfunkConnection.Compositor(name: name) {
pref = c effective.compositor = Int(c.rawValue)
} }
var pad = GamepadManager.shared.resolveType( var pad = GamepadManager.shared.resolveType(
setting: PunktfunkConnection.GamepadType( setting: PunktfunkConnection.GamepadType(
rawValue: UInt32(clamping: gamepadType)) ?? .auto) rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto)
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"], if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
let g = PunktfunkConnection.GamepadType(name: name) { let g = PunktfunkConnection.GamepadType(name: name) {
// Back through resolveType so the lever is adopted as the session's setting: the // Back through resolveType so the lever is adopted as the session's setting: the
// per-pad arrivals declare it too, which is what the host actually builds from. // per-pad arrivals declare it too, which is what the host actually builds from.
pad = GamepadManager.shared.resolveType(setting: g) pad = GamepadManager.shared.resolveType(setting: g)
} }
var bitrate = UInt32(clamping: bitrateKbps)
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"], if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
let v = UInt32(kbps) { let v = Int(kbps) {
bitrate = v effective.bitrateKbps = v
} }
model.connect( model.connect(to: host, effective: effective, gamepad: pad, autoTrust: true)
to: host,
width: scaledMode().width, height: scaledMode().height,
hz: UInt32(clamping: hz),
compositor: pref,
gamepad: pad,
bitrateKbps: bitrate,
audioChannels: UInt8(clamping: audioChannels),
hdrEnabled: hdrEnabled,
preferredCodec: preferredCodecByte,
autoTrust: true)
} }
} }
@@ -20,6 +20,16 @@ struct AddHostSheet: View {
@State private var address: String @State private var address: String
@State private var port: Int @State private var port: Int
@State private var mac: String @State private var mac: String
#if !os(tvOS)
/// This host's DEFAULT settings profile what a plain click/tap uses. Empty = Default
/// settings (design/client-settings-profiles.md §5.2). Changing it here is the only way the
/// default moves; "Connect with " is deliberately a one-off.
@State private var profileID: String
/// Profiles pinned as their own cards for this host (§5.2a) presentation only, and
/// independent of the default above.
@State private var pinnedIDs: Set<String>
@ObservedObject private var profiles = ProfileStore.shared
#endif
#if os(macOS) #if os(macOS)
/// Share the clipboard with this host (macOS sessions only; design /// Share the clipboard with this host (macOS sessions only; design
/// clipboard-and-file-transfer.md §5.3). Off by default; honored only when the host /// clipboard-and-file-transfer.md §5.3). Off by default; honored only when the host
@@ -34,6 +44,21 @@ struct AddHostSheet: View {
@State private var editingField: EditField? @State private var editingField: EditField?
#endif #endif
/// A field's placeholder, which is not the same job on both platforms.
///
/// macOS draws a `TextField`'s title as a leading label, so the prompt only has to hint at the
/// VALUE. On iOS that title becomes an accessibility label the moment a prompt exists and
/// nothing is drawn the prompt is the field's entire visible identity, so it has to name the
/// field as well as hint at it. Writing one string for both leaves you a Mac panel that says
/// everything twice or a phone form of unlabelled boxes.
private static func prompt(touch: String, desktop: String) -> Text {
#if os(macOS)
Text(desktop)
#else
Text(touch)
#endif
}
private var isEditing: Bool { existing != nil } private var isEditing: Bool { existing != nil }
private var actionTitle: String { isEditing ? "Save" : "Add Host" } private var actionTitle: String { isEditing ? "Save" : "Add Host" }
private var canSave: Bool { !address.trimmingCharacters(in: .whitespaces).isEmpty } private var canSave: Bool { !address.trimmingCharacters(in: .whitespaces).isEmpty }
@@ -50,6 +75,10 @@ struct AddHostSheet: View {
#if os(macOS) #if os(macOS)
_clipboardSync = State(initialValue: existing?.clipboardSync ?? false) _clipboardSync = State(initialValue: existing?.clipboardSync ?? false)
#endif #endif
#if !os(tvOS)
_profileID = State(initialValue: existing?.profileID ?? "")
_pinnedIDs = State(initialValue: Set(existing?.pinnedProfileIDs ?? []))
#endif
} }
var body: some View { var body: some View {
@@ -60,7 +89,9 @@ struct AddHostSheet: View {
TVFieldRow(label: "Name", value: name, placeholder: "Optional") { editingField = .name } TVFieldRow(label: "Name", value: name, placeholder: "Optional") { editingField = .name }
TVFieldRow(label: "Address", value: address, placeholder: "IP or hostname") { editingField = .address } TVFieldRow(label: "Address", value: address, placeholder: "IP or hostname") { editingField = .address }
TVFieldRow(label: "Port", value: String(port), placeholder: "") { editingField = .port } TVFieldRow(label: "Port", value: String(port), placeholder: "") { editingField = .port }
TVFieldRow(label: "MAC", value: mac, placeholder: "Wake-on-LAN — auto-filled when known") { editingField = .mac } TVFieldRow(
label: "MAC address", value: mac,
placeholder: "For Wake-on-LAN, optional") { editingField = .mac }
HStack(spacing: 32) { HStack(spacing: 32) {
Button("Cancel", role: .cancel) { dismiss() } Button("Cancel", role: .cancel) { dismiss() }
Button(actionTitle) { save() }.disabled(!canSave) Button(actionTitle) { save() }.disabled(!canSave)
@@ -97,10 +128,18 @@ struct AddHostSheet: View {
#else #else
VStack(spacing: 0) { VStack(spacing: 0) {
Form { Form {
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room")) TextField(
"Name", text: $name,
prompt: Self.prompt(
touch: "Name (optional, e.g. Living Room)",
desktop: "Optional — e.g. Living Room"))
TextField("Address", text: $address, prompt: Text("IP or hostname")) TextField("Address", text: $address, prompt: Text("IP or hostname"))
TextField("Port", value: $port, format: .number.grouping(.never)) TextField("Port", value: $port, format: .number.grouping(.never))
TextField("MAC", text: $mac, prompt: Text("Wake-on-LAN — auto-filled when known")) TextField(
"MAC address", text: $mac,
prompt: Self.prompt(
touch: "MAC address (for Wake-on-LAN, optional)",
desktop: "For Wake-on-LAN, optional"))
.autocorrectionDisabled() .autocorrectionDisabled()
#if os(iOS) #if os(iOS)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
@@ -108,16 +147,23 @@ struct AddHostSheet: View {
#if os(macOS) #if os(macOS)
Toggle("Share clipboard with this host", isOn: $clipboardSync) Toggle("Share clipboard with this host", isOn: $clipboardSync)
#endif #endif
profileRows
} }
#if !os(tvOS) #if !os(tvOS)
.formStyle(.grouped) .formStyle(.grouped)
// The grouped form's default system text is oversized next to the app's Geist
// typography bring it down and on-brand so the panel doesn't read out of place.
.font(.geist(12, relativeTo: .callout))
.controlSize(.small)
#endif #endif
#if os(iOS) #if os(iOS)
.scrollDisabled(true) // As before: the sheet is sized to its content, so there is nothing to scroll. Only
// the edit sheet's profile rows can outgrow that, and only they turn it back on.
.scrollDisabled(!showsProfileRows)
#endif
#if os(macOS)
// macOS ONLY: the grouped form's default system text is oversized next to the app's
// Geist typography, so the panel reads out of place at full size. iOS keeps the app's
// body size a touch target and a field label there are not a Mac panel's, and 12pt
// made this the one sheet in the app you had to squint at.
.font(.geist(12, relativeTo: .callout))
.controlSize(.small)
#endif #endif
#if os(macOS) #if os(macOS)
HStack { HStack {
@@ -142,8 +188,8 @@ struct AddHostSheet: View {
#endif #endif
} }
#if os(iOS) #if os(iOS)
// Four fields + the action row a touch taller than the 3-field add sheet used to be. // Sized to its content (see `sheetHeight`).
.presentationDetents([.height(392)]) .presentationDetents([.height(sheetHeight)])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
#endif #endif
#if os(macOS) #if os(macOS)
@@ -153,6 +199,83 @@ struct AddHostSheet: View {
#endif #endif
} }
#if os(iOS)
/// Four fields + the action row a touch taller than the 3-field add sheet used to be. The
/// edit sheet's profile rows are the only thing that can outgrow it, and they say by how much;
/// a single fixed number is what clipped them.
private var sheetHeight: CGFloat {
var height: CGFloat = 392
if showsProfileRows {
height += 116 // the Profile picker and its footnote
height += 96 + CGFloat(profiles.profiles.count) * 44 // the pins, their header + footer
}
return height
}
#endif
/// Whether this sheet shows the per-host profile rows at all.
///
/// Only when EDITING, and only once profiles exist. Adding a host is about reaching it the
/// address, and whether we can wake it; which settings it streams with is a decision for the
/// host you already have, and stacking it onto the add flow made the first thing a new user
/// sees a longer form than the one they came for. Editing is one context-menu item away.
private var showsProfileRows: Bool {
#if os(tvOS)
return false
#else
return isEditing && !profiles.profiles.isEmpty
#endif
}
/// The per-host profile rows: which profile this host uses by default, and which extra ones
/// get their own card in the grid.
///
/// Two plain sections, no disclosure. A collapsed group had to animate its own height AND the
/// sheet's, and got both wrong; with a profile or three these are a couple of rows, and rows
/// that are simply there can't be clipped or fail to expand.
@ViewBuilder private var profileRows: some View {
#if !os(tvOS)
if showsProfileRows {
Section {
Picker("Profile", selection: $profileID) {
Text("Default settings").tag("")
ForEach(profiles.profiles) { profile in
Text(profile.name).tag(profile.id)
}
// A binding whose profile was deleted resolves as Default settings anyway;
// saying so beats an empty picker, and saving cleans the field up.
if !profileID.isEmpty, profiles.profile(id: profileID) == nil {
Text("Default settings (profile deleted)").tag(profileID)
}
}
} footer: {
Text("The settings a plain tap on this host streams with.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
Section {
ForEach(profiles.profiles) { profile in
Toggle(profile.name, isOn: Binding(
get: { pinnedIDs.contains(profile.id) },
set: { on in
if on {
pinnedIDs.insert(profile.id)
} else {
pinnedIDs.remove(profile.id)
}
}))
}
} header: {
Text("Pinned cards")
} footer: {
Text("A pinned profile gets its own card next to this host — one tap, no menu.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
#endif
}
private func save() { private func save() {
var host = existing ?? StoredHost(name: "", address: "") var host = existing ?? StoredHost(name: "", address: "")
host.name = name.trimmingCharacters(in: .whitespaces) host.name = name.trimmingCharacters(in: .whitespaces)
@@ -164,6 +287,17 @@ struct AddHostSheet: View {
// opted in" and "opted out" read the same off). // opted in" and "opted out" read the same off).
host.clipboardSync = clipboardSync ? true : nil host.clipboardSync = clipboardSync ? true : nil
#endif #endif
#if !os(tvOS)
// nil rather than "" for the same forward-compat reason, and a dangling binding is
// cleaned up on this save (§6) instead of lingering as a stale id forever.
host.profileID = profiles.profile(id: profileID) == nil ? nil : profileID
// Keep the stored ORDER (it is card order) and drop what this sheet unpinned; anything
// newly ticked goes on the end.
let kept = (host.pinnedProfileIDs ?? []).filter { pinnedIDs.contains($0) }
let added = pinnedIDs.filter { !kept.contains($0) }.sorted()
let pins = kept + added
host.pinnedProfileIDs = pins.isEmpty ? nil : pins
#endif
onSave(host) onSave(host)
dismiss() dismiss()
} }
@@ -26,7 +26,9 @@ import GameController
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host /// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
/// action. Hashable so it can be the carousel's scroll-position identity. /// action. Hashable so it can be the carousel's scroll-position identity.
private enum GamepadHomeTarget: Hashable { private enum GamepadHomeTarget: Hashable {
case saved(UUID) /// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) which on a
/// controller-first surface are THE profile affordance: focus and press, no menus.
case saved(UUID, profile: String?)
case discovered(String) case discovered(String)
case addHost case addHost
} }
@@ -37,6 +39,12 @@ private struct HomeTile: Identifiable {
let id: GamepadHomeTarget let id: GamepadHomeTarget
let title: String let title: String
let subtitle: String let subtitle: String
/// The profile this tile connects with shown as a tinted chip. Set on a pinned card; nil on
/// a plain host tile unless the host is bound to one (then it answers "what will A do?").
var profile: StreamProfile?
/// This tile IS a pinned host+profile card, not a host wearing its binding's chip the chip
/// reads loud on the former (it is the reason the tile exists) and quiet on the latter.
var isPinnedCard = false
var isOnline = false var isOnline = false
var isPaired = false var isPaired = false
var isConnecting = false var isConnecting = false
@@ -59,9 +67,12 @@ struct GamepadHomeView: View {
/// Wake-and-wait driver gates the carousel while its overlay is up, and the carousel's /// Wake-and-wait driver gates the carousel while its overlay is up, and the carousel's
/// activate routes an offline+wakeable host through it (see ContentView.startSession). /// activate routes an offline+wakeable host through it (see ContentView.startSession).
@ObservedObject var waker: HostWaker @ObservedObject var waker: HostWaker
let connect: (StoredHost) -> Void let connect: (StoredHost, ProfileSelection) -> Void
let connectDiscovered: (DiscoveredHost) -> Void let connectDiscovered: (DiscoveredHost) -> Void
/// The profile catalog pinned host+profile combos render as their own tiles here, which is
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
@ObservedObject private var profiles = ProfileStore.shared
/// Same gate the touch grid's "Browse Library" context-menu item uses (default ON; the /// Same gate the touch grid's "Browse Library" context-menu item uses (default ON; the
/// Settings "Game library" toggle opts out). /// Settings "Game library" toggle opts out).
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@@ -250,22 +261,35 @@ struct GamepadHomeView: View {
/// then discovered-but-unsaved ones, then the Add Host action tile (so the strip is never /// then discovered-but-unsaved ones, then the Add Host action tile (so the strip is never
/// empty and manual entry is always one press away). /// empty and manual entry is always one press away).
private var tiles: [HomeTile] { private var tiles: [HomeTile] {
let saved = store.hosts.map { host in var saved: [HomeTile] = []
HomeTile( for host in store.hosts {
id: .saved(host.id),
title: host.displayName,
subtitle: "\(host.address):\(String(host.port))",
// Online = advertising on mDNS OR proven reachable by the probe (a routed/VPN host // Online = advertising on mDNS OR proven reachable by the probe (a routed/VPN host
// never advertises); the wake item is offered only when neither holds. // never advertises); the wake item is offered only when neither holds.
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id), let online = discovery.advertises(host) || store.probedOnline.contains(host.id)
let bound = profiles.binding(for: host)
let connecting = model.phase == .connecting && model.activeHost?.id == host.id
// The host's own tile, then one per pinned profile the same order the touch grid
// uses, so a pin reads as belonging to its host on both surfaces.
for profile in [StreamProfile?.none] + profiles.pinned(for: host).map(Optional.some) {
saved.append(HomeTile(
id: .saved(host.id, profile: profile?.id),
title: host.displayName,
subtitle: "\(host.address):\(String(host.port))",
profile: profile ?? bound,
isPinnedCard: profile != nil,
isOnline: online,
isPaired: host.pinnedSHA256 != nil, isPaired: host.pinnedSHA256 != nil,
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, isConnecting: connecting,
filled: true, filled: true,
hasLibrary: true, // A pinned card is a shortcut, not a second host Y (library) stays on the
// host's own tile, where the host-level actions live.
hasLibrary: profile == nil,
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id) && !online && !host.wakeMacs.isEmpty,
&& !host.wakeMacs.isEmpty, activate: {
activate: { connect(host) }) connect(host, profile.map { .profile($0.id) } ?? .inherit)
}))
}
} }
let discovered = discovery.unsaved(among: store.hosts).map { d in let discovered = discovery.unsaved(among: store.hosts).map { d in
HomeTile( HomeTile(
@@ -287,7 +311,7 @@ struct GamepadHomeView: View {
/// Only saved hosts have a library matches the touch grid, where "Browse Library" is a /// Only saved hosts have a library matches the touch grid, where "Browse Library" is a
/// `HostCardView`-only action never offered on `DiscoveredCardView`. /// `HostCardView`-only action never offered on `DiscoveredCardView`.
private func openLibraryForSelected() { private func openLibraryForSelected() {
guard libraryEnabled, case .saved(let id) = selection, guard libraryEnabled, case .saved(let id, let profile) = selection, profile == nil,
let host = store.hosts.first(where: { $0.id == id }) let host = store.hosts.first(where: { $0.id == id })
else { return } else { return }
libraryTarget = host libraryTarget = host
@@ -353,6 +377,11 @@ private struct GamepadHostTile: View {
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .minimumScaleFactor(0.7)
if let profile = tile.profile {
ProfileChip(
profile: profile, size: Self.statusFont, prominent: tile.isPinnedCard)
.padding(.top, 4)
}
Text(tile.subtitle) Text(tile.subtitle)
.font(.geist(Self.subtitleFont, relativeTo: .caption)) .font(.geist(Self.subtitleFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55)) .foregroundStyle(.white.opacity(0.55))
@@ -5,7 +5,12 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(tvOS) // The tvOS slide transition ships with the Xcode PROJECT only its manifest breaks SwiftPM's
// whole-graph validation, so `swift build` never sees it. `canImport` rather than `os(tvOS)` so
// the tvOS sources still TYPECHECK from the command line (the hand recipe in the Apple client's
// README), where the module is genuinely absent; in the app it is present and the transition
// applies exactly as before.
#if os(tvOS) && canImport(SwiftUINavigationTransitions)
import SwiftUINavigationTransitions import SwiftUINavigationTransitions
#endif #endif
@@ -13,6 +18,9 @@ struct HomeView: View {
@ObservedObject var store: HostStore @ObservedObject var store: HostStore
@ObservedObject var model: SessionModel @ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery @ObservedObject var discovery: HostDiscovery
/// The profile catalog the source of the card chips, the "Connect with " menu, and the
/// pinned host+profile cards the grid renders alongside their host (design §5.2a).
@ObservedObject private var profiles = ProfileStore.shared
@Binding var showAddHost: Bool @Binding var showAddHost: Bool
@Binding var pairingTarget: StoredHost? @Binding var pairingTarget: StoredHost?
@Binding var speedTestTarget: StoredHost? @Binding var speedTestTarget: StoredHost?
@@ -20,7 +28,9 @@ struct HomeView: View {
#if !os(macOS) #if !os(macOS)
@Binding var showSettings: Bool @Binding var showSettings: Bool
#endif #endif
let connect: (StoredHost) -> Void /// Start a session with this host, using the given profile selection `.inherit` for a plain
/// card tap (the host's binding), an explicit pick from "Connect with " or a pinned card.
let connect: (StoredHost, ProfileSelection) -> Void
let connectDiscovered: (DiscoveredHost) -> Void let connectDiscovered: (DiscoveredHost) -> Void
/// Pairing succeeded (tvOS PairSheet route) pin + connect (ContentView guards staleness). /// Pairing succeeded (tvOS PairSheet route) pin + connect (ContentView guards staleness).
let onPaired: (StoredHost, Data) -> Void let onPaired: (StoredHost, Data) -> Void
@@ -34,6 +44,10 @@ struct HomeView: View {
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
/// The host being edited (name / address / port / Wake-on-LAN MAC) drives the edit sheet. /// The host being edited (name / address / port / Wake-on-LAN MAC) drives the edit sheet.
@State private var editTarget: StoredHost? @State private var editTarget: StoredHost?
// How this device shows its own list. `.added` is the default because it is what the grid
// did before it could sort at all an update should not rearrange anyone's hosts.
@AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue
@AppStorage(DefaultsKey.hostGrouping) private var groupingRaw = HostGrouping.none.rawValue
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -43,9 +57,16 @@ struct HomeView: View {
} else { } else {
ScrollView { ScrollView {
if !store.hosts.isEmpty { if !store.hosts.isEmpty {
LazyVStack(alignment: .leading, spacing: gridSpacing) {
ForEach(hostGroups) { group in
if let title = group.title {
groupHeader(title, accent: group.accent)
}
LazyVGrid(columns: gridColumns, spacing: gridSpacing) { LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
ForEach(store.hosts) { host in ForEach(group.cards) { card in
hostCard(host) hostCard(card.host, pinned: card.pinned)
}
}
} }
} }
.padding() .padding()
@@ -127,8 +148,17 @@ struct HomeView: View {
#if os(iOS) #if os(iOS)
// Adjacent trailing items share one glass pill (the system default). // Adjacent trailing items share one glass pill (the system default).
ToolbarItem(placement: .topBarTrailing) { settingsButton } ToolbarItem(placement: .topBarTrailing) { settingsButton }
if showsArrangeMenu {
ToolbarItem(placement: .topBarTrailing) { arrangeMenu }
}
ToolbarItem(placement: .topBarTrailing) { addHostButton } ToolbarItem(placement: .topBarTrailing) { addHostButton }
#else #else
if showsArrangeMenu {
ToolbarItem(placement: .primaryAction) {
arrangeMenu
.help("Sort and group the host list")
}
}
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
addHostButton addHostButton
.help("Add a host") .help("Add a host")
@@ -146,7 +176,7 @@ struct HomeView: View {
#if os(macOS) #if os(macOS)
.frame(minWidth: 480, minHeight: 360) .frame(minWidth: 480, minHeight: 360)
#endif #endif
#if os(tvOS) #if os(tvOS) && canImport(SwiftUINavigationTransitions)
// The Settings-app slide for every push in this stack (top-level routes AND // The Settings-app slide for every push in this stack (top-level routes AND
// the pickers' drill-ins) SwiftUI's default on tvOS is a bare crossfade. // the pickers' drill-ins) SwiftUI's default on tvOS is a bare crossfade.
// Spring-driven (UISpringTimingParameters): ~0.87 damping ratio settles fast // Spring-driven (UISpringTimingParameters): ~0.87 damping ratio settles fast
@@ -180,22 +210,77 @@ struct HomeView: View {
// MARK: - Cards // MARK: - Cards
private func hostCard(_ host: StoredHost) -> some View { /// The grid's bands, ordered and divided per this device's preference cards and all, so a
/// pinned card can be filed under the profile it connects with rather than under its host's
/// binding (`HostArrangement`).
private var hostGroups: [HostGroup] {
HostArrangement.groups(
hosts: store.hosts, catalog: profiles.catalog,
online: Set(store.hosts.filter(isOnline).map(\.id)),
sort: HostSort(rawValue: sortRaw) ?? .added,
grouping: HostGrouping(rawValue: groupingRaw) ?? .none)
}
/// Online = advertising on mDNS OR answered the reachability probe (a routed/VPN host never
/// advertises). One definition, used by the cards and by the Status grouping alike.
private func isOnline(_ host: StoredHost) -> Bool {
discovery.advertises(host) || store.probedOnline.contains(host.id)
}
private func groupHeader(_ title: String, accent: String?) -> some View {
HStack(spacing: 7) {
Circle()
.fill(Color(hex: accent ?? "") ?? Color.secondary.opacity(0.5))
.frame(width: 7, height: 7)
.accessibilityHidden(true) // the title says it
Text(title)
.font(.geist(13, .semibold, relativeTo: .subheadline))
.foregroundStyle(.secondary)
.textCase(.uppercase)
.tracking(0.6)
}
.padding(.top, 4)
}
private func hostCard(_ host: StoredHost, pinned: StreamProfile?) -> some View {
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
// A pinned card connects with ITS profile; the primary card follows the binding.
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
return HostCardView( return HostCardView(
host: host, host: host,
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id), isOnline: isOnline(host),
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
isMostRecent: host.id == mostRecentHostID, // The accent ring marks the most recent HOST; pinned cards stay quiet so the grid
// doesn't grow several "most recent" bars for one machine.
isMostRecent: pinned == nil && host.id == mostRecentHostID,
isBusy: model.isBusy, isBusy: model.isBusy,
onConnect: { connect(host) }, onConnect: { connect(host, selection) },
onPair: { if !model.isBusy { pairingTarget = host } }, onPair: { if !model.isBusy { pairingTarget = host } },
onSpeedTest: { if !model.isBusy { speedTestTarget = host } }, onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
onForget: { store.forgetIdentity(host) }, onForget: { store.forgetIdentity(host) },
onRemove: { store.remove(host) }, onRemove: { store.remove(host) },
onBrowseLibrary: onBrowseLibrary, onBrowseLibrary: onBrowseLibrary,
onWake: { wake(host) }, onWake: { wake(host) },
onEdit: { editTarget = host }) onEdit: { editTarget = host },
profileMenu: profileMenu(for: host),
pinnedProfile: pinned)
}
/// The profile affordances every host card carries (§5.2/§5.2a).
private func profileMenu(for host: StoredHost) -> HostProfileMenu {
HostProfileMenu(
profiles: profiles.profiles,
boundID: host.profileID,
pinnedIDs: host.pinnedProfileIDs ?? [],
connectWith: { selection in connect(host, selection) },
setDefault: { store.setProfile(host.id, profileID: $0) },
togglePin: { id in
let pinned = (host.pinnedProfileIDs ?? []).contains(id)
store.setPinned(host.id, profileID: id, pinned: !pinned)
},
copyLink: { profileID in
LinkClipboard.copy(DeepLink.forHost(host, profile: profileID).urlString)
})
} }
private var discoveredSection: some View { private var discoveredSection: some View {
@@ -260,6 +345,41 @@ struct HomeView: View {
} }
} }
#if !os(tvOS)
/// One host has no order and nothing to divide, so the control stays out of the way until
/// there is a list to arrange.
private var showsArrangeMenu: Bool { store.hosts.count > 1 }
/// Sort and group, as two inline pickers in one menu both are about the same list, and
/// splitting them across two toolbar items would say otherwise.
private var arrangeMenu: some View {
Menu {
Picker("Sort By", selection: $sortRaw) {
ForEach(HostSort.allCases) { option in
Label(option.label, systemImage: option.symbol).tag(option.rawValue)
}
}
.pickerStyle(.inline)
Divider()
Picker("Group By", selection: $groupingRaw) {
ForEach(HostGrouping.allCases) { option in
Label(option.label, systemImage: option.symbol).tag(option.rawValue)
}
}
.pickerStyle(.inline)
} label: {
Label("Sort and Group", systemImage: "line.3.horizontal.decrease.circle")
}
#if os(macOS)
// A Menu draws its label in the ACCENT colour where a Button draws it in the label
// colour, so next to Add Host and Settings this one came out brand-purple. macOS only:
// on iOS every toolbar item is accent-tinted, and pinning this one to primary would make
// it the odd one out there instead.
.tint(.primary)
#endif
}
#endif
#if !os(macOS) #if !os(macOS)
private var settingsButton: some View { private var settingsButton: some View {
Button { Button {
@@ -70,8 +70,32 @@ private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, fi
} }
} }
/// Everything a card's profile affordances need: the catalog to offer, what this host is bound
/// and pinned to, and the acts a menu can perform (design/client-settings-profiles.md §5.2/§5.2a).
///
/// Passed as one value rather than eight closures because every surface that renders a host card
/// has to offer the SAME set a menu that quietly lacks "Pin as card" on one screen is how a
/// feature becomes folklore.
struct HostProfileMenu {
var profiles: [StreamProfile]
/// The host's default profile the chip, and the checkmark in "Connect with ".
var boundID: String?
var pinnedIDs: [String]
/// A ONE-OFF connect. Never rebinds: rebinding is `setDefault`, an explicit act (§5.2).
var connectWith: (ProfileSelection) -> Void
var setDefault: (String?) -> Void
var togglePin: (String) -> Void
/// Copy a `punktfunk://` link for this host, optionally carrying a profile.
var copyLink: (String?) -> Void
}
/// A saved host. A left accent bar marks the most-recently-connected one; the context menu /// A saved host. A left accent bar marks the most-recently-connected one; the context menu
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy. /// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
///
/// The same view renders a PINNED host+profile card (§5.2a): same host, same live status, with
/// the profile as the prominent subtitle. A pinned card is a shortcut, not a second host, so its
/// menu carries only connect-shaped actions edit/pair/forget/remove stay on the primary card,
/// where the thing they act on actually lives.
struct HostCardView: View { struct HostCardView: View {
let host: StoredHost let host: StoredHost
/// Currently advertising on the LAN (matched against live mDNS discovery). False means /// Currently advertising on the LAN (matched against live mDNS discovery). False means
@@ -92,6 +116,18 @@ struct HostCardView: View {
var onWake: (() -> Void)? = nil var onWake: (() -> Void)? = nil
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC). /// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
var onEdit: (() -> Void)? = nil var onEdit: (() -> Void)? = nil
/// This card's profile affordances nil on surfaces that don't offer them.
var profileMenu: HostProfileMenu? = nil
/// Set on a PINNED card: the profile this card connects with. nil = the host's primary card,
/// which follows the binding.
var pinnedProfile: StreamProfile? = nil
/// The profile this card announces: a pinned card's own, else the host's binding.
private var shownProfile: StreamProfile? {
pinnedProfile ?? profileMenu.flatMap { menu in
menu.boundID.flatMap { id in menu.profiles.first { $0.id == id } }
}
}
var body: some View { var body: some View {
let m = CardMetrics.current let m = CardMetrics.current
@@ -99,17 +135,35 @@ struct HostCardView: View {
HStack(spacing: m.spacing) { HStack(spacing: m.spacing) {
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true) monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
// The chip rides the TITLE line, anchored to the card's trailing edge not
// trailing the name, where it read as part of the title, and not on a line of
// its own, where it made cards with a profile taller than cards without and a
// pinned card stuck out of its grid row. The spacer is what anchors it: the
// name truncates against that gap instead of ever running into the chip.
HStack(spacing: 6) {
Text(host.displayName) Text(host.displayName)
.font(.geist(m.name, .bold, relativeTo: .title3)) .font(.geist(m.name, .bold, relativeTo: .title3))
.foregroundStyle(.primary) .foregroundStyle(.primary)
.lineLimit(1) .lineLimit(1)
Spacer(minLength: 8)
if let profile = shownProfile {
ProfileChip(
profile: profile, size: m.status,
prominent: pinnedProfile != nil)
// The name gives up width first: a truncated host name still reads,
// a truncated profile name is the one thing the chip exists to say.
.layoutPriority(1)
}
}
Text("\(host.address):\(String(host.port))") Text("\(host.address):\(String(host.port))")
.font(.geist(m.meta, relativeTo: .caption)) .font(.geist(m.meta, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
statusRow(m) statusRow(m)
} }
Spacer(minLength: 0) // Fills the card so the title row's trailing spacer reaches the real edge; without
// it the text column hugs its content and "trailing" means "just after the name".
.frame(maxWidth: .infinity, alignment: .leading)
} }
.padding(m.padding) .padding(m.padding)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -138,10 +192,32 @@ struct HostCardView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
#endif #endif
.disabled(isBusy) .disabled(isBusy)
.contextMenu { .contextMenu { menuItems }
}
@ViewBuilder private var menuItems: some View {
if let pinned = pinnedProfile, let menu = profileMenu {
// A pinned card is a shortcut, not a second host: only connect-shaped actions, plus
// the way to remove the shortcut itself. Unpinning touches neither the profile nor
// the host's default binding.
connectWithMenu(menu)
if LinkClipboard.isAvailable {
Button("Copy Link") { menu.copyLink(pinned.id) }
}
Button("Unpin Card", systemImage: "pin.slash", role: .destructive) {
menu.togglePin(pinned.id)
}
} else {
if let onEdit { if let onEdit {
Button("Edit…", systemImage: "pencil", action: onEdit) Button("Edit…", systemImage: "pencil", action: onEdit)
} }
if let menu = profileMenu {
connectWithMenu(menu)
pinMenu(menu)
if LinkClipboard.isAvailable {
Button("Copy Link") { menu.copyLink(nil) }
}
}
Button("Pair with PIN…", action: onPair) Button("Pair with PIN…", action: onPair)
Button("Test Network Speed…", action: onSpeedTest) Button("Test Network Speed…", action: onSpeedTest)
if let onBrowseLibrary { if let onBrowseLibrary {
@@ -159,10 +235,76 @@ struct HostCardView: View {
} }
} }
/// "Connect with " a ONE-OFF pick that never rebinds the host, with a checkmark on what a
/// plain click would use. Its last item is the explicit way to rebind, for the users who do
/// want that from here.
@ViewBuilder private func connectWithMenu(_ menu: HostProfileMenu) -> some View {
if !menu.profiles.isEmpty {
Menu("Connect with") {
Button {
menu.connectWith(.defaults)
} label: {
checkable("Default settings", on: menu.boundID == nil)
}
ForEach(menu.profiles) { profile in
Button {
menu.connectWith(.profile(profile.id))
} label: {
checkable(profile.name, on: menu.boundID == profile.id)
}
}
Divider()
Menu("Set Default Profile") {
Button("Default settings") { menu.setDefault(nil) }
ForEach(menu.profiles) { profile in
Button(profile.name) { menu.setDefault(profile.id) }
}
}
}
}
}
/// "Pin as card " a host+profile combo gets its own one-click card in the grid, which is
/// what turns a regularly-used profile from a menu dive into a press (§5.2a).
@ViewBuilder private func pinMenu(_ menu: HostProfileMenu) -> some View {
if !menu.profiles.isEmpty {
Menu("Pin as Card") {
ForEach(menu.profiles) { profile in
Button {
menu.togglePin(profile.id)
} label: {
checkable(profile.name, on: menu.pinnedIDs.contains(profile.id))
}
}
}
}
}
/// A menu row that carries a checkmark when it is the current choice. Built as two shapes
/// rather than one `Label` with an empty symbol name `Image(systemName: "")` is not a
/// blank image, it is an invalid one.
@ViewBuilder private func checkable(_ title: String, on: Bool) -> some View {
if on {
Label(title, systemImage: "checkmark")
} else {
Text(title)
}
}
/// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a /// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a
/// certificate is pinned (the lock state, spelled out). /// certificate is pinned (the lock state, spelled out).
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View { @ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
HStack(spacing: 6) { HStack(spacing: 6) {
// The host's OS mark leads the row (template asset tints like an SF Symbol);
// absent entirely for a host that never advertised one, so those cards render
// exactly as they always did.
if let mark = osIconImage(for: host.osChain) {
mark
.resizable()
.scaledToFit()
.frame(width: m.status + 2, height: m.status + 2)
.accessibilityLabel(host.osChain ?? "")
}
RoundedRectangle(cornerRadius: 1.5) RoundedRectangle(cornerRadius: 1.5)
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4)) .fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
.frame(width: 6, height: 6) .frame(width: 6, height: 6)
@@ -181,6 +323,36 @@ struct HostCardView: View {
} }
} }
/// The profile a card connects with, as a tinted pill. Quiet on a bound primary card (it only
/// answers "what will a click do?"); prominent on a pinned card, where the profile IS the reason
/// the card exists which is where the catalog's `accent` earns its keep.
///
/// Prominence is fill and weight only, never TYPE SIZE: the chip sits on the card's title line,
/// and a chip taller than the name would make pinned cards taller than their host's.
struct ProfileChip: View {
let profile: StreamProfile
let size: CGFloat
var prominent = false
var body: some View {
let tint = profile.accentColor
return HStack(spacing: 5) {
Circle()
.fill(tint)
.frame(width: size * 0.55, height: size * 0.55)
.accessibilityHidden(true) // the name is right there
Text(profile.name)
.font(.geist(size, prominent ? .bold : .semibold, relativeTo: .caption2))
.lineLimit(1)
}
.foregroundStyle(tint)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(Capsule().fill(tint.opacity(prominent ? 0.24 : 0.12)))
.accessibilityLabel("Profile \(profile.name)")
}
}
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border /// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required). /// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
struct DiscoveredCardView: View { struct DiscoveredCardView: View {
@@ -203,6 +375,14 @@ struct DiscoveredCardView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
HStack(spacing: 6) { HStack(spacing: 6) {
// Same leading OS mark as a saved card's status row live from the advert.
if let mark = osIconImage(for: discovered.osChain) {
mark
.resizable()
.scaledToFit()
.frame(width: m.status + 2, height: m.status + 2)
.accessibilityLabel(discovered.osChain)
}
Image(systemName: discovered.requiresPairing Image(systemName: discovered.requiresPairing
? "lock.fill" : "antenna.radiowaves.left.and.right") ? "lock.fill" : "antenna.radiowaves.left.and.right")
.font(.system(size: m.status)) .font(.system(size: m.status))
@@ -34,10 +34,10 @@ struct SpeedTestSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let host: StoredHost let host: StoredHost
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0 @AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
/// The catalog, so the Apply button can write to the layer this host actually reads its
/// bitrate from (design/client-settings-profiles.md §5.3).
@ObservedObject private var profiles = ProfileStore.shared
private enum Phase: Equatable { private enum Phase: Equatable {
case connecting case connecting
@@ -87,14 +87,7 @@ struct SpeedTestSheet: View {
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
#endif #endif
if case .done(let result) = phase, let rec = Self.recommendedKbps(result) { if case .done(let result) = phase, let rec = Self.recommendedKbps(result) {
Button("Use \(Self.mbpsLabel(kbps: rec))") { applyButtons(rec)
bitrateKbps = rec
dismiss()
}
.glassProminentButtonStyle()
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
} }
if case .failed = phase { if case .failed = phase {
Button("Retry") { run() } Button("Retry") { run() }
@@ -122,6 +115,45 @@ struct SpeedTestSheet: View {
.onDisappear { token.cancelled = true } .onDisappear { token.cancelled = true }
} }
/// Apply writes to **the layer this host actually resolves its bitrate from** (§5.3) the
/// sharpest symptom of the missing feature was a per-host measurement overwriting the global.
///
/// unbound host the global, exactly as before;
/// bound to a profile that already overrides bitrate that override;
/// bound to a profile that INHERITS bitrate both are offered, because either is a
/// defensible answer and guessing would silently pick one.
///
/// Every button names its target, so what a click changes is never inferred from context.
@ViewBuilder private func applyButtons(_ recommended: Int) -> some View {
let bound = profiles.binding(for: host)
let label = Self.mbpsLabel(kbps: recommended)
if let bound {
Button("Apply to “\(bound.name)") {
profiles.setOverride(bound.id, \.bitrateKbps, recommended)
dismiss()
}
.glassProminentButtonStyle()
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
if bound.overrides.bitrateKbps == nil {
Button("Set as default (\(label))") {
bitrateKbps = recommended
dismiss()
}
}
} else {
Button("Use \(label)") {
bitrateKbps = recommended
dismiss()
}
.glassProminentButtonStyle()
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
}
}
private var phaseIsFinal: Bool { private var phaseIsFinal: Bool {
switch phase { switch phase {
case .done, .failed: return true case .done, .failed: return true
@@ -190,7 +222,12 @@ struct SpeedTestSheet: View {
let address = host.address let address = host.address
let port = host.port let port = host.port
let pin = host.pinnedSHA256 let pin = host.pinnedSHA256
let (w, h, fps) = (UInt32(clamping: width), UInt32(clamping: height), UInt32(clamping: hz)) // Probe at the mode this host would actually stream at its profile's, if it is bound to
// one. The measurement IS the streaming path, so it should be the streaming path's mode.
let mode = EffectiveSettings.resolve(host: host, catalog: profiles.catalog)
let (w, h, fps) = (
UInt32(clamping: mode.width), UInt32(clamping: mode.height),
UInt32(clamping: mode.refreshHz))
Task.detached(priority: .userInitiated) { Task.detached(priority: .userInitiated) {
// Connect (blocking) same identity/trust as a session, but TOFU results are // Connect (blocking) same identity/trust as a session, but TOFU results are
// NOT persisted from here. // NOT persisted from here.
@@ -1,13 +1,17 @@
// Siri / Shortcuts / Spotlight surface (design §M4). Deliberately thin: every action already has an // Siri / Shortcuts / Spotlight surface (design §M4, extended by client-deep-links.md §6).
// internal entry point M0's deep-link router (connect / connect-and-launch), M3's in-process // Deliberately thin: every action already has an internal entry point the deep-link router
// end-session hook, and the existing Wake-on-LAN path so these intents only wrap them. // (connect / connect-and-launch / connect-with-a-profile), the in-process end-session hook, and
// the existing Wake-on-LAN path so these intents only wrap them.
// //
// Gated os(iOS): the AppShortcutsProvider bundles `EndStreamIntent`, which is a LiveActivityIntent // Connect and Wake compile on macOS and tvOS too: AppIntents is genuinely available there
// (iPhone/iPad only). Connect/Wake themselves are plain AppIntents; they live here with the // (macOS 13+ / tvOS 16+), and "Stream Desktop with Work" from Spotlight on a Mac is part of the
// provider rather than being split across platforms. `HostEntity` (the parameter type) is in // ask. Only the AppShortcutsProvider stays iOS-gated, because it bundles `EndStreamIntent` a
// PunktfunkShared so the widget's configuration intent can share it. // LiveActivityIntent, and ActivityKit is iPhone/iPad only.
//
// `HostEntity` and `ProfileEntity` (the parameter types) live in PunktfunkShared so a widget's
// configuration intent, which executes in the EXTENSION process, can share them.
#if os(iOS) #if canImport(AppIntents)
import AppIntents import AppIntents
import Foundation import Foundation
import PunktfunkKit import PunktfunkKit
@@ -21,9 +25,10 @@ private func loadStoredHost(_ id: UUID) -> StoredHost? {
return hosts.first { $0.id == id } return hosts.first { $0.id == id }
} }
/// Start a session with a stored host (optionally launching a title). Foregrounds the app and /// Start a session with a stored host (optionally launching a title, optionally with a settings
/// routes through the SAME `.onOpenURL` path a widget tap uses trust policy, WoL and the approval /// profile). Foregrounds the app and routes through the SAME `.onOpenURL` path a widget tap uses
/// sheet all apply, and its guards (unknown host, already-streaming) hold. /// trust policy, WoL and the approval sheet all apply, and its guards (unknown host, ambiguous or
/// unknown profile, already-streaming) hold. One router, not a second connect path.
struct ConnectToHostIntent: AppIntent { struct ConnectToHostIntent: AppIntent {
static let title: LocalizedStringResource = "Connect to Host" static let title: LocalizedStringResource = "Connect to Host"
static let description = IntentDescription("Start a Punktfunk streaming session with a host.") static let description = IntentDescription("Start a Punktfunk streaming session with a host.")
@@ -32,9 +37,13 @@ struct ConnectToHostIntent: AppIntent {
@Parameter(title: "Host") var host: HostEntity @Parameter(title: "Host") var host: HostEntity
@Parameter(title: "Game ID", description: "Optional store id like steam:570") @Parameter(title: "Game ID", description: "Optional store id like steam:570")
var launchID: String? var launchID: String?
@Parameter(
title: "Profile",
description: "Which settings profile to use — leave empty for the host's default")
var profile: ProfileEntity?
func perform() async throws -> some IntentResult { func perform() async throws -> some IntentResult {
let url = DeepLink.connect(host: host.id, launchID: launchID).url let url = DeepLink.connect(host: host.id, launchID: launchID, profile: profile?.id).url
await MainActor.run { await MainActor.run {
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url) NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
} }
@@ -74,8 +83,11 @@ enum IntentError: Error, CustomLocalizedStringResourceConvertible {
} }
} }
#if os(iOS)
/// Zero-setup Siri / Spotlight phrases. Parameterized phrases resolve a `HostEntity` by name; stays /// Zero-setup Siri / Spotlight phrases. Parameterized phrases resolve a `HostEntity` by name; stays
/// well under the 10-shortcut cap. /// well under the 10-shortcut cap. Phrases stay host-parameterized App Shortcut phrases can't
/// embed a second arbitrary `AppEntity`, so the profile is picked in the Shortcuts editor. No
/// phrase regression.
struct PunktfunkShortcuts: AppShortcutsProvider { struct PunktfunkShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] { static var appShortcuts: [AppShortcut] {
AppShortcut( AppShortcut(
@@ -100,3 +112,4 @@ struct PunktfunkShortcuts: AppShortcutsProvider {
} }
} }
#endif #endif
#endif

Some files were not shown because too many files have changed in this diff Show More