Compare commits

..
Author SHA1 Message Date
enricobuehler fcf4076eb7 Merge remote-tracking branch 'origin/main' into worktree-haptics-m9-richfb
ci / docs-site (pull_request) Successful in 1m14s
ci / web (pull_request) Successful in 2m10s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m45s
android / android (pull_request) Successful in 4m54s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m17s
ci / rust (pull_request) Successful in 7m59s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m0s
# Conflicts:
#	clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt
#	crates/pf-client-core/src/gamepad.rs
2026-08-04 23:07:18 +02:00
enricobuehler 53eb592c43 Merge pull request 'fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn' (#43) from worktree-haptics-m8-proto into main
deb / build-publish-client-arm64 (push) Successful in 1m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
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 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
android / android (push) Canceled after 0s
ci / rust-arm64 (push) Successful in 2m58s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / web (push) Successful in 2m52s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 3m8s
ci / docs-site (push) Canceled after 2m56s
deb / build-publish (push) Canceled after 2m50s
deb / build-publish-host (push) Canceled after 2m48s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 13s
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 18s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-04 21:03:33 +00:00
enricobuehler 956d8dd8ef Merge pull request 'fix(host/windows): two virtual pads stop tearing each other's reports' (#39) from worktree-haptics-m7-windows into main
deb / build-publish-host (push) Canceled after 4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
android / android (push) Canceled after 10s
deb / build-publish-client-arm64 (push) Canceled after 4s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
ci / rust (push) Canceled after 10s
ci / rust-arm64 (push) Canceled after 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
ci / web (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 14s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 14s
docker / builders-arm64cross (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Successful in 33s
windows-drivers / driver-build (push) Successful in 1m58s
2026-08-04 21:03:13 +00:00
enricobuehler b2e716ad5f Merge pull request 'fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit' (#38) from worktree-haptics-m6-presenter into main
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
docker / deploy-docs (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
android / android (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 / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
apple / swift (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 27s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 4m25s
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
2026-08-04 21:02:36 +00:00
enricobuehler ec288d64d3 Merge pull request 'fix(client/android): rumble survives a vibrator fault, and an unplug stops leaking' (#35) from worktree-haptics-m5-android into main
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
android / android (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
ci / rust (push) Canceled after 4s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
2026-08-04 21:02:15 +00:00
enricobuehler 68353a5d57 Merge pull request 'fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying' (#32) from worktree-haptics-m4-apple into main
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
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
apple / swift (push) Canceled after 53s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
2026-08-04 21:01:51 +00:00
enricobuehler ffd5a33598 Merge pull request 'fix(core/rumble): the Deck's keepalive stops being swallowed by its own renewals' (#30) from worktree-haptics-m3-rumble-engine into main
windows-host / package (push) Canceled after 1m3s
windows-host / canary-manifest (push) Canceled after 0s
deb / build-publish (push) Canceled after 58s
deb / build-publish-host (push) Canceled after 56s
deb / build-publish-client-arm64 (push) Canceled after 49s
windows-host / winget-source (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
apple / swift (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
arch / build-publish (push) Canceled after 56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 49s
windows / build (x86_64-pc-windows-msvc) (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
android / android (push) Canceled after 7s
release / apple (push) Canceled after 5m31s
2026-08-04 21:01:32 +00:00
enricobuehler 4af8b02be1 Merge pull request 'fix(host): a leftover Sunshine folder is not a conflict, and a crashed host gives the screen back' (#52) from worktree-conflict-detect-and-isolate-recovery into main
deb / build-publish (push) Canceled after 1s
deb / build-publish-client-arm64 (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
android / android (push) Canceled after 25s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
windows-host / package (push) Canceled after 1m11s
docker / builders-arm64cross (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
apple / swift (push) Canceled after 32s
windows-host / winget-source (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
arch / build-publish (push) Canceled after 37s
ci / rust (push) Canceled after 46s
ci / web (push) Canceled after 47s
ci / rust-arm64 (push) Canceled after 48s
ci / docs-site (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
Reviewed-on: #52
2026-08-04 21:00:56 +00:00
enricobuehler b31495bea5 fix(host): a leftover Sunshine folder is not a conflict, and a crashed host gives the screen back
ci / rust (pull_request) Successful in 9m53s
ci / rust-arm64 (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
apple / swift (pull_request) Successful in 1m20s
ci / docs-site (pull_request) Successful in 1m12s
android / android (pull_request) Successful in 2m56s
ci / web (pull_request) Successful in 2m0s
Three things a field report (Discord, upgrade from 0.1x) turned up, all on the
Windows host.

1. "It thinks I have Sunshine/Apollo running." It didn't — they were uninstalled.
   Both uninstallers leave their config/log directory in Program Files behind, and
   `detect.rs` counted a bare directory, or a service registered at ANY start type
   (including `disabled`), as a live conflict. The installer's own probe was
   narrowed to "service start type <= 2" after exactly this cried wolf on a
   `winget install`, and the tray dropped its always-on warning for the same reason
   in 3e782852 — the runtime probe never got the same treatment, so the one surface
   the user actually looks at kept shouting. `Evidence::is_active` now draws the
   line (running, or set to start on its own) and only active detections reach the
   startup warning, the `detect-conflicts` exit code, and `/local/summary`. Dormant
   findings still print in the full report, under a heading that says they need no
   action — that report is where "why does it think I have Apollo?" gets answered.

2. The console's conflicts card hardcoded "Another game-streaming server is
   **running** on this machine" regardless of what was found, so a dormant leftover
   was announced as a running server. It now says "active", and each entry names
   the observation — `Sunshine (running)`, `Apollo (starts automatically)`.

3. "The exclusive screen never times out going back to re-enabling the display."
   `isolate_displays_ccd` deactivates the operator's panels and hands the
   pre-isolate topology to the caller, which restores it at teardown — but that
   snapshot is PROCESS MEMORY, and Windows deliberately never saves the isolated
   topology to the CCD database. So a host that crashed, was killed, or was stopped
   mid-session left the desk dark with nothing in the product to undo it. There was
   one startup recovery leg already, but only for the EXPERIMENTAL
   `pnp_disable_monitors` axis, which is off by default — the default Exclusive path
   had none. `isolate_journal` now marks what an isolate is about to switch off
   (before the apply, so dying mid-apply is covered), clears the mark on restore,
   and force-EXTENDs at host startup if a mark survived. EXTEND rather than
   replaying the saved blob: the blob pins the virtual display's target id, which
   dies with the crashed host, so a replay would mostly fail BAD_CONFIGURATION into
   the very same backstop `restore_displays_ccd` already keeps — and EXTEND stays
   correct across a reboot, where saved ids would be stale.
2026-08-04 22:34:33 +02:00
enricobuehler ee0b179618 Merge pull request 'docs(apple/store): App Store copy for iOS, macOS and tvOS, counted against Apple's limits' (#49) from worktree-appstore-copy into main
ci / rust-arm64 (push) Successful in 2m29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
ci / web (push) Successful in 2m49s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / docs-site (push) Successful in 2m38s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m42s
release / apple (push) Successful in 9m23s
apple / swift (push) Successful in 1m34s
ci / rust (push) Successful in 7m27s
docker / deploy-docs (push) Failing after 6m11s
apple / screenshots (push) Successful in 6m13s
Reviewed-on: #49
2026-08-04 20:03:34 +00:00
enricobuehler d7e22c3db2 Merge pull request 'fix(apple/shots): the store screenshots show the app as it actually is' (#48) from worktree-apple-store-screenshots into main
apple / swift (push) Canceled after 15s
apple / screenshots (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
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
Reviewed-on: #48
2026-08-04 20:02:37 +00:00
enricobuehler c1231fa2e6 Merge pull request 'feat(clients/input): system buttons route around local overlays' (#47) from worktree-system-buttons-routing into main
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
arch / build-publish (push) Successful in 8m52s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m1s
release / apple (push) Canceled after 0s
decky / build-publish (push) Successful in 41s
deb / build-publish-client-arm64 (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m11s
android / android (push) Successful in 5m19s
deb / build-publish (push) Successful in 5m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m32s
deb / build-publish-host (push) Successful in 5m51s
apple / swift (push) Canceled after 9s
apple / screenshots (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m57s
ci / rust (push) Canceled after 12s
flatpak / build-publish (push) Successful in 6m23s
ci / rust-arm64 (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
Reviewed-on: #47
2026-08-04 20:02:22 +00:00
enricobuehler 1db7058a5d feat(clients/input): system buttons route around local overlays
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m16s
ci / rust-arm64 (pull_request) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
ci / rust (pull_request) Successful in 10m36s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26
opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes
it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for
one press — the local one covering the stream.

Two cross-client tier-P settings, zero wire changes:

- system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto
  forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the
  same physical press no matter what.
- guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's
  guide, down until release — held on, that's the host's long-press, which
  opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered
  on release with its up TAP_PRESS (50ms) behind, because per-transition
  sends fold into seq'd GamepadState snapshots and a back-to-back pair can
  coalesce into no press at all. A Select inside a combo (the escape chord)
  passes through untouched. Auto arms it only where the raw press can't
  reach the host cleanly: gamescope, iOS/iPadOS, tvOS.

The same SelectGesture rules live in pf-client-core (pure state machine +
unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and
Android's GamepadRouter. Settings rows on every surface (GTK, WinUI,
console UI, Decky, Apple x2, Android x2) with profile plumbing throughout.

punktfunk-session grows a control socket
($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one
runtime path a flatpak and the host see identically): 'guide'/'qam' verbs
inject synthetic taps. The Decky panel gains a Host menus section (visible
while the client runs) whose buttons press the host's Steam/QAM and close
the local menu so the host's shows through.

iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the
class needs the Xcode 27 SDK to compile). Docs: input, client-settings,
steam-deck. Design: punktfunk-planning design/system-buttons-routing.md.

Gates: docker clippy --all-targets --locked -D warnings + tests
(pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47),
cargo fmt --all --check, swift build (macOS), gradle kit+app compile,
decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
2026-08-04 21:46:27 +02:00
enricobuehler 83a12c7413 Merge pull request 'Decky, slimmed: a thin Gaming-Mode wrapper around the client' (#46) from worktree-decky-slim-rework into main
ci / web (push) Successful in 1m19s
ci / docs-site (push) Successful in 1m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
deb / build-publish-client-arm64 (push) Successful in 2m25s
android / android (push) Successful in 7m27s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m56s
decky / build-publish (push) Successful in 19s
apple / screenshots (push) Successful in 6m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders-arm64cross (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / deploy-docs (push) Successful in 34s
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 10s
flatpak / build-publish (push) Canceled after 10m10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / rust (push) Successful in 7m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 10m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
arch / build-publish (push) Successful in 8m21s
deb / build-publish-host (push) Successful in 4m33s
apple / swift (push) Successful in 1m18s
deb / build-publish (push) Successful in 5m28s
ci / rust-arm64 (push) Successful in 2m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
Reviewed-on: #46
2026-08-04 19:41:32 +00:00
enricobuehler 7b1554af4b fix(apple/shots): fill the grid, open Settings on Display, note the iPad orientation limit
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / web (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 2m34s
ci / rust (pull_request) Successful in 7m6s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
- Six mock hosts rather than three. An iPad-13 portrait grid is three columns
  wide and 2752 px tall; three cards left ~60% of the capture as black.
- Settings opens on Display, not General. Resolution, frame rate, bitrate,
  HDR and codec are what someone reads a streaming app's settings shot for.
- The wake scene is the modal-over-grid variant. The gamepad-UI one is a
  full-screen takeover over a bare gradient — correct, but four lines of text
  on an empty aurora; the modal shows the same overlay over the host grid.
- `requestGeometryUpdate` now reports a refusal instead of failing silently.
  It does not help on the simulator (an app's stdout doesn't reach the driver
  through `simctl launch`) but it will on macOS and on a device.
- Documented that `.landscape` does not rotate on iPad: a multitasking-capable
  iPad app is resizable, so iPadOS ignores the request and simctl cannot
  rotate a simulated device. The iPad set is portrait throughout.
2026-08-04 21:34:18 +02:00
enricobuehler 8f35155c14 fix(apple/shots): the store screenshots show the app as it actually is
Uploading the 0.24.0 set surfaced a pair screen that reads as broken, and a
hero that was never the orientation it claimed.

The capture harness:

- Landscape scenes were captured in PORTRAIT. `IOSOrientationConfigurator`
  asked for the geometry update from `updateUIViewController`, where
  `view.window` is still nil — SwiftUI makes one update pass for a
  `.background` representable, before the hierarchy is in a window, so the
  guard fell through and nothing ever asked again. Both `.landscape` scenes
  (the stream hero, the trust card) shipped as portrait. Now a real
  UIViewController asks from `viewDidAppear` and pins
  `supportedInterfaceOrientations`.

- The shot host applied `.ignoresSafeArea()` to the whole scene, so the
  hero's HUD — resolution, bitrate, the latency breakdown, the entire point
  of that screenshot — sat under the Dynamic Island. Only the black backing
  ignores it now; scenes that want full bleed already ignore it themselves.

- `03-pair` was hand-composed into a ZStack rather than presented. PairSheet
  is a bottom sheet on iOS: its detents and the system's Liquid Glass only
  exist inside a real `.sheet`. Composed, the grouped Form stretched to full
  screen height and the capture was a strip of content over a black void,
  with a DISABLED "Pair & Connect" (empty PIN) and the capture simulator's
  own name — `pf-shot-iphone-6.9` — rendered in as the device name.

- Sheets do not inherit `.environment(\.colorScheme, .dark)` across the
  presentation boundary; they follow the DEVICE. The pairing sheet came out
  light grey over the dark app. The simulator is now set to dark appearance.

- Discovery browsed the live LAN mid-capture, so a bystanding machine's
  hostname went out on the listing and no two runs matched. `HostDiscovery`
  gains a `debugSet` seam (the counterpart to `HostWaker.debugSet`); the
  mock hosts advertise, so cards read ONLINE through the real `advertises`
  path and the reachability probe never touches the network.

- Created simulators were named `pf-shot-<prefix>`, which the reuse regex
  never matches: every run created another simulator and none was reused.
  They are named after the device now — reusable, and not user-visible junk.

Two bugs found on the way, neither screenshot-only:

- HostStore/ProfileStore PERSISTED the harness's mock data. On a dev Mac
  that is the same App-Group suite the real app reads, so running the
  script could replace the tester's saved hosts with "Battlestation" & co.
- GamepadHomeView drew the controller chip as a trailing `.overlay`, which
  reserves no width — on a portrait phone it sat on top of the centred
  "Select a Host". Laid out as a row with a hidden leading mirror.
- The pairing sheet's field prompt said "How the host lists this Mac" on
  iPhone and iPad.

Coverage: the listing set is six scenes in listing order, and is now the
stream, the machines it found, the couch/controller mode, waking a sleeping
host, the quality controls and pairing — the console and wake screens
already existed in `ShotScenes.all` and were simply never captured. Mock
hosts carry OS marks, Wake-on-LAN MACs and profile chips so the grid is
full rather than three offline rows over an empty half-screen. `SCENES=`
overrides the set for the dev scenes.
2026-08-04 21:30:18 +02:00
enricobuehler 0d407a866d fix: a host that changed DHCP lease could no longer be streamed from the panel
ci / rust (pull_request) Successful in 7m15s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m58s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m54s
ci / rust-arm64 (pull_request) Successful in 3m5s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m16s
android / android (pull_request) Successful in 5m2s
An adversarial review of this branch found a regression I introduced, plus three smaller
defects. All four are fixed here, each verified on .21.

**The regression.** `mergeHosts` names a host by its record's stable id, and `hosts list --json`
always emits one (`KnownHosts::load` mints ids for every record). So a launch always went out as
`punktfunk launch <uuid>` → `ConnectPlan::for_host` → `HostTarget::from(&KnownHost)`, which
copies the address stored ON THE RECORD. Meanwhile the panel deliberately renders the LIVE
advert's address. Nothing on a Deck ever writes a moved address back — `discover` and
`hosts list` are both reads, and only the desktop shells' hosts pages update one.

So after any DHCP move the row read "online" at the new address and every press dialled the old
one: a 15 s dead connect, or — if a MAC had ever been learned — a black Steam "game" for the
full 90 s wake budget. Proven with a stub session binary: `launch abc-123` emitted
`--connect 10.0.0.5:9777` for a host answering at `10.0.0.99`.

This worked on origin/main, which dialled `toHost(v).host` — the advert's address. The fix
restores that without giving up stable ids: `hosts add <new-addr> --fp <known-fp>` now MOVES the
matching record instead of filing a second one (the fingerprint is the identity — this is the
same rule that makes the verb idempotent), and the panel re-points a host it can see has moved
before launching it. Verified: `moved 10.0.0.5:9777 to 10.0.0.99:9777`, one record still, and
`launch abc-123` then emits `--connect 10.0.0.99:9777`.

**"No hosts yet" was also how a missing client looked.** `_cli_argv()` returning None becomes
`client-unavailable`, which the panel dropped on the floor — so a Deck with no client installed
was told its network was empty, under a button that launches the client that isn't there. It now
says which of the two it is.

**The browse worker never exited on a quiet LAN.** `discover_for` drops the receiver and the
doc claimed that stops the thread. It does not: the worker parks in `recv()`, and the arms that
ignore an event (`SearchStarted`, `ServiceFound`, `SearchStopped`, a v6-only advert) never touch
the sender, so on a LAN with no Punktfunk host nothing ever wakes it. Harmless today because the
only caller is a short-lived CLI process, but the function invites in-process use, where it would
leak a thread and an mDNS daemon per call. Now polled with a 250 ms tick and a check at the top
of the loop. Verified: ten back-to-back browses settle back to the baseline thread count.

**A `pair=optional` host was recorded as paired.** Every unsaved host now goes through the trust
sheet (it has no pin, so it cannot stream without one), but the sheet's only non-PIN action ran
`--request-access`, which persists `paired: true` on Ready. An optional host admits anyone who
pins its identity — there is no operator decision, so nothing was approved and the same box read
"paired" here and "trusted" in the desktop client. Such a host now gets **Connect** instead,
which pins and streams without claiming an approval, and the "approve this Deck" toast is no
longer shown to someone who has nobody to ask.

Also: `PF_CLIENT_BIN` was the one launch-option value never validated — a client installed under
a path with a space would split Steam's tokenizer.
2026-08-04 21:26:09 +02:00
enricobuehler 0890cf3244 docs(apple/store): App Store copy for iOS, macOS and tvOS, counted against Apple's limits
ci / docs-site (pull_request) Successful in 1m47s
ci / rust (pull_request) Successful in 7m49s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 2m13s
German-first Promotional Text, Descriptions, Keywords and App Review notes,
plus the app-specific privacy text the existing website policy is missing.

Every character-limited field is checked by check-limits.py, which also catches
headings whose stated count has drifted from the real length. Three things the
brief assumed turned out not to hold, and the copy says so rather than shipping
the claim: a Mac cannot act as a host, the published privacy policy covers only
the website, and the App Review notes field caps at 4000 characters.
2026-08-04 21:25:55 +02:00
enricobuehler bf2d8505cf docs(decky): the gamepad-UI shortcut comment still named PF_HOST
PF_HOST is gone; the browse branch is keyed on PF_BROWSE alone and runs the SESSION binary,
which is the one path this rework deliberately did not repoint. Comment only.
2026-08-04 21:08:52 +02:00
enricobuehler 414380fc9e fix(cli): discover reads the host store without writing to it
`KnownHosts::load()` mints a stable id for any record that lacks one and SAVES it — which makes
it a write, and `discover` was calling it purely to annotate what the browse found with
saved/paired. It never hands those ids back to anyone.

That matters because the Decky panel issues `discover` and `hosts list` together, in parallel.
Against a store written before ids existed, both processes read it, both mint DIFFERENT ids for
the same record, and both save. Whichever loses the race has already handed its ids to its
caller — so the panel could draw a row whose host reference no longer resolves, and pressing it
would exit 5 ("no saved host matches") until the next refresh settled things.

`KnownHosts::read()` is `load` without the mint: the store exactly as it is on disk. `discover`
uses it; every caller that dials a host by id still uses `load`, so ids are still minted the
first time anything needs one.

Verified on a fixture store with no ids: `punktfunk discover` leaves it byte-identical, and a
following `punktfunk hosts list` mints as before.
2026-08-04 21:07:37 +02:00
enricobuehler 6267dcdcd3 fix(decky): let a CLI payload's own key never override this layer's ok
`{"ok": True, **data}` let a future payload carrying its own `ok` report failure through the
field the shell layer owns. Spread first, set `ok` last.
2026-08-04 21:04:16 +02:00
enricobuehler 8042a2fd52 fix(decky): a saved host's pin is what it PINNED, never what it's advertising
`mergeHosts` filled a row's fingerprint as `s.fp_hex || advert?.fp || ""`, so a host saved by
address — nothing pinned on disk — borrowed the fingerprint of whatever was advertising at that
address and rendered as ready to stream. The launch then refused for want of a pin, from a row
that had just shown "Stream" and "trusted".

Under the old rule the mistake was mostly hidden, because `needsPair` asked a different
question for saved and unsaved rows. This rework makes a pinned fingerprint the ONLY rule, so
the same conflation would now decide the whole thing.

The two are different facts and are now separate fields. `fp` is what the RECORD pins — the
thing the session binary requires. `advertisedFp` is what the host is offering right now, which
is what request access would pin, and moving one to the other is a trust decision the user
makes in the sheet rather than something the merge does behind them.

The trust sheet gates on and pins `advertisedFp` accordingly: a saved placeholder that happens
to be advertising can now be let in with request access, and one that isn't still gets the PIN
path with the reason.
2026-08-04 21:01:59 +02:00
enricobuehler 7e40098bc6 test(decky): tear the CLI fixture dir down before building it
The "a native install with no sibling CLI resolves to None" check created
/tmp/pf-test-native/bin/punktfunk and never removed it, so the assertion that the sibling is
ABSENT held only on the first run on a given machine and failed on every rerun. Caught by
running the suite twice.
2026-08-04 21:00:15 +02:00
enricobuehler ac5299d4ce docs: the Deck plugin is a launcher now, not a second client
The plugin's settings tab, fullscreen page, host editor and games picker are gone, and the
docs described all four in detail. Sweeps clients/decky/README.md and the docs site.

steam-deck.md gains a **Request access** section — the no-PIN path where the host's operator
approves the Deck, which is the one genuinely new thing a user gets — and says plainly where
the settings went: **Open Punktfunk → Settings**, the same rows over the same store, one tap
from the same panel. A removal that reads as a regression is worth a sentence, not a silence.
The troubleshooting table drops the rows for surfaces that no longer exist and gains the two
questions the new path will actually raise ("request access isn't offered", "the stream just
sits there").

client-settings.md claimed ~18 settings were "offered by … and Decky". None are; the console
home offers them. Its intro now names the console home's real sections (Stream, Video,
Presentation, Audio, Controller, Touchscreen, Interface, Profiles) instead of describing the
deleted sidebar.

Three claims in that file turned out to be wrong ALREADY, independent of this rework, and are
fixed here because verifying against crates/pf-console-ui/src/screens/settings.rs is what
found them:

  • "Render scale — offered everywhere except the console home's list". RowId::RenderScale has
    been in the console's ROWS since 2026-07-31.
  • wake-on-lan.md: "Punktfunk Console has no auto-wake setting of its own". It does —
    RowId::AutoWake, "Wake hosts automatically". Its Wake & Connect BUTTON is independent of
    the setting, which is the true half that sentence was built on.
  • The console home's Library button was documented as gated on the "Show game library"
    toggle. It isn't — `library_enabled` appears nowhere in pf-console-ui outside the toggle
    row itself; home.rs offers Library on any paired, saved host.

Also updated: support-matrix (Decky's Profiles and Game library go /⚠️ — the panel shows
pinned profile cards but creates none, and the library lives in the console home),
wake-on-lan (the plugin no longer fires its own packet or stretches the connect budget — the
CLI runs the real wake-and-wait), pairing, game-library, profiles-and-links, input, clipboard
and install-client.
2026-08-04 20:58:11 +02:00
enricobuehler 017c37b78a feat(decky): rebuild the panel as a launcher — nested cards and request access
What is left of the plugin is what only a Decky plugin can do: start a stream through Steam so
gamescope focuses it, and stand in front of the trust decision that gates it. One Quick Access
panel, four sections, no route.

HOSTS. One `useHosts()` calls discover and hosts-list together and merges them by fingerprint
first, address second — so a host that moved DHCP lease still matches its record, and a
different box that inherited the old address does not inherit its pairing. The CLI annotates
`saved`/`paired` by that same rule, so the two surfaces cannot disagree. Rows sort online
first, then most recently used, then by name: the host you streamed last night is the first
thing under your thumb, and a host that is off right now never is.

`needsPair` is now ONE rule: no pinned fingerprint. The session binary refuses a pinless
connect, so a row without one can offer nothing but a button that fails. The old rule also
consulted the advertised policy for unsaved hosts, which made the same box read differently
before and after being saved.

PINNED CARDS render NESTED under their host as `▸ <Profile name>`, not in a section of their
own — a card IS a (host, profile) pair, and a row floating free of its host is exactly the "a
pinned tile reads as a duplicate host" problem the desktop shells still have. The host's own
BOUND profile is deliberately not drawn as a card: it applies silently on the plain row, and
showing it twice would suggest the two do different things. This plugin creates, edits and
deletes no profile and no card — pin creation belongs where profiles are edited.

TRUST SHEET (new, trust.tsx). Request access (default) / Use a PIN instead… / Cancel, in the
GTK dialog's order and wording. Request access is not a second ceremony — it saves the host
with the fingerprint it ADVERTISED, then launches; the host parks that connect until its
operator approves this Deck, admits it, and the stream starts by itself.

No fingerprint, no request access. A host typed in by address advertises none, so the sheet
offers the PIN path only and says why, rather than showing a button that could only fail. The
sheet never TOFUs past a missing fingerprint: that pin is the only thing standing between a
185 s wait and an impostor answering for the host.

The sheet is a `showModal` portal, so it captures its callbacks once and never re-renders from
panel state — everything it acts on later is read through a ref. Reading a captured value is
precisely what made pinning a second game compute from a stale base and clobber the first.

LAUNCH PATH. The wrapper's contract becomes PF_REF / PF_PROFILE / PF_REQUEST_ACCESS /
PF_BROWSE; PF_HOST, PF_LAUNCH, PF_MGMT and PF_CONNECT_TIMEOUT are gone. A stream is now
`punktfunk launch <ref> [--profile <id>] --exec --fullscreen`, and a reference is all that ever
rides Steam's launch options — no resolution, bitrate or codec, the same rule the deep-link
grammar enforces.

Request-access launches run SUPERVISED, without `--exec`: under --exec the CLI becomes the
session, so no process survives to see the stream come up and record the approval. Safe for
gamescope because focus follows reaper's descendant tree, not a single process, and
flatpak-run/bwrap already sit in that tree on every other path.

Wake-on-LAN comes out entirely. The plugin used to fire a magic packet itself and then stretch
the connect budget to 75 s to cover the host's resume — a workaround for the CLI-less era.
`punktfunk launch` runs the real wake-and-wait loop and only dials once the host answers, which
is strictly better and deletes a backend method, a frontend call and a shell branch.

The console-home branch of the wrapper is untouched on purpose: the shell binary already execs
the session for `--browse`, so there is nothing to repoint and no reason to spend a diff there.

Everything else in steam.ts — two shortcuts sharing one name (and so one Steam Input configset
key), artwork versioning, appId verification, controller config, stopStream — is unchanged.
2026-08-04 20:41:30 +02:00
enricobuehler 2fd303e22f refactor(decky): delete the second client
The Decky plugin was a second client. It had its own mDNS discovery, its own host-store
editor, its own settings UI over the entire client settings store, its own per-game pin store
and picker, and its own fullscreen route with three tabs — about 3,000 lines of TypeScript and
Python mirroring, in two other languages, things the Rust client already does. Every one of
them drifted from the original: the TXT parser fell behind each key the host advert added, the
settings screen modelled a subset of a store that kept growing.

They existed because when this plugin was written there was nothing headless to ask. There has
been since v0.22.0, so this deletes them.

GONE, frontend: page.tsx (the fullscreen route), settings.tsx (a seven-page sidebar over the
whole store), hostmgmt.tsx (add/edit/forget), library.tsx (the games picker), ui.tsx (row
primitives only the page used).

GONE, backend: get/set_settings, list/refresh_devices, library, get/set_pins, list_hosts,
add/edit/forget_host, probe_host, reset_config, wake, the avahi browse and its TXT parser, and
the direct reads of client-known-hosts.json.

WHAT REPLACES THE BACKEND is four shells, each about fifteen lines of build-argv-run-parse:

  discover()    -> punktfunk discover --json
  hosts()       -> punktfunk hosts list --probe --json
  pair()        -> punktfunk pair <addr:port> --pin N --name LABEL
  trust_host()  -> punktfunk hosts add <addr:port> --fp HEX --name LABEL

trust_host is the ONLY write this backend makes to the client's store, and it goes through the
CLI — which writes temp+rename into a user-owned directory, so a root backend driving it
cannot lock the desktop client out of its own files. Nothing here opens client-known-hosts.json
or client-profiles.json any more; `hosts list --json` returns profile bindings and pinned cards
already resolved against the catalog.

_cli_argv mirrors the deleted _session_argv exactly, pointed at `punktfunk`: the flatpak app id
stays LAST, because flatpak treats everything after it as the app's own argv. The
LD_LIBRARY_PATH repair applies unchanged — Decky's PyInstaller leak breaks the flatpak's
libcurl whichever binary inside the sandbox is being started.

A client too old for a verb now announces itself DETERMINISTICALLY: exit 5 plus
`unknown command "<verb>"`, mapped to `client-outdated`, which the panel renders as one
explanatory row plus the update button that fixes it. That replaces guessing from GTK-init
noise, which survives only where the update check still drives `punktfunk-client` directly.

KEPT unchanged in mechanism, because only a Decky plugin can do them: runner_info,
shortcut_art, apply_controller_config, check_update/update_client, kill_stream.

The settings screen is not lost, it moved: console home -> Settings has the same rows over the
same store, is gamepad-navigable, and is one tap from this same panel. Per-game pins have no
shared equivalent yet — decky-pinned.json is deliberately left ON DISK, untouched, so a later
migration can read it.

test-backend.py is rewritten against what is left — argv shape, the exit-code mapping, and the
Steam configset editor, which was untested until now and is the riskiest thing that survived:
it edits a file holding hundreds of other games' bindings, in place.
2026-08-04 20:41:07 +02:00
enricobuehler a9a514dea0 fix(feedback): the pad stops keeping a game's trigger effect after the stream ends
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m0s
ci / docs-site (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m34s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m5s
ci / rust-arm64 (pull_request) Successful in 3m26s
android / android (pull_request) Successful in 4m9s
ci / rust (pull_request) Successful in 7m51s
Two faults in the rich-feedback plane — the lightbar, player LEDs and adaptive
triggers — both of which leave a controller physically wrong with nothing to
put it right.

Nothing reset the pad on teardown. Rumble stops on its own the moment nothing
renews it, but the rich planes are LATCHED in the controller's firmware: they
outlive the stream, the app, and being unplugged. Ending a session while a game
held a weapon's trigger resistance left the physical trigger stiff on the
desktop afterwards, and its lightbar showing whatever the game last set, until
another game happened to set one. The Apple client already reset on teardown;
the desktop and Android halves now do too — triggers to mode 0x00, lightbar
dark, player indicator cleared. Android writes them EP0-direct like its rumble
stop, because the reader thread is stopping and the queue would never drain.

A single lost datagram stranded the pad on the previous value. The plane is
deduped AND rides unreliable datagrams, which is a bad pairing: a change is
forwarded exactly once, so when that datagram is dropped nothing re-derives it
— the game keeps sending the same value and the dedup swallows every copy. The
pad then holds the last weapon's trigger effect, or the last lightbar colour,
for as long as the game keeps that setting, which can be the rest of a level.
The dedup already remembers the current state, so it can repair itself: it now
re-emits what it has latched once a second. Slow on purpose — this is a repair
mechanism, not a transport, and every value is idempotent, so a client that did
receive the original simply re-applies it. A forward re-stamps the clock, so a
plane the game is actively driving never pays for a renewal it does not need.

One-shot pulses are deliberately excluded from that renewal: replaying a
trackpad haptic would be a new pulse, not a repair. Raw passthrough reports are
excluded too — the device's own refresh cadence already re-sends them verbatim.
2026-08-04 20:37:25 +02:00
enricobuehler f84c5b8114 feat(cli): launch --request-access — let the host's operator admit this device
Request access is not a second pairing ceremony, it is a LAUNCH: an ordinary identified
connect with the advertised fingerprint pinned and the handshake budget stretched past
the host's approval window. The host parks the connection until somebody approves the
device in its console or web UI, then admits the same connection and the stream starts
by itself. The desktop shells and the console home have had this for a while
(`SpawnOpts::persist_paired`, `screens/pair.rs`); headless callers had no door to it.

  punktfunk launch <host-ref> --request-access

Two behaviours, both small:

* `connect_timeout_secs = 185`, matching the host's PENDING_APPROVAL_WAIT. Anything
  shorter gives up while the approval prompt is still on the operator's screen.
* `run_plan` records the host as paired on SessionEvent::Ready. That event IS the
  approval arriving, and it records the pin the session actually connected WITH rather
  than re-reading the store — the handshake completed against that identity, which is
  what makes the record true. Every other launch still records nothing: a plain connect
  proves reachability, not a new trust decision.

Refused under `--exec` (exit 5) rather than silently downgraded. Under --exec the CLI
BECOMES the session, so no process survives to observe Ready — a quiet downgrade would
leave hosts reading "trusted" forever with nobody able to explain why.
2026-08-04 20:28:34 +02:00
enricobuehler aec02b9d26 fix(cli): hosts add --fp fills in an empty fingerprint instead of dropping it
`punktfunk hosts add <addr> --fp <hex>` against an address already in the store printed
"is already saved" and exited 0 — having done nothing at all. The --fp was silently
discarded, so a host saved by address stayed pinless and every later connect refused
for want of a fingerprint, with no line anywhere saying why.

Three outcomes now, and the difference between them is a trust decision:

  • no fingerprint on the record, one offered  → fill it in, print `updated <addr>:<port>`
  • the same fingerprint offered again        → no-op, exit 0 (a panel may retry a step
                                                whose state is already correct without
                                                having to invent an error to show)
  • a DIFFERENT fingerprint                    → refuse, exit 3

The refusal is the important one. A changed identity is a decision for a person at a
surface that can show them both — the rule `upsert_trusted` exists to enforce — and
quietly overwriting a pin here would be a back door through the pinning the rest of the
client is built on.

A record still named after its own address takes an offered --name; a label the user
chose is theirs and an advert's name must not overwrite it.
2026-08-04 20:28:11 +02:00
enricobuehler 48bb1769b4 feat(cli): punktfunk discover — browse the LAN, annotated against what you've saved
The CLI could do everything with a host except FIND one, so every headless consumer
grew its own mDNS: the Decky plugin parses ~120 lines of avahi TXT escaping in Python,
which drifts from the host's advert every time a key is added and makes the plugin
depend on Avahi being the resolver.

`discovery::discover_for(timeout)` is the bounded collector beside the streaming
`browse()` the UI uses — same service type, same TXT keys, folded to one row per host.
A refreshed advert wins (it carries the newer address), a removal drops the row, and
dropping the receiver on the way out stops the worker so a one-shot call can't leak a
browse per invocation.

The verb annotates each hit against the saved-hosts store rather than handing back two
lists to join: `saved`/`paired` are answered by fingerprint first and address second —
the same rule every other surface uses. That is what stops a host that moved DHCP lease
from reading as new, and stops a different box that inherited the old address from
reading as paired.

  punktfunk discover [--json] [--timeout SECS]

Default 3 s, capped at 30 — this is called from a Quick Access panel, and a typo'd
`--timeout 3000` would hang that panel with no way to cancel. An empty LAN exits 0: a
caller branching on the code is asking whether the browse ran, and it did.
2026-08-04 20:26:59 +02:00
enricobuehler 454fa2e0cb Merge pull request 'feat(gamepad-ui): profiles integration — pinned cards, pin management, settings section on all three gamepad UIs' (#42) from worktree-gamepad-ui-profiles into main
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13s
deb / build-publish-host (push) Successful in 4m30s
android / android (push) Successful in 7m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
deb / build-publish (push) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 5s
apple / screenshots (push) Successful in 5m57s
arch / build-publish (push) Successful in 8m37s
release / apple (push) Successful in 9m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m55s
ci / rust (push) Successful in 8m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m19s
docker / deploy-docs (push) Failing after 9m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m30s
apple / swift (push) Successful in 1m32s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
ci / web (push) Successful in 1m8s
flatpak / build-publish (push) Successful in 27m16s
ci / rust-arm64 (push) Successful in 1m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
ci / docs-site (push) Successful in 1m51s
deb / build-publish-client-arm64 (push) Successful in 1m21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
Reviewed-on: #42
2026-08-04 18:12:40 +00:00
enricobuehler 6e001e54b4 fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn
android / android (pull_request) Successful in 9m9s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 11m18s
ci / web (pull_request) Successful in 2m37s
ci / docs-site (pull_request) Successful in 2m8s
ci / rust-arm64 (pull_request) Successful in 4m2s
Four encoder faults, plus a note on a fifth that turned out not to be one.

A centred stick did not encode as centre on the Y axes. The mapper inverted the
already-quantised byte, and 0..255 has no exact midpoint: the forward map puts
centre at 0x80, so mirroring the output lands on 0x7F — one below the 0x80 that
DsState::neutral and the pad's own resting report use. Games idle-poll a
centred stick constantly, so a DualSense, Edge or DS4 sat under a permanent
sub-deadzone tilt. Inverting in i16 space instead maps centre to centre by
construction and keeps both extremes exact; the only cost is i16::MIN and
-32767 sharing a code, one LSB at the very end of the travel.

Force-feedback ignored replay.delay. It was decoded on upload and never read:
an effect started the moment it was played and ended replay.length later, so
anything scheduling a delayed effect — DirectInput under Wine does this
routinely — fired early AND finished early by the same amount. The delay now
shifts the whole window, with length measured from the end of the delay rather
than eaten into by it. Writing the test for that surfaced a second bug in the
same path: a waiting effect was still a candidate for the abandoned-effect
force-off, and since the play command is itself the last FF activity, an
infinite effect with a delay longer than the idle window would be killed on its
first contributing tick after sitting silent the whole time it waited. Being
abandoned now requires the effect to have been audible for the window too.

An empty serial panicked the service thread. The reply builder clamped the
length to at least 1 and then sliced that many bytes out of the string, which
asks a zero-byte slice for one byte. The kernel already has a graceful answer
for a length it rejects, so report the true one and let it fall back.

Deck triggers could not reach full pull. Scaling by 128 tops out at 32640 of a
declared 32767, leaving the last 127 counts unreachable, so no game could ever
see the axis bottom out. One multiply gets both ends exact.

The idle watchdog is left alone. It does cut finite multi-second effects that
the uinput path exempts, but only the uinput path is handed an explicit
duration; the protocols behind the watchdog are level-triggered with no
duration field anywhere in a report, so there is nothing at that layer to
exempt. The choice is between cutting a long effect and letting an abandoned
one drone forever, and only the latter has field evidence behind it. Recorded
at the constant so the next reader sees the cost rather than rediscovering it.
2026-08-04 20:08:56 +02:00
enricobuehler 9f1f23eb40 Merge pull request 'feat(wire): mid-session shard-payload renegotiation — the black screen heals in seconds, jumbo behind an opt-in' (#41) from worktree-shard-payload-reneg into main
release / apple (push) Canceled after 2m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1s
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
windows-host / canary-manifest (push) Successful in 16s
windows-host / package (push) Successful in 11m13s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 1m26s
android / android (push) Canceled after 3m42s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m45s
ci / rust (push) Canceled after 3m28s
ci / rust-arm64 (push) Canceled after 1m59s
ci / web (push) Canceled after 1m54s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / 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
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
Reviewed-on: #41
2026-08-04 18:06:41 +00:00
enricobuehler d1c4cb18dd test(core/session): pin the low-MTU chunk-aligned guarantee at clamped shard sizes
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m2s
ci / rust-arm64 (pull_request) Successful in 1m44s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 2m3s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m51s
ci / rust (pull_request) Successful in 8m33s
android / android (pull_request) Successful in 5m21s
PyroWave sessions are gated out of mid-session renegotiation, so a
constrained path serves them through the leg-1 SESSION-START clamp. This
pins the consistency that guarantee rests on: everything chunk-aligned
derives from the one Welcome::shard_payload number — the host
packetizes at it, the client's C-ABI parse window reads it back, and
partial delivery zero-fills exact windows of it — verified at the two
clamp shapes a constrained path actually produces (1216, the
WARP/Tailscale budget, and the 512 floor) over the sealed loopback wire
with real loss.
2026-08-04 20:00:09 +02:00
enricobuehler 91aa684f0d Merge pull request 'docs: Android is on Google Play production, not a closed test track' (#40) from worktree-docs-play-production into main
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m30s
docker / deploy-docs (push) Successful in 37s
ci / web (push) Failing after 29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
ci / docs-site (push) Successful in 1m18s
ci / rust (push) Successful in 10m40s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
ci / rust-arm64 (push) Successful in 3m10s
docker / builders-arm64cross (push) Successful in 16s
Reviewed-on: #40
2026-08-04 17:53:23 +00:00
enricobuehler e629606e39 docs: Android is on Google Play production, not a closed test track
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / web (pull_request) Successful in 1m2s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust (pull_request) Successful in 6m48s
Play production access landed 2026-08-01 and the listing is live, but the
docs still told Android users to beg for a tester invite on Discord and
warned that the Play link "only resolves once your account is on the
tester list". Both are now wrong, and the install page is the first thing
a new Android user reads.

Stable is a public Play listing. Canary is unchanged — it still goes to
the invite-only Internal testing track — so each page now draws that line
explicitly instead of describing both as test tracks.

Also corrects the release process: channels.md said CI "never
auto-publishes to the public stores" and that someone promotes alpha ->
production by hand. Since 43e3c7b6 a vX.Y.Z tag publishes to production
at 100% with no further click (android.yml resolves TRACK=production on
refs/tags/v*). Apple is still manual, so that half stands.

Touches install-client.md, clients.md, channels.md, support-matrix.md and
uninstall.md — the last one told people to ask on Discord to be removed
from a tester list that no longer gates the app.
2026-08-04 19:48:59 +02:00
enricobuehler ff5602361f fix(android/gamepad): TV wording points at the Controller-optimized UI toggle
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m30s
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 8m35s
android / android (pull_request) Successful in 5m22s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
'Created and edited in the touch interface' is dead advice on a TV box — no
touch to reach it with. Unlike tvOS the editor DOES exist on-device (same
APK), behind this screen's own Controller-optimized UI toggle, so on TV the
Profiles strings now name that route instead.
2026-08-04 19:47:20 +02:00
enricobuehler 5e319f3b77 Merge pull request 'fix(client/abr): the decode-cap latch fires on the knee's real presentations' (#36) from worktree-abr-decode-cap-latch into main
windows-host / canary-manifest (push) Successful in 29s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 13m16s
apple / screenshots (push) Successful in 5m51s
flatpak / build-publish (push) Successful in 9m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13m6s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m53s
deb / build-publish (push) Successful in 6m13s
arch / build-publish (push) Successful in 8m42s
ci / web (push) Successful in 1m3s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m9s
apple / swift (push) Successful in 1m24s
deb / build-publish-host (push) Successful in 7m20s
ci / docs-site (push) Successful in 1m43s
ci / rust-arm64 (push) Successful in 2m32s
release / apple (push) Successful in 8m58s
android / android (push) Successful in 5m50s
deb / build-publish-client-arm64 (push) Successful in 3m24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
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 8s
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 9s
ci / rust (push) Canceled after 6m55s
windows-host / package (push) Successful in 12m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 1m18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 27s
docker / builders-arm64cross (push) Canceled after 0s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Canceled after 0s
Reviewed-on: #36
2026-08-04 17:46:27 +00:00
enricobuehler 34ad3cc611 feat(host/wire): mid-session shard-payload renegotiation, driven by the MTU verdict
Phases 1-2 of design/shard-payload-reneg.md, on top of the Phase 0
per-frame geometry. The leg-1 watcher stops merely diagnosing the
constrained path and heals the CURRENT session; the same machinery,
inverted, takes a proven jumbo LAN up to ~8.9 KB shards.

- Messages: MSG_SHARD_PAYLOAD_CHANGED (0x08, host→client, {shard_payload
  u16}) and MSG_SHARD_PAYLOAD_ACK (0x09, the echo). Asymmetric by
  design: a shrink re-keys the packetizer at the next AU immediately
  after sending (per-frame pinning makes ordering irrelevant; the ack is
  telemetry), a grow emits nothing above the old size until the ack —
  the ack is the gate even though client buffers are statically sized.
- Client: one dispatch arm in the shared pump control task (all client
  families) — validate against the advertised receive bounds, ack;
  out-of-bounds requests get SILENCE, not an ack, so a buggy host can
  never read a granted grow out of garbage.
- Host driver: the wire_mtu watcher grows a ShardReneg arm — on a
  below-ceiling verdict it still records the learned budget (session 2
  starts right) and now also shrinks session 1 at the ~3-10 s verdict
  mark; with the jumbo opt-in (PUNKTFUNK_JUMBO=1, or PUNKTFUNK_WIRE_MTU
  > 1500 — one knob, derived) it sends the ack-gated grow after a
  settled-at-sealed-jumbo proof and then stays alive as the revert
  guard: quinn's blackhole detection lowering current_mtu shrinks the
  wire back through the same path. The QUIC MTUD probe ceiling rises
  from 1472 to the sealed jumbo size with the opt-in (per-ENDPOINT: a
  few extra failed probes toward non-jumbo peers, zero cost otherwise).
- Apply point: Session::set_shard_payload drained in the send loop next
  to the adaptive-FEC target, gated on no open streamed AU (a streamed
  frame's shard-aligned tiling derives from the size it began with).
- Renegotiation is gated OFF for PyroWave sessions: their clients parse
  chunk-aligned AUs in windows of the Welcome value pinned at session
  start (read once over the C ABI), so a mid-stream re-key would corrupt
  the parse — those sessions keep the leg-1 next-session clamp. This
  also settles the plan's open question on the two wire_chunk consumers:
  both are PyroWave-only, so the gate covers them entirely.
- Legacy peers are inert both ways: no Hello advertisement → the host
  never constructs the driver; an old host never sends the message.

core: 296/296 --features quic + clippy -D warnings (macOS), fmt; the
regenerated header carries the new message ids (drift gate).
2026-08-04 19:42:36 +02:00
enricobuehler 857d7d7b6b feat(android/gamepad): Profiles section + pin-to-hosts dialog in Default settings
GamepadSettingsScreen gains the trailing Profiles section (per-profile rows
with live pin counts, touch-interface explainer) and a console-styled
GamepadPinHostsDialog — controller- and TV-remote-navigable pin management
writing KnownHost.pinnedProfileIds through the existing store path. Pin-add
was previously touch-only; pinned-card rendering and unpin stay as they
were.
2026-08-04 19:41:04 +02:00
enricobuehler f3c0ee47d7 Merge pull request 'fix(client/windows): "Open log folder" stops opening Documents' (#31) from worktree-client-logs-folder-msix into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 28s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 1m21s
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
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
ci / web (push) Successful in 1m3s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
ci / docs-site (push) Successful in 1m18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 20s
docker / builders-arm64cross (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
ci / rust-arm64 (push) Successful in 1m55s
docker / deploy-docs (push) Successful in 25s
ci / rust (push) Canceled after 5m21s
Reviewed-on: #31
2026-08-04 17:41:03 +00:00
enricobuehler 80b4eccff9 feat(apple/gamepad): Profiles section + pin picker in gamepad settings
GamepadSettingsView gains a trailing Profiles section (one row per catalog
profile, live pinned-to-N-hosts counts) and an in-place pin-to-hosts picker
driving HostStore.setPinned — the first pin management reachable from the
controller-first UI, and on tvOS the only possible one. tvOS wording drops
the 'create them in the standard interface' promise (no profile editor
exists there); other platforms keep it. Pinned-card rendering and the
connect path were already in from WP5 and stay untouched.
2026-08-04 19:41:03 +02:00
enricobuehler 63a4f583b9 feat(console): profiles reach the gamepad UI — pinned cards, pin management, settings section
The Skia console now renders a pinned profile card after its host's primary
tile (KnownHost::pinned_profiles resolved by the service thread), connects
with that profile as a one-off via the existing effective_settings resolver,
and shows the bound default profile on the primary tile. The settings screen
gains a trailing Profiles section — one row per catalog profile with a live
pin count — whose activation opens a pin-to-hosts screen; toggles ride the
new ConsoleCmd::SetPin to the binary, which persists pinned_profiles (the
same field the CLI resolves for Decky's host list). Profiles themselves stay
desktop-authored (design client-settings-profiles.md §5.2a, §5.4).
2026-08-04 19:40:53 +02:00
enricobuehler 290d760ea4 feat(core/wire): per-frame shard geometry, jumbo ceiling, Hello advertisement
Phase 0 of mid-session shard-payload renegotiation (planning
design/shard-payload-reneg.md), stacked on the leg-1 MTU resilience. All
three legs are client-side and forward-compatible: deployed clients that
carry them accept a mid-session shard change the moment a future host
sends one, and nothing changes on the wire until then.

- W0.1 — the reassembler's strict shard_bytes firewall becomes per-frame
  pinning: a frame's first-arriving packet pins that frame's shard size
  (bounds-checked to [min_shard_bytes, max_shard_bytes], even), later
  packets must match the pin, and the per-frame block ceiling derives
  from the pinned size (a session-level cap would reject legitimate
  post-shrink frames). The reorder race between an ordered control
  message and unordered video dies structurally: old-geometry frames in
  flight complete under their own pin while new frames arrive under the
  new one, and no cross-geometry splice can land in one buffer. The
  in-flight budget stays byte-based and exact.
- W0.2 — MAX_DATAGRAM_BYTES 2048 → 9216: every receive path (transport
  RECV_BUF, the recvmmsg ring) now accepts sealed jumbo datagrams
  (9000-MTU LAN ≈ 8908-byte shards). Static buffers over resize-on-ack:
  the ring delta is 128 × ~7 KiB ≈ 896 KiB per client session, lazily
  allocated, hosts unaffected. Grep verdict: no embedder uses the
  constant directly, so no C ABI bump — the regenerated header rides
  along (drift gate).
- W0.3 — trailing Hello field max_shard_payload: u16 (0/absent =
  legacy), the append-with-placeholder discipline of video_caps/
  client_caps. One field is both the renegotiation capability flag and
  the jumbo ceiling; core's pump advertises it for all client families,
  the probe too.
- Host seam for Phase 1, dead until wired: Packetizer::set_shard_payload
  (re-derives the block ceilings; construction delegates to it) +
  Session::set_shard_payload (host-only, Config::validate parity).

Verification (the 0.23.0 lesson — geometry changes breed sizing bugs):
the slice-wire suite re-runs at shard 512/1216/1408/8908 (exact-multiple
sweep, lossy + reversed roundtrips, sentinel path, in-flight budget);
mid-stream shrink→grow→revert delivery; the old-geometry reorder race;
cross-geometry splice rejection; firewall bounds non-vacuous both ways;
a 48-case mixed-geometry reorder-torture proptest asserting per-frame
byte-identical DELIVERY and an exactly-zero final budget; and a sealed
loopback session test (continuous crypto/replay) delivering frames
across live re-keys — every test asserts delivered frames, never the
absence of errors.

core: 294/294 --features quic + clippy -D warnings (macOS), fmt.
2026-08-04 19:27:09 +02:00
enricobuehler 31b5f90b12 fix(host/windows): two virtual pads stop tearing each other's reports
apple / swift (pull_request) Successful in 1m16s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m53s
android / android (pull_request) Successful in 2m52s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m20s
ci / rust (pull_request) Successful in 25m42s
windows-drivers / probe-and-proto (pull_request) Successful in 29s
windows-drivers / driver-build (pull_request) Successful in 1m37s
Three faults on the Windows pad path, two of them races that only bite when a
game drives a pad hard enough for two callbacks to overlap.

pf-gamepad's output ring could hand the host a torn report. Publishing is a
read-modify-write — read the cursor, write the slot it names, advance it — and
the framework dispatches output callbacks in parallel, so two could be inside
it at once: both read the same head, both wrote the SAME slot, and both stored
head+1, so the cursor moved once for two reports and the host read a single
entry with two reports mixed into it. An atomic fetch_add does not fix this. It
hands each writer its own slot but advances the cursor before the bytes exist,
so the host is then invited to read a slot still being filled. Serializing the
publish is what makes the cursor bump mean "the slot below is complete". The
ring exists to stop a rumble STOP being coalesced away, and a torn slot can eat
that STOP with no idle watchdog behind it.

Both drivers also promised the host an ordering they never established. The
host loads out_seq and rumble_seq with Acquire and says so in its own comments
— "Acquire pairs with the driver's publish-then-bump store order" — but the
drivers bumped both with plain writes, and an Acquire load pairs with a Release
store and nothing else. On a weakly-ordered core the host could see a fresh seq
against stale bytes. pf-xusb's rumble seq was racy in the same way as the ring:
two SET_STATE calls could both read one value and both write back value+1, so
the host saw one bump for two writes and skipped a level. A skipped stop is the
one that hurts — the pad buzzes until the ~2.5 s idle force-off notices the
game went quiet, which is what bounds the damage.

Diagnosing an unattached driver stalled the session. The pad service thread —
the one feeding input and rumble — waited up to two seconds for a pnputil
enumeration, per unattached pad, at exactly the moment a session was already
going wrong. The diagnosis now runs on its own thread. Off the hot path the
wait no longer has to be a compromise, so it is generous enough to report what
it actually found instead of giving up with "still enumerating" — which, given
pnputil routinely takes longer than the old budget, is what it usually did.
2026-08-04 19:20:01 +02:00
enricobuehler 8abdd74a62 fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m15s
android / android (pull_request) Successful in 8m52s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
ci / rust (pull_request) Successful in 12m50s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
Three faults in the desktop session's gamepad path.

The Steam Deck lost its built-in trackpad-mouse at the start of every session.
SDL's Valve HIDAPI driver clears the pad's digital mappings during
*enumeration*, which is part of bringing the gamepad subsystem up — so holding
the drivers off from inside GamepadService::pumped could never work: receiving
a GamepadSubsystem means the enumeration has already happened. The hint set
there detached a driver that had already done the damage, and lizard mode only
came back seconds later when the firmware watchdog restored it. The presenter
now disables them with its other pre-SDL_Init hints. The threaded worker always
had this right; only the caller-pumped path was wrong, and it could not fix
itself, hence a separate entry point its callers can place correctly.

Player LEDs did nothing at all on any pad that is not a DualSense. The match
arm handled the DualSense raw-effects path and let everything else fall through
a bare `_`, though SDL exposes set_player_index and owns the per-device
pattern. The wire carries a positional bitmask rather than an index, and the
bridge is the popcount: every convention that reaches this wire spells "player
N" as N lit LEDs — the DualSense patterns 0x04/0x0A/0x15/0x1B/0x1F and the
Switch/XInput run 0x01/0x03/0x07/0x0F alike — so counting them works for both,
where reading a bit position would only ever suit one. No lit LED means no
player, not player 0. The remaining unhandled variants are now named rather
than swept up by `_`, so a new one cannot join them silently.

A forwarded pad could be left buzzing when the session ended. detach() only
posts Ctl::Detach; the close that flushes the pad, tells the host to remove it
and explicitly zeroes the motors runs when the pump next drains that message.
Single mode broke out of the loop immediately after detaching and Event::Quit
never detached at all, so both skipped it entirely. The teardown now sits where
every exit converges instead of on the individual breaks. That still leaves the
several paths that leave by `?` on a fatal overlay or present error, so the
pump also silences its slots on Drop — the explicit call stays, because a pad
should go quiet before a long teardown rather than after it. Drop closes the
slots directly rather than draining the queue that would have done it: same
physical outcome, and it touches no lock, where draining reaches an unwrap on a
Mutex that would abort the process if it panicked mid-unwind.
2026-08-04 19:10:45 +02:00
enricobuehler 69f1db5ea9 Merge pull request 'feat(host/wire): MTU resilience for the video data plane' (#37) from worktree-wire-mtu-resilience into main
apple / swift (push) Successful in 1m30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / builders-arm64cross (push) Successful in 11s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m37s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m50s
windows-host / package (push) Successful in 16m8s
android / android (push) Successful in 5m39s
windows-host / winget-source (push) Skipped
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m36s
windows-host / canary-manifest (push) Successful in 30s
deb / build-publish (push) Successful in 21m51s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
deb / build-publish-client-arm64 (push) Successful in 9m9s
ci / rust (push) Canceled after 27m31s
flatpak / build-publish (push) Successful in 8m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
release / apple (push) Successful in 9m14s
arch / build-publish (push) Successful in 9m41s
docker / deploy-docs (push) Successful in 6m19s
apple / screenshots (push) Successful in 6m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m27s
ci / web (push) Successful in 1m3s
deb / build-publish-host (push) Failing after 30s
ci / docs-site (push) Successful in 1m15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
ci / rust-arm64 (push) Successful in 2m41s
Reviewed-on: #37
2026-08-04 17:02:01 +00:00
enricobuehler 7331be0a40 Merge pull request 'fix(audio): the quality root cause, the latency ratchet, and making the plane observable' (#33) from worktree-audio-quality-latency into main
ci / web (push) Successful in 1m4s
apple / swift (push) Successful in 1m28s
ci / rust-arm64 (push) Successful in 2m31s
ci / docs-site (push) Successful in 1m19s
deb / build-publish-client-arm64 (push) Successful in 3m11s
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 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m38s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m58s
deb / build-publish-host (push) Successful in 6m0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
android / android (push) Successful in 7m13s
arch / build-publish (push) Successful in 8m1s
ci / rust (push) Successful in 10m0s
apple / screenshots (push) Canceled after 0s
deb / build-publish (push) Canceled after 8m16s
docker / builders-arm64cross (push) Canceled after 9s
docker / deploy-docs (push) Canceled after 42s
release / apple (push) Successful in 9m17s
flatpak / build-publish (push) Canceled after 4m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m48s
windows-host / package (push) Canceled after 5m27s
windows-host / canary-manifest (push) Canceled after 0s
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 / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #33
2026-08-04 16:51:34 +00:00
enricobuehler 4bc7eecf05 feat(host/wire): MTU resilience for the video data plane
android / android (pull_request) Successful in 3m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m16s
ci / rust (pull_request) Successful in 22m23s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m47s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m46s
Video datagrams are sealed at a shard payload sized for a clean 1500-byte
MTU (1472-byte UDP payloads). A host whose route to the client crosses a
smaller-MTU hop (a VPN/overlay adapter claiming the LAN route, a lowered
NIC MTU) delivers every small flow — QUIC control, hole punch, input,
audio — while 100% of video datagrams die: the client sits on a black
screen reporting zero loss and the host streams into the void with every
gauge green. Field-reported as 'connects fine, black screen forever'.

Three legs, none of which changes a session on a healthy path:

- PUNKTFUNK_WIRE_MTU operator override: shard payload derived from a
  given on-wire IP MTU. Wire-compatible — Welcome::shard_payload is
  already negotiated per session (the v4/v6 split ships two values
  today) and every client follows the negotiated value.
- Detection: the QUIC MTU-discovery probe ceiling moves from quinn's
  stock 1452 to exactly the sealed video-datagram size (1472), so a
  control connection's settled MTU becomes a verdict on the path:
  settled at the ceiling proves it carries video, settled below proves
  it cannot. A per-session watcher samples after the search has settled
  (live-connection guard against mid-search false learns) and logs an
  actionable WARN naming the failure shape and the diagnosis commands.
- Healing: the measured budget is recorded per peer IP; the next
  handshake clamps shard_payload to fit, so a reconnect self-heals. A
  later session that reaches the ceiling erases the record.

Verified: core 286/286 --features quic + clippy -D warnings (macOS);
host clippy -D warnings + native:: tests 44/44 (pf-lxcheck container).
The regenerated C header picks up the new MIN_SHARD_PAYLOAD constant.
2026-08-04 18:30:33 +02:00
enricobuehler dbc12dedcc Merge pull request 'fix(client/ios): a click wins the pointer back after Escape drops it' (#34) from worktree-ipad-click-relock into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
ci / web (push) Successful in 58s
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 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / swift (push) Successful in 1m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
ci / docs-site (push) Successful in 1m24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 25s
ci / rust-arm64 (push) Successful in 1m41s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 28s
docker / builders-arm64cross (push) Successful in 25s
docker / deploy-docs (push) Successful in 40s
apple / screenshots (push) Successful in 6m7s
release / apple (push) Successful in 11m50s
ci / rust (push) Successful in 26m18s
Reviewed-on: #34
2026-08-04 16:20:47 +00:00
enricobuehlerandClaude Opus 5 2dfb7791a2 fix(apple): the drift test tripped Swift's static exclusivity check
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 1m44s
ci / rust-arm64 (pull_request) Successful in 2m15s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m3s
android / android (pull_request) Successful in 4m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m17s
ci / rust (pull_request) Successful in 10m50s
CI caught what my local harness could not: reading `huge.count` inside the closure that already
holds `huge` exclusively is an exclusivity violation, so PunktfunkKitTests failed to compile.

The blind spot is worth recording. I verified `AudioRing` by compiling it against a standalone
harness whose bodies were TOP-LEVEL code, where Swift applies DYNAMIC exclusivity — the same
statement in a function body gets the static check and is a hard error. A harness that does not
share the shape of the thing it stands in for can be green for a reason the real build does not
have. The harness now puts every body in a method and compiles with
`-enforce-exclusivity=checked`.

Length now comes off the buffer pointer (`$0.count`), which is what the closure already owns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:14:24 +02:00
enricobuehler 66a28d5abb ci(android): run the kit's unit tests
ci / web (pull_request) Successful in 1m3s
ci / rust-arm64 (pull_request) Successful in 2m24s
ci / docs-site (pull_request) Successful in 1m42s
android / android (pull_request) Successful in 3m17s
ci / rust (pull_request) Failing after 13m58s
They were running nowhere. This workflow only assembled, and the screenshot
workflow runs the :app module's tests, so nothing enforced :kit's — the pure
parsers, migrations and feedback policies could go red without anyone
noticing. A couple of seconds against a module the build already produces.
2026-08-04 18:14:13 +02:00
enricobuehler e2faecfd42 fix(client/android): rumble survives a vibrator fault, and an unplug stops leaking
Four faults in the Android feedback path, all of them silent.

Rumble stopped for the rest of the session if one vibrator call threw. The
poll thread called cancel() unguarded while every call around it was already
wrapped, so an unchecked throw — DeadSystemRuntimeException, or the
RuntimeException a dying service wraps a RemoteException in — unwound the
thread. `running` stayed true, so nothing noticed it was gone and nothing
restarted it. Guarding the two bare cancels is not enough on its own: the
binder calls that bind a vibrator can throw just the same, so the loop itself
now survives a failed render, and the same guard covers the hidout thread.

A rumble stop that was never written was treated as one that landed. The
DualSense capture disarmed its backstop timer *before* the write, on a queue
that discarded failed submits without saying so, so a dropped stop left the
motors running with nothing scheduled to try again — and a USB pad holds its
last level until told zero. Writes now report whether they were accepted, the
backstop is disarmed only once the stop is actually on its way, and the
backstop re-arms rather than giving up if its own write is refused.

A full write queue dropped lightbar colours, player-LED masks and trigger
effects. Its overflow rule was "drop the oldest", which is right for rumble —
re-sent continuously, so a lost frame returns milliseconds later — and wrong
for everything else, which the host sends once on change and never repeats.
Eviction is now driven by an explicit key from the caller rather than by
inspecting the bytes: rumble supersedes the pending rumble in place, and a
one-shot is discarded only if the queue holds nothing but one-shots. The key
cannot be recovered from the report itself, which is why this is not keyed by
report id — every DualSense output report carries the *same* id and differs
only in its valid_flag bytes, so an id-keyed rule would let a rumble
supersede a lightbar, which is this bug again by another route.

An unplug leaked the USB connection and the detach receiver. The link only
signalled the drop; neither capture released anything, so the interfaces
stayed claimed (the pad could not return to Android's own input stack) and a
re-plug overwrote the field holding the receiver, stranding one live for the
rest of the process. The captures now release the transport, stop() is safe
to call from the callback it arrives on — the reader thread must not join
itself — and a close is reported exactly once however many detectors see it.
A reader that could not queue a single request now reports itself down too,
instead of leaving the owner waiting on a capture that never streams.
2026-08-04 18:14:07 +02:00
enricobuehler 5e19a4611f fix(client/abr): the decode-cap latch fires on the knee's real presentations
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m26s
ci / rust (pull_request) Successful in 10m22s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
ci / rust-arm64 (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m32s
ci / docs-site (pull_request) Successful in 1m37s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m10s
The client-decoder knee latch (decode_cap_kbps) was unreachable in
production — zero "decode cap learned" lines across every field log, while
its own doc named the exact sawtooth it exists to end (the 2026-08-03
1440p120 field trace: 220↔450 Mbps for nine minutes, five knee backoffs,
no latch):

- The ordinary two-bad-window backoff — the knee's most common
  presentation, a standing 15–45 ms decode rise below the severe tier —
  carried no decode evidence at decision time, because evidence was judged
  from the deciding window alone. Worse, the backoff the decode signal
  itself caused then RESET the knee streak. Now the streak carries its own
  attribution (streak_decode_windows): a backoff whose bad windows were
  all decode-flagged is decode evidence.

- A cascade's second backoff can never agree with the first: a live host
  acks the ×0.7 request in ~100 ms, so the second sample always sits at
  the reduced rate — outside the ±1/8 similarity band by construction
  (0.7 < 7/8). The canonical test never acked between its backoffs, which
  is how the premise survived. Now a backoff only samples a rate the
  controller climbed back to (climb_since_backoff, armed by any ack that
  raises the rate); a drain-time backoff neither latches nor erases the
  reference the real knee set.

- A keyframe-ask storm on a clean link (the Steam Deck presentation: the
  overdriven decoder wedges and begs instead of queueing — 14–19 asks at
  ~300 Mbps with loss_ppm=0 in the field traces) is decode evidence too;
  with real loss present the asks stay network-attributed.

The reworked tests model the ack round-trip (choke → ack → re-climb →
choke), including a regression test replaying the field trace's rates and
decode figures, which must latch at its second knee encounter.
2026-08-04 18:08:49 +02:00
enricobuehler 6f54fcdd2d fix(client/ios): a click wins the pointer back after Escape drops it
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / rust (pull_request) Successful in 13m38s
Pressing Escape mid-stream on an iPad leaves the capture in a state it
could never leave: iPadOS releases the pointer lock by itself, a bare
Escape deliberately never clears `captured` (it is a game key), and the
re-lock burst added with the Escape-drop fix is the only thing that ever
asks for the lock back. That burst fires in the 0.6 s immediately after
the platform's own "let me out" gesture — precisely when it is least
likely to be granted — and once its budget is spent nothing re-asks:
`setCaptured` is the only other requester, and `captured` never went
false. The capture then spends the rest of its life on the absolute
pointer path, which is why the field report reads the way it does —
clicks still land exactly where you aim, because absolute positions keep
forwarding, but the game receives no relative deltas and camera look is
dead for the rest of the session.

Make the click the second stage of the recovery. A click into the video
while captured-but-unlocked now re-anchors the lock chain and re-asks,
which is the request the platform actually wants: a genuine user
gesture rather than an app grabbing the pointer straight back.

Asked on the button UP, so the click has fully forwarded on one
transport first — asking on the DOWN can flip `gcMouseForwarding`
mid-click and strand the release on the GCMouse path. Gated on
`pointerLockWasEngaged`, exactly as the drop path is, so a scene that
never qualifies (Stage Manager, Split View) is never bursted at, and on
no burst already being in flight, since a pending burst mutes absolute
motion and re-arming one per click would freeze the cursor between
clicks of a menu the user is still aiming around.

Worst case is now today's behaviour rather than a permanent one: a
refused burst settles, and the next click tries again.

Typechecked for arm64-apple-ios17.0 (PunktfunkKit builds clean). NOT yet
verified on glass — the premise that a click-driven re-request is
honoured is exactly what the previous fix got wrong.
2026-08-04 18:07:07 +02:00
enricobuehlerandClaude Opus 5 c6597cbeb5 docs(troubleshooting): the audio quality knobs are a request, not a guarantee
android / android (pull_request) Failing after 2s
apple / swift (pull_request) Failing after 1m13s
ci / docs-site (pull_request) Successful in 1m14s
ci / web (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m59s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m26s
ci / rust (pull_request) Canceled after 10m32s
The page claimed "audio is a fraction of a percent of a stream's bandwidth, so high costs
nothing worth counting". At 256 kbps plus redundancy that is 512 kbps — true of a 20 Mbps
session, wrong by an order of magnitude on a 5 Mbps one, which is why the budget now exists.
Says what actually happens on a narrow link, and points at the log line that reports the
settled tier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:39 +02:00
enricobuehlerandClaude Opus 5 2cfc82e96c fix(audio): budget the audio plane against the link, and close the review's gaps
Findings from the post-implementation review of design/audio-quality-and-latency.md.

**The bandwidth gap (highest).** Tier `High` (256 kbps) and the redundant `0xD2` plane were
added separately, each costed as "~1 % of the video budget", and nobody added them together:
256 kbps sent twice is 512 kbps — ~2.5 % of a 20 Mbps session but ~10 % of a 5 Mbps one. Audio
rides QUIC datagrams, OUTSIDE the ABR loop, so ABR could neither see that nor reclaim it; a
constrained link quietly handed a tenth of its bandwidth to audio while ABR carefully managed
the rest.

`plan_audio_budget` now makes tier and redundancy ONE decision against the session's resolved
video bitrate, ordered by preference rather than cost — transparent audio beats redundant audio,
since the field report was about quality and redundancy only pays under loss, so `High` alone
outranks `Standard`+redundancy even though they cost the same. It can lower what the operator
asked for, never raise it, and never goes below `Low`: a stream with unintelligible audio is
worse than one spending a few percent more.

**The Linux host kept the exact defect fixed on Windows.** `let _ = tx.try_send(samples)` —
silent, uncounted data loss, where the encoder concatenates across the hole, so every drop is a
click AND a permanent shift of everything after it. WP0.2 turned out to be Windows-only and had
not said so. Linux now shares `capture_policy::CaptureStats`: drops counted and warned, plus
per-window peak/RMS/delivered%. A Linux audio report was until now exactly as un-triageable as
the Windows one was on 2026-08-03.

**Apple's WP0.3 was half-done** — `bufferedMS` was added and wired to nothing. The drain thread
now logs buffer/target/underruns/sheds like the other three, from one locked snapshot so the
numbers in a line describe the same instant.

Also: the Linux "audio format negotiated" line now says WHICH mode produced it, because that
changes what it is worth — in stream-sink mode the host owns the sink so the mix cannot have
been narrowed upstream, but in legacy monitor mode a 16 kHz Bluetooth sink would still be
reported as a clean 48 kHz through PipeWire's resampler, the same way WASAPI's autoconvert hid
it on Windows. Reading the monitored node's own rate needs a registry lookup this stream does
not do; recorded as an open gap rather than implied to be covered.

Two stale docs: `audio_wasapi.rs` cited `clients/windows/src/audio.rs` (deleted) and still
described the pre-shared-policy "prime to ~3 quanta" behaviour. And the Apple ring's `prefill:`
parameter, dead since the depth moved into the ring, is gone.

Verified: clippy --all-targets -D warnings on Linux (docker) AND Windows (runner .133, forced
clean rebuild of punktfunk-host + pf-client-core); core 167 tests; host 57 audio tests on
Windows; Android clippy count identical to pristine (6, all documented arm64 artifacts); Apple
ring re-simulated. The host suite's `gamestream::stream::tests::sender_delivers_batches` fails
under qemu — the recorded environmental flake, unrelated to audio, green on the earlier
less-loaded run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:17 +02:00
enricobuehlerandClaude Opus 5 e9a209ef61 docs(troubleshooting): why streamed audio can sound worse than the host, and the knobs
WP0.4. The 2026-08-03 reporter had no way to know their desktop mix was being routed through
Steam's voice-carrier endpoint, and no documented way to change it — `PUNKTFUNK_HOST_AUDIO`
existed only in a module doc comment.

Two new sections: what the host actually captures (a render endpoint, not "the sound card"),
what the new `engine_hz/engine_ch/engine_bits` log line tells you, and the
`PUNKTFUNK_AUDIO_OUTPUT_MODE` / `_QUALITY` / `_REDUNDANCY` knobs — with host_and_client called
out as the quickest A/B for the endpoint question; and why audio that lags the picture should
now correct itself, plus what to check when it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:42:06 +02:00
enricobuehlerandClaude Opus 5 a12f1f092c feat(clients/audio): one de-jitter policy for all four rings, and lossless single-packet recovery
Phase 4 + WP3.2 of design/audio-quality-and-latency.md.

**The defect.** Every client ring primed *up* to a target and clamped at a ceiling, and none
walked the depth back *down*. Any transient — a Wi-Fi arrival burst, a host stall, or plain
host-DAC-vs-client-DAC skew of a few dozen ppm — therefore added latency permanently, until
an underrun happened to re-prime. Android, with no shed at all, converged on its 120 ms hard
cap and stayed there for the rest of the session; that is the "audio latency is too high"
report. Apple did shed, 40 ms in one go, which its own comment called "one audible blip".

All four now share `punktfunk_core::audio::JitterPolicy`: depths in MILLISECONDS rather than
device quanta (`3 x quantum` meant 15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms
one), a crossfaded 5 ms shed once the depth average has sat above target for 2 s of consumed
audio, and de-prime hysteresis. Linux and Windows had never had that hysteresis — they still
carried the `if ring.is_empty()` instant re-prime that Android identified as self-inflicted
crackle, where one transient drain manufactured a whole target's worth of silence.

Android's floor drops 40 -> 25 ms: the policy grows the target on the devices that actually
underrun, instead of every device pre-paying for the worst one. The Windows ring moves from
raw bytes to interleaved f32 so it can share the policy and the crossfade helper at all.

Apple is the one client where the policy is hand-written in a second language, so it gets
its own XCTest (`AudioRingDriftTests`). Verified here by compiling `AudioRing.swift`
standalone against a simulation harness — +200 ppm for 5 minutes settles at 30 ms with zero
silent callbacks, where the old ring would have ridden its 80 ms high-water mark.

**WP3.2 — recovery lives in core, not in the clients.** The rebuilt frame is re-inserted into
the demux queue in order, so every embedder (including any C-ABI consumer) gets a complete
stream without knowing the `0xD2` plane exists, and their `AudioGapTracker` simply stops
seeing the gap. `recovery_and_the_gap_tracker_agree` pins exactly that. For the same reason
core advertises CLIENT_CAP_AUDIO_RED itself rather than making four embedders remember to.

Verified: clippy --all-targets -D warnings and the full test suites for punktfunk-core,
pf-client-core, punktfunk-host, pf-host-config under Linux/docker (163 + 61 tests);
punktfunk-client-android `cargo ndk check` for aarch64 with the gate proven non-vacuous by a
planted type error, and its 6 clippy findings confirmed IDENTICAL to the pristine file (all
are the documented arm64-only artifacts); AudioRing.swift type-checked and simulated on
macOS; fmt. The Windows client half (audio_wasapi.rs) is still not compile-verified anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:28:01 +02:00
enricobuehlerandClaude Opus 5 3055e29ebb feat(host/audio): make audio observable, fix the endpoint choice, raise the encode quality
Phases 0-3 of design/audio-quality-and-latency.md, host side.

**WP2.1 — the 2026-08-03 root cause.** The client-only loopback preference took Steam's
Streaming *Microphone* render endpoint over real hardware unconditionally, because it is
silent on the host. But that endpoint exists to carry remote VOICE, and nothing checked
whether it could carry music: on the reporter's box it won all 31 loopback opens across 25
sessions while a clean AMD HD Audio endpoint sat idle, and the whole desktop mix went
through it before reaching Opus. A silent sink now has to EARN its preference — if its mix
format narrows the mix it drops below real hardware. It is still taken when nothing better
exists (narrow audio beats no audio), but flagged so the capture side says why.

`plan_with_formats` takes a probe rather than reading WASAPI, so all 26 wiring-plan tests
still run on every platform. An unknown format counts as fine, which is asserted:
`unknown_formats_reproduce_the_formatless_plan` proves a probe failure can never make the
plan worse than it was before formats existed.

**WP0.1 — log the endpoint's ACTUAL mix format.** Everything the old log printed ("48 kHz
f32 channels=2") was our REQUEST; with `autoconvert` WASAPI converts silently from whatever
the endpoint really runs. That is why a 3,600-line log filed over an audio-quality
complaint contained nothing that could diagnose it.

**WP0.2 — count what we drop.** The capture->encode handoff was a silent lossy `try_send`:
a stalled encode thread lost chunks, the encoder concatenated across the hole, and nothing
recorded it — a click plus a permanent shift of everything after. Now counted and warned,
alongside per-window peak/RMS/delivered% so a quiet host, a broken endpoint and a stream we
are damaging ourselves stop looking identical.

**WP2.4 — stop the default-device tug-of-war.** In Assert mode the capture is bound to the
planned endpoint EXPLICITLY, so a hijacked default changes only where apps render — the old
full reopen tore the capture down for nothing. The field log shows the cost: something
re-set the default every ~4 s and each round was a teardown, a wiring pass with
IPolicyConfig writes, and an audible dropout — seven in sixteen seconds, one ending in a
2 s error backoff. Now: put the default back, keep the stream, and after four rounds in
twenty seconds concede for a minute and say so once.

**WP1.1/1.2 — encode quality.** Constrained VBR (the hard-CBR comment justifies itself with
GameStream's audio FEC, which this plane does not have) and `AudioTier::High` by default:
stereo 128 -> 256 kbps, ~1 % of a 20 Mbps session. GameStream's encoder is deliberately
untouched — its FEC really does need fixed-size packets.

**WP3.1 — redundant `0xD2` plane**, sent when the client asked for it.

**WP2.2 — `audio.output_mode`** as a first-class setting (`client_only` / `host_and_client`
/ `follow_default`), superseding the two undocumented env vars, which stay honoured. The
enum lives in pf-host-config, which is deliberately dependency-free, so the tier table stays
in core where the codec knowledge is.

`capture_policy.rs` is split out for the same reason `wiring_plan.rs` is: both encode field
behaviour, so their tests must run on Linux CI, not only on a Windows box. That split
immediately earned itself — `capture_stats_separate_silence_from_signal` caught RMS being
divided by the FRAME count while summed over interleaved SAMPLES, which inflated it by
sqrt(channels) and made a sine report an RMS equal to its own peak.

WP4.5 (open the loopback at the minimum device period) is deliberately NOT done: in shared
mode `IAudioClient::Initialize` cannot change the engine period at all, so it would be a
no-op at best and a new failure path at worst. Recorded in the code. WP2.3 (force the parked
endpoint's volume) is deferred — `wasapi` keeps IMMDevice private, so it needs new raw COM
on a path this tree cannot compile, let alone test; its diagnostic half ships as the RMS
line above.

Verified: punktfunk-host + pf-host-config clippy --all-targets -D warnings and the audio
test suite under Linux/docker (gate proven non-vacuous with a planted type error); 26
wiring-plan tests standalone; fmt. The Windows-only halves of wasapi_cap.rs and
audio_control.rs are NOT compile-verified anywhere yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:02:12 +02:00
enricobuehler 76832a5b86 fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying
apple / swift (pull_request) Successful in 1m25s
ci / web (pull_request) Successful in 1m23s
ci / docs-site (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m48s
ci / rust (pull_request) Successful in 7m11s
Five faults in the Apple client's feedback path.

With two DualSenses attached, each pad's renderer opened "the first connected
DualSense" — taken from an unordered Set, so the choice could differ between
two calls in one process. Both renderers could land on the same device, one
pad's rumble coming out of the other while their per-instance write dedupes
fought over it, or they could split by luck. Each renderer now asks for the
device its own controller is, correlating GameController's stable ordering with
IOKit's location ids; the selection rule is a pure function so it can be tested
without an IOHIDDevice, which cannot be constructed. Without a preference the
lowest location id wins — still arbitrary, but stable, which Set.first was not.

A failed HID write was logged and swallowed, so a write that never reached the
device still counted as a successful render. That matters most for a stop,
which has nothing behind it: the renderer stamped its write clock even on
failure, the keepalive only re-writes non-zero levels, the ticker is cancelled
once the target is zero, and on USB there is no firmware timeout. A swallowed
stop therefore left the motors running with nothing scheduled to try again.
The write result now reaches the caller, which drops the handle and falls back
to CoreHaptics rather than claiming success.

A half-failed split-handle setup reported HEALTHY. Only the all-nil case
counted as failure, so one surviving handle passed silently while rendering
something wrong in a direction that depended on which handle died: lose the
right one and render falls to the combined branch, playing max(low, high) on
the LEFT handle; lose the left and the split branch discards the heavy motor
outright. A half-open split now tears the survivor down and takes the combined
path, which at least renders both motors somewhere.

Session end never put the lightbar out. This class is what turned it on, and
every DS write is valid-flag-selective, so a game's last colour stayed lit in
firmware after the stream ended — a DS4 was cleared incidentally because its
player indicator IS the lightbar, a DualSense was not.

And the renderer's stop() ran on the main actor. It is a queue.sync whose body
is a per-motor CHHapticEngine.stop() — an XPC round trip the renderer's own
notes record as able to hang — plus a blocking HID write to a device that has
just departed, and it queues behind any in-flight setup(). It runs on every
unplug and every pin change, and the main thread drives the presenter's
CADisplayLink, so it hitched the picture mid-stream. It is detached now; the
renderer is already off routing by then, so nothing observes it.

Verified: swift build clean, 188 tests pass (185 before), and the three new
device-selection tests fail if the deterministic fallback is reverted.

Note for anyone rebuilding here: the checked-in xcframework was stale (it
predates punktfunk_connection_report_phase) and build-xcframework.sh still dies
on this Mac at its macOS-floor guard. A macos-arm64 slice assembled by hand
from `cargo build --target aarch64-apple-darwin` is enough to typecheck.

From the 2026-08-03 force-feedback sweep (B14, B15, B18, B19, B20).
2026-08-04 08:20:12 +02:00
enricobuehlerandClaude Opus 5 7077b0a0df feat(core/audio): bitrate tiers, a shared de-jitter policy, and a redundant audio plane
Foundation for the audio quality + latency plan (design/audio-quality-and-latency.md).
All three pieces are pure and unit-tested here so the four client rings and the Windows
host glue that follow stay thin.

**Bitrate tiers** (`AudioTier`). The layout table's `bitrate` becomes the `Standard`
value, so that tier reproduces the pre-tier wire byte-for-byte — the tier machinery is
provably non-regressive. `High` (stereo 256 kbps) is the default: 5 ms Opus frames are
much less efficient than 20 ms ones, so the historical 128 kbps buys roughly what
~100 kbps buys at 20 ms, while the same session carries tens of Mbps of video. Purely a
host-side encoder knob — libopus reads the bitrate out of the packet, so no client
change and no negotiation.

**`JitterPolicy`** — the ms-denominated de-jitter state machine every client will share.
Two defects it exists to fix: (1) each ring computed its target as `3 x quantum`, a sane
15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms one; (2) every ring primed *up* and
clamped at a ceiling, and none walked the depth back *down*, so drift/bursts added latency
permanently — Android, with no shed at all, converged on its 120 ms cap. Here a depth EWMA
that sits above target for 2 s of consumed audio sheds ONE 5 ms frame with a crossfade.
Driven by samples consumed rather than the wall clock: allocation- and syscall-free (safe
in a realtime callback) and deterministic under test.

`every_preset_sheds_before_it_trims` pins the invariant that makes this real rather than
decorative. The first draft had `headroom_ms` <= the shed threshold on all four presets,
so the ring was trimmed back before the average could ever reach the shed point: drift
correction was dead code and the ratchet test passed for the wrong reason (the hard cap
did the work). `a_transient_burst_does_not_shed` caught it. The shed point is now derived
from `headroom_ms` so it cannot invert again.

**`0xD2` redundant audio** — each datagram carries its frame plus a copy of the previous
one, so a single lost packet is reconstructed instead of concealed. Opus in-band FEC
cannot do this job: LBRR is a SILK feature and the desktop encoder is CELT-only
(RESTRICTED_LOWDELAY, 5 ms), so `set_inband_fec` there is a no-op. Costs no latency —
the copy rides the successor, which arrives inside de-jitter slack that already exists.
Gated capable-and-agreed via CLIENT_CAP_AUDIO_RED/HOST_CAP_AUDIO_RED; every other session
keeps the `0xC9` wire unchanged. 0xD1 is left free for the pad-audio program.

cbindgen: prefix the four new exported constants. `FRAME_MS`/`SAMPLE_RATE_HZ` as bare C
macros are the same hazard the BTN_* renames already document — a clashing #define takes
the last definition silently rather than failing to compile.

Verified: 300 core tests, clippy -D warnings, fmt. (`c_abi` fails identically on a
pristine tree — this Mac has no system libopus for the C harness link.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 08:11:35 +02:00
enricobuehler e5453aebb7 fix(client/windows): "Open log folder" stops opening Documents
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m55s
ci / rust (pull_request) Successful in 7m46s
ci / web (pull_request) Successful in 59s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust-arm64 (pull_request) Successful in 1m36s
The button shipped in d839f4c2 opens the user's Documents folder instead of the log
directory on every packaged install. Nothing is wrong with the button — the path is.

The client ships as a full-trust MSIX package, and Windows redirects a packaged app's
%LOCALAPPDATA% writes into its private ...\Packages\<family>\LocalCache\Local\. The log
module creates and appends through that redirection without ever seeing it, so the
literal %LOCALAPPDATA%\punktfunk\logs it hands out is right to WRITE to and names a
directory that never exists on disk. Explorer runs outside the container: it resolves the
literal path, finds nothing, and — instead of failing — silently falls back to Documents.
An unpackaged dev run creates that directory for real, which is why this only ever showed
up in the field.

Two more places handed the same phantom path straight to the user, both added by the same
commit and both wrong in the same way: the "client log file" startup line, and the
failed-spawn banner's "Check <path>" — the one people are told to follow after a session
dies. Anyone who did landed in an empty or absent directory.

So the fix is one resolver, not three call-site patches. `real_dir` canonicalizes the
directory it just created, which resolves through the redirection on a packaged run and
changes nothing on an unpackaged one — no package identity to detect, no LocalCache path
to hand-assemble. `log_dir` stays as the write path and goes private so a future caller
can't reach for the wrong one; `path` now resolves too, which fixes both messages.

`canonicalize` always returns a `\\?\` verbatim path and Explorer refuses those (taking
the same silent Documents fallback), so `strip_verbatim` undoes the prefix — including
the `\\?\UNC\` form a roaming profile on a share resolves to. The button additionally
guards on `is_dir()`: if the resolve ever comes back wrong, the click does nothing rather
than landing the user somewhere misleading again.
2026-08-04 07:46:43 +02:00
enricobuehler ec4bf75a6e fix(core/rumble): the Deck's keepalive stops being swallowed by its own renewals
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m54s
ci / rust-arm64 (pull_request) Successful in 4m8s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m46s
android / android (pull_request) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 4m11s
ci / rust (pull_request) Successful in 9m39s
Three faults in the shared rumble policy engine, all answered by one change of
shape: the free-running jitter phase becomes `last_emit` — the exact value last
handed to an embedder — and every emit routes through one helper. That single
field answers all three live questions: would re-sending this be a no-op device
write, is this stop redundant, and would the nudge invent a stop.

The Steam Deck declares a 40 ms keepalive with a 1-LSB nudge, because an
SDL-class layer discards a write identical to the last one. But the nudge lived
only in the keepalive branch, so every host renewal re-emitted the raw level,
collided with the last jittered write, was discarded, AND re-anchored the
keepalive timer. The gap between distinct device writes stretched to 80 ms at
the 400 ms default TTL and 100 ms at the hatch floor — two to two and a half
times the cadence the quirk exists to guarantee. Nudging on any repeat closes
it: 40 ms throughout.

Level (1, 0) turned that nudge into (0, 0) — the value the engine reserves for
"stop now" — and handed it out with a non-zero backstop, under a live lease.
It is the only such level: high must already be zero, and low ^ 1 == 0 implies
low == 1. The nudge now steps the LSB up instead, so the phase still alternates
and no stop is ever invented.

A zero for a pad the engine already believes silent is now dropped. Under the
legacy hatch the host re-sends zeros for every latched pad every 500 ms for the
rest of the session, which cost Android an unconditional log line and a binder
cancel() at 2 Hz per pad. The deliberate stop-burst heal is untouched, because
a stop that was LOST leaves the pad buzzing, and that is exactly the guard's
pass condition.

The client also now bounds the lease it will honour. RUMBLE_TTL_CEIL_MS is
sender-side only, so a modified or third-party host could stamp a long TTL and
wedge its pump, leaving Apple — whose renderer deliberately keeps no staleness
policy of its own — and a Deck slot buzzing for all of it.

Every new test was proven to fail with its own fix reverted, including the two
that guard against over-reach: a default-quirks pad must still get the level
verbatim, or an off-by-one amplitude would land in Apple's identical-target
comparison and Android's one-shots.

One suspicion from the audit did NOT survive: a v2 envelope carrying ttl_ms 0
cannot take the legacy backstop, because the expiry check preempts the relay
branch. No fix; pinned with a test so that ordering stays load-bearing.

Verified: 17/17 rumble tests, clippy --all-targets --features quic -D warnings
= 0, fmt clean, generated C header unchanged. (`c_abi_harness_round_trips`
fails on this Mac with a linker error, identically on an unmodified tree.)

From the 2026-08-03 force-feedback sweep (B12, B22, R9, T1).
2026-08-04 07:40:19 +02:00
enricobuehler 2c03290a5e Merge pull request 'chore(release): bump workspace version to 0.24.0' (#29) from worktree-release-0240 into main
audit / cargo-audit (push) Successful in 50s
apple / swift (push) Successful in 1m19s
audit / bun-audit (plugin-kit) (push) Failing after 24s
audit / bun-audit (sdk) (push) Failing after 21s
audit / bun-audit (web) (push) Failing after 36s
audit / docs-site-audit (push) Successful in 33s
audit / pnpm-audit (push) Successful in 24s
apple / screenshots (push) Successful in 5m40s
audit / license-gate (push) Successful in 5m48s
android-screenshots / screenshots (push) Successful in 1m33s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m0s
decky / build-publish (push) Successful in 36s
linux-client-screenshots / screenshots (push) Successful in 1m42s
android / android (push) Successful in 9m17s
sbom / sbom (push) Successful in 28s
arch / build-publish (push) Successful in 13m16s
web-screenshots / screenshots (push) Successful in 4m26s
flatpak / build-publish (push) Successful in 8m8s
release / apple (push) Successful in 10m44s
ci / rust (push) Successful in 6m48s
ci / web (push) Successful in 1m33s
ci / docs-site (push) Successful in 1m36s
ci / rust-arm64 (push) Successful in 1m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m45s
windows-host / package (push) Successful in 11m53s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 24s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
deb / build-publish (push) Successful in 6m25s
deb / build-publish-client-arm64 (push) Successful in 1m19s
docker / deploy-docs (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders-arm64cross (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m22s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m58s
deb / build-publish-host (push) Successful in 4m11s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m10s
Reviewed-on: #29
2026-08-03 19:43:24 +00:00
enricobuehler b6a370a0fd Merge remote-tracking branch 'origin/main' into worktree-release-0240
ci / web (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m14s
android / android (pull_request) Successful in 4m35s
ci / rust (pull_request) Successful in 6m0s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m56s
2026-08-03 21:40:38 +02:00
enricobuehler 7db83445b2 Merge pull request 'fix(host/input): rumble comes back when a controller does' (#25) from worktree-haptics-m1-rumble-seq into main
apple / swift (push) Successful in 1m18s
ci / web (push) Successful in 1m31s
ci / rust-arm64 (push) Successful in 2m9s
ci / docs-site (push) Successful in 1m43s
deb / build-publish-client-arm64 (push) Successful in 1m47s
android / android (push) Successful in 5m55s
deb / build-publish-host (push) Successful in 4m49s
apple / screenshots (push) Successful in 5m54s
arch / build-publish (push) Successful in 8m33s
deb / build-publish (push) Successful in 4m53s
ci / rust (push) Successful in 6m22s
windows-host / package (push) Successful in 12m32s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / deploy-docs (push) Successful in 30s
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 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders-arm64cross (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 19m43s
Reviewed-on: #25
2026-08-03 19:17:49 +00:00
enricobuehler 5582a6ea51 Merge branch 'main' into worktree-haptics-m1-rumble-seq
apple / swift (pull_request) Successful in 1m17s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m51s
ci / docs-site (pull_request) Successful in 2m5s
ci / rust-arm64 (pull_request) Successful in 2m22s
android / android (pull_request) Successful in 3m25s
ci / rust (pull_request) Successful in 8m4s
2026-08-03 19:17:01 +00:00
enricobuehler f7b85ec1fd Merge pull request 'fix(host/pads): an unplugged controller actually disappears' (#26) from worktree-haptics-m2-pad-slots into main
android / android (push) Canceled after 1m16s
apple / swift (push) Canceled after 1m18s
apple / screenshots (push) Canceled after 0s
ci / docs-site (push) Successful in 1m21s
arch / build-publish (push) Canceled after 1m33s
ci / rust (push) Canceled after 1m33s
ci / web (push) Canceled after 1m36s
ci / rust-arm64 (push) Canceled after 1m42s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 1m35s
deb / build-publish-client-arm64 (push) Canceled after 48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 24s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 2s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 18s
windows-host / package (push) Canceled after 1m48s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #26
2026-08-03 19:16:24 +00:00
enricobuehler 327301e012 docs(release): the 0.24.0 notes cover the two controller fixes
ci / web (pull_request) Successful in 1m23s
ci / docs-site (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 5m0s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 6m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m20s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m1s
ci / rust (pull_request) Successful in 12m53s
PRs #25 and #26 are going into this release, and neither was in the notes.

Both are user-visible and easy to have lived with without knowing why:
force-feedback stopping for good after a controller reconnect (roughly half of
reconnects, every platform), and an unplugged pad staying visible to the game
for the rest of the session (every time, if it was your only controller).

The whatsnew line for the rumble fix is Play listing copy and that file has a
500-character ceiling, so "A decoder hiccup no longer snowballs into a burst of
broken frames" loses "snowballs into" for "causes" — same meaning, and the new
line is kept short. 498 of 500 used.
2026-08-03 19:52:05 +02:00
enricobuehler ab4cd06e86 Merge remote-tracking branch 'origin/main' into worktree-haptics-m2-pad-slots
ci / docs-site (pull_request) Successful in 3m0s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m42s
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Failing after 11m17s
ci / rust (pull_request) Successful in 7m36s
2026-08-03 19:47:34 +02:00
enricobuehler 3eab1e41df Merge remote-tracking branch 'origin/main' into worktree-haptics-m1-rumble-seq
ci / web (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 3m22s
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m8s
ci / rust (pull_request) Successful in 8m11s
2026-08-03 19:47:32 +02:00
enricobuehlerandClaude Opus 5 62573d2781 docs(release): the 0.24.0 notes cover the ABR sweep
ci / web (pull_request) Successful in 1m11s
android / android (pull_request) Canceled after 0s
apple / swift (pull_request) Canceled after 0s
ci / rust (pull_request) Failing after 3m8s
apple / screenshots (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 1m56s
ci / docs-site (pull_request) Canceled after 32s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
PR #28 merged after the bump commit was written, so the notes described a
release that no longer matched the tree. Merged origin/main and added what it
brings: 45 commits since v0.23.0 now, not 39.

Four user-facing entries, because eleven defects in one path is not one bullet
and the pinning is the headline the field reports have been describing for
months ("my bitrate is stuck at 20"):

- the 20 Mbps pin itself, with the measured escape (150 Mbps in ~16 s against
  ~17 minutes) — the number is the point, since the old behaviour was not "slow
  to climb" but "never arrives"
- the five single-window lessons the controller treated as permanent
- throughput counted with FEC parity, which rose with the loss it was meant to
  detect
- the silent host re-target, which made a client's first climb a request to go
  DOWN

The Under the hood section gets the whole sweep in one bullet rather than
scattering it, and PUNKTFUNK_ABR_MAX_MBPS moves from the probe bullet into it
(it now binds at construction, not only on probe-learned ceilings, so it no
longer belongs to the probe).

Play notes gain an ABR line and now run 459/500 chars; the gate's real logic was
re-run against the file, including the byte-identical check. Voice check over
everything above "Under the hood" is clean of internal vocabulary.

Re-verified after the merge: cargo metadata --locked resolves, cargo fmt --all
--check clean, doc lazy-continuation scanner 0 hits. #28 touched no manifest, so
the version bump and the versions-only lock diff are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:47:01 +02:00
enricobuehler d383fa6103 Merge remote-tracking branch 'origin/main' into worktree-release-0240 2026-08-03 19:44:53 +02:00
enricobuehlerandClaude Opus 5 93608980ae chore(release): bump workspace version to 0.24.0
ci / web (pull_request) Successful in 1m5s
android / android (pull_request) Canceled after 1m19s
apple / swift (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 2m50s
ci / rust-arm64 (pull_request) Canceled after 2m0s
ci / docs-site (pull_request) Canceled after 1m29s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
A minor bump: 39 commits since v0.23.0 across 121 files. Mostly a fix-up of
0.23.0 — the slice wire's reassembler sized every sentinel-opened AU at
max_frame_bytes and lost 9 of 12 in-flight frames on any link that reorders,
which is the freeze field reports were seeing on Android and the session client
— plus the desktop presenter rebuild (intent model, V-Sync/VRR as real settings,
the driver's queue-free vblank mode where it exists), the Decky settings tab
growing from nine rows to the whole store, a "Forward controllers" off switch
for passthrough couches, and plugin output finally reaching the console's log
page. The canary base is already 0.24 — scripts/ci/pf-version.sh derives it as
one minor ahead of the latest stable tag — so this is the version canary has
been publishing against all along.

No wire, ABI or driver-protocol change: wire protocol 2, C ABI 14, virtual-display
driver protocol 6 and the Windows virtual-gamepad channel 3 are all identical to
0.23.0. No new capability bits either — VIDEO_CAP_MULTI_SLICE took the video-caps
byte's last free bit in 0.23.0 and nothing here needed the next one. The only
generated-header change since the tag is documentation (probe elapsed_ms
semantics), already committed and verified by ci.yml's staleness gate on main.

Lock touched for the 32 workspace members only, via `cargo update --workspace`:
diff against origin/main is versions-only, 32 insertions and 32 deletions (the
33rd 0.23.0 line in the lock is the third-party `wasapi` crate, which sits at
0.23.0 itself — same trap as the last cut). `cargo metadata --locked` resolves;
`cargo fmt --all --check` clean in both the main and the packaging/windows/drivers
workspaces.

api/openapi.json is deliberately left at 0.23.0: it tracks API edits and lags a
release, as in every prior cut.

Notes at docs/releases/v0.24.0.md, per docs/releases/README.md — authored with the
bump so CI's ensure_release seeds the release body at tag creation. Play's "What's
new" at docs/releases/whatsnew/v0.24.0.txt (409/500 chars), which android.yml now
gates as a hard failure at step 1; the gate's own logic was run locally against
this file, including the byte-identical-to-another-release check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:39:00 +02:00
enricobuehler 1feeff3ca6 Merge pull request 'fix(abr): eleven defects from a sweep of the Automatic-bitrate path' (#28) from fix/abr-sweep into main
apple / swift (push) Successful in 1m19s
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m23s
ci / rust-arm64 (push) Successful in 2m23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 1m23s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
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 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
android / android (push) Successful in 5m58s
deb / build-publish (push) Successful in 5m52s
docker / builders-arm64cross (push) Successful in 13s
deb / build-publish-host (push) Successful in 5m5s
docker / deploy-docs (push) Successful in 36s
apple / screenshots (push) Successful in 6m14s
arch / build-publish (push) Successful in 8m31s
ci / rust (push) Successful in 8m36s
flatpak / build-publish (push) Successful in 7m27s
windows-host / package (push) Successful in 13m53s
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 3m31s
windows-host / canary-manifest (push) Successful in 35s
release / apple (push) Successful in 11m43s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m42s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m12s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m2s
Reviewed-on: #28
2026-08-03 17:33:28 +00:00
enricobuehler 1ae8b4d4ca fix(client/abr): let the ceiling follow a host-initiated re-target
apple / swift (pull_request) Successful in 1m15s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m8s
ci / web (pull_request) Successful in 2m1s
ci / rust-arm64 (pull_request) Successful in 2m25s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m14s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m44s
ci / rust (pull_request) Successful in 9m9s
Interaction between two fixes in this series. The host now tells the client when
a rebuild re-resolves an Automatic rate, and that rate can legitimately sit ABOVE
the client's climb ceiling — the ceiling is the negotiated start rate until the
capacity probe raises it, while the host's re-resolve answers "what do these
pixels actually need" (a 1080p session mirroring a 4K panel resolves ~3× higher).

Left alone, the client would learn the new rate, notice it was above a stale
ceiling, and step the host straight back down off the rate it had just chosen
for itself. So an ack raises the ceiling to meet it. `set_ceiling` only ever
raises and still clamps to PUNKTFUNK_ABR_MAX_MBPS, which is the one limit that
should bind here. No effect on ordinary acks: a climb is never requested above
the effective ceiling to begin with.
2026-08-03 18:53:02 +02:00
enricobuehler 33ecd8e1a5 fix(client/abr): a granted climb disproves the learned cap
Completing the cap-escape fix. Backing the re-probe clock off to 12 s got the
client asking again quickly, but each ask only LIFTED the cap by +12.5 % — so
even a host that had fully recovered still granted the session its real ceiling
one small step at a time, ~4 minutes from the 20 Mbps default to a 300 Mbps
link. The crawl was never the point; re-learning was.

A request granted IN FULL at or above the cap is the host's own word that the
limit is gone. Drop the cap outright at that point instead of nudging it. A
standing limit is unaffected — it answers the same re-probe with another short
ack, which re-latches it and doubles its clock, exactly as before.

Adds the end-to-end regression the sweep was really about: a session pinned at
20 Mbps by a transient cadence refusal, under a probe-measured 300 Mbps ceiling,
now reaches 150 Mbps in 22 windows (~16 s) where it used to need ~17 minutes.
2026-08-03 18:53:02 +02:00
enricobuehler 48565c4e9e fix(host/abr): stop pinning Automatic sessions, and tell the client when the rate moves
Two host-side halves of the same sweep.

**The cadence latch.** `cadence_degraded` — which makes the control task refuse
bitrate CLIMBS — was latched true for as long as the session was escalated
(adaptive capture depth or pipelined retrieve), independently of whether encode
was still missing deadlines. The client cannot tell that refusal apart from an
encoder's real ceiling: both arrive as a short `BitrateChanged`, and two
identical ones latch a cap. Escalation needs ~20 net behind-frames, which a
startup hitch supplies while the ABR is still in slow start at the 20 Mbps
default — so one transient pinned the whole session there, long after the
escalation had bought back the headroom it was for, and escaping cost +12.5 %
per 60 s. An escalated session is still judged strictly (ANY net behind-frame
keeps it flagged, where an unescalated one gets the full bucket), but being
escalated no longer flags it by itself: escalating exists so cadence CAN be
held, and once it is, refusing climbs refuses the thing that worked. The rule
moves into `encode_behind_cadence` so it is stateable and testable.

**The silent re-target.** `adopt_built_bitrate` publishes the rate a rebuilt
pipeline actually opened at — `build_pipeline` re-resolves an Automatic rate
whenever the source delivers a size the session did not negotiate, the
mirrored-panel case — and the encoder's own clamp can land below what the
control task already acked. Neither reached the client, whose controller keeps
its own copy of that number as its climb base. A 1080p client mirroring a 4K
panel therefore believed 20 Mbps while the host encoded 60, and its first climb
computed from the stale base asked for 40: a re-target DOWNWARD, paying an
encoder rebuild to get there. Both paths now push the applied rate to the
control task, which sends `BitrateChanged` — the existing 9-byte message, which
already means precisely this and which clients already handle arriving
unprompted. No wire-format change, no capability negotiation, old clients
unaffected.

2 host tests added.
2026-08-03 18:53:02 +02:00
enricobuehler e9a7373c76 fix(client/abr): measure delivered throughput in media bytes, not wire bytes
The controller's two throughput-driven gates both compare "what the pipeline
carried" against the ENCODER's target: the utilization gate asks whether a clean
window actually tested that target (a calm menu proves nothing), and the
never-decaying proven mark bounds how far every later climb may step.

Both were fed `bytes_received`, which counts every accepted datagram — headers,
FEC parity, probe filler, audio. So the figure rose with the redundancy the host
adds in ANSWER to loss: at 25 % FEC the gate passed with the encoder emitting
~55 % of target, and the proven mark inherited the same inflation permanently.
The signal was weakest exactly on the lossy links it exists for.

Count data-shard payload separately at the reassembler's routing decision — the
same place, and for the same reason, the probe counters are already stamped —
and feed the ABR that. First time both gates are dimensionally honest: a media
rate compared against a media target.
2026-08-03 18:53:02 +02:00
enricobuehler f7a8c2013d fix(client/abr): the controller stops learning the wrong lessons from one window
Six defects found by a sweep of the Automatic-bitrate path, all of them the same
shape: a single window, or a single refusal, taught the controller something it
then treated as permanent.

- Rolling baselines (OWD, client decode, host encode) armed off ONE sample. The
  baseline is a rolling minimum, so one window IS the floor — and `on_ack`
  deliberately clears the encode baseline after every decrease we ourselves
  asked for, re-opening that hole each time. A calm re-seed window followed by
  ordinary motion read as 4 ms of "congestion", backed off, cleared again, and
  ratcheted toward the floor on a link that was never the problem. All three now
  need BASELINE_MIN_WINDOWS of evidence before they may fire, via one shared
  `score_baseline` (the three copies had already drifted apart).

- A mode switch rebased only the encode baseline. Decode and OWD are just as
  mode-scoped: 4K120 decodes slower and puts bigger frames on the wire than
  1080p60, so the old floor was one the new mode cleared on its first window —
  ~30 s of every window scoring bad, i.e. a backoff every other window. A switch
  UP in mode cratered the rate instead of raising it. `proven_kbps` goes with
  them; throughput the old mode's decoder digested is not evidence about this one.

- `proven_kbps` — never decayed, and permanent authority over how far every
  later climb may step — was raised by any window without a decode rise,
  including ones scored SEVERE. The windows that overstate delivered throughput
  are exactly the damaged ones: a stall's backlog draining at once, a flush's
  queue, the FEC surge answering a loss burst. Now only clean windows raise it.

- A learned cap escaped at +12.5 % per ~60 s. The host cannot distinguish a
  durable encoder ceiling from a climb refused while it is transiently behind
  cadence, and the latter routinely latches during slow start at the 20 Mbps
  default — from which crossing the gap to a probe-measured ceiling took upwards
  of twenty minutes. Re-probe after 12 s instead, doubling the interval each time
  the lift is immediately re-learned: a transient is out in one interval, a real
  ceiling settles into a slow poll.

- The decode cap latched AT the rate that choked, authorizing a climb straight
  back into the failure, and a bare jump-to-live flush could teach a "decoder
  knee" from what was a network event. It now latches just under the choke rate
  (inside the ±1/8 band the evidence already required) and only credits a flush
  where the decode signal is absent and cannot speak for itself.

- PUNKTFUNK_ABR_MAX_MBPS bound only probe-learned ceilings, not the negotiated
  start rate — so the one knob an Automatic session gives the operator did
  nothing when the session already started above it. It now binds at
  construction, and a session sitting above its ceiling steps down to it (no
  congestion signal will ever find that: the link is fine, the cap is policy).

Also: a SetBitrate dropped by a full control queue counted toward MAX_UNACKED,
so three of them retired the controller for the session while logging that an
"older host" was at fault. The pump now tells the controller what happened.

Wire format and ABI untouched. 34 abr tests green (3 new).
2026-08-03 18:53:02 +02:00
enricobuehler 926e2ccbdd Merge pull request 'feat(decky): the settings tab covers the whole store, as a SteamOS-style sidebar' (#24) from worktree-decky-stats-overlay-toggle into main
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 2m12s
decky / build-publish (push) Successful in 30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
ci / docs-site (push) Successful in 1m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m26s
docker / deploy-docs (push) Failing after 6m7s
ci / rust (push) Successful in 8m27s
Reviewed-on: #24
2026-08-03 16:44:55 +00:00
enricobuehler b8b38d082e Merge pull request 'fix(plugins): plugin output reaches the console's log page, and /tmp is no longer hidden from the runner' (#27) from worktree-plugin-logs-and-vh-fixes into main
ci / rust (push) Canceled after 23s
ci / rust-arm64 (push) Canceled after 24s
ci / web (push) Canceled after 24s
ci / docs-site (push) Canceled after 24s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 1m16s
deb / build-publish-client-arm64 (push) Successful in 1m12s
deb / build-publish (push) Successful in 3m52s
deb / build-publish-host (push) Successful in 4m45s
android / android (push) Successful in 5m24s
apple / screenshots (push) Successful in 5m57s
arch / build-publish (push) Successful in 8m4s
windows-host / package (push) Successful in 17m44s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m42s
Reviewed-on: #27
2026-08-03 16:44:31 +00:00
enricobuehler 9979489b56 fix(host/pads): an unplugged controller actually disappears
ci / web (pull_request) Successful in 1m11s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m24s
ci / rust-arm64 (pull_request) Successful in 1m35s
android / android (pull_request) Successful in 3m11s
ci / rust (pull_request) Successful in 6m3s
Unplug a controller mid-session and the virtual pad it was driving outlives
it: the game keeps seeing a connected, permanently idle device for the rest of
the session. The single-controller session — the common case — hits this every
time.

`PadSlots::sweep` needs two passes to retire a pad. The first pass to see the
mask bit clear only ARMS the 300 ms devnode-churn grace; the drop lands on a
later pass. But sweep runs only from a state frame, and the producer emits
exactly one frame per detach — `native/input.rs` guards the emit on the bit
still being set — so for a pad with no still-changing sibling in the same
manager, the second pass never comes. Nothing periodic reaches sweep:
`heartbeat` and `pump` walk the slots without it.

Split the two halves. `sweep` still folds a frame's mask into the grace
clocks, and `reap` — new — drops whatever has run out, with no frame needed.
Every manager now reaps on the periodic pump it already runs, so the teardown
completes ~300 ms after the detach instead of never.

`reap` deliberately cannot arm a clock: it only reads `inactive_since` and
clears it, so a pad whose bit never went clear has nothing to run out and no
amount of reaping can drop it. That is what makes it safe on a hot loop, and
it keeps the anti-flap guarantee intact — a mask that blips clear and returns
still never churns a devnode.

The two existing tests hand-fed a SECOND removal frame, which production never
sends; they passed while the real path leaked. Both now drive the unplug
through a pump tick, and PadSlots gains three tests pinning the new
invariants. Verified non-vacuous: with the reap neutered, both manager tests
fail with "the pump tick never completed the unplug".

Behaviour notes: this puts UI_DEV_DESTROY on the GameStream control thread's
budget for the first time, and a mask glitch longer than the grace now really
does flap — which is SWEEP_GRACE working as documented, so the constant stays.

Found by the 2026-08-03 force-feedback sweep (B2 — see the backlog in
punktfunk-planning design/haptics-sweep-2026-08-03.md).
2026-08-03 17:34:47 +02:00
enricobuehler 14502769e0 fix(host/input): rumble comes back when a controller does
android / android (pull_request) Failing after 18s
ci / docs-site (pull_request) Successful in 1m7s
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m15s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m45s
ci / rust (pull_request) Successful in 6m9s
Unplug a pad mid-session and plug it back in, and roughly half the time it
never rumbles again for the rest of the session.

The removal arm restarted the pad's rumble sequence counter. The client's
reorder gate does not restart: `rumble_last_seq` lives for the whole QUIC
connection and has no reset path, so it still holds whatever the pad reached
before the unplug. Restarting the host counter therefore hands the client a
seq it has already seen, and its wrapping half-space compare drops every
envelope until the counter climbs back past the stored value — up to 128
sends. Since the counter only advances on a level change or a ~120 ms renewal
while a level is non-zero, that spans many separate rumble events, so it reads
as a flaky controller rather than a clean outage.

Whether it bites is decided by how much the pad rumbled beforehand, which is
why it looks intermittent: a pad that never rumbled before the re-plug has
`None` on the client side and always heals.

The counter now survives, matching the sibling pad-state gate — whose comment
eleven lines above already explains that a re-plug must arrive with a still-
newer seq to be accepted. The three clears that actually end the stale lease
move into `clear_pad_feedback`, whose signature deliberately has no seq
parameter so the arm cannot regress by editing.

Covered by a regression test that drives the real wire encoder and the real
client gate, and asserts the pre-fix behaviour is genuinely rejected across
the whole forward window, so it cannot pass vacuously.

Found by the 2026-08-03 force-feedback sweep (B1/T5 — see the backlog in
punktfunk-planning design/haptics-sweep-2026-08-03.md).
2026-08-03 16:57:11 +02:00
enricobuehlerandClaude Opus 5 db1faef9fb docs(plugins): don't name a release that doesn't exist yet
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 2m39s
android / android (pull_request) Successful in 3m16s
ci / rust (pull_request) Successful in 7m58s
The /tmp troubleshooting note said PrivateTmp=yes shipped "until 0.23.1".
0.23.0 is the latest tag and the next number isn't decided, so that could be
wrong on arrival. "In earlier releases" is true whichever number it gets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:00:48 +02:00
enricobuehlerandClaude Opus 5 442ea12b96 test(mgmt): pin the two things that would silently break plugin logging
The runner holds the PLUGIN token and nothing else — on Windows its LocalService
principal cannot read the admin one at all. `plugin_may_access` is an exclusion
list, so `/plugins/logs` is reachable today only because it happens not to match
`/ui-credential`. If that ever changed, plugin logs would go quiet in the console
with no other symptom and no failing test. Now asserted on that lane directly.

The second test covers ingest end to end through `GET /logs`: the `plugin:` target
prefix the console's Host/Plugins filter keys on, the level coercion (an unranked
level would sort as 0 and hide under every filter setting), a sourceless line
being attributed to the runner rather than to nothing, the caller's timestamp
surviving the trip, and an oversized batch being refused whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:56:25 +02:00
enricobuehlerandClaude Opus 5 1e56705b86 fix(sdk/log-ship): a busy plugin's lines survive a POST, and the shutdown tail is actually sent
Two bugs in the log shipper, both found by re-reading it rather than by a
failing test, and both of the kind where the symptom is a missing log line —
which is the one failure a logging path must not have.

The recursion guard was held across the whole `await fetch`, and `enqueue`
checked it. So every line logged while a POST was open was dropped, silently.
That window is milliseconds when the host is healthy and much longer when it is
not, and the lines lost are whatever a busy plugin happened to be saying — so
the shipper was least reliable exactly when it was most needed. The flag now
guards flush re-entry only (the interval can fire while a slow POST is still
open, and two concurrent flushes would splice disjoint batches out of one queue
and deliver them out of order). Nothing on the shipping path logs, so the
recursion it was guarding cannot form; that is now a stated rule at the top of
the file rather than a flag that costs real lines.

An explicit `flush()` hit that same re-entry guard and returned having sent
nothing. That is the shutdown path: the runner flushes once more after its
units' finalizers have run, and those last lines are the ones that say whether
the shutdown was clean. It now waits for an in-flight flush before starting its
own.

Both are covered by tests that fail against the previous code. The first needed
a server that signals when it has the request — logging merely "after calling
flush()" passes against the bug, because flush yields at its own awaits long
before the fetch starts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:53:29 +02:00
enricobuehlerandClaude Opus 5 365caa23be fix(plugins): plugin output reaches the console's log page, and /tmp is no longer hidden from the runner
A user could not get the VirtualHere plugin to use their VirtualHere client
and asked, reasonably, where the logs were. There was no good answer, and the
reason they were stuck turned out to be ours.

**The runner could not see /tmp.** `punktfunk-scripting.service` set
PrivateTmp=yes, which hands the unit a private tmpfs. But integrating with
things already running on the box is the entire job of a plugin, and on Linux
those talk over /tmp: VirtualHere's client IPC is the FIFO pair /tmp/vhclient +
/tmp/vhclient_response, X11 is /tmp/.X11-unix. So the plugin launched the vendor
binary happily and could then never reach the daemon behind it — while the same
command worked perfectly in the operator's own shell, because that shell has the
real /tmp. No config change could fix it, which is exactly the loop the report
described. PrivateTmp is now off, with /tmp added to ReadWritePaths (which
ProtectSystem=strict would otherwise make read-only).

**Plugin logs now land in the console.** Plugins are not host child processes —
the runner is a separate bun process that import()s each plugin in-process — so
nothing they print passed through the host's tracing, and the console's Logs
page could not show a single plugin line. The fallback was journalctl on Linux;
on Windows the runner's scheduled task writes no log file at all, so a failing
plugin was diagnosable only by stopping the task and re-running the runner by
hand. Both mean shell access on the host box, which is what the console exists
to avoid — and it left the one question a stuck user asks with no answer.

So the runner now tees its output to POST /api/v1/plugins/logs, and those lines
join the host's own ring under one cursor, targeted plugin:<name>. The console
grows a Host/Plugins switch beside the level filter; an empty Plugins view says
the thing that is actually usually wrong (the runner isn't running) rather than
"adjust the filter".

The shipper keeps stdout authoritative — journald and foreground output are
unchanged whatever the host is doing — and is built so that logging can never
hurt the thing being logged: it never throws into a caller, holds a bounded
queue that drops oldest and then says how many, backs off when the host is away
(a restart is normal), and re-sends a batch the host failed to take. Lines
logged while a POST is in flight are kept, which cost one round to get right:
the first version held its recursion guard across the await and silently dropped
exactly the lines a busy plugin produces.

Runner lines that report a failure (a refused unit file, a crashed plugin, a
give-up) now go out at warn/error instead of all arriving as INFO, so the
console's level filter means something for them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:47:51 +02:00
enricobuehlerandClaude Opus 5 f71bee917b Merge origin/main; the presenter rebuild's four settings join the sidebar
ci / web (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / docs-site (pull_request) Successful in 1m36s
ci / rust (pull_request) Successful in 6m1s
`#20` landed while this branch was open and added four settings the
console screen groups under a new "Presentation" header: Prioritize,
Smoothness buffer, V-Sync and Follow variable refresh. A branch whose
whole claim is "everything the store holds is reachable" cannot merge
past those, so they get a Presentation page of their own, in the console
screen's position (after Video, before Audio) and with its wording.
Smoothness buffer is indented under Prioritize and disabled until the
intent is Smoothness — the same relationship the console's `enabled`
gate draws.

The docs conflict resolves to main's side plus this branch's correction:
the 4:4:4 advertisement claim main rewrote is the current one and stays,
while "Android, Decky and the console home don't offer it" was wrong
about two of the three before this branch and about all three after it.
The four new settings' paragraphs pick up the console home and Decky the
same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:40:05 +02:00
enricobuehlerandClaude Opus 5 6de78213ee feat(decky): the settings tab covers the whole store, as a SteamOS-style sidebar
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / rust (pull_request) Canceled after 3m0s
The stats overlay was the visible half of a general problem: nine of the
client's settings had a row here and twenty didn't, so a Deck that never
sees a desktop could not reach its own decoder, chroma, HDR, audio
layout, echo cancellation, touch or mouse model, scroll direction,
auto-wake, or either audio endpoint. Everything the store holds is here
now — except the two things a plugin backend genuinely cannot answer,
named in `backend.ts` so the next reader doesn't go looking: which
physical pad is player 1 (SDL's live device list lives in the client
process, and no CLI enumerates it) and the session's remembered window
size, which is not a preference.

Thirty rows is too many to scroll past on a thumbstick, so they are
split across a `SidebarNavigation` — the left-rail-of-categories layout
SteamOS's own Settings uses, and the one Deck users already know. Every
page fits on screen without scrolling, which is the point: the rail is
the index, so nothing is more than one hop away. The categories, their
order and the wording of the rows are the console settings screen's — it
is the other settings editor reachable without leaving Gaming Mode, and
two different orders for one store is how people stop trusting either.
It shows them as one steppable list because it has no pointer and no
room for a rail; here they become the rail's pages. The six pages take
one shared settings object rather than each holding state, so a change
on one is visible on the others the moment you switch.

Three more rules:

- A dependent setting is INDENTED under what it depends on and DISABLED,
  never hidden: mic device and echo cancellation under the microphone,
  controller type under forwarding. The console dims those rows for the
  same reason, and a row that vanishes as you toggle the one above it is
  a moving target for a thumbstick. The device row at the foot of Audio
  is rendered even while it reads, for that reason.
- A picker with nothing to pick doesn't appear: the GPU row shows up
  only where the enumeration found more than one adapter, so it is
  absent on a Deck and present on a Bazzite desktop with a dGPU.
- A setting that behaves differently HERE says so in its own
  description rather than being dropped. Capture system shortcuts holds
  nothing back under gamescope; fullscreen-on-stream can't lose to a
  launch that always passes `--fullscreen`; the client's library toggle
  isn't this plugin's browser. Each says which.

The device pickers are real, not stubs: `list_devices` reads
`--list-adapters` and `--list-audio` off the SESSION binary, the same
two enumerations the GTK shell shells out for because it links no Vulkan
itself. It is cached for the life of the backend (that call inits Vulkan
and PipeWire) with an explicit Refresh for the headset you just plugged
in, and a failure — a client too old to ship the session binary — leaves
the pickers on Automatic and says so instead of claiming you have no
devices. `_parse_audio_endpoints` is split out and unit-tested with the
malformed lines that must never reach a picker.

Two smaller honesty fixes fall out of building it. A Dropdown can only
display a value that is one of its options, and this store has four
other writers — so a stored value the table doesn't list is carried as
its own entry rather than rendering blank or, worse, showing a different
value than the stream will use. And a stored audio endpoint that isn't
currently connected keeps a "(not connected)" entry, the way the Linux
picker keeps "(not detected)", instead of silently re-pointing the next
stream at the default.

The Settings tab's wrapper deliberately stops being a scroll area: a
SidebarNavigation given an indefinite height to fill collapses its rail,
so the pane hands it the full height and keeps its hands off the
overflow, and the footer inset moves inside the pages.

The docs claimed nine things about this plugin that are no longer true,
and three about the console home that stopped being true when its own
row set grew on 2026-07-31 (4:4:4, echo cancellation, auto-wake and the
library toggle are all there in `screens/settings.rs`). Both corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:21:15 +02:00
enricobuehler aa3bcfd0d0 Merge pull request 'feat(client/present): desktop presenter rebuild — intent engine, V-Sync/VRR, display-stat split, settings UI' (#20) from worktree-desktop-presenter into main
apple / swift (push) Successful in 1m18s
arch / build-publish (push) Failing after 1m17s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m30s
deb / build-publish-host (push) Failing after 11s
ci / rust-arm64 (push) Successful in 3m51s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
apple / screenshots (push) Successful in 5m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 26s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m42s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
android / android (push) Successful in 6m54s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
docker / builders-arm64cross (push) Successful in 17s
deb / build-publish (push) Failing after 4m50s
ci / rust (push) Failing after 7m29s
docker / deploy-docs (push) Successful in 36s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m3s
flatpak / build-publish (push) Successful in 6m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m21s
Reviewed-on: #20
2026-08-02 22:06:37 +00:00
enricobuehlerandClaude Opus 5 6b3c582eb1 feat(client/present): use the driver's queue-free vblank mode where it exists
apple / swift (pull_request) Successful in 1m14s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 2m51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
android / android (pull_request) Successful in 5m32s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m53s
ci / rust (pull_request) Successful in 7m13s
`VK_PRESENT_MODE_FIFO_LATEST_READY_EXT` is FIFO's tear-free vblank pacing that
presents the LATEST READY image at each refresh and retires the older ones,
instead of draining a queue. That is precisely what the software glass gate
emulates — so where the driver offers it, the driver does the job, and it does
it exactly where the gate matters most: a surface with no MAILBOX gets
newest-wins behaviour back without the app holding frames.

Found by asking the surface what it actually offers rather than trusting a
comment: the previous commit's `surface present modes` line read back
`[MAILBOX, 1000361000, FIFO]` on NVIDIA/Wayland, and 1000361000 is this mode.

The extension postdates the Vulkan headers ash 0.38 is generated from (1.3.281),
so there is no binding — hence the bare number in the log. It is hand-declared
here: mode value, extension name, and
`VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT` spliced into the device
pNext chain. One trap worth naming: the SURFACE advertises the mode even with
the extension disabled, and using it on that basis is undefined — so the ladder
only offers it when the device feature actually came back true and we enabled it.

The gate/probe predicate had to split in two, and the distinction is the point:

* `needs_glass_gate()` — FIFO and FIFO_RELAXED only. NOT this mode: gating on
  top of a driver that already retires stale images would hold frames back to
  emulate something the presentation engine is doing, paying the serialisation
  twice, which is the ~27 ms the last commit measured.
* `vblank_locked()` — the whole FIFO family INCLUDING this mode, because it
  still presents on the refresh boundary, so the VRR cadence probe's premise
  ("with VRR off, a present waits for vblank") still holds.

Ranking: MAILBOX first (measured good at 1.4 ms), then LATEST_READY, then plain
FIFO — so a MAILBOX-less surface reaches newest-wins in the driver rather than
in our gate.

MEASURED ON GLASS (.21, NVIDIA 610.43.03, GNOME/Wayland): the extension probe,
feature enable and swapchain creation all succeed with a mode ash has no binding
for. Default ladder selects MAILBOX with `fifo_latest_ready=true`; the VRR ladder
selects `present_mode=1000361000` and measures `display 2.6 ms (pace 0.6 + latch
2.0)` — against 13-28 ms for plain FIFO + gate on the same box. The vblank-locked
path is now MAILBOX-class.

That changes the previous commit's reversal. The VRR ladder was reverted to
opt-in because it led with plain FIFO and cost ~27 ms; led with LATEST_READY it
costs 0.6 ms over MAILBOX. So `allow_vrr` is automatic again WHERE THE DEVICE
OFFERS THE MODE, and stays behind `PUNKTFUNK_VRR_FIFO=1` where it does not — on
those drivers the ladder would fall back to plain FIFO and the regression
returns. Both branches are pinned by tests. This also retires a dead switch: the
"Follow variable refresh rate" row did nothing at all after the reversal, and now
does something real on any driver with the extension.

⚠ Still unverified off this box: whether Windows and Intel drivers expose the
mode at all. Nothing measured here carries over — Windows Vulkan WSI goes through
DXGI, so exposing the enum and mapping it usefully onto flip-model semantics are
separate questions, and Intel is a different vendor stack again. Both facts are
logged unconditionally now (`surface present modes` + `fifo_latest_ready=`), so
one run on any box settles it. The code is safe either way: the mode is only
requested where the device feature enabled, and `allow_vrr` only goes automatic
there — everywhere else the shipped MAILBOX-first behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 e08474d96d fix(client/present): log the surface's actual present modes, and document the VRR opt-in
"AMD's Windows driver offers no MAILBOX" is the premise the FIFO glass gate is
built on, and it has been carried in a code comment rather than measured. Present
modes are a property of the (surface, device) pair — they vary by platform
surface, driver version and fullscreen state — so the only way to settle it is to
read them back from real machines. One unconditional log line makes every field
log answer the question.

First reading, .21 (NVIDIA 610.43.03, GNOME/Wayland):
  surface present modes available=[MAILBOX, 1000361000, FIFO]

Two things fall out. No IMMEDIATE and no FIFO_RELAXED on this surface, which is
why a PUNKTFUNK_PRESENT_MODE=immediate run reported mode=fifo — the pin was not
offered and the ladder fell through; previously that looked like a puzzling
result and is now evidence. And 1000361000 is
VK_PRESENT_MODE_FIFO_LATEST_READY_EXT: FIFO's tear-free vblank pacing that
presents the LATEST READY image instead of draining a queue — the driver-native
version of what the glass gate emulates in software, and a candidate to replace
it wherever the driver exposes it (needs VK_EXT_present_mode_fifo_latest_ready
enabled at device creation, so a work package rather than a tweak).

Also documents PUNKTFUNK_VRR_FIFO, which the previous commit introduced without
a docs entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 f422ae3e38 fix(client/present): what the first on-glass session found, including a reversed default
WP6 ran against .21 (CachyOS, RTX 5070 Ti, NVIDIA 610.43.03, GNOME/Wayland,
1080p60 HDMI, VRR provably disabled — `org.gnome.mutter experimental-features`
is empty), host and client on the same box, `VK_KHR_present_wait` available.

Five defects that unit tests and both CI gates had passed over:

1. The latch learner and the VRR probe observed NOTHING. Both derived spacings
   with `windows(2)` inside a single batch, but the run loop drains present-wait
   samples every pass, so a batch is normally ONE stamp. `period_us` read back
   exactly the mode fallback — correct by luck on a 60 Hz panel, wrong the moment
   a mode lies, which is the entire reason PanelGrid exists. The tests fed
   40-stamp batches, a shape the live loop never produces. Spacings are now
   measured against the previous stamp across calls.

2. The VRR reference was circular. It compared spacings against the LEARNED
   period, but the grid cannot be learned from our own presents when the stream
   runs below panel rate — we only ever observe multiples ≥ our frame interval,
   so the learner adopts our own cadence and every delta is on-grid by
   construction. It learned 18-22 ms from a 40-50 fps stream and reported VRR on
   a display with VRR off. The reference is now the DISPLAY MODE's period, which
   is the vblank grid presents actually quantize to.

3. The probe is meaningless outside FIFO. MAILBOX deliberately decouples presents
   from scanout, so its stamps are never grid-quantized: same panel, same minute,
   FIFO read `no` (correct, period 16.4 ms) and MAILBOX read `yes` (wrong).
   Outside a FIFO-family mode the honest answer is Unknown, and that is now what
   it reports.

4. Round evaluation was per-CALL rather than per-sample, so the verdict depended
   on how the caller batched its stamps. Closed inside the sample loop now, with
   a test pinning bulk-vs-one-at-a-time equivalence — the same invariant (1)
   violated, in a second place.

5. `force_latency` was dead code without the `pyrowave` feature: a warning in the
   `--no-default-features` build CI actually ships (the Windows ARM64 leg). The
   gate only ever tested default features; it now tests both.

DESIGN REVERSAL — the VRR FIFO-first ladder is opt-in (`PUNKTFUNK_VRR_FIFO=1`),
no longer default. It shipped default-on for `allow_vrr` + fullscreen, which is
the default configuration. Measured A/B, same box, back to back, reproduced
across three runs: FIFO+engine `display 28.4 ms (pace 11.8 + latch 16.6)` versus
MAILBOX `1.4 ms (0.2 + 1.2)`. Under a compositor the FIFO present's on-glass
confirmation arrives a whole refresh later and the presenter serialises behind
it. The VRR upside is real in principle but UNMEASURED — no VRR panel was
available — and a default that is measurably ~27 ms worse on the hardware we
could test, bought against an unproven win on hardware we could not, is the
wrong way round. A test pins the default to MAILBOX; flip it back when a VRR
panel confirms the win.

NOT measured, and not claimed: the FIFO glass gate's own headline. The standing
queue only forms when the stream rate approaches the panel rate, and an idle
GNOME desktop is damage-driven at 40-50 fps on a 60 Hz panel, so `gated`/`forced`
read 0 in every mode and the mechanism never engaged. The 11-13 ms figure is
still the code's inherited documentation, not a fresh measurement. It needs its
actual target: AMD-on-Windows (no MAILBOX, direct scanout) under load.

Rig caveats recorded rather than smoothed over: host and client shared one GPU,
so absolute latencies are contended and run-to-run variance was large, and it
could not be visually confirmed what the physical screen showed. Mode selection,
the fallback ladder, the VRR verdict and the counter plumbing are robust to
that; absolute numbers are not.

Gates: fmt, clippy -D warnings over the five client crates AND the
`--no-default-features` build (added because defect 5 hid there), 160 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 e38e3c44c9 feat(client/present): V-Sync and VRR become real settings, and VRR is measured
WP3 of design/desktop-presentation-rebuild.md. The `vsync` and `allow_vrr`
settings have existed since WP1 but nothing consumed them — the swapchain picked
MAILBOX-or-FIFO once, from an env var, and froze. This makes them mean
something, which is also what unblocks their settings rows (deliberately
withheld from WP5 rather than shipped as dead switches).

Present-mode selection is now a preference ladder, not a constant:

* V-Sync off — IMMEDIATE, then FIFO_RELAXED, then the tear-free modes. Asking
  to tear and silently getting vsync is a lie, so the mode that actually took is
  named in the stats line and a refused preference is logged requested-vs-active.
* V-Sync on + VRR allowed + fullscreen — FIFO first. On a variable-refresh panel
  with direct scanout the FIFO present IS the flip, so the panel follows the
  stream's cadence instead of a fixed grid; MAILBOX would decouple presents from
  scanout and re-quantize to the compositor's clock. This is only safe because
  WP2's glass gate bounds the standing queue that historically made FIFO costly.
* Otherwise — MAILBOX then FIFO, the shipped default, unchanged.

`PUNKTFUNK_PRESENT_MODE` still pins a mode outright and now falls back to the
settings (rather than to mailbox) when the name is unknown.

VRR detection is MEASURED, never queried. No portable query exists — SDL exposes
none, Wayland does not report adaptive-sync state, Windows surfaces nothing
through Vulkan — and the platforms that do answer have been caught lying (see
the Android per-uid refresh-rate finding). The discriminator is quantization: on
a fixed-refresh panel every on-glass instant lands on the vblank grid, so the
spacing between presents is ~k×period for whole k even when the stream runs
slower than the panel (it just picks a larger k); under real VRR the panel
refreshes when we present, so the spacing follows our own cadence and sits off
the grid. `CadenceProbe` folds each delta to its distance from the nearest
multiple of the learned period and takes the median. Tri-state: it stays Unknown
below 24 deltas and after a display change, so `vrr` is reported only when it
has been measured — never inferred from what the display claims.

Also fixes the read-once refresh rate: `native.refresh_hz` was sampled at
startup and never revisited, so dragging the window to another monitor left a
60 Hz-seeded clock pacing a 144 Hz panel. `WindowEvent::DisplayChanged` now
relearns the latch grid, resets the cadence verdict, and clears the served-slot
latch.

Settings rows for both, on all three surfaces (GTK, WinUI, console). The
console's V-Sync row is reachable in Gaming Mode, which is the only editor a
Deck user has.

Gates: punktfunk-rust-ci linux/amd64 — fmt, clippy -D warnings over
pf-client-core, pf-presenter, pf-console-ui, the session binary and the GTK
client, 160 tests (the two new ones cover every ladder and both cadence
regimes, including the case that matters most: a stream slower than a FIXED
panel must still read as fixed). WinUI leg on the Windows runner .133:
clippy=0 tests=0, against a tree proven by content to contain the edit.

⚠ On-glass validation is still owed and is NOT claimed here: every box with a
real display was powered off when this landed, so the VRR ladder and the
detector have been exercised only against synthetic stamps in unit tests.

Rebase follow-up: `20de58a7` landed the same "panel grid can be wrong in both
directions" defect fix on Android and extracted the corrected learner into
`punktfunk_core::phase::PanelGrid` for the iOS and desktop presenters to share.
This clock had the identical bug — it capped the learned period at the display
mode's refresh, and the mode is only a CLAIM, so a display really running slower
than it advertises pinned a grid whose instants never arrive, for the session,
with no way back. Adopted the shared learner rather than carrying a second,
buggier copy; still fed the window's MIN spacing, which preserves the k×period
resistance the cap was actually aimed at while the streak requirement lets a
genuinely slower panel be discovered. New test: seed 120 Hz, real panel 60 Hz,
the clock must climb back out.

Took the same commit's third lesson too: the adaptive margin widened on a
latch over 1.5×period (a number picked here), and now widens on the latch
exceeding one period plus the lead already applied — the slot actually aimed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 b1ac4d02de feat(client/present): the display stat splits, and the intent reaches the settings UI
WP4 + WP5 of design/desktop-presentation-rebuild.md, on top of the WP1/WP2
engine. The engine shipped with no way to choose it and no way to see what it
cost; this closes both.

WP4 — the display stage splits into `pace` (decoded → present-submit, our own
pipeline) + `latch` (submit → on-glass, the presentation queue and the vblank
wait), off the `submitted_ns` stamp WP2 already carried. That split is what
makes a high `display` self-diagnosing: latch dominating is the vsync floor or
a standing queue, pace dominating is us. A `present:` line joins the Detailed
tier naming the live swapchain mode — the answer to most "why is my latch a
whole refresh" questions, since a MAILBOX request silently lands on FIFO
wherever the driver has no mailbox — plus the engine's counters, rendered only
when they are non-zero so a healthy latency session shows just the mode.

Deviation from the plan: the planned `display_adj` twin is NOT here. It was
specified as `display − latch_p50` for parity with the Apple HUD's shaved
figure, but with a real per-sample `pace` percentile that twin is the same
quantity derived worse (subtracting percentiles). `pace` IS the
Apple-comparable number — Apple subtracts its OS present floor, the latch is
ours — and the user docs now say exactly that.

WP5 — Prioritize + Smoothness buffer on all three surfaces: the GTK dialog (a
new Presentation group on the Display page), the WinUI settings page, and the
console settings screen, which is the ONLY editor reachable in Gaming Mode and
so the one that decides whether Deck users can reach this at all. The buffer
control follows the intent the way echo cancellation follows the mic: hidden on
the desktop shells, dimmed and inert on the console, where a row that vanished
mid-list would shift everything under the cursor.

The V-Sync and VRR rows are deliberately NOT here. Their settings exist and are
profile-routed, but the swapchain does not honour them until WP3, and a toggle
that does nothing is exactly how "Full chroma (4:4:4)" shipped inert on desktop
for three releases after being announced.

Buffer labels carry no millisecond hints (Apple/Android derive them from the
session refresh): under a Native mode the shells do not know the refresh at
settings time, so the captions state the cost as one refresh per frame rather
than a confident wrong number.

Docs: the stats page documents the split and the `present:` line, and stops
claiming Linux/Windows measure to the present instant (untrue since
present_wait); client-settings documents both new rows and drops the stale
claim that the desktop 4:4:4 toggle has no effect (it was wired to
VIDEO_CAP_444); configuration documents PUNKTFUNK_PRESENTER and
PUNKTFUNK_PRESENT_DEBUG.

Gates: punktfunk-rust-ci linux/amd64 — fmt, clippy -D warnings over
pf-client-core, pf-presenter, pf-console-ui, the session binary and the GTK
client, 158 tests. The WinUI leg cannot be reached by any Linux or macOS check,
so it was compiled on the Windows runner .133: clippy -D warnings and tests
both exit 0, against a tree proven by content to contain the edit. ⚠ The first
run there reported a false pass — the script printed its done-marker while the
log carried a test failure (a STATUS_DLL_NOT_FOUND launch failure, ffmpeg's
DLLs missing from PATH); the harness now echoes each phase's exit code so the
verdict is a fact in the log rather than an inference from a marker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:38 +02:00
enricobuehlerandClaude Opus 5 5f55fa874a feat(client/present): the desktop presenter gains the Apple/Android intent model
WP1+WP2 of design/desktop-presentation-rebuild.md. The shared Linux/Windows
session client presented arrival-paced with no pacing layer at all: two depth-2
newest-wins hops into a drain-to-newest and an immediate present. That IS the
lowest-latency intent, but it was unnamed, unselectable, and had no alternative
— and on a surface without MAILBOX (AMD's Windows driver offers none, and any
compositor holding images does the same) the swapchain's own FIFO becomes a
standing queue worth a measured 11-13 ms at 60 Hz.

WP1 — the settings cluster, under the keys the Apple client already writes into
the shared profile catalog (present_priority / smooth_buffer / vsync /
allow_vrr): mismatched names would ride SettingsOverlay::extra, carried but
never applied. PresentPriority::resolve mirrors the Android reference exactly
(anything but an explicit "smooth" is latency; a buffer outside 1..=3 becomes
2), so a profile authored on any client means the same thing on all of them.
Only the first two are consumed here; vsync/allow_vrr land in WP3.

WP2 — the engine (present_pace.rs, pure state + arithmetic, 6 tests):
- FrameStore: newest-wins slot, or the smoothing FIFO with preroll-to-capacity,
  drop-oldest overflow, and an underflow that re-arms the preroll (repeat by
  omission) — the Apple/Android semantics, with qDrop/qDry counters.
- LatchClock: the panel grid learned from VK_KHR_present_wait glass stamps,
  min positive spacing capped by the mode refresh (measured, never queried —
  VRR and Android's per-uid refresh lie both punish trusting a reported rate).
  It now also publishes the host-facing LatchGrid, so the phase-lock report and
  the local scheduler cannot disagree about the grid.
- PresentGate: one undisplayed present in flight on FIFO surfaces, with the
  100 ms stale force-open. This is the standing-queue killer, and it is inert
  on MAILBOX/IMMEDIATE and without present timing — where behaviour stays
  byte-for-byte the shipped arrival pacing.

Wiring: glass samples drain every pass (a 1 Hz batch would starve clock and
gate) and the waiter pushes an SDL wake, so a gate reopen never waits out the
event timeout; smoothness serves one frame per latch slot and tightens the
loop's wait to that deadline; the adaptive slot margin starts at 0 and widens
+500 us per missed window toward 2.5 ms (a fixed lead was measured to be pure
display tax). PUNKTFUNK_PRESENTER=arrival disables the whole engine for field
A/B without a rebuild.

PyroWave collapses smoothness to latency for the stream: its plane-ring
retirement accounting assumes the depth-2 newest-wins hand-off, and all-intra
frames make buffering moot anyway.

Gates (punktfunk-rust-ci, linux/amd64, sources touched first so a warm target
cannot print a vacuous Finished): clippy -D warnings across pf-client-core,
pf-presenter and punktfunk-client-session; 80 + 32 tests pass; rustfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:00:50 +02:00
enricobuehlerandClaude Opus 5 8af6e2dd02 feat(decky): the stats overlay gets an off switch in Gaming Mode
Field report: "as of version 0.23 of this plugin, there is no setting to
toggle off the stat overlay." Correct, and it never had one — no commit in
`clients/decky` has ever touched a stats key. Every other client does:
the GTK dialog, the Windows page, the Apple app, and the console's own
settings screen all carry the four-tier picker.

The tier defaults to on. `Settings::default` is `show_stats: true` and
`stats_verbosity: None`, which `Settings::stats_verbosity` resolves to
Normal — so a Deck that has only ever been configured through this panel
streams with the overlay up and no way here to put it down. What escapes
exist are not discoverable: Ctrl+Alt+Shift+S wants a keyboard, and the
three-finger touchscreen tap is documented in `docs/stats`, not on the
glass. The console's picker is reachable (X on console home), but that is
a different shortcut than the one-tap stream this panel launches, and a
user editing stream settings here has no reason to look there.

So the row lands here, last in the section, matching the console's
wording. It writes `stats_verbosity` AND the legacy `show_stats` in the
same pairing `Settings::set_stats_verbosity` keeps, so a client too old
for the tiers still honours an Off chosen here; it reads them back the
way `Settings::stats_verbosity` does, so a pre-tier file — including
every file this plugin wrote before today — shows the Normal the stream
actually runs at.

`set_settings` stops replacing the file and merges onto it instead. This
JSON is shared with the desktop client and the console, and holds many
more keys than this panel models (decoder, GPU, profiles, touch/mouse
model). The panel reads it once when it mounts, so a wholesale write
posts a snapshot that predates anything another editor stored while it
sat open — silently reverting it. That was invisible until 0.23.0:
`9c5af8d7` fixed the GTK shell handing the session a spec built from
`Settings::default()`, and only since then does this file reach a stream
at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:53:57 +02:00
enricobuehlerandClaude Fable 5 d839f4c2b6 fix(client/windows): settings stop going stale behind your back, and the log has a door
ci / web (push) Successful in 1m1s
ci / rust-arm64 (push) Successful in 2m35s
ci / docs-site (push) Successful in 2m35s
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 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 33s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
deb / build-publish-client-arm64 (push) Successful in 1m16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
deb / build-publish (push) Successful in 3m52s
apple / swift (push) Successful in 1m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
deb / build-publish-host (push) Successful in 4m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m28s
ci / rust (push) Successful in 7m1s
android / android (push) Successful in 7m5s
arch / build-publish (push) Successful in 8m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m55s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m0s
docker / builders-arm64cross (push) Successful in 8s
apple / screenshots (push) Successful in 5m42s
docker / deploy-docs (push) Successful in 26s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m25s
flatpak / build-publish (push) Canceled after 9m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m11s
A field reporter's codec setting "changed by itself" between sessions. Nothing writes
the negotiated codec back — what they saw was a stale snapshot. `AppCtx.settings` is
loaded ONCE at process start and the page renders from it, but this process is not the
file's only writer (the spawned session persists its match-window size, the console UI
and Decky save too), so the page showed values another process had already replaced —
until a row was touched and `commit`'s rebase pulled the file in, at which point the
value visibly jumped. The 2026-07-31 rebase fix covered the whole-file writers and
missed two spots: nothing re-based on page ENTRY, and the profile-scope commit arm
cloned the snapshot without reloading, so overlay absorption diffed against stale
globals. Both now re-base on the file.

Two more ways a setting could vanish or cost time:

* An older binary's whole-file save DROPPED a newer client's keys — `Settings` had no
  unknown-key passthrough, unlike `SettingsOverlay`, whose `extra` map already gives
  profiles exactly that contract. Extended to the globals: additive, empty on every
  existing store, and an empty map serializes to nothing so no file churns. (`save()`
  was already temp+rename, so the torn-file → silent-Default reset was closed.)
* "Check the client log" never said WHERE. Settings ▸ About grows an Open log folder
  row (%LOCALAPPDATA%\punktfunk\logs, folder not file so the rotated .old generation
  is in reach), and the failed-spawn banner now names the path.

The 4:4:4 caption said "HEVC only, and only where the host can encode it", which sends
people hunting: the host gate is PyroWave or an NVENC backend. It says so now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 23:49:45 +02:00
enricobuehler 0de161e29b Merge pull request 'feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way' (#22) from worktree-gamepad-passthrough-toggle into main
ci / web (push) Successful in 1m10s
apple / swift (push) Successful in 1m22s
ci / docs-site (push) Successful in 2m0s
ci / rust-arm64 (push) Successful in 2m37s
decky / build-publish (push) Successful in 48s
deb / build-publish-client-arm64 (push) Successful in 1m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
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 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m33s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
deb / build-publish (push) Successful in 4m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m3s
deb / build-publish-host (push) Successful in 3m51s
docker / builders-arm64cross (push) Successful in 14s
arch / build-publish (push) Failing after 5m22s
docker / deploy-docs (push) Successful in 33s
android / android (push) Successful in 7m22s
ci / rust (push) Failing after 9m12s
flatpak / build-publish (push) Failing after 5m29s
release / apple (push) Successful in 9m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m12s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m34s
apple / screenshots (push) Canceled after 4m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 10m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m8s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m31s
Reviewed-on: #22
2026-08-02 21:38:01 +00:00
enricobuehlerandClaude Opus 5 b297542c4d feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / docs-site (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 2m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m27s
ci / rust (pull_request) Successful in 7m59s
A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a
pad plugged into the host — arrived there twice: once as the real device, once as the virtual
pad this client built from the same hands. Games read both, so a stick drifts against the
centred second pad and menus take every input twice.

New per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P,
so a profile can decline what another profile forwards.

On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is
what CLAIMS it — SDL's HIDAPI drivers take the device node — and a claimed device is one a
passthrough tool cannot bind, so with this off the session opens no slot at all and never
enables the Valve HIDAPI drivers. Menu navigation is untouched: the launcher still opens the
active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free
for the whole time a stream is up. The consequence, documented at both the setting and the
chord: the controller escape chord is read off forwarded pads, so it is unavailable there.

The Apple and Android input stacks claim nothing, so those clients keep their slots and their
chords and only gate the wire sends — losing tvOS's only controller way out of a stream would
have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures,
which do claim the device.

Surfaces: GTK, WinUI, the console settings screen, Apple's touch and gamepad settings, the
Android touch and gamepad settings, and Decky (which also hides the rows that now have nothing
to act on). Everywhere the "which pad" and "pad type" rows grey out while it is off.

Verified: cargo clippy --all-targets -D warnings + 79 tests on pf-client-core, pf-console-ui,
punktfunk-client-session and punktfunk-client-linux (linux/amd64 container, gate proven
non-vacuous with a planted error); swift build for the Apple clients; gradle compile + 49 unit
tests for Android (likewise proven); tsc for Decky. clients/windows is UNCOMPILED — both
Windows boxes were offline; its edits were reviewed against the helper signatures by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:21:26 +02:00
enricobuehlerandClaude Fable 5 98e040fd01 fix(host/stream): the wire holds the session rate when the display outruns it
apple / swift (push) Successful in 1m20s
ci / rust (push) Successful in 5m0s
android / android (push) Successful in 6m30s
ci / rust-arm64 (push) Successful in 1m50s
apple / screenshots (push) Successful in 5m37s
arch / build-publish (push) Successful in 8m0s
ci / web (push) Successful in 1m28s
ci / docs-site (push) Successful in 1m36s
deb / build-publish-client-arm64 (push) Successful in 2m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
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 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
windows-host / package (push) Successful in 13m17s
windows-host / winget-source (push) Skipped
deb / build-publish-host (push) Successful in 5m33s
deb / build-publish (push) Successful in 5m39s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 24s
windows-host / canary-manifest (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m18s
PUNKTFUNK_VDISPLAY_HZ_MULT promises extra display refreshes without one
extra frame on the wire, but the frame-driven trigger enforced its pace only
as a per-gap floor: sleep to 0.9×interval, then wake on arrival. A source
that always has a frame pending — the overdriven display under uncapped
content — settled at 0.9-interval spacing, 1.11× the negotiated rate. That
is the field report's 132 fps on a 120 fps session: ten percent more
bitrate, encode and decode for frames a 120 Hz panel can only drop.

A credit bucket (PaceBudget) now pins the long-run average at the pacing
rate: credit accrues at one frame per interval of real elapsed time, capped
at 1.25 frames of post-stall burst, and every submitted frame spends one. A
grab may run early only against banked credit, so the 0.9 floor keeps its
per-gap jitter headroom while the average cannot exceed the rate — and a
source at or below it banks faster than it spends and is never delayed.
Anchoring to real elapsed time also keeps the synchronous-encode overlap the
arrival-anchored floor bought (the owed fraction absorbs a constant encode
tail instead of stacking on top of it), and it cannot fight the phase lock's
submit grid: both agree the period is the interval.

The charge lives under the same guard as the gate — the legacy fixed tick
paces by its own grid, and charging it without ever accruing would bank
unbounded debt that stalls the loop if a rebuild later flips the capturer to
arrival-wait.

Verified on .25: native::stream tests 15/15 (three new PaceBudget tests),
punktfunk-host 369/369, clippy -D warnings clean, fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:54:09 +02:00
enricobuehlerandClaude Fable 5 5174a59832 fix(capture/kwin): a hidden cursor leaves the stream — KWin's id-0 meta is the hide
Since the 0.22.0 cursor work (the seat-pointer park + the metadata
composite), a KWin capture-model stream always has a cursor — and it never
went away again: not in game, not in Big Picture, not with a controller in
hand (field report, 2026-08-01). The host blended the arrow forever because
pf-capture deliberately ignores SPA_META_Cursor id 0, and once `visible`
latched true nothing on Linux ever cleared it.

Two producer contracts meet on id 0, and one flag now carries which one a
stream follows. KWin rewrites the cursor meta on EVERY enqueued buffer and
writes id 0 whenever Cursor::isOnOutput says the pointer is not in this
stream — which covers both a globally hidden cursor and a client null-cursor
surface (empty geometry intersects nothing). There id 0 IS the hide, and
honoring it is what lets a game hide the pointer mid-stream. Mutter only
rewrites a buffer's meta when the cursor changed, so recycled buffers carry
stale id-0 regions between damage frames — honoring those flickered the
cursor off between hovers (on-glass round 5), and that path keeps its
last-known-state behavior.

The flag rides from the backend that created the output (correct for
registry-pooled reuse too — a kept display only ever matches its own
backend) through capture_virtual_output into the parser's CursorState. The
portal-monitor path stays on the stale-meta contract: the only thing routed
through it today is Mutter's HDR mirror.

Verified on .25: pf-capture 45/45, punktfunk-host 369/369, clippy
-D warnings clean (pf-capture, punktfunk-host, cursor-probe), fmt clean.
On-glass KDE validation still owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:54:09 +02:00
enricobuehlerandClaude Opus 5 c2a6d30d7b fix(android/decode): a codec input slot the feeder can't fill goes back, and so does the AU
deb / build-publish (push) Failing after 2s
deb / build-publish-host (push) Failing after 2s
deb / build-publish-client-arm64 (push) Failing after 3s
apple / swift (push) Successful in 1m19s
ci / rust (push) Successful in 5m52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 9m12s
release / apple (push) Successful in 9m14s
ci / rust-arm64 (push) Successful in 1m57s
android / android (push) Successful in 11m23s
windows-host / package (push) Successful in 11m26s
windows-host / winget-source (push) Skipped
ci / docs-site (push) Failing after 11m59s
ci / web (push) Failing after 12m2s
flatpak / build-publish (push) Successful in 6m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m33s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 28s
apple / screenshots (push) Successful in 5m54s
windows-host / canary-manifest (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m26s
`AMediaCodec_getInputBuffer` returning null for an index the input-available
callback had just handed us dropped both the slot and the access unit on the
floor. Every sibling path in this loop recycles the slot — the orphan-part
discard and the oversize drop both say so in as many words — because nothing was
written and nothing was queued, so it is still ours. Forgetting it leaks one of
the codec's input buffers per occurrence: we never use it again and the codec
never frees what it never received, so the pipeline runs out of input slots,
`pending_aus` overflows into its drop-oldest arm, and the resulting keyframe storm
reads as a decode fault rather than a bookkeeping one.

The AU went with it, silently — no keyframe request, no freeze gate, unlike every
other loss path here — leaving a hole in the reference chain whose concealment
was free to reach the screen.

Both go back now. `break` rather than `continue`, because a codec that cannot
hand out an input buffer it has just advertised is in no state to be fed the rest
of the parked queue on this pass, and retrying the same index against every
parked AU would burn the whole backlog for nothing; the loop comes round again on
the housekeeping wake within 5 ms if it was transient.

Gates: cargo ndk check green on arm64 and armv7, fmt clean, Android clippy at the
same 4 pre-existing warnings as the base commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehlerandClaude Opus 5 20de58a78a fix(android/present): the panel grid can be wrong in both directions, and the margin listens to the latch
Three defects in the 0.23.0 timeline presenter, all found while root-causing the
field report that turned out to be the slice wire. None of them is that bug; all
three are real, and the first is the one that would still bite once it is fixed.

The panel-period learner could only ever narrow. It is seeded from the display
mode Kotlin asked for — and `preferredDisplayModeId` is a REQUEST the system may
refuse (Smooth Display off, battery saver, thermal, an OEM governor). Ask for
120 Hz on a panel that stays at 60 and the presenter pins an 8.33 ms grid on a
16.67 ms display with no way back, for the rest of the session: it then aims at
instants that never arrive and releases faster than the panel scans. The learner
moves both ways now, and lives in `punktfunk_core::phase::PanelGrid` where it is
host-testable and where the iOS and desktop presenters can share it. The
asymmetry is kept and made explicit — narrowing is immediate (a finer real grid
is always safe to subdivide onto, and it is the per-uid down-rate case the seed
most often gets wrong), widening needs eight consecutive agreeing observations
and then takes the narrowest of them, because one wide sample is a missed
callback and eight in a row is a display that really did slow down.

The glass budget was a prediction with nothing underneath it. `OnFrameRendered`
already reports what actually reached glass, but the budget never consulted it,
so a wrong grid could hand SurfaceFlinger frames indefinitely: BufferQueue fills,
MediaCodec runs out of output buffers, the decoder stalls, and the no-output
backstop starts begging for keyframes. Releases are now counted against their
confirms and the presenter holds back past six outstanding — loose on purpose,
since the callbacks are allowed to arrive batched and a held frame in the
newest-wins slot is a dropped one. It self-clears when the confirms catch up, and
writes the ledger off after the same 100 ms the stale reopen uses, so a platform
that stops confirming can never wedge the stream. `qWait` and `unconfirmed` join
the 1 Hz pf.present line, which is what would have made this visible from a log.

The adaptive latch margin widened on `paced_drops` — the newest-wins store's own
policy evictions, which happen whenever the stream out-runs the panel and say
nothing about SurfaceFlinger's latch lead. On a healthy device that walked the
margin to its 2.5 ms ceiling and re-imposed the display latency the P2e sweep had
just measured away. It now widens on the measured latch exceeding one panel
period plus the live margin, which is what a missed vsync actually looks like.

Also corrects two doc comments that named `display.refreshRate` as the panel_hz
source; it has been the mode table since the A024 down-rate fix.

Gates: 278 punktfunk-core lib tests (7 new PanelGrid cases incl. the refused-mode
regression), clippy -D warnings and fmt clean, cargo ndk check green on arm64 and
armv7. Android clippy reports the same 4 warnings as the base commit and no new
ones. NOT yet confirmed on glass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehlerandClaude Opus 5 97b2c01ac1 fix(core/packet): a slice-streamed frame costs its own size, not the whole frame ceiling
The 0.23.0 slice wire flushes a block every MIN_STREAM_BLOCK_SHARDS, so every
ordinary access unit is now opened by a SENTINEL — a header with no totals. The
reassembler sized those frames at `max_frame_bytes`, which the QUIC handshake
clamps to 8-64 MiB. That was survivable while sentinels were rare (the streamed
path emitted one only for an AU exceeding a whole FEC block, ~281 KB); it is not
survivable now that every frame is one.

Two consequences, both measured: each access unit allocated and ZEROED a
multi-megabyte buffer, and the in-flight budget (IN_FLIGHT_BUF_FACTOR x
max_frame_bytes) was spent after ~3 concurrent frames — with production geometry,
12 ordinary AUs in flight lost 9 of them outright, every packet dropped before it
could be placed. On a link with normal reorder that is a permanent loss storm:
frames never complete, the re-anchor gate freezes the picture, and the client begs
for keyframes. Only clients advertising VIDEO_CAP_MULTI_SLICE reach this path —
Android and the Linux/Windows session client; Apple and the Windows in-process
client never did, which is why it read as a platform-specific "video pipeline"
fault in the field.

A sentinel carries no total but does pin its own block's extent: a slice sentinel
by its wire base, a legacy one by its full-K position. Size the buffer to that and
grow as later blocks (or the final block's totals) reveal more. The budget is
re-checked on growth for the same reason it is checked at open.

The same flush also drained `pending` to empty whenever the AU's length was an
exact multiple of the shard payload, leaving `finish_streamed` to seal a final
block of one zero-padded FILLER shard. Its derived base overlapped the block
flushed a moment earlier, retro-validation correctly read that as a lying header,
and the whole AU died — one frame in every 1408 on a 1500-MTU link, ~12 s apart at
120 fps, each costing a freeze and a recovery keyframe. A flush now keeps one
whole shard back, restoring the invariant `StreamedAu::pending` already documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehler 29473d6280 Merge pull request 'fix(client/ios): Escape keeps the pointer captured instead of handing it back to iPadOS' (#19) from worktree-ipad-esc-pointer-relock into main
ci / rust-arm64 (push) Failing after 2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 24s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 34s
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 1m21s
docker / builders-arm64cross (push) Successful in 45s
ci / rust (push) Successful in 4m0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 5m6s
docker / deploy-docs (push) Failing after 9s
ci / web (push) Successful in 6m50s
release / apple (push) Successful in 9m10s
apple / screenshots (push) Successful in 5m53s
Reviewed-on: #19
2026-08-02 17:27:54 +00:00
enricobuehlerandClaude Opus 5 b6acbd096e fix(host/vdisplay): waking the PC stops failing the first session
android / android (push) Failing after 2s
apple / swift (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 1m49s
ci / web (push) Failing after 1s
ci / docs-site (push) Successful in 1m32s
ci / rust (push) Successful in 6m38s
apple / screenshots (push) Successful in 5m55s
arch / build-publish (push) Successful in 8m58s
deb / build-publish (push) Successful in 6m38s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3m19s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
deb / build-publish-host (push) Successful in 4m57s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 3m43s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3m59s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
windows-host / package (push) Successful in 12m4s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Successful in 55s
windows-host / canary-manifest (push) Successful in 1m30s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 10m36s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 10m55s
deb / build-publish-client-arm64 (push) Failing after 11m12s
docker / builders-arm64cross (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 11m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m27s
A woken Windows host refused every connection with "pf-vdisplay driver
interface not found", on a box where the driver was installed and running.

Resuming re-enters D0 and re-registers the IddCx control interface while
the rest of the resume storm is still going. A client reconnecting a
second after wake lands inside that gap. `ensure_available` probed
exactly ONCE, so it read the gap as a dead driver and answered a device
that was seconds from ready by disabling and re-enabling it — then gave
the interface 4 s to come back, which a contended post-resume PnP does
not meet. The session failed, and the log blamed a missing install.

The recovery also could not tell whether it had recovered anything. It
ran the whole cycle under `SilentlyContinue` and reported
`(Get-PnpDevice).Status` — the DEVICE's status, not the cycle's outcome —
so a disable that was REFUSED left the adapter untouched, started, and
reading `OK`. That is the reporter's `cycled the adapter device …
status=OK` line: a recovery that never happened, announcing success. And
a refusal is the expected case here, not the exotic one:
reset-pf-vdisplay.ps1 stops the host service first precisely because the
host holds the driver's control device open, a step an in-process cycle
structurally cannot take.

- Distinguish a devnode MID-TRANSITION (interface registered, not started
  yet, or the open refused) from one genuinely ABSENT. Wait the first
  out; only the second earns a reload. `Probe` carries the counts.
- Report what the reload DID, not what the device looks like afterwards:
  every failable step is `-ErrorAction Stop` in a `try`, and
  `pnputil /restart-device` is the fallback for the in-use device that
  `Disable-PnpDevice` refuses. Failure paths re-enable, so a half-cycle
  can never strand the adapter DISABLED.
- Give the interface 15 s to arrive after a reload, not 4 — under a 30 s
  hard ceiling so a permanently wedged devnode still fails predictably.
- Serialize recovery: N sessions racing in after a wake perform ONE
  reload, not N interleaved ones. The lock is taken only where no manager
  lock is held, so the order stays one-way.
- Retire the manager's cached control handle when a reload runs, instead
  of letting the next session discover it via a failed IOCTL.
- Surface the real reason. `ensure_available` returns `Result`, so the
  log names how long it waited, whether a reload ran, and how many
  interface instances were seen in what state — the detail that would
  have identified this from the field report's log alone.

`VdisplayDriver::open` now shares the wait (brief, no reload) instead of
carrying a second, drifted copy of it — that path is also reached by
`hw_cursor_capable` mid-handshake, where a reload would be the wrong
trade for one capability bool.

Windows-gated, so verified with scripts/xcheck.sh (check + clippy -D
warnings, --all-targets) and cargo fmt; on-glass wake test still owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:12:54 +02:00
enricobuehlerandClaude Opus 5 d63e913f52 fix(client/ios): Escape keeps the pointer captured instead of handing it back to iPadOS
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m26s
ci / docs-site (pull_request) Successful in 3m46s
ci / rust (pull_request) Successful in 4m8s
iPadOS releases the scene's pointer lock by itself when Escape is pressed — the platform's
built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing in
our code does it: a bare Esc never touches `captured`, and it keeps forwarding to the host as
the game key it is. But the lock going away flips the mouse onto the absolute UIKit path and
un-hides the iPadOS cursor, so pressing Esc for an in-game menu silently cost the capture until
the user clicked into the video to win it back.

Esc is a GAME key in a stream, not a request to hand the pointer back to iPadOS, so an unwanted
drop is now re-requested. `syncPointerLock` arms a short, bounded burst (3 attempts over ~0.6 s,
no restart inside 2 s) whenever the lock is wanted, was previously HELD, and is now gone; the
first attempt re-asserts `prefersPointerLocked`, later ones present a real false→true transition
and re-anchor the PointerLockChain. Every deliberate release (⌘⎋, ⌃⌥⇧Q, the Stream menu,
resigning active) clears `captured` first, so `wantsPointerLock` is already false when their drop
is observed and none of them are fought.

The "previously held" half of the condition keeps a scene that never qualifies (Stage Manager,
Split View) from paying for a lock that isn't coming — there, a first grant is still driven by
the chain engage in setCaptured/viewDidAppear exactly as before.

While a re-lock is in flight the local cursor stays hidden and absolute pointer MOTION stays
muted, so the couple of frames it takes read as "Esc did nothing to my mouse" rather than a
cursor that blinks in and out and a host cursor that teleports to the pointer's absolute
position. Buttons still forward (they carry no position), so a click mid-relock isn't swallowed.
The burst clears itself on give-up, so the cursor can never stay hidden on a lock the system
won't grant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:08:31 +02:00
enricobuehlerandClaude Opus 5 362595b20f fix(vdisplay/kwin): the streamed output declares it mirrors nothing, so a stored replicationSource can't clone a panel into the stream
apple / swift (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m13s
ci / rust (pull_request) Successful in 4m22s
ci / docs-site (pull_request) Failing after 18m2s
ci / web (pull_request) Failing after 18m8s
ci / rust (push) Failing after 3s
ci / rust-arm64 (push) Failing after 5s
deb / build-publish-host (push) Failing after 3s
deb / build-publish-client-arm64 (push) Failing after 3s
apple / swift (push) Successful in 1m22s
ci / docs-site (push) Successful in 1m24s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 4m14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4m13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4m15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 2s
docker / builders-arm64cross (push) Successful in 21s
android / android (push) Successful in 6m2s
docker / deploy-docs (push) Successful in 1m14s
apple / screenshots (push) Successful in 6m3s
arch / build-publish (push) Successful in 8m11s
deb / build-publish (push) Successful in 5m4s
ci / web (push) Failing after 10m16s
windows-host / package (push) Successful in 11m15s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 10m25s
KWin stores output configuration per *setup* — the exact set of connected
outputs, matched by EDID/connector — in `kwinoutputconfig.json`, and
`replicationSource` is one of the fields it saves and restores
(`OutputConfigurationStore::storeConfig` / `setupToConfig`). Our virtual output
carries a STABLE name on purpose, so once any setup has an entry making
`Virtual-punktfunk` a mirror of a physical head, KWin re-applies it to OUR
output on every session that reproduces that same monitor set — and only that
set, which is why the failure looks environment-dependent: a field report has
the stream cloning the panel whenever exactly one monitor is live, and behaving
normally the moment the others come back (a different setup key, a different
stored entry).

A mirroring output is not a desktop. KWin's `applyMirroring` overrides its scale
and render offset to the source's, so the stream carries the physical screen's
viewport at the physical screen's size instead of the mode the client
negotiated. The protocol says the rest out loud on `priority`: "an output may
not be in the output order if it's disabled or mirroring another screen" — so
the primary assertion this module works so hard to verify silently stops meaning
anything too.

Nothing we sent ever contradicted the stored value. The topology config enabled
our output, took priority 1 and disabled the others, but never stated the one
property that decides whether the thing is its own screen. Now it does:
`set_replication_source(ours, "")` rides along in the config we already build
(free, idempotent — an empty source is exactly what KWin resolves to "mirrors
nothing"), gated on management v13 where the request appeared, since wayland-rs
does not range-check requests and an out-of-range opcode would kill the
connection.

`extend`/`auto` issue no topology calls by design — the streamed output is meant
to join the desk without rearranging it — but a mirror is not an arrangement, it
is a broken source under every topology. So they get `clear_replication_source`,
which enumerates and applies ONLY when our output really is mirroring.

The device's `replication_source` event is now read, so the state is visible: a
mirrored streamed output names its source in a warn instead of leaving "the
stream just shows my monitor" as something only the reporter can see.

Verified on 192.168.1.25 (Ubuntu, cargo 1.96): `cargo test -p pf-vdisplay` 128
pass (7 in `kwin_output_mgmt`), `cargo clippy -p pf-vdisplay --all-targets
--locked -D warnings` clean, `scripts/xcheck.sh linux` clean, fmt clean. NOT yet
on-glass — no KDE box here reproduces a stored mirror; the reporter's setup is
the real test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:55:34 +02:00
enricobuehlerandClaude Fable 5 caa47e28e6 fix(client/decode): AV1 hardware decode stops silently opening libdav1d
apple / swift (push) Successful in 1m27s
ci / rust (push) Successful in 5m28s
android / android (push) Successful in 5m59s
ci / rust-arm64 (push) Successful in 2m24s
ci / web (push) Successful in 1m55s
arch / build-publish (push) Successful in 9m23s
ci / docs-site (push) Successful in 1m45s
deb / build-publish-client-arm64 (push) Successful in 2m23s
windows-host / package (push) Successful in 12m12s
windows-host / winget-source (push) Skipped
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
release / apple (push) Successful in 12m6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
deb / build-publish (push) Successful in 5m54s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
deb / build-publish-host (push) Successful in 5m19s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m44s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
windows-host / canary-manifest (push) Successful in 24s
docker / deploy-docs (push) Successful in 45s
docker / builders-arm64cross (push) Successful in 8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m7s
apple / screenshots (push) Successful in 5m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m17s
flatpak / build-publish (push) Successful in 5m38s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m7s
avcodec_find_decoder(id) returns the registry's FIRST decoder for the id, and
upstream orders the native av1 decoder LAST on purpose ("hwaccel hooks only,
so prefer external decoders" — allcodecs.c). All three hardware backends
selected by id, so every AV1 session opened libdav1d: a software decoder that
silently ignores hw_device_ctx and never calls get_format. Each frame then
failed the backend's hw-format guard and the session burned the demotion
ladder MID-STREAM — field-logged as 68 Vulkan fails → D3D11VA → 102 fails →
software, ~3 s of black — with "hardware decode active" already printed and
the D3D11 profile/pool probes all green. H.264/HEVC never hit this only
because their native decoders happen to be registered first.

Selection is now by capability: find_hw_decoder walks av_codec_iterate and
takes the first decoder whose avcodec_get_hw_config advertises the backend's
surface via HW_DEVICE_CTX, so a build without a usable hw decoder fails at
OPEN in milliseconds and the ladder runs there — the idiom the D3D11 probes
already follow. Registry order still wins among capable decoders, so
H.264/HEVC select exactly what they always did. The software path keeps the
id lookup on purpose: libdav1d is the fastest CPU AV1, and the native av1
decoder has no software path at all.

Every decode log now carries the selected decoder's name — decoder="av1" vs
decoder="libdav1d" is the whole diagnosis, and no log line said it. The
session log names the WIRE codec and drops the FFmpeg id for PyroWave
(ffmpeg_codec_id's fallthrough claimed codec_id=HEVC for wavelet sessions
that never touch FFmpeg).

The CPU lane also stops passing raw PQ off as a tone-map: software-decoded
frames deliberately never take the HDR10 swapchain, but a PQ stream there was
then shown UNtonemapped (washed out) with no warning — the pq-downgrade warn
keys off the swapchain answer — while the Detailed OSD badge claimed the
"HDR→SDR" tone-map that only the hardware lane's CSC runs. The presenter now
warns once when a PQ CpuFrame arrives, and the badge distinguishes
"HDR→SDR (raw)" (no tone-map pass) from the hardware lane's real "HDR→SDR".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:54:43 +02:00
enricobuehlerandClaude Fable 5 652abeb397 fix(host/audio): an unsatisfiable wiring plan waits for an endpoint change instead of hammering
Field case 2026-08: the display isolate invalidated the only real render
endpoint; the mic held the Steam Streaming Microphone, the Speakers were
blacklisted, and the capture loop re-ran the full wiring pass — three
IPolicyConfig SetDefaultEndpoint writes included — every 2 s for 8+
minutes, retrying a verdict that could never change.

- wiring_plan: a plan with no loopback is a typed structural verdict
  (Wiring::loopback_unsatisfiable + an endpoint-set fingerprint); the
  dead leftover() tier (byte-identical to real_hw()) becomes a real last
  resort that accepts ONLY the Steam Streaming Speakers, flagged
  loopback_last_resort — a known-silent-loopback QUALITY risk, never the
  cable/VoiceMeeter echo CORRECTNESS risks. excluded_from_loopback stays
  untouched (judge_default's mid-stream snap-back semantics).
- wasapi_cap: an unsatisfiable plan errors ONCE per topology with the
  render inventory, per-endpoint rejection reasons and only the remedies
  not already taken, then parks on a cheap enumerate-and-hash poll and
  re-plans the instant the set changes; transient failures get a real
  capped exponential backoff (2 s → 60 s, reset on success or set
  change); a last-resort capture re-plans on any set change and promotes
  the 30 s zero-packet breadcrumb to warn.
- audio_control: the recording default is asserted only when the plan
  changed or the default drifted — set_default_endpoint fires all three
  SetDefaultEndpoint roles unconditionally, so the 2 s loop silently
  stomped any operator recording-device change; the "attach one, or let
  the host install the Steam Streaming pair" warn (already satisfied in
  the field case) is replaced by the same diagnosis.

Verified: 19/19 wiring_plan tests (native rustc --test and the Linux CI
image via docker); both Windows files type-check and clippy clean
against wasapi 0.23.0 / windows 0.62.2 for x86_64-pc-windows-msvc via an
xcheck-style stub harness (the in-tree target check dies in
openh264-sys2's build script on macOS, as scripts/xcheck.sh documents).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:36 +02:00
enricobuehlerandClaude Fable 5 48511d1267 fix(abr): probe throughput is measured over the client's receive interval
The capacity probe divided client-side bytes by the HOST's burst
duration — a window wrong on both edges (base snapshotted before the
burst reached the host, frozen only when the ProbeResult landed, while
the host's clock stops the moment ITS send window closes, before the
switch/kernel queue finishes draining toward the client). On a 1 GbE
link a 2 Gbps burst target "measured" 1266 Mbps and set an 886 Mbps
climb ceiling the link could never carry — permanent for the session,
because set_ceiling never lowers.

The reassembler now stamps probe-scoped counters (bytes, packets,
first/last arrival, monotonic ns) at its FLAG_PROBE routing, so video
around the burst contaminates neither numerator nor denominator; the
throughput divisor is the client's first→last arrival interval, with
the host duration kept as the fallback when fewer than two probe
packets arrived. The user-facing speed test shares the corrected
computation (ProbeOutcome/PunktfunkProbeResult layouts unchanged;
elapsed_ms docs updated to the new semantics).

Two guards ride along:
- PUNKTFUNK_ABR_MAX_MBPS clamps inside set_ceiling — the one funnel
  every learned ceiling passes through — so a user cap binds no matter
  what any probe concludes.
- The controller latches decode_cap_kbps when two CONSECUTIVE backoffs
  carry decode-severe evidence (deep decode excursion or jump-to-live
  flush) at a similar pre-backoff rate, mirroring host_cap_kbps for the
  client decoder: a knee below the link ceiling was a permanent 30-60 s
  sawtooth costing a flush + dropped-frame burst per cycle (1440p120
  HEVC field case, knee ~490 Mbps). One spurious flush never latches;
  the cap re-probes on the CAP_REPROBE_WINDOWS clock, so it lifts when
  the decoder recovers.

Also rights the three stale "3 Gbps" probe-clamp comments (the host
constant has been 10 Gbps since MAX_PROBE_KBPS moved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:36 +02:00
enricobuehlerandClaude Fable 5 3e649d372e fix(capture/stall): the ETW witness testifies in QPC, so compose-silence stops convicting content
The stall classifier's present witness never worked: the consumer was opened
without PROCESS_TRACE_MODE_RAW_TIMESTAMP, so ProcessTrace converted every
event's TimeStamp to FILETIME regardless of the session's ClientContext=1 —
FILETIME ticks (100 ns since 1601) are ~4 orders of magnitude above QPC, so
every ts <= to_q comparison was false. summary() always printed etw=none,
window_counts() always returned presents=0/queue_adds=0 while present_history
was still true (satisfied by the unfiltered ring), and classify() therefore
convicted EVERY compose-silence hole as CONTENT-SILENCE; FRAME-GENERATION —
the class the program exists to catch — was unreachable. Two comments
asserted the wrong contract ("TimeStamp IS a QPC value"); both now state the
real one: ClientContext selects the session clock, RAW_TIMESTAMP is what
stops the FILETIME conversion on delivery.

Three adjacent defects fixed with it:

- summary() and window_counts() each took their own ring lock and their own
  (Instant::now(), qpc_now()) anchor, with OpenProcess syscalls between the
  two calls — the prose and the verdict could disagree about the same hole.
  Merged into window_report(): one snapshot, one anchor, both halves; the
  summary keeps its 300 ms lead-in, the counts keep the gap-only window, and
  the etw=/etw_presents=/etw_queue_adds= log fields are unchanged.

- present_history/queue_history meant "an event EVER sat in the ring" —
  satisfied by events arriving after the hole, or by a previous session's
  leftovers in the never-cleared static RING. Both flags now mean witness
  LIVENESS: at least one event inside a 5 s LOOKBACK ending at the hole's
  start, i.e. the witness demonstrably worked before the hole opened. The
  ring is cleared when a new session starts, so a dead session's events can
  never pose as the next one's history.

- window_counts() accepted only BltQueueAddEntry (1071) as queue history
  while summary() also took BltQueueCompleteIndirectPresent (1068); either
  proves the queue witness works, so the merged reader takes both.

The windowing math is factored into a pure count_window() (plain i64 tick
arithmetic) with unit tests, and the classify() matrix gains the live-witness
zero-presents case. Conviction thresholds are untouched.

Verified: scripts/xcheck.sh windows clippy (-D warnings, --all-targets) green
for pf-frame/pf-win-display/pf-capture/pf-vdisplay; native cargo check green.
The new Windows-gated tests type-check but need a Windows box to run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:36 +02:00
enricobuehlerandClaude Fable 5 0d004c4680 fix(host/handshake): the 4:4:4 gate names the encoder backend, not the capturer
capture_supports_444 was an encoder-backend fact (direct NVENC or PyroWave)
logged under a capture-ish name — a field report burned real time hunting a
capture problem because of it. The 'encode chroma' line now says
ingest_chain_supports_444, a requested-but-declined session logs WHICH gate
lost, and the console UI's Full chroma explainer names the real requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:29 +02:00
enricobuehlerandClaude Fable 5 8d7e273a96 feat(client/present): PUNKTFUNK_PRESENT_MODE gains explicit mailbox and fifo_relaxed arms, and the docs stop guessing
The env knob silently folded 'mailbox' and every typo into the default arm,
FIFO_RELAXED was not reachable at all, and clients/session/README.md claimed
the default is FIFO (it is MAILBOX with a FIFO fallback). An AMD-on-Windows
client always lands on FIFO because that driver offers no MAILBOX — now
documented at the picker and in the docs-site client table, next to the ABR
probe/ceiling knobs a field report went looking for and couldn't find.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:53:28 +02:00
enricobuehlerandClaude Opus 5 e726542f96 docs(winget): the vhost belongs in unom/infra, not on the box
ci / rust-arm64 (push) Successful in 1m23s
ci / web (push) Successful in 1m42s
ci / rust (push) Successful in 4m45s
ci / docs-site (push) Successful in 1m30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
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 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 28s
Step 2 told you to add the Caddy vhost by hand on unom-1. That instruction is
what broke the source: ~/caddy/Caddyfile is a copy that deploy-all.sh rsyncs
over from unom/infra, with no .git there to warn you, so the hand-added block
survived until the 2026-07-31 hardening commit rewrote the file from the repo's
own copy and deleted it.

Point step 2 at unom/infra and record how the failure presents, since it does
not look like an ingress problem from the client side: no vhost means no
certificate for that SNI, so Caddy answers with TLS internal_error (alert 80)
before sending one, and winget surfaces that as
WINHTTP_CALLBACK_STATUS_FLAG_SECURITY_CHANNEL_ERROR / 0x8a15003b.

Also note that port 80 is useless for diagnosing it — Caddy 308s every Host to
https including names it has never heard of — and give the SNI probe that does
work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 10:43:39 +02:00
enricobuehlerandClaude Opus 5 e0427a3bb6 feat(ci/android): a release tag without Play notes fails before it builds
ci / rust (push) Canceled after 5m55s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
android / android (push) Successful in 6m28s
Play does not show an empty "What's new" when the file is missing — it carries
the PREVIOUS release's text onto the new version. So the store listing ends up
describing a build nobody is getting, and nothing surfaces it except reading
the listing. That is the shape of the v0.22.3 notes announcing a feature the
tag never contained, and a soft warning in a log nobody reads does not prevent
it.

The gate runs FIRST in the job, before the ten-minute build: a miss costs a
second and leaves nothing half-published — no build, no assets on the Gitea
release, nothing on Play. It rejects three things: a missing file, a file
byte-identical to another release's (the same bug reached by copy-paste rather
than omission), and an empty or over-500-char one.

Length is checked here as well as in play-upload.py on purpose. The uploader
stays the last line of defence and is the only check android-promote.yml gets,
but it runs at step 9; this catches an unedited TEMPLATE copy at step 1. It
counts CHARACTERS, not bytes — Play's cap is 500 chars and `•` is three bytes
in UTF-8, so a `wc -c` check would have called the 356-char v0.23.0 notes 365
and can reject a legal file.

whatsnew/TEMPLATE.txt gives the file a starting point and says what the gate
does and does not enforce: it cannot tell whether the prose was ever edited, so
a copy that still reads "<The headline change>" ships exactly as written.

Canary stays exempt — no curated notes, and Play reusing text for internal
testers costs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 10:34:32 +02:00
enricobuehlerandClaude Fable 5 02a5bdb965 fix(gamepad): the virtual DualSense stops demanding a firmware update it cannot take
windows-drivers / probe-and-proto (push) Successful in 53s
apple / swift (push) Successful in 1m25s
ci / rust (push) Failing after 2m11s
windows-drivers / driver-build (push) Successful in 1m44s
ci / rust-arm64 (push) Successful in 1m38s
ci / web (push) Successful in 1m4s
android / android (push) Successful in 5m58s
ci / docs-site (push) Successful in 1m11s
apple / screenshots (push) Successful in 6m6s
arch / build-publish (push) Successful in 9m7s
deb / build-publish-client-arm64 (push) Successful in 1m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
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 5s
deb / build-publish (push) Successful in 4m57s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
deb / build-publish-host (push) Successful in 4m52s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / builders-arm64cross (push) Successful in 8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 18s
docker / deploy-docs (push) Successful in 26s
windows-host / package (push) Successful in 11m30s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m35s
The emulated pad's firmware-info feature report (0x20) advertised update
version 0x0154 — a 2021-era number. PlayStation Accessories compares it
against Sony's latest (0x0630 as of 2026-08) and offers an Update that can
only end in "can't complete the update", since the virtual pad speaks no DFU;
libScePad titles (Stellar Blade) surface the same nag in-game. A real pad
plugged in directly reads up to date, which made the prompt look like
punktfunk corrupting the controller.

The old value was chosen to keep the kernel and SDL on the flag0
COMPATIBLE_VIBRATION convention, but parse_ds_output has since learned the
firmware-≥2.24 COMPATIBLE_VIBRATION2 flag as well, so nothing depends on
looking old anymore. Advertise 0x0999 — above anything Sony has shipped and
comfortably ahead of their ~yearly cadence — instead of chasing their exact
latest, which would resurrect the prompt on every Sony release. Writers that
read the version now use the v2 flag; both conventions land in the same
rumble plane. Bumped in both copies of the blob (host uhid + Windows driver);
the DualSense Edge shares them, and its own versioning (0x0217 latest) sits
below the new value too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:24:20 +02:00
enricobuehlerandClaude Opus 5 09b9ee8f53 feat(ci/android): a release tag publishes to Play production, not alpha
ci / rust (push) Successful in 5m15s
ci / rust-arm64 (push) Successful in 2m3s
ci / web (push) Successful in 1m15s
ci / docs-site (push) Successful in 1m43s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
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 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
android / android (push) Successful in 5m55s
docker / builders-arm64cross (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m10s
docker / deploy-docs (push) Successful in 36s
Production access came through on 2026-08-01. Until now a `vX.Y.Z` tag could
only reach `alpha` and someone had to promote it by hand in the Console; it now
goes to `production` at 100% (`completed`). Canary is unchanged on `internal`,
and its run-number versionCodes always outrank production, so testers keep
getting the newer build.

A tag therefore reaches real users with no further click. What keeps that
honest: the tag is only pushed once every platform is green, and Play reviews
each production release before it ships. Ramping instead is `--status
inProgress --user-fraction 0.2` on the upload step.

Play's "What's new" gets its own file, docs/releases/whatsnew/vX.Y.Z.txt — the
vX.Y.Z.md body is ~34 KB against a 500-char cap, so it cannot be reused. Only
tags have one; canary is a moving target and Play carrying the previous text
over is fine for internal testers. Same freeze rule as the notes: once the tag
exists, the file describes what that versionCode shipped.

android-promote.yml is the lever for everything that is not a fresh tag —
promote a tested build, halt a rollout, or roll production back onto an older
versionCode. It is separate from android.yml because promotion must not
rebuild, and an `if:` on all ten build steps is worse than one small workflow.
dry_run defaults to true, so a mis-typed versionCode validates and deletes the
edit instead of publishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:29:20 +02:00
enricobuehlerandClaude Opus 5 43e3c7b69f feat(ci/android): play-upload can attach release notes and promote a build
Two things it could not do, both needed now that a tag ships to production.

Release notes: it never sent `releaseNotes`, so Play's "What's new" was whatever
the previous release said. It now takes --release-notes-file, and refuses text
over Play's 500-char-per-language cap with the actual count — that check has to
happen before the upload, because the API only rejects it at commit, by which
point the AAB is already on Play.

Promotion: --promote assigns a versionCode that is already on Play instead of
uploading, so what reaches production is the byte-identical artifact the testers
ran. Rebuilding would mint a fresh versionCode from possibly-newer sources and
ship something nobody tested. --promote-from asserts the code really is on that
track (a typo'd versionCode now fails before it touches production) and clears
that track in the SAME edit, so the build is never active on both at once.

--user-fraction comes along because --status inProgress is an API error without
it; it is validated as strictly between 0 and 1 rather than left to Google.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:29:04 +02:00
enricobuehler ea469162f9 fix(docs): three doc comments start a markdown list they never meant to
apple / swift (push) Successful in 1m14s
ci / rust-arm64 (push) Successful in 3m31s
ci / web (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m53s
apple / screenshots (push) Successful in 5m57s
ci / rust (push) Successful in 8m9s
android-screenshots / screenshots (push) Successful in 1m54s
android / android (push) Successful in 6m53s
deb / build-publish-host (push) Successful in 5m17s
decky / build-publish (push) Successful in 42s
deb / build-publish-client-arm64 (push) Successful in 1m33s
release / apple (push) Successful in 9m50s
arch / build-publish (push) Successful in 11m34s
deb / build-publish (push) Successful in 11m20s
sbom / sbom (push) Successful in 24s
linux-client-screenshots / screenshots (push) Successful in 5m18s
docker / builders-arm64cross (push) Successful in 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
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 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m42s
flatpak / build-publish (push) Successful in 6m44s
docker / deploy-docs (push) Successful in 1m25s
web-screenshots / screenshots (push) Successful in 3m57s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m6s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m51s
windows-host / package (push) Successful in 11m36s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m38s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m1s
Windows clippy on the v0.23.0 tag: `doc_lazy_continuation` in
crates/pf-client-core/src/audio_wasapi.rs:37. The cause is one line break —
`+ wire cost.` begins a line, so the markdown parser reads `+` as a bullet
marker and the following line becomes a lazy continuation of that list item.

Fixed by reflowing so the `+` is mid-line rather than by taking clippy's
suggested indent: indenting would keep the accidental bullet in the rendered
docs, which is the actual defect. Same treatment for the two siblings a sweep
of every `///` line found, both invisible to the Linux gate for their own
reasons:

- gamestream/audio.rs:237 — `+ libopus;` at line start, on the
  cfg(not(linux/windows)) stub, so only a macOS clippy would ever see it.
- mgmt/tests.rs:1653 — `404.` at line start IS an ordered-list marker
  (CommonMark: 1-9 digits + `.`), and it is behind cfg(test), so only an
  --all-targets run sees it.

This is the [[Windows clippy sees what the Linux gate structurally cannot]]
shape again: audio_wasapi.rs is cfg(windows), so no amount of Linux CI would
have caught it.

Verified: a scanner over every .rs doc comment in the tree now reports zero
line-initial list markers with an unindented continuation; rustfmt clean (it
does not reflow doc comments, so these edits are stable).
2026-08-01 01:30:45 +02:00
enricobuehler 213b353dad docs(release): the 0.23.0 notes link to the docs site that exists
ci / rust (push) Canceled after 1m18s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
Both links pointed at https://punktfunk.unom.io/docs/... — the marketing host,
which 404s (verified live; the docs site serves from docs.punktfunk.unom.io, as
README.md has used throughout). The Updating link was the one a reader following
the one-click update section would actually click.

The echo link additionally pointed at the docs ROOT rather than the page it
names; both now resolve to their real slugs, confirmed against
docs-site/content/docs/{echo,updating}.md and by fetching them (200/200, vs 404
for the old host).

Release body re-synced by body-only PATCH.
2026-08-01 01:25:01 +02:00
enricobuehler 23ec0822d8 docs(release): the 0.23.0 notes stop calling gamescope HDR a handheld feature
ci / web (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
Two wording fixes to the shipped v0.23.0 notes, which docs/releases/README.md
explicitly allows after the tag — the file stays authoritative and the announce
step re-syncs from it.

"HDR turns itself on for Steam Deck and other gamescope handhelds" framed a
LINUX HOST change as a device one. What 19392918 actually did is default
PUNKTFUNK_GAMESCOPE_HDR on and get the patched gamescope onto the Linux install
routes (Bazzite + Arch sysext, the nix module, the Deck's on-device build
script) — which covers any host running its games through gamescope: a Bazzite
or SteamOS box in Game Mode, an HTPC, a desktop, not only a handheld. The
lead-in carried the same framing and mattered more, since everything above the
first `##` is what the Discord embed shows.

Also rewords the mic-mute lead-in ("stop the room being heard" read oddly).

No claim changes: same features, same scope, same release. The live release body
needs a re-sync — done via a body-only PATCH rather than an announce dispatch,
since announcing also posts to Discord and publishes the stable update manifest,
neither of which should fire before the fleet is green.
2026-08-01 01:21:54 +02:00
enricobuehler 49bbdcf4ef chore(release): bump workspace version to 0.23.0
audit / bun-audit (plugin-kit) (push) Successful in 30s
audit / bun-audit (sdk) (push) Successful in 31s
audit / bun-audit (web) (push) Successful in 33s
audit / pnpm-audit (push) Successful in 28s
audit / cargo-audit (push) Successful in 1m17s
audit / docs-site-audit (push) Successful in 1m2s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m52s
apple / swift (push) Successful in 1m34s
audit / license-gate (push) Successful in 5m54s
ci / rust-arm64 (push) Successful in 6m26s
android-screenshots / screenshots (push) Successful in 3m2s
android / android (push) Successful in 4m59s
ci / rust (push) Canceled after 18m52s
decky / build-publish (push) Successful in 29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
deb / build-publish (push) Successful in 5m57s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
deb / build-publish-host (push) Successful in 4m26s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish-client-arm64 (push) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 12s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 11s
release / apple (push) Successful in 10m31s
windows-host / winget-source (push) Skipped
sbom / sbom (push) Successful in 19s
linux-client-screenshots / screenshots (push) Successful in 3m59s
arch / build-publish (push) Failing after 44s
windows-host / canary-manifest (push) Successful in 20s
windows-host / package (push) Successful in 15m12s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 34s
apple / screenshots (push) Canceled after 4m13s
web-screenshots / screenshots (push) Successful in 4m56s
flatpak / build-publish (push) Canceled after 11m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m11s
A minor bump: 133 commits since v0.22.3 across 400-odd files. The wire grew two
negotiated abilities (slice-streamed access units, phase-locked capture); the
Android presenter was rebuilt; the microphone path was rebuilt end to end on
every client; the web console moved from polling to the host's event stream;
gamescope HDR is on by default; HDR and 4:4:4 stopped being mutually exclusive
on Windows; and the one-click update apply that missed the 0.22.3 cut ships here.
The canary base is already 0.23 — release.yml derives it as one minor ahead of
the latest stable tag — so this is the version the canary channel has been
publishing against all along.

Lock touched for the 32 workspace members only, via `cargo update --workspace`
rather than a sed: `wasapi` is itself at 0.23.0 and `rustls` at 0.23.41, so the
version space we are moving into is occupied by third-party crates this time.
Diff against origin/main is versions-only, 32 insertions and 32 deletions;
`cargo metadata --locked` resolves; `cargo fmt --all --check` clean in both the
main and the packaging/windows/drivers workspaces.

Notes at docs/releases/v0.23.0.md, per docs/releases/README.md — authored with
the bump so CI's ensure_release seeds the body at tag creation.
2026-08-01 01:01:16 +02:00
enricobuehler 3c509d48c9 feat(core/abi): report_phase earns its version — C ABI 13 -> 14
`punktfunk_connection_report_phase` (fa822744, coherence tail 1d31e4c5) and the
`PUNKTFUNK_CLIENT_CAP_PHASE_LOCK` mirror const (7cf71dd2) grew the embeddable C
surface without moving ABI_VERSION. Every prior additive entry in that doc list
bumped it — v3's wake_on_lan, v5's next_rumble2, v8's clipboard block, v13's
send_pen — precisely so an embedder can ask punktfunk_abi_version() whether the
function it wants to link is there. Left at 13, the one number that answers that
question said "no report_phase" about a core that has one.

The header was already regenerated with both symbols, so it carried the new
surface under the old number; the regen here changes exactly the #define and its
doc block and nothing else, which also confirms the committed header was
otherwise current.

Additive and capability-gated: the host arms on report receipt, the wire grows
only PhaseReport (0x32) — a control message an old host never reads — and a
strict-prefix append on the 0xCF host-timing tail, so WIRE_VERSION stays 2. No
in-tree caller compares ABI_VERSION against a literal; mgmt/tests.rs asserts
against the symbol.

Verified: cargo build -p punktfunk-core regenerates include/punktfunk_core.h to
exactly this diff; punktfunk-core lib suite 134/134; rustfmt clean. The C ABI
harness cannot run on this Mac (`ld: library 'opus' not found`, the documented
pre-existing local linker gap) — CI's Linux leg is the gate for it.
2026-08-01 00:58:11 +02:00
enricobuehler b8d987b145 docs(release): the v0.22.3 notes stop claiming one-click updating it never shipped
The v0.22.3 tag is `1c836afc`, cut 14:36. The one-click apply work landed on
main between 15:01 and 16:28, and this file was then edited at 17:23 (5790a3e3)
to announce it — three "New"/"Under the hood" claims about a build that does not
contain them. The live release body is still the pre-edit text, so nothing wrong
has been published yet; but `announce.yml` re-asserts this file over the release
on every announce, and 0.22.3 has not been announced. Announcing it would have
published the false version and put its lead-in ("can install it for you where
the platform allows") into the Discord embed.

Verified against the tag rather than the commit graph: `update.available` is in
`1c836afc`, `update.applied` is not, and `mgmt/tests.rs` there literally probes
`/api/v1/update/apply-does-not-exist-yet` while `auth.rs` carries the comment
"today it is only a check". The Updates card itself (b275e6d3, cc015626) IS in
the tag, so that bullet stays; only the apply half goes.

The removed material is not lost — it is in docs/releases/v0.23.0.md, which is
the release that actually ships it.
2026-08-01 00:58:11 +02:00
enricobuehler 2d3f9f8690 Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo
ci / docs-site (push) Successful in 1m28s
ci / web (push) Successful in 2m36s
ci / rust (push) Failing after 2m43s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
deb / build-publish (push) Successful in 3m30s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 56s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
ci / rust-arm64 (push) Successful in 4m10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m35s
deb / build-publish-client-arm64 (push) Successful in 3m38s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 30s
arch / build-publish (push) Successful in 7m0s
android / android (push) Canceled after 7m27s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 5m44s
release / apple (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 3m12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1m40s
windows-host / package (push) Canceled after 7m13s
windows-host / canary-manifest (push) Canceled after 0s
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
2026-08-01 00:54:20 +02:00
enricobuehler 951bcec650 Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo 2026-08-01 00:47:50 +02:00
enricobuehlerandClaude Fable 5 badda070ef docs(android): the stats-array KDoc counts the doubles it actually returns
ci / rust-arm64 (push) Successful in 1m25s
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 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
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 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
ci / web (push) Successful in 2m12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
ci / docs-site (push) Successful in 2m1s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
android / android (push) Canceled after 6m2s
ci / rust (push) Canceled after 5m55s
nativeVideoStats grew to 33 with the decode split and the overflow counter, but
its own KDoc still promised 30 and StatsOverlay still said 26 — a count that
was already two extensions stale before this one. Both now list the full index
set, with the JNI KDoc named as the authoritative one so the next extension has
a single place to update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:47:04 +02:00
enricobuehlerandClaude Fable 5 46bcfc3041 fix(scripts/windows): the installer-run scripts go back to pure ASCII
CI's guard fired on build-web.ps1: an em-dash in the header comment. The
rule exists because PowerShell 5.1 mis-parses non-UTF-8-locale files, and
the check covers every script the installer can run, comments included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:41:07 +02:00
enricobuehlerandClaude Fable 5 f3a39df7b3 test(host/audio): prove a reopen recovered with a live uplink, not one frame
`reopens_after_push_death` failed about one run in nine, and widening its
timeout did not help — the earlier commit blamed the backoff and was wrong.

The pump drops whatever queued while it was down: audio from before the
device came back is stale, so a fresh instance drains the channel right
after opening. The harness counts `opens` from the START of the open, so
the moment the test sees the counter move, the pump has not reached that
drain yet. The single frame it then sent landed inside the drain window and
was discarded exactly as designed, leaving the test waiting for audio that
was never going to arrive.

So the test now keeps feeding, which is what a real uplink does and what
the drain assumes. The sequence advances each time or the de-jitter reads
the repeats as duplicates and drops them for a second, correct reason.

Production behaviour is unchanged: this was the test asserting something
the pump never promised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:41:07 +02:00
enricobuehlerandClaude Fable 5 f9c56eaf5c feat(android): Automatic prefers AV1 where the silicon says it should
ci / docs-site (push) Successful in 2m12s
ci / web (push) Successful in 2m36s
ci / rust-arm64 (push) Successful in 3m20s
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 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
android / android (push) Successful in 6m13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / rust (push) Canceled after 6m49s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Successful in 3m42s
arch / build-publish (push) Successful in 7m46s
deb / build-publish (push) Successful in 5m58s
deb / build-publish-host (push) Successful in 5m58s
apple / swift (push) Successful in 6m9s
apple / screenshots (push) Canceled after 3m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 6m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m21s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
The P3 format A/B (NP3 ↔ RTX 4090, identical conditions) measured AV1 ~1.2 ms
faster end-to-end than HEVC with slightly better codec-pure decode time. Under
"Automatic" the client now sends AV1 as its soft preference when this device
hardware-decodes it (the advertised AV1 bit is already gated on a real,
non-blocked hardware decoder) AND it lacks FEATURE_PartialFrame — a
partial-frame device keeps HEVC, whose slice-progressive overlap AV1 cannot
ride (no slices, the chunked poll never arms). The host honors the preference
only inside its probed shared codec set, so an AV1-less encoder still resolves
HEVC, and an explicit user choice wins unchanged. The codec picker caption
mirrors the same rule so "Automatic" says what it does on this device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:40:58 +02:00
enricobuehlerandClaude Fable 5 b69ef02f4d fix(android): a cold-start connect no longer loses HDR or the native mode
A punktfunk:// deep link can reach the connect before the activity is attached
to its display; context.display then throws and the display probes silently
fell to their worst answers — displaySupportsHdr advertised SDR (the whole
session pinned to 8-bit BT.709) and nativeDisplayMode fell back to 1080p60.
Seen live on the NP3: one cold connect advertised hdr=false, the warm retry
true, nothing in the log either way.

Both probes now share probeDisplay: the context display when attached, else
DisplayManager DEFAULT_DISPLAY — which IS the panel on phones and TVs; the
activity-display distinction only matters on multi-display setups, where the
attached path still wins whenever available. Each fallback leg logs itself, so
a downgraded session can never again be silent about why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:40:58 +02:00
enricobuehlerandClaude Fable 5 4d45a96ff9 feat(encode/windows): sub-frame readback defaults on where the GPU supports it
Linux parity, validated by the .173 on-glass A/B (no regression; the win goes
to clients that actually consume slice-progressive parts): the caps probe now
reads NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK and seeds resolve_subframe with it
instead of a hard false, so PUNKTFUNK_NVENC_SUBFRAME becomes the tri-state
escape it already is on Linux, and the split×sub-frame arbitration hears the
real forced flag for its log severity.

The A/B also caught the default path opening every session with a WARN: the
submit-time idr_hint missed that NVENC emits the session-opening frame as an
IDR regardless of pic flags, so frame 1's early chunks went out unflagged and
the divergence check fired at every start. The hint now carries the Linux
twin's `opening` term.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:40:58 +02:00
enricobuehlerandClaude Fable 5 849baea881 feat(android): the stream re-votes its refresh rate and touches keep their curvature
surfaceChanged re-asserts the frame-rate vote (FIXED_SOURCE; ALWAYS only on the
TV low-latency path, mirroring the native hint) — a buffer-geometry change on
some OEM builds silently drops the 120 Hz pin mid-stream. Touch passthrough and
direct-pointer moves forward the MotionEvent historical samples before the
current point, so a fast swipe lands with its real shape; the trackpad path
keeps summed deltas on purpose — its acceleration curve is tuned for per-frame
dt and historicals would change the feel, not the sum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:40:58 +02:00
enricobuehlerandClaude Fable 5 c767a904d2 feat(android): the decode stage answers where its time goes, HUD on or off
P3 decode science: every AU is stamped as its last piece enters the codec, so
the decode stage splits into feed (received→queued: hand-off + input-slot wait)
and codec (queued→decoded: the decoder alone — a slice head start would show
here). The split + an always-on capture→decoded e2e ride the 1 Hz pf-present
line, so a wireless HUD-off A/B reads everything from logcat; the HUD equation
gains the split (indices 30/31), the skipped counter tells benign newest-wins
pacing from parked-AU overflow (32), and a −2-refresh Apple-HUD-equivalent twin
makes iPhone comparisons honest (Apple shaves its OS floor; Android shows raw).

Connect now logs the per-mime decoder picks + FEATURE_PartialFrame verdicts
(tag pf.caps) — on the NP3 all three c2.qti low-latency decoders say no, so
parts delivery never arms and P2d is inert there; a debug.punktfunk.force_parts
sysprop overrides the probe for the on-glass question the API cannot answer.
Forced on glass: c2.qti accepts PARTIAL_FRAME pieces without erroring but only
assembles them — codec time unchanged, so the overlap is dead on SM8735 either
way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:40:58 +02:00
enricobuehlerandClaude Opus 5 0985726415 fix(host,web): an empty update channel stops looking like a broken host
android / android (push) Canceled after 36s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 37s
ci / rust (push) Canceled after 37s
ci / rust-arm64 (push) Canceled after 36s
ci / web (push) Canceled after 34s
ci / docs-site (push) Canceled after 34s
deb / build-publish (push) Canceled after 19s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / canary-manifest (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
windows-host / package (push) Canceled after 33s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 5m43s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m59s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m9s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 2m19s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
A channel nobody has published to answers `manifest.json` with a 404, and the
check reported that the same way it reports a dead registry or a bad signature:
"Last check failed: feed returned HTTP 404". Every host on the stable channel
shows it today, because the stable manifest only publishes when someone
dispatches `announce` for a release tag — so the first thing an operator sees
from the new Updates card is a red failure caused by nothing being wrong.

The shared checker now distinguishes the two. `feed::fetch_manifest_blocking`
returns a typed `FeedError` instead of a string, and only a 404 on the manifest
ITSELF becomes `NotPublished` — a 404 on the detached signature still fails
loudly, because that is the half-published pair the manifest-then-signature
upload order can produce, and it must stay fail-closed.

The host carries that through as `UpdateStatus.not_published`, mutually
exclusive with `last_error`. It is benign only while no manifest has ever been
seen for the channel: once a check has succeeded, the same 404 means the feed
LOST a document it used to serve, which stays an error. The console then shows a
plain sentence naming the channel instead of the failure banner, and "None
published yet" rather than "Not checked yet".

The Linux client makes the same distinction but deliberately NOT the same
choice: `--check-update` keeps exiting 1 and keeps `error` set, because its
consumer is a shell script and an empty channel is the absence of evidence that
this build is current — not a confirmation that it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:37:56 +02:00
enricobuehler 23f1debe69 Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo 2026-08-01 00:21:24 +02:00
enricobuehlerandClaude Opus 5 ff190e9825 fix(web): the logs filter row stops touching the card's edge on desktop
windows-host / package (push) Failing after 28s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 2m13s
ci / docs-site (push) Successful in 2m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
ci / rust-arm64 (push) Successful in 3m30s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 34s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
deb / build-publish (push) Successful in 3m43s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
deb / build-publish-host (push) Successful in 4m4s
docker / builders-arm64cross (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / deploy-docs (push) Successful in 27s
deb / build-publish-client-arm64 (push) Successful in 3m1s
ci / rust (push) Successful in 6m29s
arch / build-publish (push) Successful in 7m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m42s
Found on glass. The logs card has no CardHeader, so it puts the top padding back
itself — but only at one breakpoint. `CardContent` is `p-4 pt-0 sm:p-6 sm:pt-0`,
and tailwind-merge resolves conflicts only within the same variant: a bare
`pt-6` cancels `pt-0` and leaves `sm:pt-0` standing. Measured on .173: 24px of
top padding at 420px wide, 0px at 1280px, with the level filters and the search
box sitting flush against the card border.

It is the same trap `components/ui/card.tsx` documents for `p-0` — a variant
cancelling its unprefixed counterpart and nothing else — just in the other
direction, so the note now points both ways. This card was the only offender.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 f66de3eba4 fix(web): close the last two high-severity items from the closeout audit
**The streamed-screen pin could still be clobbered by three other write paths.**
Deferring it to the server's value on Save fixed the reported sequence but not
the general case: the draft is only re-seeded while it is CLEAN, so once there is
an unsaved edit its `capture_monitor` is frozen at whatever it was before the
operator used the picker — and `applyAxis` (which spreads the last saved policy),
the built-in preset switch and the custom-preset apply all put that stale value
back. Every write path reads `serverCaptureMonitor()` now; no path spreads the
draft's copy.

**The session⇄game grace input had no accessible name.** That card has its own
`Field` and only DisplayCard's was fixed, so the number input was still announced
as an unnamed spin button. Same treatment: `htmlFor`/`id` for the single control,
`fieldset`/`legend` for the two button groups. Verified in a browser — zero
inputs without an accessible name across the Displays page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 f2e1b9872c fix(web): four "fixes" from this branch that did not actually fix anything
A verification pass re-read every finding from the original sweep against the
code on this branch rather than against the commit messages. It found that four
of them were still broken, two because the edit I made was inert. Commit
messages claim; code decides.

- **The Storybook typecheck was never on.** `tsconfig.json` listed `.storybook`
  as a bare directory name, and tsc silently skips dot-prefixed directories in
  that form — so the entry typechecked nothing at all. Proved it by planting
  `export const __probe: string = 1` in `.storybook/preview.tsx` and watching
  `bun run lint` pass. `.storybook/**/*` is what actually pulls it in; the same
  probe now fails as it should.

- **The Moonlight stale-PIN reset was a no-op.** `submit.reset()` sat at the top
  of `onSubmit`, immediately before `submit.mutate(...)` — which moves the status
  to pending in the same update, so it cleared a flag that was already changing.
  The green "PIN sent" note therefore still greeted the next pairing attempt over
  an empty PIN box. It now resets on the transition that actually matters:
  `pin_pending` going false → true.

- **The session⇄game controls had the enforcement flag inverted**, and I never
  touched it. `enforced.length === 0 || …` reads an EMPTY list as "this build
  enforces everything", when the contract says the opposite in as many words:
  "Empty on a platform with no launch path (macOS), so the console can say so
  instead of offering a switch that does nothing". On exactly the platform the
  flag exists for, every control stayed live and reported success for an axis the
  host would never act on. Absent still means "assume it acts" — that is the
  compatible reading for an older host, and a different case from present-empty.

- **Logout stopped revoking after a restart.** The epoch was a module-level
  counter starting at 1, so it revoked within one process run and then reset —
  and since the seal key derives from the stable mgmt token, a cookie captured
  before a restart unsealed fine and was accepted again for the rest of its
  7-day TTL. One service restart undid the whole fix. It persists next to the
  host's config now. Verified: log out, restart the console, the captured cookie
  still 401s, a fresh login still works.

Two more the pass rated as partial, both worth closing:

- The plugin-UI response filter was a denylist of four header names, so
  `Clear-Site-Data` sailed through — a plugin error page could wipe `pf_session`
  and sign the operator out of the console, on our own origin, because the iframe
  is same-origin by design. It is an allowlist now; a plugin-supplied CSP,
  `X-Frame-Options` or CORS header no longer speaks for us either.

- A half-configured TLS setup now refuses to start instead of logging a warning
  and serving anyway. Neither shape can work — one path missing puts the login
  password on the LAN in the clear, and PUNKTFUNK_UI_SECURE without TLS marks the
  cookie Secure so the browser drops it and login can never stick. Exiting with a
  reason beats a console that looks fine and is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 dc57aa653c feat(web): the console can say what just happened, and hand a phone the way in
**Recent activity.** The console could describe the present — a status snapshot —
but never the recent past. A client that connected and left while you were on
another page left no trace anywhere you could look, and the host's own log is a
developer artifact rather than a narrative. The event stream was already open for
cache invalidation, so a feed costs one ring buffer next to it: every frame is
recorded, labelled per kind, and rendered newest-first on the dashboard.

Deliberately in-memory and bounded to 200. It starts empty on a page load and
fills as things happen, which is the honest shape for a live tail — an audit
trail would need the host to keep one, and pretending otherwise would be worse
than not having it.

**Connect a device.** The console knew the host's address and identity all along
and never offered either in a form you could hand to a phone: pairing meant
reading an IP off the Host page and retyping it on a couch. There is a card now
with the address and a `punktfunk://connect/<uniqueid>` deep link, both
copyable — the link is the shipped client grammar
(clients/shared/deeplink-vectors.json), so an installed client opens straight
onto this host. No QR: rendering one needs an encoder we do not bundle, and a
wrong QR is worse than none.

**Installable.** A web manifest and the theme/apple meta tags, so the console can
live on a phone's home screen — which is where it is used from as often as from a
desk. No service worker on purpose: an offline shell for a console whose every
screen is live host state would only ever show stale numbers convincingly. The
manifest is reachable without a session (install needs it, and it says nothing
the login page doesn't); /api stays gated, verified.

Verified in a browser: three host-emitted events appear in the feed with the
right labels, the deep link renders and copies as
`punktfunk://connect/abc123`, and the manifest serves 200 as
application/manifest+json while /api/v1/host still answers 401.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 b8f603c8a1 feat(web): the numbers behind "it's slow to start" and a way to clean up after a plugin
Three things the host already reports and the console never showed.

**Stream diagnostics.** `RuntimeStatus.stream` has carried the session bring-up
time, the last mid-stream resize cost, the client's FEC parity floor and the
packet size for as long as the endpoint has existed, and the dashboard showed
none of them. So "it takes ages to start" and "it hitches when I change
resolution" had no number attached anywhere in the console — you had to take a
stats capture to see a value the status endpoint was already returning. The two
timings are native-plane only and null until the first frame lands, so each
appears once it means something.

**Loss and FEC recovery while the capture runs.** The health chart existed but
only in the saved-recording view, which is backwards: dropped frames and FEC
recovery are what you watch a live capture for. It now sits under the latency and
throughput charts on the live card, keeping the GameStream caveat (only `frames`
is instrumented on that plane).

**Provider-owned library entries.** A plugin can sync entries into the library,
and the host then refuses to edit or delete them one at a time — correct, and
completely opaque once the plugin is gone: its games sit in the library with no
console-side way to remove them. `DELETE /library/provider/{provider}` is the
documented clean-uninstall path and nothing called it. There is a card now that
names each provider, counts what it owns, filters the grid to it, and removes its
entries in one go.

Also: the dashboard's PIN tile really does say "Waiting"/"None" now. The earlier
commit added the strings but the edit that was supposed to use them silently did
not apply, so the tile still rendered a bare "●". Caught by auditing every
message key for a call site — the other 543 are wired.

Verified in a browser: the providers card shows, counts, and filtering hides
non-provider entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 0751265105 fix(web): editing a library entry warns before it wipes what the console cannot see
`PUT /library/custom/{id}` replaces the whole entry — the host assigns
`slot.prep = input.prep` and `slot.detect = input.detect` outright
(library/custom.rs). But `GET /library` returns a `GameEntry`, which carries
neither field, so the console builds its payload from a read model that has
already lost them. Editing a title to fix a typo silently cleared any prep/undo
commands and detection hints the entry had.

The console cannot round-trip what the read API will not tell it, so this is a
warning, not a fix: the edit form now says plainly that saving replaces the entry
and that anything configured outside the console will be cleared. The actual fix
is host-side — expose `detect` and `prep` on the library read model — and is
noted in the code where it belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 b9b0df349d fix(web): the charts stop lying about time, and logging out logs you out
The stats charts drew every sample as an evenly-spaced slot, because recharts
defaults to a category axis. A capture that idled for two minutes rendered that
gap as a single step — so the one view you open specifically to find where the
time went was the view least able to show it. All three charts use a numeric time
axis now, so the spacing is the elapsed time.

They also joined samples across a session boundary into one continuous line,
implying a continuity that never existed: the stream stopped and somebody else
started a new one. A capture is split at each `session_id` change now. And the
live card plotted the whole capture-so-far every 2 s, re-serialising and
re-plotting an unbounded series for a capture left running all evening; it plots
a bounded tail and says so, with the full series still in the saved recording.

Logging out only deleted the browser's copy of the cookie. The session is
stateless, so a value captured beforehand — a shared machine, a shell history, a
TLS-inspecting proxy — stayed valid for its full 7-day TTL and there was nothing
the operator could do about it. Sessions carry an epoch now and logging out bumps
it, which invalidates every cookie issued so far. Verified end to end: a captured
cookie works, survives nothing across a logout, and a fresh login still works.

The rest:

- The update card could not show its own timeout warning. It was suppressed by a
  `job` field read from the last snapshot — which, when the host has gone away
  mid-job, is exactly the case the warning exists for. Nothing ever cleared the
  applying state either, so the card waited forever with no way out; there is a
  button now. "Check now" also surfaces the host's 429 instead of looking dead.
- A running install survives a reload: the job id lived only in component state,
  so refreshing lost sight of an install that was still running while the Install
  buttons stayed armed against a host that answers 409. The host keeps the list —
  ask it.
- An all-sources-failed catalog said "no plugins available". That is a successful
  request carrying nothing, not an empty store; it names the sources that failed.
- The Installed tab rendered "vundefined" for a plugin with no recorded version
  (nullable in the contract, typed required here).
- The Displays "In effect" badges were computed from the local draft, so they
  restated the operator's unsaved edits back to them as though the host had
  adopted them. They read the API's `effective` now. A failed background poll no
  longer replaces a form someone is editing, and leaving the page with unsaved
  edits prompts — the old `beforeunload` guard never fired for in-app navigation,
  which is how you actually leave.
- Enter or Space on a preset's rename/update/delete icon applied the preset
  instead of running the action: keydown bubbled to the card.
- Dates follow the console's locale, not the browser's. The dashboard's
  PIN-pending tile says "Waiting"/"None" instead of "●"/"—".
- The Bun entry warns when TLS is half-configured, or when PUNKTFUNK_UI_SECURE
  is set without it — both of which silently break login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 de17ceb8f8 fix(web): a newly installed plugin shows up on its own, and the display form can be used without a mouse
Installing a plugin left the sidebar unchanged until a reload. The reason is
timing, not caching: the host restarts the scripting runner AFTER the job reports
done, and the plugin only registers its UI once that comes back — several seconds
later, by which point the one-shot invalidation had already run and found the old
list. The nav then waited out the 30 s idle poll, which in practice meant "until
I reloaded". Anything that changes the installed set now switches the directory
to a 2 s poll for a minute, so the entry lands about a second after the plugin
actually comes up. Measured end to end in a browser: 29 s → 7 s, with the plugin
registering at 6 s.

The plugin entries also never animated. They are rendered outside the `motion.nav`
that carries the variants and the stagger, so they inherited neither and simply
appeared — most visibly in exactly the case above, where one shows up in a nav
that is already on screen. They get their own animation container now, matching
the main nav. (A motion-wrapped div around the link, not `motion(Link)`, which
erases TanStack's typed `params`.)

The accessibility pass on the display form, where the console's densest controls
live:

- The Custom block's numeric inputs had a `<label>` with no `htmlFor` next to an
  `<input>` with no `id`, which labels nothing at all — a screen reader announced
  them as unnamed spin buttons. Single controls are paired properly now; the
  button groups became real `<fieldset>`/`<legend>`, which is what they are.
- Every option group signalled its active choice with fill colour alone. They
  carry `aria-pressed` now, so the state is available to assistive tech and not
  only to people who can compare two button variants.
- `QueryState`'s error branch is a live region, so a query that fails announces
  the failure instead of silently swapping one region for another.
- Motion honours `prefers-reduced-motion` instead of overriding it.
- `<html lang>` follows the locale instead of claiming "en" while the app renders
  German. Verified: switching to de flips the attribute.
- "Close menu", "Language" and "Loading" went through the message catalogue.

Also: ten dead message keys removed (a whole removed Clients page and the old
Settings token field), and the README no longer tells operators to set the
management token under "Settings → API token" — that field is gone and the token
has been server-side only for some time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 9e505aba41 fix(web): the console stops swallowing the host's answer when it says no
The host writes genuinely useful refusals — "entry is owned by provider `x`,
update it through its reconcile" — and a dozen call sites threw them away. The
pattern was always one of two: a mutation whose `error` nothing rendered, or an
`await mutateAsync(...)` with no catch, which additionally produced an unhandled
rejection. Either way the operator clicked, nothing visible happened, and the
thing they asked for silently hadn't.

Fixed at each site, with the host's own message shown where there is one:

- Adding or editing a library entry kept the form open and said why, instead of
  closing it as if it had saved and taking the typing with it. Deleting one
  reports the refusal rather than leaving the card sitting there.
- The GPU preference, capture start/stop, recording delete and download, and the
  dashboard's stop-session / request-keyframe / end-game all report failure. The
  failed capture STOP is the one that mattered most: it is "stop & save", so a
  swallowed error meant minutes of recording vanished with nothing on screen.
- The recordings Download had a comment claiming the detail view surfaces its
  errors. It only does that for the selected row, and Download is on every row.

Two related fixes in the same area:

- `apiFetch` no longer navigates to /login synchronously from inside whichever
  call noticed a 401 — very often a background poll the user never started.
  Tearing the page down mid-render took unsaved editing state with it, which the
  Displays page explicitly models. It defers a beat and coalesces, so a burst of
  parallel 401s schedules one navigation.
- The plugin liveness probe treated the auth gate's 302 → /login → 200 HTML as a
  healthy plugin, and rendered the console's own login page inside the plugin's
  iframe. It also gave up permanently on the first failed probe, so the runner
  restart at the end of every install threw away whatever was open in another
  plugin. It rejects the redirect and keeps probing on a slower beat while down.

`apiErrorMessage` moves out of the display card into src/lib/errors.ts, since
half the console needs it now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 4a5d4b0a71 feat(web): the console follows the host's events instead of asking ten times a minute
The host has published every lifecycle transition on GET /api/v1/events since the
API existed — client connect/disconnect, session and stream start/end, pairing
decisions, display create/release, library, store and plugin changes — and
nothing consumed a byte of it. The console instead polled ten endpoints on 1-5 s
timers, so a change was up to 5 s stale and two pages could disagree while you
looked at them. The Library page polled not at all: install a game in Steam and
it never appeared until a full reload.

The console now subscribes once and invalidates exactly the queries an event
affects. Events never carry data into the cache — they only say "this is stale" —
so an unknown future kind costs nothing and a missed event degrades to the
polling that is still there underneath, now at a slow safety-net interval. The
fast ticks that remain are the ones events cannot express: the live stream
numbers while streaming, and a lingering display's teardown countdown.

Four things had to be true for this to work, and none of them were. Each was
found by measuring, not by reading:

- Nitro's `localFetch` accumulates the response and only builds it when the
  handler returns, so nothing streams through the deployed Bun server. Three
  frames sent a second apart arrived together, three seconds late, when the
  upstream closed — and an SSE stream never closes, so nothing would ever have
  arrived. /api/v1/events gets its own route that hands back a web Response
  wrapping the upstream stream, which passes straight through.
- Hydration mounts the app shell and discards it ~15 ms later. A subscription
  owned by that effect opened, closed, and never came back. It is a refcounted
  module singleton now, with a grace period so a remount re-attaches instead of
  reconnecting.
- `getRouter()` runs more than once in the browser, and each call built its own
  QueryClient. The subscription held the first, the live pages read the second,
  and every invalidation went to a cache nobody was reading. One client per
  browser session; the server still gets a fresh one per request, which it must.
- `invalidateQueries` only refetches queries that currently have an observer.
  An event means the HOST changed, so every cached copy is wrong whether or not
  something is watching it.

Two features fall out of the same work:

- **Automation** — a page for GET/PUT /api/v1/hooks. The host has run these
  hooks all along and the console never showed them, so the only way to see what
  your machine does when a stream starts was to open the config file. Writing one
  means writing a shell command the host will execute, so saving re-asks for the
  console password, like an update or an unreviewed install.
- The Host page warns when another Moonlight-compatible server (Sunshine,
  Apollo) is running on the same machine. The host has detected this at startup
  for ages and reported it in /local/summary; nothing surfaced it. It is the most
  common reason a host looks installed and working but no client can reach it.

Verified in a real browser against a mock host: three events drive three
refetches of a query with no polling timer, the conflicts card names the
intruder, the hook list and its dialog render, and the console reports no errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 55e01c1460 fix(web): stop shipping 7 MB of sound the console cannot play
`@unom/ui/button` reaches `sound/defaults.js`, which resolves two game-UI sprite
sheets with `new URL(…, import.meta.url)` at module scope — a 4.8 MB .wav and a
2.2 MB .mp3. Vite emitted both into the build, so they rode into the Windows
installer and the .deb. The console never mounts UnomProviders, so no player is
bundled and not one byte of it could ever be played. A build-time rewrite of
those two expressions takes the asset payload from 8.2 MB to 1.5 MB; the login
page and the button chunk are unchanged. Deleting the plugin is the whole revert
if the console ever wants click sounds.

Also:

- `bun run dev` forwards the management bearer, so developing against a real
  host stops 401ing into a /login bounce that dev has no gate to satisfy.
- `check-i18n` runs after `build`, not only inside `codegen`. It exists to stop a
  zero-message console shipping, and the CI job and the installer build both
  install with `--ignore-scripts`, so it had never once run where it mattered.
- Bun's idle timeout goes from its 10 s default to 120 s. The host sends SSE
  keep-alives every 15 s, so anything long-lived proxied through the console was
  cut by us first — which the event stream is about to depend on.
- The typecheck covers the Storybook config and preview.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 e30d94573a fix(web): the logs come back after a host restart, and eight more that quietly lied
The Logs page died permanently every time the host restarted — which the
console's own update flow does. The host's log ring restarts at seq 1 while the
page's cursor stays where it got to, and `GET /logs?after=8000` against a fresh
ring is not an error, it is an empty page forever: no error, no dropped badge,
stale lines on screen, and nothing short of a full reload to get out. A restart
always breaks the poll first, so a failed poll now triggers a re-read from the
start of the ring, and a page whose newest entry is older than what we hold is
recognised as the sequence having restarted.

Follow mode also stopped following at exactly the wrong moment. The autoscroll
effect was keyed on the rendered row count, which pins at the 1000-row DOM cap —
so once the log got busy enough to matter, the effect never re-ran again. It is
keyed on the newest rendered seq now. And pausing now actually pauses: stopping
the interval left React Query's focus/reconnect refetches landing, which evicted
the very lines the operator had paused on.

The rest:

- A plugin could white-screen the whole console by registering `icon:
  "constructor"`. The icon map is a plain object, so the inherited key resolved
  to `Object`, which is truthy — the fallback never fired and React was handed
  `Object` as a component, from inside the app shell.
- Saving a display arrangement deleted the saved position of every device that
  was not connected at that moment: the host replaces the whole map, and we only
  ever sent the displays we could see.
- Flipping DDC, PnP or dedicated-game-sessions committed whatever unsaved edits
  the Custom block was holding, then cleared the "unsaved" badge so there was no
  trace of it. Those three apply on top of the SAVED policy now.
- Saving the Custom block put the streamed-screen pin back to whatever it was
  when the form was seeded, undoing a change made in the picker below it.
- "End now" on a running game calls the host's only stop, which ends EVERY live
  session; on a grace row with no app id it ended every waiting game. Both say so
  first now, when there is more than one to lose.
- Edit and Delete were offered on library entries owned by a provider plugin,
  which the host refuses with 409 — silently. They are attributed instead.
- An install whose first poll failed never polled again, and one whose host
  restarted spun forever with no way to dismiss it.
- Submitting a second pairing PIN showed the previous attempt's "PIN sent"
  before a digit was typed, and the paired list it points you at never refreshed.
- The streamed-screen picker claimed an env pin during every slow load, and rows
  that cannot be picked now look that way instead of silently eating the click.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 4575134c21 fix(web): one bad password from anywhere stops locking out the whole console
The console's login throttle was documented as per-IP and was not. Nitro's
`localFetch` hands the app a synthetic request whose socket has no
`remoteAddress`, so `getRequestIP()` returned undefined for every request and
every attempt was charged to one shared "unknown" bucket. Five wrong guesses
from any LAN peer locked out everyone — including the operator, and including
the update-apply route, which shares that budget. The Bun entry is the only
place the real peer is knowable, so it now stamps it into a header (deleting
any client-supplied copy first) and `peerAddress()` reads it back.

Verified on a real build bound to 0.0.0.0: seven wrong logins from 127.0.0.1
lock 127.0.0.1 out, a different peer still logs in on the first try, and a
request forging the header is charged to its real address.

Also on the way through:

- Installing an unreviewed package and adding a catalog source now re-ask for
  the console password, like applying an update already did. A 7-day session
  cookie should not be able to run new code on the host, and `store/install`
  with `accept_unverified` did exactly that through the generic passthrough.
  The gate sits at the trust boundary — adding a source, or a raw spec — not
  on every install from a source the operator already chose to trust.
- The ui-credential denylist is matched against the normalised path too, so
  `/api//v1/...` and friends can no longer walk around it.
- The console serves nosniff, a no-referrer policy, and a CSP that pins
  frame-ancestors, object-src and base-uri.
- A plugin UI's response no longer re-emits the content-encoding that `fetch`
  already decoded (which made compressed plugin pages fail to load), no longer
  sets cookies on the console's origin, and OPTIONS reaches the plugin instead
  of being refused 405 by us.
- An unreachable host reads as 502 on these routes, matching the passthrough,
  instead of a bare 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Fable 5 10a1863cc7 test(host/audio): give the reopen tests longer than the backoff they wait for
`wait_until` allowed 200 × 10 ms — exactly the 2 s `backoff_start` the reopen
path spends before it can succeed. On a warm, idle machine it wins the race;
on a cold binary or a loaded box it does not, and `reopens_after_push_death`
failed 3 of ~5 cold runs while gating this branch. CI is always cold.

Six seconds costs nothing when the test passes and only delays a genuine
failure, so the budget now clears the backoff with room to spare.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:15:54 +02:00
enricobuehlerandClaude Opus 5 6a4ffcb15c fix(client): HDR stops leaking out of the stream and blowing out the console UI
ci / web (push) Successful in 1m3s
ci / rust-arm64 (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m29s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
deb / build-publish-client-arm64 (push) Successful in 1m57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish-host (push) Successful in 3m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m55s
deb / build-publish (push) Successful in 5m59s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m7s
docker / builders-arm64cross (push) Successful in 4s
docker / deploy-docs (push) Successful in 27s
android / android (push) Successful in 8m10s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m12s
arch / build-publish (push) Successful in 9m35s
flatpak / build-publish (push) Successful in 6m57s
ci / rust (push) Successful in 10m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m17s
apple / swift (push) Successful in 5m10s
apple / screenshots (push) Canceled after 19m1s
The gamepad/console UI looked right on launch, and wrong forever after the first HDR
session: connect to an HDR host, disconnect, and the UI came back overblown with wrong
colours.

The UI is not its own renderer. It is a `pf_presenter::overlay::Overlay` composited into
the SAME swapchain the stream used, and it draws plain sRGB with no HDR awareness at all —
so the swapchain's colorspace decides how its pixels are read. `present` switches SDR↔HDR10
from the FRAME's colour signalling, and a UI-only present is `FrameInput::Redraw`, which
carries none: the mode block is skipped entirely and nothing ever hands HDR10 back. The
UI's sRGB mid-tones were then emitted as PQ code points, i.e. near-peak nits.

`leave_hdr` drops back to SDR, called where the UI-only present already happens and gated
on the existing `browse_idle` — Browse mode with no live connector, i.e. the UI owns the
screen. That covers every route back to the UI rather than just the Ended/Failed arms, and
it is guarded internally so idle iterations stay free.

It also bails when minimized, which is load-bearing rather than an optimization:
`recreate_swapchain` keeps the old swapchain at a zero extent, but `set_hdr_mode` would by
then have rebuilt the CSC and overlay pipes against the SDR format — mismatched against
live HDR10 images. `present` early-returns on a zero extent above its HDR block, so this
was unreachable until a caller outside `present` existed.

Deliberately not applied to the `resize_scrim` arm of the same present: that scrim is a
mid-stream gap in a session that is still HDR, and flipping there would rebuild the
swapchain twice per resize.

Does not address the adjacent case: an overlay drawn DURING a live HDR session (the stats
HUD, the resize scrim) is blown out the same way. That needs the overlay to PQ-encode when
`hdr_active`; dropping to SDR is only correct here because the console UI shows exactly
when no stream is live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:08:38 +02:00
enricobuehlerandClaude Fable 5 a7143a6510 Merge origin/main into audio/mic-latency-echo
Main moved through the same surfaces while the audio work was in flight, so
three files needed hand-resolution:

- clients/windows/src/app/settings.rs — main gave Windows its speaker and
  microphone endpoint pickers, the gap this branch could only report. Both
  keep their rows: the pickers, then Echo cancellation, and the microphone
  description keeps the sentence naming the mute chord.
- crates/pf-console-ui/src/screens/settings.rs — both sides grew the couch
  row list. Main's seven new rows and Echo cancellation are all reachable
  in Gaming Mode; the count is 22 and the rationale comment names echo
  cancellation among the fields that would otherwise be unreachable there.
- crates/pf-presenter/src/run.rs — both sides added a stats test at the same
  line. Both are kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:04:34 +02:00
enricobuehlerandClaude Opus 5 43a631ea9c fix(clients): a host that re-keys stops locking the client out for good
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 2m48s
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 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
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 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish-client-arm64 (push) Successful in 2m25s
docker / deploy-docs (push) Canceled after 25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
deb / build-publish (push) Successful in 3m52s
apple / swift (push) Successful in 5m20s
arch / build-publish (push) Successful in 6m52s
docker / builders-arm64cross (push) Successful in 8s
android / android (push) Canceled after 7m17s
deb / build-publish-host (push) Successful in 5m45s
apple / screenshots (push) Canceled after 1m5s
ci / rust (push) Canceled after 7m24s
flatpak / build-publish (push) Canceled after 3m32s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m47s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 11s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reinstall a host, wipe its ProgramData, or otherwise regenerate its identity,
and the desktop clients refused it forever: "Host identity rejected — wrong
fingerprint, or the host requires pairing", including immediately after a
successful re-pair. There was no way out of it from the UI — the host list
showed two cards for one address and forgetting the wrong one was a guess.

`KnownHosts::upsert` matches on the FINGERPRINT, which is what lets a host that
moved address keep its record and everything the user set on it. A host that
changed identity matched nothing, so pairing appended a SECOND record for an
address that already had one, and `find_by_addr` returned whichever came first
in the file — the dead one, every time.

Trust decisions (PIN ceremony, delegated approval, TOFU accept, headless pair —
all funnelled through `persist_host`, plus the Windows shell's two direct
upserts) now go through `upsert_trusted`, which retires any OTHER record for
that address. Retired means DELETED, not demoted: a record whose certificate
the host no longer holds cannot connect, so keeping it only reproduces the two-
cards-one-address confusion this fixes. What described the box rather than the
identity — its MAC, its OS chain, the bound profile, the pinned cards, when it
was last used — moves onto the record that survives, so a reinstall doesn't
quietly cost the user their setup. What described the dead identity does not:
`paired` and `clipboard_sync` are decisions about one specific certificate and
have to be made again for a new one, and the retired record's stable id stays
retired (a deep link written from it falls through to the `host=` recovery the
link grammar already specifies).

Only trust decisions may retire a record. The wake path's address re-key and
every learn-from-advert path stay on plain `upsert`: those are driven by
unauthenticated mDNS, and letting an advert delete a saved host by claiming its
address would trade this bug for a much worse one. A plain reconnect still
fails closed on a pin mismatch — nothing here changes what the pin is checked
against.

Stores that already hold the duplicate recover on the next connect, not at
load: which of two records is live isn't knowable at load time and guessing
wrong would throw away the good one. Instead `find_by_addr` stops being
positional — a real fingerprint beats a placeholder, and among real ones the
newest trust decision wins, since records are only ever appended by one. The
next successful pair then cleans the store up for good. Every lookup that picks
a pin or a per-host decision for a connect now goes through it (the session's
pin and clipboard read, the deep-link resolver, orchestrate's plan, both speed
tests, the CLI's --wake and --library, which had also been ignoring the port),
and an advert's learned MAC/OS lands on the record it identified rather than on
a stale namesake that merely came first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:00:49 +02:00
enricobuehlerandClaude Opus 5 6af067da2d fix(client): the bottom rows stop smearing — the decode pool is taller than the picture
ci / web (push) Successful in 1m49s
ci / rust-arm64 (push) Successful in 2m19s
ci / docs-site (push) Successful in 2m29s
ci / rust (push) Successful in 7m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
deb / build-publish (push) Successful in 4m17s
deb / build-publish-host (push) Successful in 4m51s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 36s
android / android (push) Successful in 8m24s
arch / build-publish (push) Successful in 8m28s
flatpak / build-publish (push) Successful in 6m8s
apple / swift (push) Successful in 5m54s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 13m5s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m10s
apple / screenshots (push) Canceled after 7m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m57s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 5m3s
A user's 1080p stream repeated its last row of pixels over the final few rows, so the
image looked stretched at the bottom.

The Vulkan-Video CSC pass sampled the decoded planes with the fullscreen triangle's
normalized 0..1 UVs, but its render target is built at the CROPPED frame size. Those are
not the same rectangle: FFmpeg sizes the decode pool from `avctx->coded_*`, and H.264
codes `16 * mb_height` — so a 1080-row picture decodes into a 1088-row pool. Destination
row 1079 sampled source row ~1087.5, dragging the 8 alignment rows into view and squashing
the picture 0.7%. Encoders fill that padding by replicating the last picture line, which
is why it reads as a smeared bottom row rather than garbage.

Confirmed on glass (.173, RTX, H.264 1080p, vulkan-video):
  Vulkan Video first frame width=1920 height=1080 pool_w=1920 pool_h=1088

`VkVideoFrame` now carries the pool extent and `record_csc` takes a `uv_scale`, written to
the shader's `params.zw` — which the CSC shader already reserved for a use like this. The
chroma cositing offset is unchanged and stays correct: `textureSize` reports the pool
width, which is the space the scaled UV is already in.

Only the Vulkan-Video path passes a scale below 1.0. D3D11VA already clamps this in its
VideoProcessor blit (the same bug, seen as a green bar there because DXVA padding is
uninitialized rather than replicated); dmabuf imports its planes at the crop over the real
stride; PyroWave allocates its ring at exact stream dims. Apple and Android crop at the OS
layer. Every other call site passes [1.0, 1.0], so the change is inert there.

Also adds a one-time first-frame layout log mirroring the D3D11VA one, so the frame-vs-pool
gap is visible in the field instead of having to be re-derived from FFmpeg internals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:40:33 +02:00
enricobuehlerandClaude Fable 5 8e877ad25f fix(android): a mute is a pause, not a hole the host tries to conceal
The Android uplink kept advancing `seq` while muted, so the first frame
after an unmute looked to the host like loss the width of the mute. The
de-jitter reads that as a gap: up to five concealment frames of stale
voice, and a seq gap counted in the uplink-health line. Past 600 ms the
pump's stale flush resets the chain first and hides it, which is why the
usual long mute looks fine — a quick toggle does not.

Freeze `seq` while muted, as the desktop uplink already does, so the
frame after an unmute continues the chain. `reset_stream` says it
plainly: a pause is not loss, and must not conceal or count a gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 23:15:03 +02:00
enricobuehler 0c4a543831 Merge branch 'audio/desktop-mute-and-aec-toggle' into audio/mic-latency-echo 2026-07-31 23:14:09 +02:00
enricobuehler 4c98e2f788 Merge branch 'audio/android-mic-mute' into audio/mic-latency-echo 2026-07-31 23:14:09 +02:00
enricobuehler 6be8e5d6fa Merge branch 'audio/apple-mic-mute' into audio/mic-latency-echo 2026-07-31 23:14:09 +02:00
enricobuehlerandClaude Fable 5 7ec3107b4a feat(client): the mic has an off switch, and echo cancellation has a switch too
Ctrl+Alt+Shift+V mutes and unmutes the microphone mid-stream — V for
voice, since M and S were taken. The uplink keeps running while muted:
`MicStreamer::spawn` takes a shared AtomicBool the capture callback reads
every quantum, and a muted callback drains whole frames and sends
nothing. Stopping the stream instead would have re-primed the device
buffers and, on Linux, re-run source selection on every unmute — a
second of glitch for a key people press mid-sentence. The sequence
counter deliberately does NOT advance while muted, so the host sees one
continuous sequence with a pause rather than a gap the size of the mute,
which its de-jitter would try to conceal frame by frame (its 600 ms
stale-flush covers the rest).

The mute lives on SessionHandle as a MicControl with two flags, not one:
`live` is raised by the pump only once the uplink is actually running, so
a session with the mic off in Settings — or whose capture device wouldn't
open — reports "nothing to mute", the chord says so in the log, and no
indicator appears. Per session, never persisted.

Muted state draws as a persistent "Microphone muted" badge in the stream's
top-right corner, off `FrameCtx::mic_muted` rather than the stats text: it
has to be there with the stats overlay Off, which is where most people
leave it. The Detailed mic line still reads throughput, so it simply falls
to zero — the badge is what answers "am I muted".

Echo cancellation stops being an env-only lever. `Settings::echo_cancel`
(default on, `#[serde(default)]` so every stored file loads with it on)
now gates the same hooks PUNKTFUNK_NO_AEC gated: the echo-cancelled
PipeWire source preference and WASAPI's Communications stream category.
The env var still wins, one-way — it can only turn AEC off, never back on
— and both `aec_enabled` helpers say so. The row ships in the GTK, WinUI
and console settings, under the microphone toggle and greyed out while it
is off, matching what Apple and Android shipped in wave 1.

SettingsOverlay grows `echo_cancel` as a first-class field — apply,
absorb, clear, is_empty — instead of riding the `extra` passthrough, where
`clear_override("echo_cancel")` answered false. The JSON key is the one
Apple and Android already write, so one catalog round-trips through all
three.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 23:07:30 +02:00
enricobuehlerandClaude Fable 5 9f72a3b6ad feat(host): HDR and 4:4:4 stop being mutually exclusive on Windows
windows-host / package (push) Failing after 22s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 1m30s
ci / docs-site (push) Successful in 1m31s
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 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
deb / build-publish-client-arm64 (push) Successful in 2m21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m6s
deb / build-publish (push) Successful in 4m43s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 33s
deb / build-publish-host (push) Successful in 5m4s
android / android (push) Successful in 6m40s
ci / rust (push) Successful in 7m10s
arch / build-publish (push) Successful in 8m11s
apple / swift (push) Successful in 5m8s
apple / screenshots (push) Canceled after 10m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m50s
An HDR display cost you full chroma: the IDD-push capturer's only 10-bit
output was P010, so a session that negotiated 4:4:4 on an HDR desktop was
converted to 4:2:0 at capture time — *after* the Welcome had already told
the client 4:4:4. The client believed it (nothing on the wire contradicts
a Welcome), and the new chroma tag in the stats overlay is what finally
made the discrepancy visible.

Everything except the source was already in place, which is why this is
small: the NVENC config layer has stamped `FREXT` + `chromaFormatIDC=3` +
`pixelBitDepthMinus8=2` — HEVC Main 4:4:4 10 — with a unit test since the
4:4:4 work landed, `PixelFormat::Rgb10a2` already maps to `ABGR10` and
already counts as a full-chroma input, and the desktop client learned the
10-bit 4:4:4 Vulkan pool format in 74863c96. The one missing piece was a
capture format that keeps 10 bits AND full chroma.

`HdrRgb10Converter` is that piece: one full-res pass from the FP16 scRGB
desktop to packed `R10G10B10A2` in BT.2020 PQ, reusing the P010 shader's
`scrgb_to_pq2020` verbatim so both HDR outputs share bit-identical colour
math — it simply stops before the RGB→YUV matrix, the studio-range
squeeze and the chroma decimation. NVENC then does the CSC to YUV 4:4:4
itself under FREXT, exactly as the SDR BGRA passthrough has always done
at 8 bits.

No swizzle is involved and that is worth stating, because it looks like
it should be: NVENC names packed formats from the MSB down, so its
`ABGR10` (A2B10G10R10) puts R in the low 10 bits — bit-identical to DXGI
`R10G10B10A2_UNORM`. It is the same relationship the proven SDR pair
relies on between DXGI `B8G8R8A8` and NVENC's `ARGB`.

The honesty gap closes as a consequence: `capturer_supports_444` no
longer has a depth it cannot serve, so the chroma resolved before the
Welcome is the chroma the wire carries. AV1 is deliberately untouched —
Range Extensions are HEVC-only and no consumer encoder does AV1 4:4:4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 23:06:44 +02:00
enricobuehlerandClaude Fable 5 253e0bbe7c feat(android): the mic has an off switch you can reach mid-stream
Wave 1 gave the Android client a mic worth using. It gave it no way to stop
talking: leaving the stream, or digging through Settings to turn the whole
feature off, were the only ways to stop the room being heard. Now a tap (or
Select + Y on a pad) mutes it, and the screen says so while it lasts.

How muting gates the capture, and why that way. The AAudio input stream is
never stopped: a stop/start would re-run the input-preset fallback ladder and
re-prime the buffers on every toggle — hundreds of milliseconds, and possibly
a landing on a different rung, silently losing the HAL echo canceller wave 1
went to some trouble to get. Instead the encode loop reads an AtomicBool per
10 ms frame and, while it is set, drains the frame out of its ring and drops
it there — the last point before it would have become an Opus packet. Nothing
is encoded, nothing is sent, and the realtime capture callback is untouched,
so its allocation-free discipline and the queue policy stay exactly as wave 1
verified them. A toggle costs one atomic store and takes effect on the next
10 ms boundary.

The frame counter keeps advancing across a mute, because it numbers the
captured 10 ms TIMELINE rather than the datagrams. The gap the host then sees
is exactly the audio that never came: its de-jitter conceals at most a few
frames of it before the pump's 600 ms stale-gap flush resets the chain
outright, which is the right reading of a mute. Encoding silence instead
would have kept a pointless uplink and a host-side ring alive for its whole
duration.

Mute is per session and nothing is persisted — a new stream always starts
unmuted, and no new setting exists. The flag lives on the session handle
rather than on the capture, so the mic stop/start a surface recreate performs
brings the user's choice back with it, with no window in which the fresh
capture could send an unmuted frame.

The control is offered on the evidence that a capture is actually running
(nativeMicActive), not on the setting: with the mic disabled, RECORD_AUDIO
denied, or every AAudio input rung refused, there is nothing on screen to
lie about. On touch it is a pill in the corner the stats HUD doesn't use —
the one in-stream control, so it sits above the gesture layer to take its own
taps — dim while live, a red "Muted" badge while it isn't. On TV that badge
is the indicator alone: Select + Y is the control there, and a focusable
button would fight the game for the D-pad. Y is deliberately not one of the
exit chord's buttons, so neither chord can be reached through the other.

One honest consequence of keeping the stream open: the platform's recording
indicator stays lit while muted, because the mic really is still open. What
stops is the encode and the send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:41:09 +02:00
enricobuehlerandClaude Fable 5 47eb8c9f6f fix(windows): a web-console deploy that half-succeeded stops reporting success
windows-host / package (push) Failing after 26s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 1m7s
apple / swift (push) Successful in 1m28s
ci / rust-arm64 (push) Successful in 1m57s
ci / docs-site (push) Successful in 2m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
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 6s
deb / build-publish-client-arm64 (push) Successful in 2m22s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
deb / build-publish-host (push) Successful in 4m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m26s
deb / build-publish (push) Successful in 5m45s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 31s
android / android (push) Successful in 7m33s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m55s
arch / build-publish (push) Successful in 8m58s
ci / rust (push) Successful in 7m17s
flatpak / build-publish (push) Successful in 7m11s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m2s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
release / apple (push) Successful in 16m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 8m53s
apple / screenshots (push) Canceled after 16m31s
Found by investigating a console that had been serving errors on .173 for
hours while every health check said HTTP 200.

Nitro's `entry.mjs` imports its sibling chunks by CONTENT HASH, so a
`.output` that mixes two builds is not degraded — it is dead: bun answers
every page with a `ResolveMessage` JSON body ("Cannot find module
../_/router-<hash>.mjs") under a 200 status. Two defects here let exactly
that ship and then hid it:

* The pre-copy `Remove-Item` used `-ErrorAction SilentlyContinue`, so a
  removal blocked by a still-running bun was swallowed and `Copy-Item`
  merged the new build into the old tree. (Reproduced live: an older
  task-based copy of this script, run against the now supervised-child
  host, tried `schtasks /end` for a task that no longer exists, never
  stopped the service, and so could never unlock the files.) The removal
  is now verified and refuses to copy over a tree it could not clear.
* The success probe read only the status code, so it reported a healthy
  console for a server that serves nothing but an error. It now checks
  that `/login` actually returns HTML, and says so loudly when the body
  is a module-resolution error instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:40:26 +02:00
enricobuehlerandClaude Fable 5 766991cf6a feat(apple): the mic has an off switch you can reach mid-stream
Until now the only way to stop sending the room was to end the session and turn
"Send microphone to the host" off in Settings — a per-app setting for something
that is really a per-conversation act. The mic now mutes from inside the stream:
a button on the HUD card, the Stream menu's ⌃⌥⇧A (macOS menu bar and iPad
hardware keyboards), the same chord while input is captured (both platforms
detect it in InputCapture, where the reserved ⌃⌥⇧Q/D/S already live — ⌃⌥⇧M is
the mouse-model flip, cross-client, so the mic gets A), and on iPhone/iPad a mic
disc beside the touch exit for the stats tiers whose HUD carries no buttons.

Muting is local and instant: it gates capture on this device, the host is never
asked and never told. The muted state gets its own badge over the stream —
independent of the stats overlay, because "am I muted?" is not a statistic and
the overlay is exactly what a player turns off. The badge is also the way back:
tapping it unmutes, which is the guaranteed path for a touch user who muted with
the overlay off.

Mute is session state and is deliberately not persisted. Every stream starts live
if the mic is enabled at all, rather than carrying a mute nobody remembers making
into a call three days later.

The mechanism is the one wave 1 built. `SessionAudio.setMicMuted` — which mutes
the voice processor's input on the combined engine and pauses the capture engine
on the split one — stays the single muting path; what changes is that it now
takes an EFFECTIVE mute the session composes from its two reasons: the user's
mute and the background keep-alive's privacy mute. Neither can clear the other,
so a user who muted before pocketing their phone comes back still muted, and
backgrounding no longer un-mutes anyone on return. It also latches the state, so
a mute made while the microphone permission prompt is still open lands on the
engine that grant creates instead of being lost.

The control is offered only where there is something to mute: the session's
resolved `micEnabled` (a profile can turn the mic on or off), a platform with an
app-accessible input (never tvOS), and a TCC grant the OS hasn't refused. Absent
rather than greyed on the HUD and the touch discs, greyed on the menu, and the
macOS start-of-stream banner only teaches ⌃⌥⇧A when the session actually sends a
microphone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:35:48 +02:00
enricobuehler 41e7035441 Merge branch 'audio/apple-mic' into audio/mic-latency-echo 2026-07-31 22:12:52 +02:00
enricobuehler 27861b52b8 Merge branch 'audio/android-mic' into audio/mic-latency-echo 2026-07-31 22:12:52 +02:00
enricobuehler c846b165ae Merge branch 'audio/core-desktop-uplink' into audio/mic-latency-echo 2026-07-31 22:12:51 +02:00
enricobuehler d3870294d0 Merge branch 'audio/host-mic-jitter' into audio/mic-latency-echo 2026-07-31 22:12:51 +02:00
enricobuehlerandClaude Fable 5 e861565e27 docs: "Why do I hear myself" — the four echo loops, and which knob stops each
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:11:19 +02:00
enricobuehlerandClaude Fable 5 79856d2c50 fix(host/audio): a VoiceMeeter box can no longer loop the mic into the stream
wiring_plan could hand the mic Voicemeeter Input and the loopback
Voicemeeter Aux Input — two strips of the same internal mixer, i.e. a
digital feedback loop with no acoustic path to break it, because
leftover() never asked virtualish() and the exclusion list only knew
"cable" and the Steam Speakers. Now every VoiceMeeter or generically
"virtual" render is excluded from loopback, and the last-resort tier
refuses anything virtual: a box with only mixer endpoints gets
loopback=None, honest like the cable-only case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:11:17 +02:00
enricobuehlerandClaude Fable 5 efac33fd33 feat(host/audio): the virtual mic buffers for the client it has, not the worst one ever
The mic pump grows a real de-jitter (audio/mic_jitter.rs): a two-frame
reorder window in front of the decoder, libopus concealment on sequence
gaps (up to 5 frames — a lost datagram no longer drains the ring into a
silence + re-prime crackle), and an adaptive target depth measured from
inter-arrival jitter, clamped to 10–60 ms. Both backend rings now prime
at one consumer quantum + that target: the old bursty Mac client still
measures ~42 ms and lands where the fixed 48 ms prime protected it, a
modern 10 ms-cadence client settles at ~25–35 ms, and a 2048-frame
recorder on Linux stops buying 128 ms of latency from the 3-quanta
clamp. Depth stuck above target sheds near-silent frames a few ms per
100 ms — never speech, never a hard clear.

PUNKTFUNK_MIC_LEGACY_BUFFER=1 (documented) is the one-release escape
hatch back to the fixed constants, and a "mic uplink health" line every
30 s (depth/target, cadence, gaps, conceals, reorders, drops, re-primes)
finally says which side of the link a bad mic lives on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:11:08 +02:00
enricobuehlerandClaude Fable 5 2ce0bea830 feat(client): desktop phase-locked capture, a real display volume on Windows, and the console's missing rows
The cross-platform half of the gap sweep:

* Phase-locked capture reaches the DESKTOP clients (it shipped on
  Apple/Android only, though the desktop presenter has the best latch
  signal of all — true on-glass stamps via VK_KHR_present_wait). The
  presenter's 1 Hz fold publishes a latch grid (anchor = last on-glass
  instant; period = min positive present spacing, capped by the display
  mode's refresh so an arrival-paced sub-panel-rate stream can't claim a
  slower grid); the session pump folds every AU's arrival stamp against
  it with the SHARED `phase::circular_latch` statistic and sends the
  ~1 Hz PhaseReport (1 ms uncertainty — reference-client parity). The
  cap is advertised only when present timing is real
  (`VulkanDecodeDevice::present_timing` gates `SessionParams::phase_lock`),
  and the host's applied grid offset from the 0xCF tail is logged so an
  on-glass run can watch the controller engage.
* `Hello::display_hdr` stops being hardcoded `None`: Windows reads the
  panel's colour volume from DXGI (`IDXGIOutput6::GetDesc1`, the
  `--window-pos` output else the primary, advanced-color outputs only,
  gated on the HDR setting) so the host's virtual-display EDID matches
  the real glass. Linux keeps the EDID defaults — no portable
  Wayland/X11 query exists — and the comment now says exactly that.
* The console settings screen (the ONLY editor in Gaming Mode) learns
  the rows it was missing: render scale, full chroma 4:4:4, invert
  scroll, capture system shortcuts, fullscreen-on-stream, auto-wake and
  the game-library toggle.
* A spec-run session's device picks (GPU adapter, speaker, microphone)
  now come from the `--resolved-spec` instead of a raw Settings load —
  the last store read the spec path still owed (§5), and what would
  make those fields profileable.
* `session_args()` documents why the GTK/CLI spawners pass no
  `--window-pos` (Wayland exposes no global coordinates to read and SDL
  can't apply them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:05:24 +02:00
enricobuehlerandClaude Fable 5 fd75e66041 feat(windows): the shell spawns from the shared brain, and links, tests and pairing stop losing things
Windows-shell parity gaps from the 2026-07-31 sweep, closed:

* The spawner goes through the shared brain: `ConnectPlan::for_target` →
  `session_args()` + a written `--resolved-spec`, replacing the
  hand-assembled argv that meant Windows sessions ALWAYS took the compat
  path (re-resolving every setting from the stores — the drift
  orchestrate.rs documents as a trap) and that any field added to the
  spec was silently Windows-dead. Fullscreen now comes from the plan's
  effective settings (profile-aware) instead of a caller argument, the
  spec temp file is cleaned up at exit, and the reader finally parses
  the `{"window":…}` line so the SPAWNER persists the match-window size
  (§5) instead of the renderer's load-modify-save fallback.
* A deep link to an unpaired host runs the trust ceremony instead of
  refusing: the Pair screen opens seeded with what the link CLAIMED —
  name shown as claimed, fingerprint carried, and the launch/profile
  surviving the detour (`Target` grew a `launch` field for exactly
  this). GTK parity; a shared link was a dead end here before.
* The speed test learns the Ask tier: a bound host whose profile
  INHERITS bitrate now offers both "Set as default" and "Set in
  profile" instead of silently creating a profile override. The global
  write (and the forwarded-controller handler) also rebase on the file
  before their whole-struct saves — two more stale-snapshot writers.
* The Controllers card shows the detected-pad inventory (read-only,
  with the Steam-Input-virtual note) — the fastest answer to "is my
  controller even detected?", present on GTK all along.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:05:05 +02:00
enricobuehlerandClaude Fable 5 f3e122b0d0 feat(apple): the mic hears you, not the game — voice processing joins the engines
The Apple client on a loudspeaker was the primary reported echo source:
two AVAudioEngines by design (arbitrary mic/speaker pairs, two clocks)
meant no unit ever saw render and capture together, which is exactly
what setVoiceProcessingEnabled needs. With the mic enabled and the new
"Echo cancellation" toggle on (both defaults), playback and capture now
share ONE engine with the system voice processor engaged — AEC, noise
suppression and AGC — and the input node's other-audio ducking pinned
to .min with advanced ducking off, because this is a game stream, not a
call: the host's audio must never dip under the outgoing voice.

The two-engine path survives where it is the right answer: echo cancel
off, macOS sessions with a hand-picked speaker/mic UID or input channel
(the voice processor only follows the system default devices, and its
capture side is its own mono mix — a per-channel pick can't survive
it), and any machine whose voice processor refuses to engage. Every
failure falls back to a working configuration rather than silence. The
capture chain reads the input format after the processor is enabled,
so its mono/lower-rate output flows through the existing converter
unchanged; a first-run permission grant swaps the playback-only engine
for the combined one in place, reusing the same jitter ring and drain
thread; background mic muting mutes the processor's input instead of
pausing the shared engine (playback keeps running).

echo_cancel rides the full settings plumbing micEnabled has — defaults
key, effective settings, profile overlay (serialized as `echo_cancel`;
the Rust catalog carries it via unknown-key passthrough until it grows
the field), a captioned toggle beside the Microphone row, and a gamepad
settings row. tvOS behavior is untouched (playback only, as before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:58:31 +02:00
enricobuehlerandClaude Fable 5 e54258b8ac feat(apple): the mic uplink drops to mono 10 ms packets and stops shoveling its FIFO
The capture path carried three latencies that had nothing to do with the
network: a 2048-frame tap request (42.7 ms bursts before the first
sample could even be sliced), 20 ms Opus framing, and a mono signal
duplicated into both stereo channels because the encoder was configured
like the downlink. CoreAudio's Opus encoder takes mFramesPerPacket=480
and mChannelsPerFrame=1 just fine — probed empirically: the converter
truly emits 10 ms mono CELT packets (TOC config 30), one per 480-frame
chunk, and the stereo-shaped decoder upmixes them with the tone intact —
so the uplink now asks for 10 ms tap buffers and encodes 48 kbps mono
10 ms packets, with 960 kept only as an init-time fallback. The host
decodes any Opus frame ≤120 ms, so nothing changes on the wire's far
end.

The tap thread also stops burning cycles per callback: the mono fold and
resampler scratch buffers are allocated once (regrown only if a larger
device quantum ever arrives), and the chunk slicer walks a head index
instead of removeFirst — which memmoved the entire backlog for every
packet on a render-adjacent thread. iOS additionally asks the session
for 5 ms IO quanta at 48 kHz when the mic is on (best-effort; the
hardware decides).

Verified: swift build (macOS arm64), swift test OpusCodecTests +
AudioChannelFoldTests, and an iOS arm64 cross-build; the 480/mono
behavior confirmed by TOC inspection on macOS 15.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:57:48 +02:00
enricobuehlerandClaude Fable 5 a681de7e5c feat(android): the mic goes through the echo canceller, and the host stops hearing itself
The capture stream opened under AAudio's default VoiceRecognition input
preset, which deliberately bypasses the HAL's acoustic echo canceller —
so a phone playing the game audio out of its own speaker fed that audio
straight back to the host. Two layers fix it, both behind a new "Echo
cancellation" setting (default ON, next to the Microphone toggle in the
touch and console settings, per-profile like every tier-P setting):

- Native: the mic opens under the VoiceCommunication preset (HAL AEC/NS
  on the capture path) and allocates an audio session id. The open
  ladder is Exclusive+voice → Shared+voice → Exclusive → Shared — some
  HALs refuse the preset or a session id outright, and a mic without
  echo cancellation still beats no mic; the last rungs are exactly the
  preset-less open this always did.

- Kotlin backstop: nativeStartMic now returns the allocated session id
  (0 = none), and StreamScreen hangs the Java AcousticEchoCanceler +
  NoiseSuppressor off it (guarded by isAvailable), releasing them on
  every mic-stop path — the surface teardown and the final dispose — so
  a surface recreate re-attaches instead of leaking effect engines.

The playback stream is untouched: retagging it voice/communication
would route it through the phone-call chain and regress quality.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:53:01 +02:00
enricobuehlerandClaude Fable 5 6286f91f89 feat(host/audio): the mic uplink keeps its sequence, and a backlog heals itself
The 0xCB datagrams always carried a seq + pts, and the ingest threw both
away one line after decoding them — the pump saw an anonymous byte pile.
Frames now travel as MicFrame {seq, pts_ns, opus} so the de-jitter that
follows can reorder, conceal and measure.

The shared queue also stops being a latency reservoir: cap 64 → 12, and
a pump that wakes to a backlog deeper than 6 frames jumps to the newest
4 instead of replaying the pile — before this, a scheduling stall could
park up to 1.28 s of standing mic delay that only a >600 ms silence gap
ever flushed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:50:29 +02:00
enricobuehlerandClaude Fable 5 55e01b4dcb feat(client): the stats overlay learns whether your mic frames arrive
NativeClient::mic_stats reaches the per-second stream window: sent and
dropped (queue-full + stale-shed) frame deltas join the tracing line and
the Stats event, and the Detailed OSD tier renders a mic line while the
uplink is live — a healthy 10 ms-frame mic reads ~100 f/s, and a drop
term means the client is shedding backlog, not the network eating audio.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:49:12 +02:00
enricobuehlerandClaude Fable 5 d17e942db0 feat(client/audio): the mic speaks 10 ms mono and asks the OS to cancel the echo
Uplink format on both desktops: 10 ms mono frames (was 20 ms stereo),
Opus Voip at 48 kbps with in-band FEC against 10 % assumed loss — half
the frame-fill latency, half the samples, and a lost datagram's audio
now rides in its successor. One datagram per frame, unchanged wire.

Linux: the capture stream finally asks for its own quantum
(NODE_LATENCY 480/48000) instead of inheriting the graph's 1024-2048
sample bursts, and when the user picked no mic it prefers an existing
echo-cancel source over the default (PUNKTFUNK_NO_AEC=1 opts out;
loading module-echo-cancel ourselves needs a load_module the pipewire
crate doesn't expose yet). Windows: the capture client declares
AudioCategory_Communications before Initialize so an endpoint's
communications APO (the system AEC) can engage; capture stays stereo
via autoconvert — the proven path — and downmixes to mono in code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:49:05 +02:00
enricobuehlerandClaude Fable 5 a7d4213778 feat(android): the mic uplink drops to mono 10 ms frames, and stops hoarding stale audio
Latency, three ways, all inside mic.rs:

- 48 kHz stereo 20 ms becomes mono 10 ms: speech gains nothing from a
  second channel, the shorter frame shaves a buffering interval off the
  uplink, and the host already decodes any Opus frame <= 120 ms with its
  stereo decoder (mono packets upmix) — no protocol change. The encoder
  follows: 48 kbps, complexity 5, in-band FEC at an assumed 10% loss so
  a dropped datagram reconstructs from its successor instead of a hole.

- The latency ratchet is gone: the capture callback drops the NEWEST
  chunk when the hand-off channel fills, so an encode-side stall used to
  convert into standing mic delay that never drained. The encode loop
  now drains the whole backlog in one lump and, past ~60 ms, jumps to
  the newest ~20 ms (one audible blip, live again), counting what it
  shed in the periodic log line. The realtime callback stays exactly as
  allocation-free as it was.

- The encode thread registers with the client's hot-thread set, so the
  ADPF session keeps mic encode on a fast core alongside audio decode.

No .frames_per_data_callback() pin: AAudio's own docs say leaving it
unset is the lowest-latency path (the callback runs at the device's
optimal burst), and the encode side re-chunks to 10 ms frames anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:46:44 +02:00
enricobuehlerandClaude Fable 5 15392bd707 fix(core/client): the mic queue stops hoarding a second of your voice
MIC_QUEUE claimed 64 was ~320 ms of 5 ms frames; the frames were 20 ms, so
it really allowed 1.28 s — and since a full tokio mpsc can only refuse the
FRESH frame, one worker stall turned the whole backlog into permanent
standing mic latency. The queue shrinks to 12 and the pump's mic task now
sheds oldest-first past a ~60 ms backlog, so a stall costs a short dropout
and heals itself. New per-stage counters (sent / dropped-full /
dropped-stale) surface through NativeClient::mic_stats for the stats HUDs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:46:02 +02:00
enricobuehlerandClaude Fable 5 698925a036 fix(clients): settings saves stop reverting each other, Windows gets audio pickers, and a game link launches its game
The rest of the client-gaps handoff, plus what a parity sweep surfaced:

* Every whole-file settings save rebases on the file first — the GTK
  dialog close and its two speed-test apply arms, the Windows
  per-control commit, the console screen. The file has five writers and
  no merge (profiles.rs documents the debt); saving a shell-lifetime
  snapshot silently reverted whatever another writer stored meanwhile,
  most visibly the spawner-persisted match-window size (item 4).
* Windows honors the Speaker/Microphone picks: `audio_wasapi` grows
  endpoint enumeration (on its own MTA thread — UI threads are STA) and
  resolves PUNKTFUNK_AUDIO_SINK/SOURCE as endpoint ids, falling back to
  the default when the picked device is gone; the settings page gets
  the two rows (defaults scope, "(not detected)" like the GPU row).
  `speaker_device`/`mic_device` stop being Linux-only fields (item 5).
* A punktfunk:// link with `launch=` opened a plain desktop session on
  Windows — the id was parsed, validated, planned, and dropped at the
  last hop. It now rides `initiate_launch` (plus a waking variant for
  the dial-first path). Linux always forwarded it.
* The console UI offered "VAAPI" on Windows — a dead option there that
  also hid d3d11va, the actual Windows hardware path. The decoder list
  is per-OS now.
* The Windows gamepad picker learns "Steam Deck" (the GTK picker had
  it; the host-side pad has always existed).
* Doc fixes: gpu.rs named a nonexistent env var (PUNKTFUNK_ADAPTER →
  PUNKTFUNK_VK_ADAPTER), and session/Cargo.toml claimed video decode is
  Linux-only while explaining what the ARM64 leg drops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:21:26 +02:00
enricobuehlerandClaude Fable 5 74863c96b3 feat(client): 4:4:4 can stay on hardware, and the overlay says what you actually got
Two halves of the same honesty problem (client-gaps handoff items 1+2,
done together as the handoff asked).

The presenter learns full chroma: `vkframe_plane_views` accepts the
2-plane 4:4:4 pool formats (8-bit, and the 10-bit 3PACK16 sibling) —
what NVIDIA's Vulkan Video reports for HEVC RExt decode — with the
accepted set extracted into `vkframe_plane_formats` and pinned by a
decision-table test. The CSC shader needed nothing: its 4:2:0 siting
correction already self-disables when the plane widths match. The VAAPI
leg gets the same treatment (NV24 in `drm_fourcc_for` and the dmabuf
import, full-size chroma plane), and the Vulkan decoder's sw-format
gate admits NV24/P410. 3-plane 4:4:4 stays rejected — it needs a third
CSC binding — and demotes cleanly like every other unsupported format.

Design call (the handoff's fork, argued here as requested): (B)+(C),
not (A). No capability probe gates VIDEO_CAP_444 — software decode is
the guaranteed display floor on both OSes (swscale → RGBA), the decoder
ladder demotes on its own, and a probe-gated bit would turn the switch
inert on exactly the boxes that rely on the fallback. What (A) wanted
from a prediction, the overlay now delivers as ground truth:

The Detailed tier prints the encoder's target next to the measured
rate — `19.4 Mb/s · target 20 Mb/s (auto)` — and the resolved chroma,
`4:4:4→4:2:0` when the host declined the ask (mirroring `HDR→SDR`).
The target is live: `NativeClient::current_bitrate_kbps()` mirrors
every BitrateChanged ack, so an Automatic session's ABR re-targets are
visible as they move. This is the figure whose absence let the
settings-drop bug (9c5af8d7) ship four releases — measured goodput
alone cannot distinguish "the encoder is capped at 20" from "my
200 Mb/s grant met a cheap scene".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:21:10 +02:00
enricobuehlerandClaude Fable 5 5926306a4c feat(windows): the web console becomes a supervised child of the host service
windows-host / package (push) Failing after 22s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / docs-site (push) Successful in 1m47s
ci / web (push) Successful in 1m53s
ci / rust-arm64 (push) Successful in 1m58s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 58s
apple / swift (push) Successful in 4m45s
deb / build-publish-client-arm64 (push) Successful in 3m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
deb / build-publish (push) Successful in 5m26s
docker / builders-arm64cross (push) Successful in 6s
android / android (push) Successful in 6m22s
docker / deploy-docs (push) Successful in 38s
ci / rust (push) Successful in 7m10s
deb / build-publish-host (push) Successful in 5m27s
arch / build-publish (push) Successful in 8m42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m16s
apple / screenshots (push) Successful in 20m19s
Three silent console outages in one week (0x1 / 0xFFFFFFFF / 0x41306),
each a different proximate cause of the same structural defect: the
console's lifecycle was owned by Task Scheduler — one best-effort start
per boot/logon/install, no retry on a plain non-zero exit, no watchdog —
while the product already shipped a real supervisor.

The service now supervises the console as a second child slot: plain
session-0 spawn (suspended → own no-breakaway kill-on-close job →
resume), started only once the host has written mgmt-token + cert.pem +
key.pem (the cert race dies by construction), secrets read from their
files at every respawn, bun's stdout finally captured in logs\web.log,
doubling backoff 0.5s→60s that never gives up. Session switches never
touch it; a service stop takes it down via the job.

The PunktfunkWeb task is retired: web setup slims to password + legacy
task delete + firewall, the 127-line web-run.cmd batch supervisor is
deleted, an [InstallDelete] entry reaps the stale copy, and service
install now sets SCM crash-recovery actions (restart 1s/5s/60s) since
the console rides on the service process. StopBunRuntimes stays for the
scripting runner + the one migrating upgrade.

Design: punktfunk-planning design/windows-web-console-lifecycle.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:16:19 +02:00
enricobuehlerandClaude Opus 5 9c5af8d7e1 fix(clients): the settings you chose reach the stream again, and four more that never did
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 2m50s
ci / rust-arm64 (push) Successful in 2m55s
deb / build-publish-host (push) Successful in 4m16s
deb / build-publish-client-arm64 (push) Successful in 4m56s
apple / swift (push) Successful in 4m41s
flatpak / build-publish (push) Successful in 6m32s
deb / build-publish (push) Successful in 3m44s
android / android (push) Successful in 8m2s
ci / rust (push) Successful in 11m18s
arch / build-publish (push) Successful in 10m24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m42s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m1s
apple / screenshots (push) Successful in 20m36s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m40s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m9s
Since spec mode (`f32c3aaa`, shipped in 0.22.0) the GTK shell has handed the session
a `--resolved-spec` built from `Settings::default()`. A session running from a spec
performs ZERO store reads by design, so every stream since has run at the defaults:
`bitrate_kbps: 0` reaches the host, which reads it as its 20 Mbps fallback — the
field report this starts from, "my bitrate seems to be stuck at 20 MBPS" on 0.22.3 —
and with it the resolution, refresh, render scale, codec, decoder, HDR, 4:4:4, audio
channels, mic, touch/mouse mode, invert scroll, stats tier, match-window and gamepad
type. Profiles never applied at all: the spec's `profile` was None, and spec mode
ignores `--profile`. The tell in the wild is that the GPU and audio-device pickers
kept working — the session reads those three off disk into env vars before it reads
the spec.

The plan carried a comment explaining that `settings` "carries only what the argv
needs (the fullscreen flag)". That was true when it was written and stopped being
true one commit later, in another file. `ConnectPlan::for_target` answers that shape
of bug: a front-end holding its own request type resolves through the same helper the
session's compat path uses, so there is no hand-built `Settings` left to go stale.
The GTK shell's `fullscreen_on_stream` parameter goes with it — that is a tier-P
field, and a shell-read global was beating a profile that set it. The Windows shell
was never affected (it spawns with no spec) but had the same fullscreen-vs-profile
bug, so it moves onto the resolver too.

The audit that followed found four more settings stored, rendered in two UIs, and
read by nothing:

- `enable_444` never became `VIDEO_CAP_444`. "Full chroma (4:4:4)" is announced in
  the 0.22.0 notes for Linux and Windows and did nothing on either; only Apple
  advertised the bit. The host already gates it on its own policy, its capturer,
  HEVC and a real GPU probe, and answers the resolved chroma in the Welcome before
  we build a decoder — the client only has to ask. There is no desktop decode probe
  (Apple has `Stage444Probe`), but the presenter bails cleanly on a plane format it
  doesn't know and demotes to software, so the downside is a slow decode, not a
  broken one.
- `session_params` picked the HEIGHT fallback off `settings.width == 0`.
- `cli::exec_session` dropped `--profile` from its forward list, so
  `punktfunk --connect … --profile Work` from a script or a Decky wrapper streamed
  with the host's binding instead.
- `ResolvedSpec::write_temp` named the spec by the SPAWNER's pid alone, so a
  cancelled connect and the retry behind it shared one path — and the first child's
  exit deleted the file the second was still starting up to read.

The regression test asserts the plan carries the profile-overlaid bitrate and that
the spec equals it. On the old code both are 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:32:51 +02:00
enricobuehlerandClaude Opus 5 3594bc029e fix(host): a host with no host.env streams a screen, not a test pattern
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 37s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 40s
ci / rust-arm64 (push) Canceled after 37s
ci / web (push) Canceled after 38s
ci / docs-site (push) Canceled after 36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 46s
deb / build-publish-client-arm64 (push) Canceled after 41s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
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 / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
docker / builders-arm64cross (push) Successful in 16s
docker / deploy-docs (push) Successful in 38s
windows-host / package (push) Successful in 17m58s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
`PUNKTFUNK_VIDEO_SOURCE` had no default, and unset fell through to the synthetic
test pattern. That was invisible while the systemd unit made host.env mandatory —
you could not start the host without the file, and every copy of the file sets
`virtual`. Now that the unit treats host.env as optional (020306b5), unset is
reachable, and a packaged Linux install with no host.env would serve a test
pattern to Moonlight clients.

Default it to `virtual`, the flagship per-client virtual output — which is what
every setter in the tree already says: packaging/bazzite/host.env,
packaging/kde/host.env, both host.env.example files, the host.env the Windows
installer generates, and scripts/bench/gpu-stream.sh. Nothing sets `synthetic`
and nothing relied on unset. `synthetic` stays reachable by naming it, since any
unrecognised value lands in that arm.

Only one of the three readers changes behaviour: stream.rs's
`== Some("virtual")` now matches when the variable is absent. gamestream/mod.rs
already folded None and Some("virtual") into the same `_` arm for the HDR
capability probe, and rtsp.rs only tests for `portal`.

The native punktfunk/1 plane never read this — it always builds a virtual
display — so this only ever affected GameStream/Moonlight sessions.

host.env.example's header no longer claims this key is the one non-optional
setting; the file is now overrides only, and the host runs without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:31:36 +02:00
enricobuehlerandClaude Opus 5 fd3c54bd43 feat(client): "Capture system shortcuts" finally decides where Alt+Tab goes
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 2m22s
ci / rust-arm64 (push) Successful in 3m34s
android / android (push) Canceled after 4m8s
apple / swift (push) Canceled after 4m13s
apple / screenshots (push) Canceled after 0s
decky / build-publish (push) Successful in 42s
arch / build-publish (push) Canceled after 4m25s
ci / rust (push) Canceled after 4m30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
deb / build-publish (push) Canceled after 4m35s
deb / build-publish-host (push) Canceled after 3m29s
deb / build-publish-client-arm64 (push) Canceled after 2m15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
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 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 10s
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 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 30s
flatpak / build-publish (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 6m14s
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 toggle has been stored, profileable and rendered in two settings UIs since
profiles landed, and nothing read it. Windows grabbed the keyboard whenever input
was captured, setting or no setting; Linux never grabbed at all, because the grab
sat behind `#[cfg(windows)]` with a comment deferring the compositor story to "the
shells" — which never picked it up.

`Settings.inhibit_shortcuts` now reaches the presenter and gates the grab, on both
platforms. SDL3 already maps `SDL_SetWindowKeyboardGrab` onto
`zwp_keyboard_shortcuts_inhibit_manager_v1` on Wayland and `XGrabKeyboard` (plus
`_XWAYLAND_MAY_GRAB_KEYBOARD`) on X11, so dropping the cfg is the Linux fix.

Capture state still gates it, so releasing input hands the chords straight back,
and the desktop mouse model never grabs. A compositor with no shortcuts-inhibit
global says so once instead of failing silently — at debug under gamescope, which
has no shortcuts to inhibit in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:27:33 +02:00
enricobuehlerandClaude Opus 5 020306b5ac fix(packaging,host): a fresh Linux install can start, and the comments stop lying
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 2m11s
ci / rust-arm64 (push) Successful in 2m41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 24s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
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 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
apple / swift (push) Successful in 4m48s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
arch / build-publish (push) Successful in 7m20s
ci / rust (push) Successful in 7m21s
deb / build-publish-host (push) Successful in 7m4s
docker / builders-arm64cross (push) Successful in 8s
deb / build-publish (push) Successful in 5m31s
docker / deploy-docs (push) Successful in 34s
android / android (push) Successful in 8m17s
windows-host / package (push) Successful in 10m43s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 17m31s
apple / screenshots (push) Successful in 20m57s
Fallout from the documentation sweep: verifying doc claims against the code
turned up defects in the code and the shipped templates. Mostly comments that
describe behaviour we no longer have — which is how the docs went wrong in the
first place, since someone reads the comment and writes the page.

The one that mattered: a fresh deb/RPM/Arch install could not start the host at
all. The unit's `EnvironmentFile=` had no `-`, making host.env mandatory, and no
package creates it — all three ship only the templates under /usr/share and the
postinst merely prints the copy command. So `systemctl --user enable --now
punktfunk-host` died on "Failed to load environment files". Every field in
HostConfig::from_env resolves through unwrap_or/filter/None, so absent means all
defaults, exactly like a hand-run `serve`; the Nix module already wrote it as
`-${environmentFile}`. The Deck installer's own generated unit gets the same
prefix for the case where an operator later removes the file.

Shipped templates: PUNKTFUNK_SECURE_DDA is read by nothing (DDA/WGC are gone;
IDD-push is the sole Windows capture path and the secure desktop is
unconditional), so it stops being written into a fresh host.env;
PUNKTFUNK_INPUT_BACKEND offered a `uinput` value that does not exist and omitted
`kwin`, which is what a KDE session actually resolves to; PUNKTFUNK_RENDER_ADAPTER
no longer claims to pick a "Desktop-Duplication" GPU.

Comments corrected rather than deleted, since each explains a real why:
PUNKTFUNK_10BIT is default-on with explicit-off grammar, not an operator opt-in;
GNOME reaches EIS through Mutter's direct RemoteDesktop API, so it needs no portal
approval and is headless-capable; and the host does not run in session 0 — the
service is the session-0 supervisor and the host runs as SYSTEM in the interactive
console session, which is why game_term has to bind the input desktop at all.

packaging/bazzite/update-punktfunk.sh is now installed to /usr/share/punktfunk/,
so the command the docs promised exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:46:38 +02:00
enricobuehlerandClaude Fable 5 5807d3a8a6 fix(tools): the loss harness catches up with seal_streamed_chunk's slice flag
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
ci / rust-arm64 (push) Successful in 2m24s
49bc93cd grew seal_streamed_chunk a slice_end parameter and updated every
caller except this one — the Linux clippy leg has been red since (run
10615+). false = the legacy full-FEC-block granularity the harness always
exercised; its loopback client never advertises the P2 slice wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:16:40 +02:00
enricobuehlerandClaude Fable 5 2a6bf3c421 fix(vdisplay/gamescope): the takeover stops a live session's DM on every flavor — the mask never stopped SDDM's relogin
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 2m12s
ci / rust-arm64 (push) Successful in 2m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
apple / swift (push) Successful in 4m49s
ci / rust (push) Failing after 4m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
android / android (push) Successful in 5m58s
deb / build-publish-client-arm64 (push) Successful in 3m31s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 32s
deb / build-publish-host (push) Successful in 4m14s
deb / build-publish (push) Successful in 5m44s
windows-host / package (push) Successful in 11m1s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
arch / build-publish (push) Successful in 14m59s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m9s
apple / screenshots (push) Successful in 21m19s
The managed takeover masked gamescope-session-plus@*.service and trusted
the mask to hold SDDM's Relogin=true loop at bay. On images whose sddm
session helper execs the session script directly (/etc/sddm/wayland-session
gamescope-session-plus steam — f43 bazzite-deck), the relogin never touches
the unit: SDDM relogged in ~3x/s for the whole stream, each attempt a full
bash --login session start failing against the managed instance. Live on
the .41 VM (2026-07-31): 328 forks/s, load 6+, 1481 logind sessions in
8 minutes, the journal flooded past its own rotation — and a 5070 Ti that
"could not hold 240fps".

A DM that drove a LIVE gaming session is now stopped for the stream's
duration on every flavor (the fragile-flavor machinery — lingering guard,
pkexec dm-helper, restore via reset-failed+restart, crash-persisted state —
already existed and is reused as-is; a pure dm_plan() derives the decision
and is unit-tested). The flavors now differ only in the degraded mode when
the stop is impossible: SDDM falls back to mask-only (the storm tax, but
Steam stays protected — never attach), fragile DMs still fail the takeover
into the attach degrade. The mask stays as belt-and-braces for the window
before the stop and for images that do route the relogin through the unit.

Verified: scripts/xcheck.sh linux clippy (-D warnings, --all-targets)
clean; pf-vdisplay suite 125/125 on Ubuntu 26.04.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:59:00 +02:00
enricobuehlerandClaude Fable 5 957cf7781c feat(apple): a Pencil near the glass pins the panel at the link ceiling
ci / web (push) Successful in 2m4s
ci / docs-site (push) Successful in 2m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / rust-arm64 (push) Successful in 3m42s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m26s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m11s
ci / rust (push) Failing after 2m7s
apple / swift (push) Successful in 4m59s
deb / build-publish (push) Successful in 6m51s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
deb / build-publish-host (push) Successful in 7m10s
docker / builders-arm64cross (push) Successful in 8s
android / android (push) Successful in 8m44s
arch / build-publish (push) Successful in 8m35s
docker / deploy-docs (push) Successful in 33s
flatpak / build-publish (push) Successful in 8m0s
windows-host / package (push) Successful in 18m16s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m5s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m1s
release / apple (push) Successful in 25m56s
apple / screenshots (push) Canceled after 4s
UIKit delivers Pencil events at the PANEL's cadence, and the deadline
link's rate hint pins the panel to the stream rate (min = preferred =
stream Hz, deliberately — the anti-idle-stall floor). Net effect: a 60 fps
stream on a 120 Hz iPad silently halved pencil sampling, 8.3 → 16.7 ms per
event batch — exactly the workload (drawing) that feels it most.

FrameRateHint gains a boost that lifts min/preferred to the range ceiling
while a Pencil is in range (hover or contact): PencilStream surfaces
proximity transitions from its emit() choke point, the view controller
debounces release by 2 s (edge-of-canvas hover flicker must not thrash the
link), and Stage2Pipeline/SessionPresenter stage it like the rate hint —
applied from the link's own thread, no-op under arrival/glass pacing.
Presents still pace at stream rate (extra updates vend into the
newest-wins stash), so the cost is empty link wakes scoped to proximity.
pf.present logs engage/release for on-glass verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:46 +02:00
enricobuehlerandClaude Fable 5 7cf71dd218 feat(clients): the Apple deadline presenter reports its latch phase
Phase-locked capture had no client half on Apple — the host's v3 controller
(grid-locked submits, coherence-gated engage) shipped with only the Android
reporter feeding it. Now the stage-4 link thread flushes the same v2
circular arrival-phase statistic at ~1 Hz:

- PhaseReporter (Stage2Pipeline): the decode callback deposits per-AU
  reassembly-completion stamps, the CAMetalDisplayLink update deposits the
  latch grid (period = window-min of update spacing), and the flush ports
  punktfunk_core::phase::circular_latch verbatim — a period-smeared Wi-Fi
  link reads coherence ≈ 0 and the host correctly never engages; a wired
  link opens the gate. Binds/unbinds per session like DecodeReport.
- PunktfunkConnection.reportPhase wraps the existing ABI entry point.
- Hello honesty: iOS/tvOS advertise CLIENT_CAP_PHASE_LOCK (macOS stays
  without — the stage-2 arrival presenter has no latch grid), Android now
  sets the bit its reporter already earned, and the ABI grows the
  PUNKTFUNK_CLIENT_CAP_PHASE_LOCK mirror const (header regenerated).
  Advisory in v1: the host arms on report receipt.
- Stage-3 doc comment no longer calls itself the tvOS default (stage-4 took
  that over in the 2026-07 rebuild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:46 +02:00
enricobuehlerandClaude Fable 5 86d4b36bfa chore(core): the Apple recv path documents its unsafe blocks
clippy -D warnings only lints this cfg(target_vendor = "apple") file on a
Mac, which CI never does — the three recv/recvmsg_x blocks predate the
undocumented_unsafe_blocks sweep and kept every local Apple-side clippy run
red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:15 +02:00
enricobuehlerandClaude Fable 5 c002ca8746 fix(clients): pen batches split at the wire cap and the heartbeat survives tracking mode
Three drawing-path defects on the pen clients:

- The Apple in-range heartbeat was re-armed on every emit (~120 run-loop
  mutations/s during a stroke) and lived in .default run-loop mode only — a
  tracking-mode excursion >200 ms with a stationary pen crossed the host's
  force-release failsafe and dropped the held stroke. Now one long-lived
  50 ms timer in .common mode, resending only after ≥50 ms of send silence.
- Both clients TRUNCATED an over-8-sample coalesced run instead of splitting
  it into consecutive batches (the send_pen contract): Apple suffix(8)
  dropped the oldest samples, the Android JNI clamped count. An over-cap run
  means the UI thread hitched — exactly when dropping stroke geometry hurts
  most. Apple splits in emit(); the Android JNI loops send_pen over ≤8-sample
  chunks (Kotlin's per-emit ceiling is now 64 = a >250 ms stall).
- The iOS letterbox mapper did a lock+FFI currentMode() read per coalesced
  sample on the main thread, contending the ABI lock the batch send takes
  next — now a 250 ms TTL cache (mid-stream requestMode still tracked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:15 +02:00
enricobuehlerandClaude Opus 5 d383161723 docs: the docs catch up with five releases of shipped work
ci / rust (push) Failing after 2m31s
ci / docs-site (push) Successful in 1m22s
ci / web (push) Successful in 1m48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m1s
ci / rust-arm64 (push) Successful in 2m2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders-arm64cross (push) Successful in 20s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 36s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 36s
docker / deploy-docs (push) Canceled after 0s
~1150 feat/fix commits landed since v0.19 and the docs drifted badly. This is a
full sweep of every page against the code as shipped: ~280 verified corrections,
nine new pages, and one deletion.

The worst of what was wrong: the quickstart's five-minute path could not work
(`serve` never started the web console, so step 3 had no PIN to read); every
packaged Linux host runs `serve --gamestream` while security.md told readers to
leave GameStream off; HDR was documented as Windows-only; `PUNKTFUNK_SECURE_DDA`
was documented as a working knob that nothing reads; `PUNKTFUNK_INPUT_BACKEND`
listed a `uinput` value that does not exist and named libei for KDE instead of
kwin; README linked three pages deleted on 2026-07-05; and the rpm-ostree update
command pointed at a script no package installs.

Completeness: about half of what shipped since v0.19 had no page at all. New:
support-matrix (what works where, from 217 verified capability cells), input
(mouse/touch/pen — and the in-stream chords, so the docs finally say how to get
your mouse back), client-settings, profiles-and-links, game-library, clipboard,
wake-on-lan, hdr, uninstall. Updating existed but had zero inbound links.

status.md is gone: its facts moved into the support matrix, its shell stays as a
redirect so the public URL does not 404. roadmap.md is themes now, not a feature
checklist — checkboxes are what rotted.

Debian is no longer claimed. The .deb's Depends resolve against Ubuntu images,
nothing in CI builds or tests Debian, and Debian 12 is below the glibc 2.39
floor. The `debian` in the repo URL is the package format.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 17:37:06 +02:00
enricobuehlerandClaude Fable 5 49bc93cd20 feat(encode/windows): the sync NVENC session reads slices out mid-encode
ci / web (push) Successful in 1m2s
apple / swift (push) Successful in 1m19s
ci / rust-arm64 (push) Successful in 1m45s
ci / docs-site (push) Successful in 2m6s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
ci / rust (push) Canceled after 2m36s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Successful in 2m8s
android / android (push) Successful in 5m37s
deb / build-publish-host (push) Successful in 4m40s
deb / build-publish (push) Successful in 5m56s
apple / screenshots (push) Canceled after 6m40s
arch / build-publish (push) Canceled after 8m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 6m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m11s
windows-host / package (push) Canceled after 8m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
The Windows direct-NVENC session gets the Linux backend's sub-frame
chunked poll: a doNotWait sampling lock cuts each completed slice out of
the bitstream while the frame is still encoding, so the send loop's
streamed slice wire starts shipping an AU's front before its tail
exists. The default retrieve mode on Windows is synchronous, which is
exactly the mode the sampler requires - the async opt-in keeps its
whole-AU path and the chunked latch drops away when it is active.

The session's slice count now honors the negotiation instead of pinning
one: the direct-NVENC default of 4 clamped by the client's ceiling, so a
single-slice client (Amlogic-class decoders that never advertised
multi-slice) keeps today's wire shape and a multi-slice client gets real
boundaries to cut at. The pending queue carries a submit-time IDR hint
because an AU's early chunks ship before the driver reports the picture
type; sub-frame write itself stays env-gated (PUNKTFUNK_NVENC_SUBFRAME=1)
until the Windows on-glass A/B validates it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:34:31 +02:00
enricobuehlerandClaude Fable 5 780791ed81 feat(android): the presenter's latch margin starts at zero and earns its width
The 2.5 ms submit margin ahead of expected present assumed
SurfaceFlinger latches well before the vsync. On-glass (NP3, 120 Hz)
that lead is not needed: with the margin at zero every release still
made its vsync (paced=0 across full sessions) - meaning the constant was
2.5 ms of pure display latency on every frame, and it was quantizing
away exactly the readiness gains the slice-overlap pipeline just bought.
e2e on the Linux-host pairing dropped from 22-24 ms to 14-18 ms.

The margin is now adaptive instead of assumed: it starts at 0 and only
widens (+500 us per 1 Hz window, capped at the old 2.5 ms) when the
paced counter shows real latch misses - one-way per stream, so a margin
that proved necessary is never re-gambled mid-session. A device that
needs lead converges within seconds; a device that doesn't keeps the
full win. debug.punktfunk.latch_margin_us (0..=8000) pins the margin
for rebuild-free sweeps, and the presenter logs the resolved mode at
stream start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:34:31 +02:00
enricobuehlerandClaude Fable 5 4240b76182 feat(android): slices feed the decoder as they arrive, not when the AU closes
The decode loop used to hold every access unit until its last wire
packet landed. With slice-progressive delivery (frame_parts) each newly
contiguous piece now goes straight into MediaCodec under
BUFFER_FLAG_PARTIAL_FRAME, the closing piece drops the flag, and the
decoder chews the front of the frame while its tail is still on the
wire.

Feeding partial input means owning its failure modes without a codec
flush: a broken sequence (dropped piece, orphan, oversize) closes the
dead AU with an empty non-partial buffer at its own pts, arms the
re-anchor freeze so the concealed output never reaches glass, and
requests a recovery keyframe - the same machinery ordinary loss already
rides. Per-AU accounting keeps its units: the RFI gap detector notes an
AU once, and the HUD stamps, host/network split and the phase-lock
arrival sensor ride only the completing delivery.

The opt-in is decoder truth and loop truth: every decoder this device
would use must pass FEATURE_PartialFrame, and only the async loop may
see parts - the legacy sync loop feeds whole AUs and stays that way.
Smoke-checked on-glass (NP3 vs the Windows host): byte-identical
degenerate path, presenter profile unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:34:31 +02:00
enricobuehlerandClaude Opus 5 de8430097e chore(clients): the bundled licence notices catch up with the root file
ci / web (push) Successful in 1m22s
ci / rust-arm64 (push) Successful in 1m31s
decky / build-publish (push) Failing after 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
ci / docs-site (push) Successful in 1m17s
ci / rust (push) Failing after 2m14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 28s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
apple / swift (push) Successful in 3m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m28s
deb / build-publish-client-arm64 (push) Successful in 4m4s
docker / builders-arm64cross (push) Successful in 8s
deb / build-publish-host (push) Successful in 5m29s
deb / build-publish (push) Successful in 4m2s
android / android (push) Successful in 7m13s
docker / deploy-docs (push) Successful in 39s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m31s
arch / build-publish (push) Successful in 9m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m54s
flatpak / build-publish (push) Successful in 6m29s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m52s
apple / screenshots (push) Successful in 20m57s
windows-host / package (push) Successful in 14m16s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 24s
release / apple (push) Successful in 25m29s
The Apple and Android apps carry their own copy of THIRD-PARTY-NOTICES.txt
and show it on the acknowledgements screen. `7fc2775f` refreshed the root
file but not the copies — running the python generator directly skips the
sync `scripts/gen-third-party-notices.sh` does — so they had drifted to a
531-crate snapshot that predates the whole vendored-source section.

That section is where the OS marks' attribution lives, so both apps have
been shipping Font Awesome's CC BY 4.0 icons with no attribution visible
at all. Re-synced, which also carries the Bazzite Apache-2.0 notice the
previous commit adds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:17:02 +02:00
enricobuehlerandClaude Opus 5 5520167958 feat(clients): the OS marks tell gaming distros apart, and Windows looks current
Three things were wrong with the host-card OS icons.

The Windows mark was Font Awesome 5's, which is still the Windows 8/10 flag
with the perspective skew — dated next to the flat four-pane mark Microsoft
has shipped since Windows 11. No icon set has the current one (Simple Icons
carries no windows/microsoft slug at all), so it is drawn here: four equal
squares at the authentic 11.377 + 1.246 proportion. The Decky plugin was
pulling FaWindows straight from react-icons, so it now inlines the masters
like the web console does, or it would have kept the old flag regardless.

Bazzite, CachyOS and Nobara collapsed onto their family's mark. The host
already advertises the full chain, so this is purely missing art: all three
now ship a leaf mark, because "a Bazzite box" and "a Fedora box" are
different machines to the person reading the card. CachyOS and Nobara come
from Simple Icons; Bazzite has no icon anywhere, so its "b" is lifted out of
the project's own badge (Apache-2.0, attributed).

On Android every non-square mark was stretched. A VectorPainter maps the
viewport onto the ImageVector's default size with independent x and y
scales, so declaring a 448x512 Tux as 24x24 dp squashed it — silently, no
crash, no warning. The longest viewport edge now sets the 24 dp box and the
other follows the ratio, which is what Icon()'s ContentScale.Fit expects.
A unit test pins the invariant; every other client was already correct.

Also: scripts/gen-os-icons.sh replaces the undocumented hand-run pipeline
that turns a master into the GTK symbolic SVG, the Windows PNG and the Apple
template PDF. It reproduces the committed artifacts byte-identically.

Verified: web and Decky typecheck, Decky bundles, Android compiles and its
tests pass, PunktfunkKit builds, osinfo's tests pass. Not verified on glass,
and the GTK/Windows client crates do not build on macOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:17:02 +02:00
enricobuehlerandClaude Fable 5 f4f83202cb feat(core/client): an AU's prefix reaches the decoder while its tail is on the wire
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
ci / web (push) Successful in 1m13s
apple / swift (push) Successful in 1m21s
ci / rust (push) Failing after 2m14s
android / android (push) Canceled after 2m26s
apple / screenshots (push) Canceled after 58s
arch / build-publish (push) Canceled after 2m42s
ci / docs-site (push) Successful in 2m36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
ci / rust-arm64 (push) Canceled after 2m47s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
deb / build-publish (push) Canceled after 2m50s
deb / build-publish-host (push) Canceled after 1m38s
deb / build-publish-client-arm64 (push) Canceled after 41s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 10s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 25s
release / apple (push) Canceled after 0s
windows-host / package (push) Canceled after 3m36s
windows-host / canary-manifest (push) Canceled after 0s
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 7s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Delivery used to be all-or-nothing: the decoder saw byte 0 only after the
last packet of the AU landed, so the whole transmit time sat in front of
decode. With the slice-streamed wire (previous commit) blocks now arrive
addressable, and a client can opt in (connect's new frame_parts) to
receive each AU's newly-contiguous prefix as Frame::part pieces - offset
tiling, first/last marked, the completing push carrying only the suffix.
A PARTIAL_FRAME-capable decoder then chews slices concurrently with the
remaining network transfer.

The reassembler walks a per-frame cursor over successfully-completed
blocks (failed FEC reconstructs don't advance it), coalesces blocks that
finished out of order into one part, keeps probe filler whole, and stops
short of the final block so the zero-padded tail still trims at
completion. Whole-frame consumers see byte-identical behavior - parts
never flow without the opt-in, and never on PyroWave (its newest-wins
draining assumes whole AUs).

Per-AU accounting keeps its units: OWD/ABR feeds, the inter-arrival
series and the clock-based jump-to-live detector only count completing
deliveries, and FrameChannel::depth() counts AUs so a part-rich queue
can't trip jump-to-live at a fraction of the real backlog. The consumer
contract (gap or orphan part = AU lost: abandon, flush, resync on the
next first) is documented on FramePart; the C ABI keeps parts off until
PunktfunkFrame can express them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:14:46 +02:00
enricobuehlerandClaude Fable 5 440cb8d1ec feat(core/wire): streamed AUs cut blocks at slice boundaries, not at 5.5 MB
The streamed-AU path only ever flushed a block when a full FEC block's
worth of bytes accumulated - at real bitrates a frame finished encoding
before that, so every AU degenerated to one tail-heavy send and the
overlap the sentinel wire was built for never happened.

Now a slice boundary flushes every whole shard (floored at
MIN_STREAM_BLOCK_SHARDS so per-block parity stays economical), which
means blocks are variable-size and can no longer be addressed by the
uniform block_index * K_max formula. USER_FLAG_SLICE_STREAM marks such
frames on every packet: sentinel blocks reuse frame_bytes as their
shard-aligned base offset, the final block's base derives from the
totals, and the receiver pins/validates positionally (sentinels must sit
strictly below the final block's base; a mixed-flag or out-of-range
header is dropped before it can place a byte).

The AU's own flag bit gates the wire shape, and the host sets it only
toward clients advertising STREAMED_AU + MULTI_SLICE, so shipped
receivers keep seeing the byte-identical legacy sentinel
(PUNKTFUNK_SLICE_STREAM=0 pins legacy for A/B). Both sides bound the
per-frame block count by the same floored formula, and the flush loop
cuts as many blocks as a chunk completes - a slice bigger than one FEC
block can't leave an oversized final block behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:14:46 +02:00
enricobuehlerandClaude Fable 5 43868af1f5 feat(android): the client advertises multi-slice tolerance per-decoder
The P0 gating branch deliberately left Android off VIDEO_CAP_MULTI_SLICE
("embedder-set decoder truth") — this is that deferred item, and the P2
slice-pipeline prerequisite. VideoDecoders.multiSliceTolerant() probes the
pick for every advertised codec: tolerant unless any pick is an Amlogic
decoder (the 0.17.0 device-rebooting wedge) or uninspectable (a null pick =
platform default → conservatively single-slice). nativeConnect carries the
bit; the JNI ORs it into the Hello's video_caps beside the panel-truth
HDR bits. Phones (Qualcomm/Exynos) advertise it; the Amlogic TV boxes the
gate exists for never will.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:14:46 +02:00
enricobuehlerandClaude Fable 5 19392918ff feat(gamescope/hdr): HDR is attempted by default and every install path ships the patched gamescope
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m14s
ci / rust-arm64 (push) Successful in 3m28s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
android / android (push) Successful in 5m47s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 6m5s
docker / deploy-docs (push) Successful in 35s
deb / build-publish-host (push) Successful in 6m35s
ci / rust (push) Successful in 6m38s
arch / build-publish (push) Successful in 9m13s
apple / swift (push) Failing after 13m37s
apple / screenshots (push) Skipped
windows-host / package (push) Successful in 17m4s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 39s
Field report (RX 5700 / SteamOS): 10-bit HEVC "unsupported" — but the GPU was
never the blocker (RADV probed Main10 fine on that box). The session stayed SDR
because stock gamescope offers no 10-bit PQ capture formats and nothing shipped
our pipewire-hdr build outside the Bazzite sysext, while PUNKTFUNK_GAMESCOPE_HDR
additionally defaulted off everywhere. Close all three gaps:

- PUNKTFUNK_GAMESCOPE_HDR now defaults ON (explicit-off grammar, the flip the
  config comment planned post-canary). Safe by construction: the +pfhdr banner
  probe, managed-spawn term, and client 10-bit cap keep stock-gamescope boxes on
  the exact 8-bit path. The nix module's gamescopeHdr (default true now) only
  controls whether the patched binary is on PATH; it no longer sets the env.
- SteamOS: new scripts/steamdeck/build-gamescope.sh builds the HDR gamescope in
  the same pf2 trixie distrobox (apt dep list validated by a full build in a
  debian:trixie container), installs ~/.local/bin/punktfunk-gamescope, and
  maintains PUNKTFUNK_GAMESCOPE_BIN in host.env ONLY while the binary passes its
  on-glass --version/+pfhdr check — a stale absolute override would break
  session spawning, not just HDR. Wired into install.sh (§3b), update.sh
  (retrofit), and rebuild-check.sh (ldd-probes it like the host binary).
- Arch: build-sysext.sh gains a --gamescope fold-in (banner-verified by
  execution, host-image-only), and punktfunk-host optdepends the companion.
  Deliberately NO CI-published host sysext: per the 2026-07-22 packaging
  verdict a prebuilt SteamOS host breaks on A/B soname bumps (and /var is
  per-partition-set), so arch.yml records that instead — SteamOS hosts use the
  distrobox flow above.

Also fixes a latent bug that made build-sysext.sh fail every invocation as
documented: a literal `}` inside `${1:?usage …{host,client}…}` terminates the
expansion early and corrupts $PKG (caught by the new synthetic-package test,
which also covers the fold-in, the unmarked-gamescope refusal, and the
client-image guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:50:31 +02:00
enricobuehlerandClaude Opus 5 78926be4ac fix(store): a plugin install lands in the plugins dir, not the nearest package.json above it
ci / docs-site (push) Successful in 2m8s
ci / web (push) Successful in 1m15s
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 4m21s
apple / swift (push) Successful in 4m52s
android / android (push) Successful in 5m22s
deb / build-publish (push) Successful in 3m57s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 3m55s
ci / rust (push) Successful in 9m19s
arch / build-publish (push) Successful in 9m25s
windows-host / package (push) Successful in 14m3s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m14s
apple / screenshots (push) Successful in 20m44s
Installing any plugin from the console failed on a user's Fedora 44 host with

  plugin store job failed: @punktfunk/plugin-virtualhere is not present after install

while the same version installed fine on every box we tried. The difference turned
out to be a file in his home directory.

`bun add` does not install into its working directory. It walks UP to the nearest
ancestor `package.json` and installs into that tree. A fresh plugins dir has no
`package.json` — the store and the runner only write `bunfig.toml` — so any stray
one above it captures the install: the packages land in `~/node_modules`, the
dependency is written to `~/package.json`, bun prints "installed …" and exits 0,
and the plugins dir stays empty. The plugins-dir `bunfig.toml` is not read in that
case either, so the outcome splits on whether `@punktfunk` resolves in the hijacked
tree: with no scope mapping there it 404s against npmjs and exits 1 ("the plugin
runner exited with status 1"); with one, it succeeds into the wrong tree and the
job dies on the presence check instead. Only the second shape looks like a broken
store, which is why this took a reproduction to find.

Seed the plugins dir with a `package.json` so it owns bun's install root. Three
call sites rather than one: the store job, the `plugins add` CLI, and the SDK's
`ensurePluginsDir`. The runner ships as its own package and can predate the host
binary, so the host cannot delegate this — the same reason `ensure_bunfig_scope`
writes the registry mapping on this side rather than passing a runner flag.

Seeding only ever touches a tree with no `node_modules`. A dir with packages and
no `package.json` is the hand-assembled/older layout that `installed_packages`
deliberately falls back to the naming convention for, and dropping an empty
`dependencies` on it reports every plugin the operator runs as uninstalled —
already pinned by `an_emptied_dependency_list_means_nothing_is_installed`.

The residual failure stops dead-ending: if the presence check fails anyway, the
error names the capturing ancestor and what to do about it.

Reproduced and fixed on glass (Nobara 44 VM, host + runner 0.22.3): with a stray
~/package.json the store reproduces the field report byte for byte — same 150 ms,
same phase sequence, same message — and with the plugins dir seeded the same
install lands correctly with nothing leaking into the home dir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:48:25 +02:00
enricobuehlerandClaude Fable 5 759aac255b feat(phase-lock): controller v3 — grid-locked submits, arrival sensing, antipode damping
ci / docs-site (push) Successful in 1m17s
ci / web (push) Successful in 2m14s
ci / rust-arm64 (push) Successful in 2m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 2m59s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / swift (push) Successful in 4m48s
deb / build-publish-host (push) Successful in 4m51s
android / android (push) Successful in 5m31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 12s
docker / deploy-docs (push) Successful in 35s
deb / build-publish (push) Successful in 6m0s
ci / rust (push) Successful in 9m53s
arch / build-publish (push) Successful in 10m32s
windows-host / package (push) Successful in 19m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m2s
apple / screenshots (push) Successful in 21m3s
Midday on-glass falsified v2's remaining assumptions: a per-frame additive
hold SATURATES the arrival-slaved loop once hold + work >= interval — submits
free-run at their own cadence and the commanded phase dissolves (measured:
+-2 ms hold steps, zero client response) — and the latch statistic hovers at
the target's ANTIPODE, where shortest-way errors sign-flip on noise (the
0<->2<->4 ms offset chatter).

v3, one change per falsified assumption:
- ACTUATOR: submits lock to an absolute grid (epoch + k x period + offset);
  the controller walks only the offset. A periodic grid cannot free-run —
  occupancy is one frame per period whatever the offset — so phase actuation
  is linear BY CONSTRUCTION. Disengaged = no grid sleeps = zero cost; every
  failure path DISENGAGES (never parks: v2's e2e-tax lesson).
- SENSOR: the client reports the circular mean + coherence of the ARRIVAL
  lead (per-AU reassembly stamps vs the panel latch grid) — the phase the
  host actually controls; latch measured downstream of the decoder pipeline,
  which absorbed the actuation. pf.phase logs "arrival lead circ= coh=".
- ANTIPODE DAMPING: errors within 1 ms of +-period/2 take half-steps until
  they commit to a side.

Harness rewritten around plants glass validated: a GRID plant (linear by
construction), the DECOUPLED plant (v2's saturation — must disengage, not
orbit or park), an antipode start (must converge within one period of
travel), incoherence (never engages), a regime change (re-engages after
backoff), and the actuator's own periodicity/offset-linearity. Also fixes
the harness's SIM_TARGET (a mis-derived max asserted 3.5 ms where the
controller's actual target is 2.5 — the controller was right, the ruler
was wrong; the Python cross-check inherited the same constant, a lesson in
replicating the CODE, not the author's belief about it).

Gates: v3 simulations 6/6; host bin suite 345/1 (the documented
environmental qemu UDP test); clippy --all-targets -D warnings clean;
cargo ndk arm64 check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:20:39 +02:00
enricobuehlerandClaude Fable 5 4457356ee4 test(phase-lock): a closed-loop simulation harness — both on-glass failures become CI regressions
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 27s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 2m48s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 2m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m1s
android / android (push) Successful in 5m31s
deb / build-publish-host (push) Successful in 6m5s
docker / builders-arm64cross (push) Successful in 18s
apple / swift (push) Successful in 4m41s
docker / deploy-docs (push) Successful in 46s
ci / rust (push) Successful in 6m45s
deb / build-publish (push) Successful in 5m9s
arch / build-publish (push) Successful in 9m4s
flatpak / build-publish (push) Successful in 6m54s
windows-host / package (push) Successful in 11m3s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m10s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m27s
release / apple (push) Successful in 25m17s
apple / screenshots (push) Successful in 21m0s
The controller iterated twice on the user's gaming PC before this existed;
never again. punktfunk_core::phase::circular_latch is extracted from the
Android presenter (shared: iOS reports next, and the harness generates its
synthetic reports through the IDENTICAL statistic the clients ship), with
unit tests including the wrap-straddling cluster an arithmetic mean gets
maximally wrong.

The stream.rs harness models the plant — latch = (base − hold + noise)
mod P, deterministic LCG noise — and drives PhaseController::adjust through
five regimes:
- tight jitter locks into the deadband within ~5 adjusts and stops moving;
- a wrap-side start takes the SHORTEST way (travel < P/2 asserted);
- an incoherent phase never steps (zero hold, ever);
- a v1 pinned-median report trips the travel budget and DECAYS TO ZERO —
  the 2026-07-31 orbit and the parked-hold e2e tax, both as asserts;
- a post-decay regime tightening re-locks.

Gates: host bin suite 344 passed / 1 failed = the documented environmental
qemu UDP-loopback test (clean main fails identically in this container);
core phase tests 4/4; clippy --all-targets -D warnings clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:18:30 +02:00
enricobuehlerandClaude Fable 5 fe552a50bb fix(android): the phase reporter actually sends the v2 circular statistic
ci / rust-arm64 (push) Successful in 2m45s
ci / web (push) Successful in 1m15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / docs-site (push) Successful in 1m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
docker / builders-arm64cross (push) Successful in 18s
android / android (push) Successful in 6m17s
ci / rust (push) Successful in 9m44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 12m57s
docker / deploy-docs (push) Canceled after 0s
The v2 client edit missed its anchor against rustfmt's reshaping and the
reporter kept sending the v1 median — caught by the arm64 build the moment
the tuple signature landed. Sends (circular mean, coherence) now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:58:19 +02:00
enricobuehlerandClaude Fable 5 1d31e4c565 feat(phase-lock): controller v2 — circular phase, coherence gate, decay-to-zero
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m34s
ci / rust-arm64 (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
android / android (push) Failing after 2m21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
ci / rust (push) Canceled after 2m52s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 4m47s
deb / build-publish-client-arm64 (push) Successful in 3m59s
deb / build-publish-host (push) Successful in 4m29s
deb / build-publish (push) Successful in 5m59s
flatpak / build-publish (push) Successful in 4m58s
arch / build-publish (push) Successful in 10m18s
windows-host / package (push) Successful in 11m6s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 33s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m8s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m41s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m46s
apple / screenshots (push) Canceled after 0s
release / apple (push) Canceled after 19m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 21m16s
Round-1 on-glass falsified two v1 assumptions in one morning (NP3 ↔ .173):

- A median lead is immovable under period-spanning jitter (uniform mod P),
  so v1 orbited the period at 2 ms/s forever — and the dead-signal freeze
  hotfix never armed, because ±1.5 ms of window sampling noise kept
  resetting its 'error responded' check. Thresholds can't referee noise.
- A HELD hold is not free: it delays sampling after the capture stamp,
  taxing e2e by up to a period (user-measured: ~+4 ms during the orbit).
  The correct failure response is DECAY TO ZERO, never freeze-in-place.

v2, all three layers:
- Wire: PhaseReport grows a length-discriminated coherence tail (27-byte
  form; the u16::MAX sentinel encodes as the byte-identical 25-byte v1 —
  strict-prefix discipline, both forms pinned by tests).
- Client: the reporter sends the CIRCULAR vector-mean latch phase mod the
  panel period + its coherence (‰); pf.present logs circ=/coh=.
- Host: steps only while coherent (floor 300‰; a v1 report bypasses the
  gate), along the signed SHORTEST way around the period, under a
  cumulative 1.25-period travel budget that is noise-immune by construction
  (it integrates applied steps, not reported errors); incoherence or budget
  exhaustion decays the hold to zero and re-arms once flat. Deadband
  convergence resets the budget — tight regimes lock exactly as designed.

report_phase + the C ABI mirror gain the coherence parameter (canary-only
ABI, hours old, sole caller in-tree). Gates: docker amd64 clippy
--all-targets -D warnings (host+core, nvenc) clean; core suite 244/244;
cargo ndk arm64 check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:55:24 +02:00
enricobuehlerandClaude Fable 5 a5896f0883 feat(android): the phase-lock ACK is logged — the closed loop reads out in logcat
ci / web (push) Successful in 1m6s
ci / docs-site (push) Successful in 2m8s
deb / build-publish (push) Successful in 4m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
deb / build-publish-host (push) Successful in 4m25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 5m46s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 3m40s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 50s
arch / build-publish (push) Successful in 7m59s
android / android (push) Successful in 8m20s
ci / rust (push) Successful in 9m33s
apple / swift (push) Successful in 5m3s
windows-host / package (push) Successful in 12m57s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 30s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m59s
apple / screenshots (push) Successful in 21m18s
The host's applied hold rides the 0xCF tail but nothing client-side showed
it; 'adb logcat -s pf.phase' now logs transitions. This line is what exposed
the dead-median controller orbit on the first on-glass run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:12:56 +02:00
enricobuehlerandClaude Fable 5 74ca42568a fix(host/phase-lock): a dead error signal freezes the hold instead of orbiting the period
First on-glass closed-loop run (NP3 ↔ .173, canary 0.23.10498) proved the
transport and both endpoints work — and exposed the controller chasing a
statistic that cannot respond: under period-spanning arrival jitter the
client's latch DISTRIBUTION is ~uniform mod the refresh, so shifting its
mean (the hold — provably reaching the client: the paced-drop bursts locked
to each wrap) cannot move its MEDIAN. The controller stepped +2 ms/s in one
direction forever, wrapping the period every ~4 s, and every wrap coalesced
~30 frames at the client — a periodic hiccup that must not ship.

The controller now remembers its last error: three consecutive steps that
fail to move it ≥1.5 ms freeze the hold (kept, not zeroed) and log; a later
report whose error moved re-arms stepping — a tight-jitter regime still
locks exactly as designed. Controller v2 (a circular phase statistic with a
coherence gate, per the design doc's follow-ups) replaces the median
outright; this freeze stays as its safety net.

Gate: docker amd64 clippy --all-targets -D warnings (host, nvenc) clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:12:56 +02:00
21787ca6cd feat(decky): the plugin updates the actual client, not only the flatpak one
audit / bun-audit (plugin-kit) (push) Successful in 17s
audit / bun-audit (sdk) (push) Successful in 20s
audit / bun-audit (web) (push) Successful in 27s
audit / pnpm-audit (push) Successful in 18s
audit / cargo-audit (push) Successful in 49s
audit / docs-site-audit (push) Successful in 42s
apple / swift (push) Successful in 1m18s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 2m10s
audit / license-gate (push) Successful in 5m17s
ci / rust-arm64 (push) Successful in 5m33s
decky / build-publish (push) Successful in 35s
deb / build-publish (push) Successful in 5m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
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 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
deb / build-publish-client-arm64 (push) Successful in 2m16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 26s
android / android (push) Successful in 9m7s
docker / builders-arm64cross (push) Successful in 8s
arch / build-publish (push) Successful in 9m19s
docker / deploy-docs (push) Successful in 31s
deb / build-publish-host (push) Successful in 7m31s
windows-host / package (push) Failing after 14m54s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
flatpak / build-publish (push) Successful in 7m57s
ci / rust (push) Successful in 15m50s
release / apple (push) Successful in 18m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m52s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m49s
apple / screenshots (push) Canceled after 9m43s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m55s
The QAM already offered a client update — for the flatpak, by comparing OSTree
commits. A native client (.deb, .rpm, pacman, sysext, nix, a source build), which
the plugin has been able to LAUNCH since it learned to drive one, went stale in
silence: no version, no check, no mention.

check_update now branches on the client the launcher will actually run. The
flatpak keeps its commit comparison, which is the exact one — a flatpak built
from main between releases carries the release's crate version, so a
version-based check would call it current when it isn't. Everything else asks
the client itself, which verifies the signed manifest. update_client routes the
same way: flatpak update --user, or `punktfunk-client --apply-update`.

Where nothing on the box can install it, the plugin says so instead of offering
a button. The QAM banner becomes the command, the About tab names the install
kind so "why is there no Update button?" has a visible answer, and a check that
FAILED reads as a failure — never as "up to date".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:41:26 +02:00
b164b4c03e feat(packaging): a client-only box can update its client without the host
The root helper, its unit, the group-scoped polkit rule and the install-kind
marker all shipped with the HOST package. A Steam Deck or a handheld has no host
package, so the client half of one-tap updates had nothing to stand on.

The client packages now ship their own copies — deliberately under their own
paths (`pf-update-client`, `punktfunk-client-update.service`,
`49-punktfunk-client-update.rules`, `/usr/share/punktfunk-client/install-kind`)
rather than sharing the host's. Two packages owning one path is a hard conflict
in dpkg, rpm and pacman alike, and the client marker needs its own DIRECTORY
too because the host RPM claims `%{_datadir}/punktfunk/*` with a glob — a
sibling file there would break `dnf install punktfunk punktfunk-client`.

`pf-update` grows the matching `apply-client` verb. The verb comes from a
root-owned unit's fixed ExecStart, never from the caller, so the zero-parameter
invariant holds; both verbs sweep every installed punktfunk package, and what
the verb selects is which marker is read and which binary the run-the-binary
gate executes afterwards. A client sysext is refused with its reason: the signed
feed carries the host image, and installing that over a client-only box is not
an update.

The opt-in group is created by both packages' scriptlets, idempotently, so
whichever lands first wins.

Also fixes the host's own helper install: it read `$R/target/release/pf-update`,
which is the source checkout, while build() exports CARGO_TARGET_DIR="$srcdir/
target" and every other binary in the file installs from `$T`. Both now read
`$T`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:41:26 +02:00
a87fbebc0a feat(client/linux): the client says whether it is current, and can update itself
`punktfunk-client --check-update` fetches the same Ed25519-signed per-channel
manifest the host trusts, works out how this client was installed, and reports
what — if anything — can be done about a newer build. `--apply-update` does the
part it owns. `--version` prints the CI-stamped build string, which is both what
the comparison needs and what a packaging gate can run the binary for.

The engine lives here rather than in the Decky plugin for two reasons: verifying
a signature needs crypto that Decky's embedded Python does not have, and the
plugin is not the only surface that wants the answer. Every UI becomes a caller,
exactly as it already is for --pair, --library and --list-hosts.

What is possible depends on the install, and the module refuses to pretend
otherwise. A flatpak is a per-user install its launcher can update. A .deb/.rpm/
pacman client goes through the packaged root helper — a fixed, parameterless
systemd oneshot polkit authorises for members of the punktfunk-update group, so
the request carries nothing an attacker could influence and the payload comes
from the distro's own signed repositories. A sysext, a nix profile or a source
build gets the command and nothing else: the signed sysext feed carries the HOST
image, so a client sysext has no feed to update from, and a button that can only
fail is worse than one honest line.

A failed check reports the failure. "Could not tell" rendering as "up to date"
is the one answer that must never happen.

The box's capabilities are probed once into a `Caps` struct so the routing rules
are a pure function of (install kind, caps) — all seven of them are tested
without a box, including that the kill switch beats every kind and that pacman
needs its own root-owned full-sysupgrade opt-in on top of the group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:41:26 +02:00
f6cfe382fd refactor(update): one signed-manifest checker, shared by the host and the client
The host has known how to answer "is a newer build available for this box's
channel?" since the update-from-web-console work. The Linux client is about to
need the same answer from the same signed document, and a trust rule that lives
in two places is a trust rule that will drift.

So the parts where being wrong is a security bug now exist exactly once, in the
new `pf-update-check`: Ed25519 verification against pinned keys, the manifest
schema and its fail-closed validation, the post-redirect fetch, the version
comparison that has to reconcile four different canary spellings, and the
install-kind ladder — the last parameterised by which product is asking, since
the delivery channels are the same ones but the markers are not.

The pinned key list moves with them. Two lists could have disagreed about who
may announce a release, and the one that drifted is the one nobody would have
noticed; `publish-update-manifest.sh` follows to the new path, and a MISSING
keys file there is now fatal rather than a warning that silently skips the
cross-check the step exists to perform.

Host call sites are unchanged throughout: `store::index` and `update::manifest`
re-export from the shared crate under their old names, and `update::detect`
keeps its cached `detect()` and the host's command hints. Verified with clippy
-D warnings on Linux and on Windows (nvenc,amf-qsv,qsv), 340 host tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:41:26 +02:00
enricobuehlerandClaude Fable 5 84c6938562 test(inject): the uhid sweep tests learn the devnode-churn grace
arch / build-publish (push) Failing after 6s
ci / docs-site (push) Successful in 1m53s
ci / web (push) Successful in 1m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
ci / rust-arm64 (push) Successful in 2m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 35s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
deb / build-publish-client-arm64 (push) Successful in 1m32s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 28s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 3m50s
docker / deploy-docs (push) Successful in 34s
deb / build-publish-host (push) Successful in 4m26s
apple / swift (push) Successful in 4m54s
android / android (push) Successful in 7m2s
ci / rust (push) Successful in 9m15s
windows-host / package (push) Successful in 19m29s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m39s
apple / screenshots (push) Successful in 20m39s
6b0bd59b gave the unplug sweep a 300 ms grace so a mask glitch can't flap
PnP devices — and updated pad_slots' own tests, but not the two uhid_manager
consumer tests that encode the old immediate-sweep contract (its CI run was
cancelled by a superseding push, so the breakage surfaced on later shas).

PadSlots gains a #[cfg(test)] expire_grace() that backdates the armed clocks,
so consumer tests drive the debounce without wall-clock sleeps. The removal
test now also PINS the new hold-inside-grace behavior explicitly.

Verified in the amd64 CI-image container: pf-inject 83/83, clippy
--all-targets -D warnings clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:35:51 +02:00
enricobuehlerandClaude Fable 5 6b0bd59b8d fix(gamepad): a mask glitch no longer flaps devnodes — the unplug sweep gets a grace
ci / docs-site (push) Successful in 1m12s
ci / web (push) Successful in 1m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 27s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
ci / rust-arm64 (push) Successful in 2m14s
deb / build-publish-client-arm64 (push) Successful in 1m6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish (push) Successful in 3m38s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 25s
deb / build-publish-host (push) Successful in 6m44s
android / android (push) Successful in 7m55s
ci / rust (push) Failing after 8m1s
windows-drivers / probe-and-proto (push) Successful in 30s
apple / swift (push) Successful in 4m43s
windows-drivers / driver-build (push) Successful in 1m47s
arch / build-publish (push) Failing after 5m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m45s
windows-host / package (push) Successful in 12m1s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 19s
apple / screenshots (push) Successful in 20m47s
The unplug sweep dropped a pad the instant its active_mask bit read
clear — but a pad teardown is a whole PnP device removal (system-wide
device-change broadcasts, and on re-create a full hidclass
re-enumeration), so a client-side mask glitch of a few state frames
cycled real devnodes. The sweep now drops a pad only after its bit has
stayed clear for 300 ms; a bit that returns inside the grace disarms it.
A real unplug tears down one grace later, which nobody observes — the
pad already went quiet. Lands once in PadSlots, so every backend
(uinput/uhid/XUSB/UMDF) inherits it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:59:50 +02:00
enricobuehlerandClaude Fable 5 15361b0091 fix(drivers/gamepad): drop the 1 s enumeration wait its own premise doomed, and gate the logger
The PnP-identity fix established that the sealed channel is STRUCTURALLY
unavailable while hidclass enumerates (the DATA section arrives over the
HID interface those very queries create) — so device_type()'s bounded
1 s pump loop, reachable only for a devnode whose hardware ids matched
nothing, could never succeed there: it burned a second of a WUDFHost
dispatch thread mid-enumeration and then fell back to LAST_DEVTYPE
anyway. The fallback chain is now wait-free (attached section → PnP
identity → LAST_DEVTYPE), and the 8 ms timer refreshes LAST_DEVTYPE
whenever the section is attached, so the last resort is always current.

The logger also joins its siblings behind file_log_enabled(): a RELEASE
driver without the opt-in no longer OutputDebugStringA's (+ CString +
format! allocs) per logged event — the rumble OUTPUT hex dumps and the
cyclic GET_STRING polls were sustained paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:59:50 +02:00
enricobuehlerandClaude Fable 5 ccf5c922ee fix(core/abi): report_phase joins its siblings behind the quic cfg — the C header parses again
ci / docs-site (push) Successful in 1m6s
deb / build-publish-host (push) Failing after 14s
ci / web (push) Successful in 2m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 1m16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
ci / rust-arm64 (push) Successful in 4m30s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m13s
apple / swift (push) Successful in 4m46s
android / android (push) Successful in 5m32s
docker / builders-arm64cross (push) Successful in 11s
deb / build-publish (push) Successful in 6m8s
docker / deploy-docs (push) Successful in 35s
ci / rust (push) Successful in 7m7s
arch / build-publish (push) Failing after 9m7s
flatpak / build-publish (push) Successful in 7m3s
windows-host / package (push) Successful in 18m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m25s
apple / screenshots (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m23s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m50s
release / apple (push) Successful in 25m47s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m44s
The new punktfunk_connection_report_phase lacked the #[cfg(feature = "quic")]
its connection-fn siblings carry, so cbindgen emitted it OUTSIDE the guarded
region that declares the opaque PunktfunkConnection — the generated header
stopped compiling as C (CI's harness caught it; my local harness failure was
a pre-existing macOS linker issue that masked this distinct cause — full-suite
comparison attributed the wrong root).

Verified: the harness compiles and passes in the amd64 CI-image container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:38:31 +02:00
enricobuehlerandClaude Fable 5 fa822744ff feat(core/host/android): phase-locked capture — frames arrive on the client's latch schedule
ci / web (push) Successful in 1m59s
ci / docs-site (push) Successful in 2m13s
ci / rust-arm64 (push) Successful in 5m17s
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 4s
android / android (push) Successful in 5m52s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
deb / build-publish (push) Successful in 5m38s
ci / rust (push) Failing after 5m52s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
arch / build-publish (push) Failing after 6m7s
deb / build-publish-client-arm64 (push) Successful in 3m55s
docker / builders-arm64cross (push) Successful in 19s
deb / build-publish-host (push) Failing after 4m24s
apple / swift (push) Successful in 4m35s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
docker / deploy-docs (push) Successful in 31s
windows-host / package (push) Failing after 6m22s
windows-host / canary-manifest (push) Skipped
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 2m35s
flatpak / build-publish (push) Successful in 6m33s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 10m8s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 11m43s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m1s
release / apple (push) Successful in 25m5s
apple / screenshots (push) Successful in 20m50s
The host's capture tick and a client's panel vsync are independent ~120 Hz
oscillators; their drifting phase sweeps every frame's wait-for-latch across
a full refresh period (measured on-glass: latch p50 oscillating 5.4-8.9 ms
with a fixed margin) — the beat is the residual judder and the fat p95, and
no client can fix it alone. Design: punktfunk-planning
design/phase-locked-capture.md.

Protocol (punktfunk-core):
- PhaseReport (control 0x32, next to the clock family): the client's next
  display latch ALREADY CONVERTED to host clock (the skew offset lives only
  client-side), panel period, uncertainty, and the measured median
  arrival-lead — the controller's error signal. ~1 Hz, latest-wins.
  CLIENT_CAP_PHASE_LOCK advertises it; CtrlRequest::Phase + report_phase()
  + the C ABI mirror carry it.
- The 0xCF host-timing tail grows a phase ACK (applied_phase_ns, 29-byte
  form) under the same strict-prefix append discipline — old readers parse
  the shorter forms; degradation pinned by tests.

Host engine (arrival-slaved loop — no backend can move the source vsync,
per the tick-ownership audit in the design doc):
- PhaseCtl bridges control task → encode loop (the fec_target pattern,
  multi-field). PhaseController walks a per-frame HOLD before submit toward
  the client's reported lead hitting target = max(2.5 ms, uncertainty+1ms):
  1 Hz adjust, 2 ms max step, 300 µs deadband, period-wrapping (the
  newest-wins capture slot makes a wrapped hold sample fresher content, not
  staler). A loop local, so every mid-stream rebuild keeps the lock; a new
  session re-acquires. PUNKTFUNK_PHASE_LOCK=0 disarms.

Android reporter: the presenter's 1 Hz pf.present flush returns the
window's measured latch p50; the async loop converts the vsync clock's next
timeline monotonic→realtime→host and reports. Inert toward old hosts.

Gates: docker amd64 clippy --all-targets -D warnings (host+core, nvenc)
clean; core suite incl. the new wire tests; cargo ndk arm64 check/clippy
clean. On-glass A/B vs the .173 host owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 f9faab780e tune(android): the latch margin drops to SF's real lead (2.5 ms)
Each ms of submit-margin is a ms on every frame's display stage; SF's latch
runs ~1-2 ms before present and the release is a sub-ms binder call, so 4 ms
was padded. Measured (A024, 120 Hz game load): latch p50 8-10 → 5.4-8.9
(phase-drift dependent), paced stays 1-5/s. A device that misses at this
margin shows it as a paced-counter rise, not stutter — the widen signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 a6ff0350e4 feat(android): frames target SurfaceFlinger's latch, not the GPU-render deadline — display 21→9.5 ms
The remaining ~21 ms display stage was the conservative release target: the
presenter aimed at the first frame timeline whose DEADLINE was still ahead,
and the platform's deadline budgets for GPU rendering the app has yet to
submit (presDeadline = 11.3 ms on the A024 — more than a full 120 Hz
period). A decoded video buffer has no GPU work left; its only real
constraint is SurfaceFlinger's own latch lead. Every frame paid a whole
extra refresh of waiting for a budget it never used.

next_target now gates (and subdivides) on the timeline's EXPECTED PRESENT
minus a 4 ms latch margin; the glass budget reopens at that latch instant
(expected present − margin) rather than the deadline, which under the
aggressive gate can already lie in the past — an instant reopen would let
two releases pile onto one vsync. A mis-gamble presents one vsync later,
which is exactly what the deadline gate paid on every frame — the trade is
one-sided.

On-glass (A024, 2800×1260@120 HDR, game load): latch p50 21→8-10 ms,
display 26,3→9,5 (pace 0,8 + latch 7,7), e2e 43,7→26,4 p50 / 29,2 p95,
released=displays=120, paced≈0, forced=0. The latch now sits under one
refresh interval — the vsync-latch floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 984f7be896 fix(android): the presenter paces to the panel's real grid, not the app's down-rated vsync
On-glass (A024, 120 Hz panel, 120 fps session) the first presenter build
released only 60/s and the HUD display term hit 40 ms. Root cause, in two
layers: Android down-rates a game-category uid's choreographer stream to
60 Hz (frame-rate categories / game default frame rate), and under that
override Display.getRefreshRate REPORTS THE OVERRIDE — so the presenter's
panel grid read 16.67 ms on an 8.33 ms panel and the subdivision became a
no-op, pacing the video at half rate and dropping every other frame.

Three-part fix, verified live on the same device:
- Kotlin passes the panel rate from the supported-modes TABLE
  (MainActivity.streamPanelFps — the mode list is not override-filtered)
  instead of display.refreshRate, and votes the app's render rate up via
  View.requestedFrameRate = streamHz (API 35+) while streaming.
- The native vsync clock LEARNS the panel period from observed timeline
  spacing (downward-only: the finest spacing SurfaceFlinger ever reports is
  the true grid) and next_target subdivides the reported timeline onto it —
  full-rate on down-rated devices, a no-op where callbacks match the panel.
- OnFrameRendered display/latch samples get the e2e clamp (0..10 s): a
  vendor's first callbacks can carry a garbage system_nano (observed: an
  epoch-sized latch max) that would poison every max it lands in.

pf.present gained panelMs next to vsyncMs, and a one-shot cadence
diagnostic logs Δ/timelines/spacing/panel on the third tick.

After: released=120 displays=120 paced=0, pace p50 <1 ms, latch p50 ~16 ms
idle / ~22 ms under game load (2 refresh intervals at 8.33 — the same
composited-pipeline law the Apple client measured), HUD display ~17-26 ms
vs 40 before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 e08fd91cd1 feat(android): a timeline presenter — frames reach glass on the panel's schedule, not decode's
The Android port of the Apple client's stage-4 deadline discipline, closing
the side-by-side feel gap (both clients 120 Hz; Android released decoded
buffers the instant they appeared, with zero vsync awareness — the latch
phase inherited every network+decode jitter and bursts queued behind the
display).

The presenter (async loop only; the sync loop stays the untouched escape
hatch behind the Low-latency toggle):

- decode/vsync.rs: an AChoreographer thread (dlsym'd like the other
  above-floor symbols) publishing the panel's vsync grid + frame timelines
  (postVsyncCallback, API 33; postFrameCallback64 fallback on 31/32) and
  ticking the decode loop's event channel. Started lazily on the first
  decoded frame.
- decode/presenter.rs: a newest-wins slot (Lowest latency, default) or a
  1-3 frame smoothing FIFO with preroll/underflow re-arm (Smoothness) between
  decode and release; a glass budget of exactly ONE undisplayed release in
  flight, reopened at the target timeline's DEADLINE (SurfaceFlinger's latch
  — reopening at present time would halve the sustainable rate) with a 100 ms
  stale force-open backstop; the release itself via
  releaseOutputBufferAtTime(expectedPresent) so the latch phase is
  deterministic. debug.punktfunk.presenter=arrival sysprop restores the
  legacy path for a rebuild-free on-device A/B.
- Metrics: DisplayTracker is now always-on and carries the release stamp, so
  the display stage splits into pace (decoded→release) + latch
  (release→displayed); a 1 Hz pf.present logcat line (released/displays/
  paced/noBudget/forced/qDry + pace/latch p50/max + measured vsync) makes a
  HUD-off wireless A/B readable; nativeVideoStats grows to 30 doubles
  (26=paceP50, 27=latchP50, 28=presents, 29=presenterActive; 0-25 frozen)
  and the DETAILED HUD prints the split + presents.
- Intent parity: present_priority/smooth_buffer — the Apple client's
  stored values and labels — as globals, profile-overlay fields (round-trip
  + scope markers), and Settings pickers under Decoding; threaded through
  nativeStartVideo into the presenter config.

Verified: cargo ndk check/clippy clean for arm64 (the two type_complexity
warnings are pre-existing audio/mic ones), armv7 via the kit gradle task,
host cargo check clean, rustfmt clean, gradle :app/:kit unit tests all pass.
On-device before/after on the Nothing Phone 3 still owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 581320df0c fix(android): the stream stops fighting large displays and owns the cutout explicitly
The three Play Console pre-release findings for 0.22.3, resolved:

- Orientation restriction: the in-stream SENSOR_LANDSCAPE lock is now applied
  on compact devices (sw < 600 dp) only. On tablets/foldables/desktop windows
  it is a large-display anti-pattern (Android 16+ ignores it there outright)
  and unnecessary — the aspect-ratio letterbox renders correctly in any
  orientation; the lock was always a phone-ergonomics choice. No manifest
  restriction existed, and resizeability stays unrestricted.
- Edge-to-edge determinism: the stream window now sets
  LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS explicitly (and restores it on the way
  out) — SDK-35 enforcement makes that the immersive default, pre-15 devices
  letterboxed the notch as a dead bar; being explicit gives both the same,
  correct behaviour. The stream's own letterbox is black, so the cutout region
  can never show anything wrong.
- Deprecated edge-to-edge APIs: audited — no in-app use of
  setStatusBarColor/setNavigationBarColor/systemUiVisibility/translucent
  flags or theme attrs; enableEdgeToEdge (androidx.activity 1.13.0) is already
  in place with explicit SystemBarStyle. What the scanner sees are androidx's
  own API-level-guarded compat branches, which Google documents as ignorable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 87b6fa8813 feat(android): the panel is pinned to the stream's refresh, and touch skips the vsync batch
Three quick latency wins for phones, ahead of the presenter rebuild:

- setStreamDisplayMode: the window-level preferredDisplayModeId is pinned to
  the stream's refresh (exact rate, else the smallest integer multiple, else
  the highest available) for the session. The surface-level frame-rate hint
  alone is advisory and some OEM refresh governors (Nothing OS's LTPO logic
  among them) ignore it for third-party apps — leaving a 120 Hz session
  presenting on a 60/90 Hz panel. nativeVideoSize gained a trailing
  refreshHz element for this (old readers index only 0/1). TV keeps the
  native HDMI mode switch instead.
- The surface hint itself now passes compatibility = FIXED_SOURCE on every
  form factor: the stream is fixed-rate video the client cannot re-pace;
  DEFAULT invited governors to not switch.
- requestUnbufferedDispatch(SOURCE_CLASS_POINTER) on the hosting view while
  streaming: touch/pointer events were vsync-batched — up to a frame of
  input latency the stream shouldn't pay.
- The HUD polls the panel's live refresh each second and flags '⚠ panel N Hz'
  when it sits below the stream rate, so an unpinned panel is visible instead
  of reading as inexplicable judder. Stale nativeStartVideo kdoc (low-latency
  'off, the default') corrected — it defaults ON under low_latency_mode_v2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehler 2748b84933 style(vdisplay/driver): rustfmt one line the audit bundle left unwrapped
ci / web (push) Successful in 1m6s
android / android (push) Canceled after 2m41s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m43s
ci / rust (push) Canceled after 2m41s
ci / rust-arm64 (push) Canceled after 2m41s
ci / docs-site (push) Canceled after 2m39s
deb / build-publish (push) Canceled after 1m33s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (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
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Successful in 23s
windows-drivers / driver-build (push) Successful in 1m46s
`5742ec95` landed `create_monitor`'s EDID call split across two lines where it
now fits on one, so the drivers workspace fails `cargo fmt --all --check`.
Whitespace only — separated from the gamepad work it happened to block.
2026-07-30 23:40:35 +02:00
enricobuehler 98147fb89c fix(gamepad/windows): the channel proof sizes its feature buffer from the descriptor
`HidD_GetFeature`/`HidD_SetFeature` reject any buffer shorter than the
collection's `FeatureReportByteLength`, and the proof hardcoded 64. That held
only because every pad enumerated with the DualSense descriptor; a Deck that
enumerates as a Deck reports 65 (its one feature report is unnumbered and 64
bytes wide, plus the report-id slot Windows always reserves), so both calls
would have failed, the proof with them, and — the gate being fail-closed — the
pad would never receive its DATA section at all.

Take the length from the descriptor, and accept the Deck's reply at either
offset 0 or 1: one driver binary serves four identities and Windows places an
unnumbered report's payload behind the report-id slot.

An answer must also VALIDATE, not merely parse. `from_feature_report`
reinterprets any 17 bytes, and a Deck serves its one unnumbered feature report
for *any* requested id — so it answers the PS `0x85` probe with Steam attribute
bytes that parse into a proof and fail on magic. Returning that first answer
stopped the search while the real proof sat one transport away.
2026-07-30 23:40:35 +02:00
enricobuehler 00c29f82f2 fix(gamepad/windows): a pad enumerates as the controller it IS, not always a DualSense
A Steam Deck client streaming to a Windows host had a stuck stick and a stuck
d-pad. The virtual pad was enumerating with the DualSense VID/PID **and the
DualSense report descriptor**, so Windows parsed the 64-byte Deck frame as
DualSense report 0x01: LX = report[1] = 0x00 (stick hard left), LY = report[2] =
0x09 (hard up), and a d-pad hat of 0 — which is UP, held forever.

The driver picked its identity from `device_type` in the sealed section, but
hidclass asks for the descriptors and attributes while it STARTS the device, and
the section can only be delivered over the HID device interface — which does not
exist until those queries are answered. The channel was structurally unavailable
at the only moment it was needed, so `device_type()`'s bounded wait always timed
out and every identity fell back to DualSense. Not a race: DualShock 4 and the
Edge enumerated as DualSenses too (verified on .173 — both report 054C:0CE6 with
a 64-byte DualSense input report, while their on-demand strings read correctly).

The devnode's own hardware ids carry the identity and are readable at
EvtDeviceAdd, before anything is asked, so resolve it there. The section stays
authoritative once attached; the old wait survives only for a devnode whose ids
match nothing.

`hwid_devtype_table_matches_the_driver` pins the host's hwid → device_type
mapping against the driver's table, including the ordering trap that
`pf_dualsense` is a prefix of `pf_dualsenseedge`.
2026-07-30 23:40:35 +02:00
enricobuehlerandClaude Fable 5 5742ec9548 fix(vdisplay/driver): the audit bundle — one timing formula, honest EDID, scoped watchdog, lock-free drain, D0-resume re-init, knobbed RT priority
ci / web (push) Successful in 1m5s
windows-drivers / probe-and-proto (push) Successful in 1m5s
deb / build-publish-host (push) Failing after 12s
apple / swift (push) Successful in 1m55s
ci / docs-site (push) Successful in 2m56s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
windows-drivers / driver-build (push) Successful in 2m10s
ci / rust-arm64 (push) Successful in 3m22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish-client-arm64 (push) Successful in 2m44s
android / android (push) Canceled after 4m33s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 4m35s
ci / rust (push) Canceled after 4m40s
deb / build-publish (push) Canceled after 4m40s
docker / builders-arm64cross (push) Canceled after 2s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 39s
windows-host / package (push) Canceled after 1m21s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 3m45s
release / apple (push) Canceled after 5m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 2m41s
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
One signing pass over the 2026-07-30 audit findings:

- Timing math unified (D4): monitor-description and target modes now come
  from ONE IddSampleDriver-exact builder differing only in
  vSyncFreqDivider; the virtual-display-rs legacy formula (width-less
  pixel rate, deliberately fractional vSync) is gone.
- EDID (D5): the preferred-timing DTD is built from the SESSION's mode
  when it fits the encoding (pf-driver-proto's tested builder; 1080p60
  stays the fallback); the range-limits descriptor covers everything the
  driver can advertise (max clock 150 MHz → 2550 MHz, max-H +255 —
  the old limits were violated by the driver's own 1080p120 default);
  product code 0 → 1. Deliberately still no HDMI VSDB — documented in
  the module doc.
- INF (D6): UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects added
  (the sibling drivers all carry it); the dead DeviceGroupId (inert under
  ProcessSharingDisabled) dropped; the IddCx0102-vs-
  IddMinimumVersionRequired=10 pairing documented as deliberate — 0102
  is the extension's registered identity, not a version request.
- Watchdog lifecycle (D7): device cleanup now stops the host-liveness
  thread (it ran forever and its reap raced device teardown over the
  same monitor list).
- Drain path off the mutex (D8): the per-frame has/take_frame_channel
  checks (≥60 locks/s per worker on the mutex the whole control plane,
  the mode DDIs and the watchdog contend) are gated by a delivery
  generation counter — the steady state takes no lock.
- Adapter cache (D9): last-write-wins slot instead of a OnceLock, and a
  D0 re-entry from a REAL low-power state clears + re-inits — the stale
  pre-power-cycle handle used to wedge every later IOCTL_ADD.
- Realtime GPU priority (D10): IddCxSetRealtimeGPUPriority is now
  A/B-able without a rebuild (PFVD_NO_RT_GPU, machine env) — no
  canonical IDD driver raises it, and it preempts the game's and DWM's
  queues at a level apps can't reach.
- Logging (D2): the logger rides file_log_enabled() as a whole — a
  RELEASE driver without the opt-in no longer OutputDebugStringA's (+2
  allocs) per logged event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 1c15ba89c3 fix(drivers/pads): the XInput path stops re-mapping the mailbox, and release logging gets a real off switch
pf-xusb pumped the sealed-channel bootstrap mailbox — an open+map+close+
unmap plus two heap allocs — on EVERY XInput IOCTL, per pad. A periodic
WDF timer (the pf-gamepad pattern) now owns the pump: adoption,
re-delivery and host-gone detection happen there, and the IOCTL path
reads the cached view. A vanished host still reads as a neutral pad
within one 8 ms tick, and the heartbeat mark still advances per serviced
IOCTL, so the host keeps seeing the GAME-visible polling path move.

Both pad drivers' log() also called OutputDebugStringA unconditionally —
a syscall + CString + format! alloc per logged event in RELEASE builds,
on per-IOCTL paths (SET_STATE hex dumps during rumble, the cyclic
GET_STRING polls). The whole logger now rides the existing
file_log_enabled() gate (debug builds, or PF*_DEBUG_LOG), and dbglog!
skips its format! too.

(pf-gamepad's identical log shape is deliberately untouched: the
unpushed fix/deck-hid-identity branch owns that file right now.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 85dd75437b feat(driver-proto): an EDID detailed-timing builder for the session's mode
An 18-byte DTD with fixed reduced blanking (80 px / 45 lines — a virtual
display's blanking only has to be self-consistent), refusing modes the
encoding cannot carry (pixel clock past the u16 10 kHz field, 4K120-class;
>12-bit actives). Lives here rather than in the driver so it is unit-
tested on every platform; pf-vdisplay splices it over its hard-coded
1080p60 preferred-timing descriptor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 8140d3f8b3 feat(capture/windows): a degraded stretch logs one summary line at recovery
Per-hole stall lines gate on prior ACTIVE flow, so inside a sustained
~2 fps phase only the first hole is reported and the log goes quiet
exactly while the user suffers — the field shape (deep 15 s stretches at
2 fps) was invisible without a stats recording.

StallWatch now tracks the stretch: opened by a reported stall, fed by
every stall-sized hole while the activity gate stays broken, closed when
sustained flow returns (or a ring recreate cuts it — its holes predate
the recreate and still count). Closure surfaces one INFO line: span,
hole count, summed hole time, worst hole. One-hole stretches dissolve
silently (the stall's own line covers them), and a ≥10 s gap closes the
stretch first so a quit-to-desktop pause never folds into the tally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 789dafc95f feat(capture/windows): the stall micro-probes get an off switch, the depth pin a backoff
Two standing costs from the interval-stutter investigation, consolidated:

- PUNKTFUNK_STALL_PROBES=0 now opts a box out of the micro-probe engine
  (per-GPU 10 Hz fence copies, a parked blocking-DwmFlush waiter, the
  5 ms-cadence CPU sentinel). Default stays ON while the field program
  runs; off, stall lines keep the driver telemetry and the cheap ETW
  present/queue discriminator and only lose the corroborating probe legs
  — the verdict matrix already treats an absent probe window honestly.

- The negotiated-depth pin-back stops hammering a display that refuses
  the flip: it was 4 CCD writes + 8 display-config queries per second,
  forever, all on the session-global display-config lock. After ~2 s of
  eager retries it re-attempts every ~4 s — still self-healing, without
  the drumbeat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 ecb3d1ab22 fix(host/windows): the pad service thread no longer waits on pnputil at its leisure
driver_store_inventory() ran `pnputil /enum-drivers` synchronously inside
the attach-failure diagnose path — on the pad service thread, with no
timeout. A busy or wedged driver store blocks that enumeration for tens
of seconds, and the thread must keep draining pad slots.

The query now runs on its own thread (spawned once per process); the
caller waits a bounded 2 s so a fast pnputil still lands in the same
report, and a slower one fills the cache for later diagnoses — the WARN
then says the store could not be queried yet instead of blocking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 e71f4696ee fix(host/windows): an unlightable sink stops the teardown warn+force loop
A box whose only external display is an off/standby TV warned "no
external physical display active after the restore (connected=1)" and
forced the EXTEND preset on EVERY teardown — an inert remedy (with one
connected display the preset can't relight anything) that reads like a
fresh failure in every field log.

The backstop now measures its own effect: after a force-EXTEND it
re-reads the inventory, and a dark set that stayed dark THROUGH the
preset is latched as unlightable — subsequent teardowns log a debug line
and leave the topology be. The poisoned-snapshot chain the backstop
exists for is not latched away: there the panel CAN light, so the first
force succeeds and the latch stays clear. Process-lifetime latch, so a
host restart re-probes once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 d014cea489 feat(core/abr): a keyframe-begging decoder backs the bitrate off, loss or no loss
The RX-9070 field trace: 14 decode-recovery keyframe requests in 2 s at
~300 Mbps with loss_ppm=0 — and the controller held the rate, because no
loss/OWD/latency signal moved. Repeated keyframe asks ARE the decoder
saying it cannot keep up; now they are an ABR input.

The control task counts outbound CtrlRequest::Keyframe at its send choke
point (the one place every emitter funnels through); the pump drains the
count per 750 ms report window into on_window. Two asks in a window is
ordinary-bad (two-window confirmation, like an OWD rise); four or more is
severe (the emitters throttle at 100 ms, so 4+ means the window was spent
begging) and backs off ×0.7 after one window. RFI asks are deliberately
not counted — they are the routine loss-recovery path and loss_ppm
already prices them in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 cd6ceb98e3 feat(capture/windows): stall lines say whether the content or the display path went silent
A compose-silence hole used to be blamed on the frame-generation path by
default — mislabeling benign content pauses (menus, loading, game hitches)
as display-path bugs. The corrected discriminator convicts on witnesses:

- Microsoft-Windows-DXGI Present/PresentMPO (ids 42/55) rides the host's
  filtered ETW session — one event per swapchain present, stamped with the
  presenting pid (named in the stall line).
- BltQueueAddEntry/CompleteIndirectPresent (ids 1071/1068) witness frames
  entering/leaving the virtual display's kernel present queue (the modern
  IDD path; anatomy proven on-glass via xperf, 2026-07-30).
- classify() now reads the window counts: presents flowing while the queue
  starved = FRAME-GENERATION (the OS dropped composed frames — the real
  bug class, never yet observed); no presents anywhere = CONTENT-SILENCE
  (benign for the display path); no working witness = UNATTRIBUTED, never
  a guess.
- DWM_TIMING_INFO.cFrame is demoted to an advisory dwm_frames_frozen=
  print: on Win11 it is refresh-synthesized and advances without real
  composes (proven against a kernel trace), so it convicts nothing.
- DxgKrnl legacy Present (id 184) is retired — it never fires on the
  redirected BltQueue path.

Stall lines carry etw_presents=/etw_queue_adds= plus named presenters;
compiled and validated on-glass (a quit-transition stall correctly labeled
CONTENT-SILENCE presents=0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:28 +02:00
enricobuehlerandClaude Fable 5 f87c1e6cec fix(encode): gate multi-slice frames on the client's decoder — the 0.17.0 Chromecast crash
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 1m15s
ci / web (push) Successful in 3m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
ci / rust-arm64 (push) Successful in 3m34s
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 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish-host (push) Successful in 4m20s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 6m17s
arch / build-publish (push) Failing after 7m58s
flatpak / build-publish (push) Successful in 9m6s
ci / rust (push) Successful in 13m37s
release / apple (push) Successful in 16m32s
windows-host / package (push) Successful in 18m21s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m56s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m54s
apple / screenshots (push) Successful in 20m45s
android / android (push) Successful in 5m0s
Field report: since 0.17.0 a stream to a Chromecast with Google TV 4K
freezes on the first frame and ~80% of the time crashes + reboots the
DEVICE — with both the Punktfunk app and Moonlight, while an Xbox
Series S is fine. Root cause: LN1 Phase 3 (67b79810) defaulted Linux
direct-NVENC to 4 slices per frame for EVERY session. Amlogic HEVC
decoders wedge on multi-slice AUs — exactly why moonlight-android
requests slicesPerFrame=1 for every hardware decoder (4 only for
software slice-threading) — and our RTSP parser never read the request.
The Phase-3 commit recorded the untested leg ("a live Moonlight re-test
joins the standing owed Moonlight item"); this report is that re-test.

The slicing ceiling now belongs to the CLIENT, threaded as open_video's
new max_slices from both planes:
- GameStream: parse x-nv-video[0].videoEncoderSlicesPerFrame into
  StreamConfig and honor it; absent/out-of-range (pre-auth input) => 1.
- punktfunk/1: new Hello cap VIDEO_CAP_MULTI_SLICE (0x80 — the byte's
  LAST free bit; the next cap needs a second byte + ABI bump).
  SessionPlan.max_slices = 32 with the bit, 1 without, applied to every
  encoder the plan opens so rebuilds can't change the wire shape. The
  desktop session client advertises it (FFmpeg/D3D11VA/Vulkan decode
  stacks are fine); Android/Apple stay off until they can decide
  per-decoder like Moonlight does — the cap is embedder-set decoder
  truth, never OR'd in by the shared pump.
- Linux direct-NVENC clamps its Phase-3 default to the ceiling
  (resolve_slices(codec, 4.min(max_slices))) and logs slices/max_slices
  in the caps-probe line; PUNKTFUNK_NVENC_SLICES stays the explicit
  operator override in both directions. Windows keeps its single-slice
  default untouched.

Also repairs the nvenc_cuda #[ignore] hardware tests: d2c46eaf added
open()'s cursor_blend param without updating them, invisible because CI
never compiles tests with the nvenc feature.

Verified on .21 (RTX 5070 Ti): pf-encode + host check/clippy clean with
nvenc; host unit suite 263/0; rtsp announce tests 7/7 incl. the new
slicesPerFrame coverage; on-hardware smokes — default e2e still 4
chunks/frame, NEW single-slice client-ceiling test clamps + disarms
chunked poll with no env involved, env escape unchanged. rustfmt clean.
Windows leg (prepare_display param) is CI-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:23:42 +02:00
enricobuehlerandClaude Opus 5 c04c5be224 docs: USB passthrough is a plugin now, not a recipe you assemble
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 26s
ci / web (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
ci / docs-site (push) Successful in 1m14s
ci / rust-arm64 (push) Successful in 1m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m14s
docker / deploy-docs (push) Successful in 31s
ci / rust (push) Successful in 7m20s
Adds a VirtualHere section to the plugins page — what it is, that both halves of VirtualHere
are yours to install and licence, that the Devices tab writes a name-based rule so it survives
the couch rebooting, and that Diagnostics is where to look when nothing happens.

States the coverage limit up front rather than letting somebody discover it: there is no
VirtualHere server for iOS or tvOS, so those clients cannot pass devices through, and nothing
on our side can change that.

Cuts the automation.md recipe from 75 lines to a pointer. It now leads with "use the plugin"
and keeps only the zero-code two-hook version for people who would rather not install one —
with its trade-offs stated instead of implied: the address is hard-coded so it breaks when the
couch reboots, and an abnormal stream end strands the device on the host. Those two failures
are exactly what the plugin exists to fix, so the reader gets to make an informed choice.

Not build-verified: docs-site does not build standalone in this checkout. Checked by hand that
Callout is in fumadocs' default MDX components with a valid `warn` type, that the JSX balances,
and that the cross-page anchor matches github-slugger of the heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:07:14 +02:00
enricobuehlerandClaude Opus 5 ef2bb56251 fix(tray): probe /login without redirects, and open the address the probe checked
ci / docs-site (push) Successful in 1m17s
apple / swift (push) Successful in 1m17s
ci / rust-arm64 (push) Successful in 1m48s
ci / web (push) Successful in 1m56s
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 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
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 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish (push) Successful in 5m3s
docker / builders-arm64cross (push) Successful in 6s
deb / build-publish-host (push) Successful in 4m14s
docker / deploy-docs (push) Successful in 27s
ci / rust (push) Successful in 6m52s
android / android (push) Successful in 7m11s
arch / build-publish (push) Failing after 7m24s
apple / screenshots (push) Successful in 14m27s
windows-host / package (push) Successful in 18m20s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m10s
Two ways the tray could misreport a healthy console.

The probe hit `/`, which is auth-gated and 302s to /login, and ureq follows
redirects by default — so TLS, `/`, and a full cold Nitro SSR of /login all had
to fit inside one 2s budget, and a console that was merely warming up read as
down. It now probes /login with redirects(0) on the agent: one round trip, and a
302 already counted as up. Neither user of that agent wants redirects; the
summary is a terminal JSON route.

The menu then opened https://localhost:<port> while the poller probed 127.0.0.1.
web-run.cmd binds HOST=0.0.0.0 — IPv4 only — and Windows resolves localhost to
::1 first, so the tray could call the console healthy and hand the browser a URL
that fails. Both are 127.0.0.1 now, which is what the Linux tray already did, so
the menu can no longer disagree with the status printed above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:42:50 +02:00
enricobuehlerandClaude Opus 5 d9912aa795 fix(windows/update): an update puts the status tray back instead of killing it for good
Every update started from the web console killed the tray permanently. Three
pieces had to line up, and they did: the installer's StopTrays force-kills every
punktfunk-tray.exe (it is one of the files being replaced), its relaunch is a
[Run] entry flagged skipifsilent, and the in-console updater spawns the installer
with /VERYSILENT. So the tray died on every such update and waited for the next
sign-in, because the Run value is a logon trigger. Confirmed on a box whose tray
was absent while its Run key, its exe and a session signed in hours before the
update were all present.

The installer cannot fix this itself. Spawned from the SYSTEM host service, its
`runasoriginaluser` resolves to SYSTEM — so relaunching there would place a
SYSTEM-owned tray in the user's session, where it would hold the per-session
Local\PunktfunkTray mutex and block the real tray at the next sign-in. Strictly
worse than the bug.

The host does it instead, because only the host has the right token. IntentRecord
gains tray_was_running, captured before the installer is spawned (reusing the
conflicting-host scan's process snapshot rather than opening a second one), and
boot reconciliation reads it off the intent before reconcile consumes it and
relaunches through tray::start(). Restored on both terminal outcomes — a
rolled-back install killed the tray just as dead as a successful one — but never
while an apply is still in flight, where the installer may only kill it again.

The field is serde(default), so an intent written by an older host reads as false
and behaves exactly as before; covered by a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:42:50 +02:00
enricobuehlerandClaude Opus 5 c77823d800 feat(windows/tray): punktfunk-host tray start|stop|status, so a dead tray is one command away
The status tray is a per-user, per-session GUI process with no recovery path of
its own: the HKLM Run value only fires at sign-in, and nothing in the product
ever restarts one. Anything that kills a tray — an upgrade's StopTrays, a crash —
therefore left the operator without an icon until they signed out and back in,
with no way to ask for it back.

windows/tray.rs becomes the one place that knows tray lifecycle (find, start,
stop, is-running), so the CLI and post-update reconciliation share an
implementation rather than growing two.

start() cannot simply spawn the exe, because the launch crosses a privilege
boundary in one direction only:

  - from the host service (SYSTEM) the tray must land in the active console
    session under the LOGGED-IN USER's token, which is WTSQueryUserToken +
    CreateProcessAsUserW and needs SE_TCB — SYSTEM-only;
  - from a shell already in the console session that call fails (an administrator
    does not hold SE_TCB either) and a plain spawn is both sufficient and right.

Trying the privileged path and falling back discriminates the two without
inspecting a token, and adds no unsafe to the crate. But a plain fallback from
ssh/RDP would put a tray in a session nobody can see and report success, so on
failure it consults console_session_mismatch() and refuses with an explanation
instead. stop() is graceful first (--quit lets the tray remove its own icon via
NIM_DELETE, as the uninstaller does), then forces any instance in another session.

Verified on Windows: launched from a SYSTEM scheduled task the tray comes up in
the console session owned by the interactive user, not SYSTEM; from an ssh
session (0 -> console 2) it refuses; a second start is a no-op; stop is graceful
and idempotent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:42:50 +02:00
enricobuehlerandClaude Opus 5 3bb30cb2f6 fix(windows/web-console): a bun exit no longer parks the console until the next sign-in
The PunktfunkWeb task registers RestartCount=10/PT1M, but Task Scheduler honours
restart-on-failure only when the action crashes or fails to start — never for a
plain non-zero exit. So any bun exit left the console down until the next boot or
interactive logon, with the host still streaming perfectly next to it. Seen on
glass on a 0.22.3 upgrade: `web setup` started the task one second after the new
service came up, bun exited at once (Last Run Result 0xFFFFFFFF), and the console
sat dead for hours while its box served clients — which reads to an operator as
"the host is gone".

web-run.cmd now supervises bun instead of exiting with it: restart on any exit,
indefinitely for a console that served a while and then died, but give up after
10 CONSECUTIVE fast exits so a genuinely broken install still surfaces as a failed
task. Any run lasting >= 60s resets that counter. It exits 0 rather than
respawning when the payload disappears, so an uninstall does not make it spin.

Uptime is measured with an unambiguous clock rather than parsed out of %TIME%,
which is locale-formatted (12-hour locales append " PM", many others use ',' as
the decimal separator) — the box this was found on prints 18:36:28,39.

stop_web_console() polls until :47992 is really free instead of sleeping a blind
second: both `schtasks /end` and `taskkill /F` are asynchronous, and web_setup
starts the new task immediately afterwards, where bun cannot bind a port the
corpse still holds. It re-ends the TASK halfway through, not just the listener —
with the launcher now supervising, killing bun alone would be undone by its own
restart loop.

The systemd unit had the same shape of hole: Restart=on-failure leaves a console
that exited 0 down. Now Restart=always, which an explicit `systemctl stop` still
overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:42:50 +02:00
enricobuehlerandClaude Fable 5 e90c5d5bcd fix(gamepad): the output-report ring absorbs haptics-rate writers (PadShm v2.2)
ci / web (push) Successful in 1m1s
windows-drivers / probe-and-proto (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m16s
windows-drivers / driver-build (push) Successful in 2m15s
ci / rust-arm64 (push) Successful in 3m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Successful in 2m21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
apple / swift (push) Successful in 4m36s
deb / build-publish-host (push) Successful in 4m2s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 29s
arch / build-publish (push) Failing after 6m11s
deb / build-publish (push) Successful in 6m6s
android / android (push) Successful in 8m29s
ci / rust (push) Successful in 10m32s
windows-host / package (push) Successful in 16m18s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m42s
apple / screenshots (push) Successful in 20m33s
A field log (2026-07-30) showed a game driving the virtual DualSense's output
endpoint at >2 kHz sustained for tens of seconds. The 8-slot ring — sized on
the assumption that 2 kHz is double any real HID output rate — overflowed
every 4 ms poll, which force-silenced rumble for each storm's whole duration
and flooded the log at ~230 WARN lines/s (96 % of the user's 5000-line
web-console export, evicting the session history it was needed to diagnose).

Three legs, negotiated so every old/new host×driver pairing keeps working:

- pf-driver-proto: the ring grows in place 8 -> 56 slots; PadShm becomes
  exactly one page (4096 B), the hard ceiling that keeps cross-generation
  section views mappable. A new out_ring_len field carries the driver's side
  of the length negotiation. Deliberately NOT a GAMEPAD_PROTO_VERSION bump
  (that fails closed - no pad at all).
- pf-gamepad driver: picks its ring length from the host's out_ring_ver
  stamp (>= 2 + a full-size map -> 56) and echoes it before every ring_head
  bump (now a Release store), so an Acquire-observing drain always reads the
  modulo that indexed the slots it copies.
- host drain: follows the echo (0 = old driver = 8); on genuine overflow it
  now salvages the legacy latest-report slot - the freshest coalesced state -
  instead of total silence, and the per-poll overflow WARN is rate-limited to
  1 line/s per pad with a suppressed count.

Verified on the Windows CI runner (drivers workspace build + clippy -D
warnings against the WDK; 64 pf-inject tests incl. the new negotiation/
salvage/limiter tests; pf-inject clippy -D warnings) and on Linux via the CI
docker image (82 pf-inject tests). DriverVer needs no manual bump - the
installer stamps a strictly-increasing build timestamp per release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:36:47 +02:00
enricobuehlerandClaude Fable 5 ec675261fc feat(android): the Sony-pad USB grant is asked on connect, not found in Settings
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 21s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
ci / docs-site (push) Successful in 1m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
ci / rust-arm64 (push) Successful in 1m21s
ci / web (push) Successful in 1m33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m6s
docker / builders-arm64cross (push) Successful in 6s
android / android (push) Canceled after 1m55s
ci / rust (push) Canceled after 1m56s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 47s
docker / deploy-docs (push) Canceled after 0s
On-glass feedback: burying the grant in the Controllers screen made the
user go find it. Now MainActivity asks the moment a Sony pad appears — a
fresh attach while the app is open, or the app foregrounding with one
already plugged in — once per attach (a deny doesn't re-nag; the
Controllers card's button stays as the re-ask). Nothing starts on the
grant: an uncaptured pad is an ordinary InputDevice at menu time, so the
grant is simply recorded and the next stream's capture engages silently.
The grant broadcast is shared with the Controllers card so an open card
refreshes from either dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:34:57 +02:00
enricobuehlerandClaude Fable 5 1984ddb942 feat(android): a USB Sony pad is captured — rumble, adaptive triggers, lightbar, gyro
A DualSense on a phone had rumble only where the kernel exposed force
feedback, and adaptive triggers / lightbar / player LEDs nowhere — Android
has no platform API for any of them, and Bluetooth offers no raw path
(L2CAP is LE-only; hidraw is root-sealed — Sony's own Remote Play declares
Android triggers unsupported). Claiming the pad's HID interface over USB is
the one unrooted route, so that is what the client now does.

- HidUsbLink: the device-agnostic half of Sc2UsbLink (claim, multiplexed
  UsbRequest loop, newest-wins write queue, signalled-unplug discipline),
  parameterized by device match / interface filter / keep-alive. Sc2UsbLink
  keeps only its SC2 specifics (Puck interfaces 2..5, lizard refresh).
- GamepadFeedback.PadFeedbackSink: 0xCA rumble + 0xCD Led/PlayerLeds/
  Trigger now route to a capture link that owns the pad BEFORE the
  InputDevice vibrator/lights paths — Trigger stops being log-and-drop.
- DsDevice: the byte-exact inverse of the host's dualsense_proto /
  dualshock4_proto — input report 0x01 parse (buttons/sticks/triggers,
  gyro+accel, both touch points; Edge FN/BACK → wire paddles) and output
  builders (DS5 0x02 valid-flag-selective incl. the 11-byte trigger blocks
  and the lightbar-animation release; DS4 0x05 as composed full-state
  writes). Covered by DsDeviceTest (pure JVM).
- DsCapture: stream-mode capture for DualSense / Edge / DS4 — lazy wire
  slot on the first parsed report, typed mirror (exit chord included),
  touch normalized onto the rich plane + per-report motion, feedback
  rendering with a rumble backstop (a USB pad holds its level, so a
  stalled poll thread self-terminates via a scheduled zero-write) and a
  teardown motor-stop over EP0. The claim releases the pad's InputDevice
  slot itself so the wire index hands over deterministically; uncaptured
  (toggle off / permission denied / Bluetooth) the pad stays on the
  ordinary InputDevice path.
- Rich-input shims: nativeSendPadTouch / nativeSendPadMotion →
  RichInput::Touchpad / Motion — the plane the desktop and Apple clients
  already feed; Android pads gain gyro + touchpad on the virtual pad.
- Settings: "DualSense / DualShock passthrough (USB)" (ds_capture, opt-out
  like the SC2 toggle); Controllers screen card with capture status and a
  front-loaded USB grant so streams start without the permission dialog.

The host needs nothing: the DS5/DS4 backends already consume the typed +
rich planes and already emit every feedback event rendered here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:34:57 +02:00
enricobuehlerandClaude Fable 5 696386dee7 fix(host/windows): clippy — SAFETY comments on the WinTrust FFI, explicit truncate(false), sort_by_key
deb / build-publish-client-arm64 (push) Failing after 4s
ci / docs-site (push) Successful in 1m18s
ci / web (push) Successful in 2m12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
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 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
deb / build-publish-host (push) Successful in 4m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 11s
ci / rust-arm64 (push) Successful in 5m49s
docker / deploy-docs (push) Successful in 31s
android / android (push) Successful in 6m54s
deb / build-publish (push) Successful in 5m0s
apple / swift (push) Successful in 5m6s
ci / rust (push) Successful in 6m13s
arch / build-publish (push) Failing after 7m37s
windows-host / package (push) Successful in 15m8s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m39s
apple / screenshots (push) Successful in 21m28s
windows-host.yml's clippy sees what the Linux gate structurally cannot (cfg(windows)
code); verified with the same clippy line on the runner this time, not just cargo check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:42:25 +02:00
enricobuehlerandClaude Fable 5 5790a3e334 docs(release): v0.22.3 notes — the Updates card and one-click updating join the installer fixes
ci / web (push) Successful in 2m25s
ci / rust-arm64 (push) Successful in 2m53s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
ci / docs-site (push) Successful in 1m6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 33s
ci / rust (push) Successful in 7m2s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:23:54 +02:00
enricobuehlerandClaude Fable 5 3b1485e2a1 fix(plugins/windows): the runner can read its own bundle, so it actually starts
ci / rust (push) Canceled after 27s
ci / rust-arm64 (push) Canceled after 28s
ci / web (push) Canceled after 28s
ci / docs-site (push) Canceled after 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 4m45s
android / android (push) Successful in 4m58s
deb / build-publish-host (push) Successful in 4m14s
deb / build-publish (push) Successful in 5m51s
deb / build-publish-client-arm64 (push) Successful in 1m19s
arch / build-publish (push) Failing after 6m54s
windows-host / package (push) Failing after 10m59s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
apple / screenshots (push) Canceled after 14m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m33s
Enabling the plugin runner did everything right and then nothing happened.
The task went Ready with LastTaskResult 1 within a second of every start, and
the console kept showing it enabled-but-not-running with nothing to explain
why. Task Scheduler discards the action's output, so the reason never
surfaced anywhere:

  error: EPERM reading "C:\Program Files\punktfunk\scripting\runner-cli.js"

This file already knows why. RUNNER_UNIT_DIRS carries the note that bun opens
the files it loads asking for FILE_WRITE_ATTRIBUTES on top of read, so a plain
(RX) grant makes them die with EPERM — found on glass when the plugin and
script directories were fixed. The runner's own entry script was missed. It
lives in {app}\scripting, which carries only Users:(RX), and LocalService
reaches that through Authenticated Users, so bun could never open the one file
it was started to run. The runner has not been able to start since it moved
off SYSTEM, which is a principal that has full control everywhere and so never
met this.

So {app}\scripting now gets the same (OI)(CI)(RX,WA) the unit dirs get, at
enable, revoked at disable like every other grant here. WA moves timestamps
and the read-only bit and cannot touch content, so the three-way split the
module maintains still holds: code read-only, secrets read-only, only
plugin-state writable. A plugin still cannot rewrite the runner.

Measured on glass on .173, same box, same task, only the ACE differing:
without it Ready/lastResult=1 and no runner process; with it Running, a live
runner, and GET /store/runtime answering running:true — which is exactly what
the console reads. Also checked what bun does with the access: nothing. It
opens the entry for write and never writes, and the directory is byte-identical
afterwards.

Windows-only code, so it is rustfmt-clean and type-reviewed but NOT
compile-verified here — the Windows CI job is the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:23:16 +02:00
enricobuehlerandClaude Fable 5 4a540bddc8 feat(host/deck): one-click source rebuild — the update.sh run as a transient user unit
ci / rust-arm64 (push) Successful in 1m36s
ci / docs-site (push) Successful in 1m28s
ci / web (push) Successful in 2m25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
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 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m47s
deb / build-publish-host (push) Successful in 5m53s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
android / android (push) Successful in 7m2s
docker / builders-arm64cross (push) Successful in 11s
arch / build-publish (push) Failing after 7m33s
ci / rust (push) Successful in 7m35s
docker / deploy-docs (push) Successful in 39s
windows-host / package (push) Failing after 11m1s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 6m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m21s
apple / swift (push) Successful in 4m58s
apple / screenshots (push) Successful in 21m32s
The U3.1 leg of planning:host-update-from-web-console.md. The Deck's on-device install
gets the Update-now button with no opt-in (user-owned, no root): update.sh --pull runs
under systemd-run --user so the script's own restart of punktfunk-host can't kill it
mid-build (a child in our cgroup would die with us). Outcome without version equality —
which a source rebuild can't promise: a failed build leaves the host alive to report it
(unit watched to failure, log attached); a successful one restarts us, and the new
source_build intent flag makes intent-present-at-boot itself the success signal (the
script only restarts the host after a successful install). The console stops treating
a live long build as a timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:28:40 +02:00
enricobuehlerandClaude Fable 5 51d5f6cb29 feat(host/windows): boot-loop auto-rollback — the supervisor re-runs the cached previous installer
ci / docs-site (push) Successful in 1m11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
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
ci / web (push) Successful in 1m58s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 42s
deb / build-publish-client-arm64 (push) Successful in 3m19s
deb / build-publish-host (push) Successful in 6m17s
ci / rust-arm64 (push) Successful in 6m58s
android / android (push) Successful in 4m51s
docker / builders-arm64cross (push) Successful in 12s
windows-host / package (push) Failing after 5m39s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Successful in 1m58s
arch / build-publish (push) Failing after 8m14s
apple / swift (push) Successful in 4m59s
apple / screenshots (push) Canceled after 0s
deb / build-publish (push) Successful in 11m51s
ci / rust (push) Canceled after 13m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m11s
The U3.2 leg of planning:host-update-from-web-console.md. The service worker loop —
the one piece that survives an upgrade — detects a fresh update-intent whose target
version is the crash-looping child (≥3 rapid restarts, intent <30 min), then exactly
once: picks the newest cached non-target installer, requires a valid Authenticode
signature, writes a rolled-back result record for the console, deletes the intent,
and spawns the installer silently. No cached installer or a failing signature degrade
to today's backoff loop with a loud log — never a brick, never an installer loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:12:22 +02:00
enricobuehlerandClaude Fable 5 3e21398c16 fix(lock): pf-update rides the 0.22.3 workspace bump
audit / bun-audit (plugin-kit) (push) Successful in 22s
audit / cargo-audit (push) Successful in 37s
audit / bun-audit (sdk) (push) Successful in 21s
audit / pnpm-audit (push) Successful in 11s
audit / docs-site-audit (push) Successful in 22s
ci / web (push) Successful in 1m52s
ci / docs-site (push) Successful in 2m4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m58s
android / android (push) Successful in 5m38s
audit / license-gate (push) Successful in 5m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
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 13s
apple / swift (push) Canceled after 0s
arch / build-publish (push) Failing after 7m37s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 6m46s
ci / rust-arm64 (push) Canceled after 6m42s
deb / build-publish (push) Canceled after 4m47s
deb / build-publish-host (push) Canceled after 4m17s
deb / build-publish-client-arm64 (push) Canceled after 2m22s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 29s
docker / deploy-docs (push) Canceled after 0s
audit / bun-audit (web) (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 28s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 28s
windows-host / package (push) Canceled after 6s
windows-host / canary-manifest (push) Canceled after 0s
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) Successful in 2m31s
flatpak / build-publish (push) Successful in 10m8s
release / apple (push) Successful in 26m17s
The U2 branch minted the lock entry at 0.22.2 and the rebase onto the version bump
left it stale — --locked builds fail until it matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:04:17 +02:00
enricobuehlerandClaude Fable 5 239c69fd71 feat(web,docs): Linux one-click in the card — opt-in hint, staged and nothing-newer outcomes
audit / bun-audit (sdk) (push) Successful in 18s
audit / bun-audit (web) (push) Successful in 18s
audit / docs-site-audit (push) Successful in 23s
audit / pnpm-audit (push) Successful in 12s
audit / cargo-audit (push) Successful in 37s
audit / bun-audit (plugin-kit) (push) Successful in 1m14s
arch / build-publish (push) Failing after 1m17s
ci / rust-arm64 (push) Failing after 44s
ci / web (push) Successful in 1m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
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 7s
ci / docs-site (push) Successful in 2m18s
deb / build-publish-host (push) Failing after 2m15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
deb / build-publish (push) Failing after 3m43s
audit / license-gate (push) Successful in 4m47s
deb / build-publish-client-arm64 (push) Successful in 3m52s
docker / builders-arm64cross (push) Successful in 14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m49s
ci / rust (push) Failing after 3m6s
docker / deploy-docs (push) Successful in 55s
windows-host / package (push) Failing after 6m27s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
android / android (push) Successful in 8m21s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
flatpak / build-publish (push) Successful in 4m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 4m19s
release / apple (push) Canceled after 57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 2m26s
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 apply panel serves staged kinds too; notify mode shows the group-join command when
the helper is installed but not yet enabled; outcomes render 'staged — reboot to
finish' and 'your package source had nothing newer yet' distinctly. Docs: the Linux
opt-in section (why it's a group, what the grant bounds, the pacman stance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehlerandClaude Fable 5 06a249e49f feat(host/linux): opt-in one-click updates — the pf-update root helper, group-scoped polkit grant, per-PM legs
The U2 leg of planning:host-update-from-web-console.md. A new dep-free root helper
(crates/pf-update) runs the distro package manager against the INSTALLED punktfunk
packages — apt (index refresh scoped to our list when present), dnf, rpm-ostree
(single-transaction re-resolve, reported staged), sysext (the proven signed-feed
updater), pacman only behind the explicit PACMAN_FULL_SYSUPGRADE opt-in — then the
run-the-binary gate, then a root-written result record. Zero attacker-influenceable
parameters end to end: fixed ExecStart oneshot (punktfunk-update.service), polkit rule
scoped to that one unit's start verb for the shipped-EMPTY punktfunk-update group
(joining it is the auditable opt-in; every postinst creates it, none populate it).
The host starts the unit, interprets the record (staged / nothing-newer-yet / changed),
and crosses its own restart on the same intent/reconcile machinery as the Windows leg.
Status now reports staged results and the opt-in hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehlerandClaude Fable 5 bb1f93d90e feat(web,docs): the Update-now flow — password re-entry at the BFF, restart-tolerant progress, same-origin hardening
apply.post.ts intercepts the one proxied route that restarts the host: the console
password is re-verified per apply (login throttle shared, stripped before forwarding) so
a 7-day cookie alone can't do it. New Sec-Fetch-Site same-origin check on every mutating
request (login CSRF included). The card's apply flow: confirm dialog → live-session
force escalation → download/verify/restart progress rendered from the last snapshot
while polls fail (the host and, on Windows, this very server restart mid-flow) →
durable success/failure from last_result. Docs: the one-click section + kill switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehlerandClaude Fable 5 bb48225414 feat(host/windows): one-click update apply — verified installer download, intent/reconcile, POST /update/apply
The U1 leg of planning:host-update-from-web-console.md. The apply request carries no
version/url/channel (the zero-parameter invariant): the host installs exactly what its
Ed25519-verified manifest announced. Pipeline: staged download (resume + disk preflight)
→ manifest sha256 → Authenticode (valid signature, untrusted root tolerated while the
cert is self-signed; leaf sha256 pinned via the signed manifest, extracted from the SAME
WinVerifyTrust state) → intent record → detached CREATE_BREAKAWAY_FROM_JOB spawn of the
winget-blessed silent flags. The installer kills this process by design; boot-time
reconciliation reports the outcome (update.applied event / a durable failure with the
installer log path) across the restart. Session guard (force to override),
PUNKTFUNK_UPDATE_APPLY=0 kill switch, single-flight via job + fresh-intent detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehler 73e1224345 fix(ci/update): an empty AUTHENTICODE_SHA256 broke the manifest build — jq -R needs one input line
ci / web (push) Successful in 1m5s
ci / docs-site (push) Successful in 2m30s
ci / rust-arm64 (push) Successful in 3m40s
android / android (push) Successful in 4m35s
decky / build-publish (push) Successful in 47s
ci / rust (push) Successful in 6m26s
arch / build-publish (push) Successful in 8m54s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 12m27s
docker / deploy-docs (push) Skipped
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders-arm64cross (push) Successful in 11s
apple / swift (push) Successful in 4m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m15s
deb / build-publish-host (push) Successful in 4m45s
deb / build-publish-client-arm64 (push) Successful in 1m25s
apple / screenshots (push) Canceled after 7m43s
deb / build-publish (push) Canceled after 3m20s
printf '%s' hands jq -R no line at all for an empty value; its empty output made
--argjson invalid. Seen on the first live canary-manifest run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit a12c8bc99d41c4f7b8e4c426d560171b2b12dd5e)
2026-07-30 15:23:52 +02:00
525 changed files with 55136 additions and 10565 deletions
+95
View File
@@ -0,0 +1,95 @@
# Move a versionCode that is ALREADY on Google Play between tracks — no rebuild.
#
# Why this is separate from android.yml: promotion must not rebuild. A rebuild produces a fresh
# versionCode (github.run_number) from possibly-newer sources, so it ships something nobody tested;
# promoting assigns the byte-identical artifact the testers already ran. Bolting this onto
# android.yml would mean an `if:` on all ten of its build steps.
#
# What it is for:
# * promote a tested build up a track (alpha -> production)
# * roll production back by re-pointing it at an older versionCode (to_track=production,
# version_code=<the good one>, from_track blank)
# * halt a rollout (status=halted)
#
# Defaults are deliberately the safe ones: dry_run starts TRUE, so a mis-typed versionCode
# validates and deletes the edit instead of publishing. Flip it to false only when the dry run
# printed what you meant.
name: android-promote
# Two concurrent promotions would race on the same Play edit; the loser fails with a stale-edit
# error. One at a time, and never cancel one mid-flight — a half-applied track change is worse
# than a queued one.
concurrency:
group: android-promote
cancel-in-progress: false
on:
workflow_dispatch:
inputs:
version_code:
description: 'versionCode already on Play (e.g. 10816)'
required: true
to_track:
description: 'destination track'
required: true
default: 'production'
from_track:
description: 'track to verify it is on, then clear (blank = touch nothing else)'
required: false
default: 'alpha'
notes_tag:
description: "tag whose docs/releases/whatsnew/<tag>.txt to attach, e.g. v0.23.0 (blank = none)"
required: false
default: ''
status:
description: 'completed (100%) | inProgress (needs user_fraction) | halted | draft'
required: true
default: 'completed'
user_fraction:
description: 'staged rollout fraction for inProgress, e.g. 0.2 (blank otherwise)'
required: false
default: ''
dry_run:
description: 'validate only, publish nothing'
required: true
default: 'true'
jobs:
promote:
runs-on: ubuntu-24.04
# Same image as android.yml purely for python3 + openssl (play-upload.py's only deps); it is
# already warm on the runner. Nothing here builds.
container:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Promote
env:
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
VERSION_CODE: ${{ inputs.version_code }}
TO_TRACK: ${{ inputs.to_track }}
FROM_TRACK: ${{ inputs.from_track }}
NOTES_TAG: ${{ inputs.notes_tag }}
STATUS: ${{ inputs.status }}
USER_FRACTION: ${{ inputs.user_fraction }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -- --package io.unom.punktfunk \
--promote "$VERSION_CODE" \
--track "$TO_TRACK" --status "$STATUS"
# Explicit `if`, not `[ ] && …`: under `sh -e` a false AND-OR list that ends up LAST in
# the script aborts the step, and these get reordered.
if [ -n "$FROM_TRACK" ]; then set -- "$@" --promote-from "$FROM_TRACK"; fi
if [ -n "$USER_FRACTION" ]; then set -- "$@" --user-fraction "$USER_FRACTION"; fi
if [ -n "$NOTES_TAG" ]; then
NOTES="docs/releases/whatsnew/${NOTES_TAG}.txt"
# Fail loudly rather than silently publishing with the PREVIOUS release's text still
# showing on the store listing.
[ -f "$NOTES" ] || { echo "ERROR: no such notes file: $NOTES"; exit 1; }
set -- "$@" --release-notes-file "$NOTES"
fi
if [ "$DRY_RUN" = "true" ]; then set -- "$@" --no-commit; fi
echo "promoting versionCode=$VERSION_CODE -> $TO_TRACK (dry_run=$DRY_RUN)"
python3 clients/android/ci/play-upload.py "$@"
+84 -10
View File
@@ -34,9 +34,10 @@ on:
- '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
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
# push is canary (Play `internal`).
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
# reach `alpha` and someone had to promote it by hand in the Console.
tags: ['v*']
pull_request:
paths:
@@ -75,6 +76,56 @@ jobs:
steps:
- uses: actions/checkout@v4
# FIRST, because it costs a second and everything after it costs ten minutes.
#
# A release tag MUST carry its own Play "What's new". If the file is absent Play does not
# show nothing — it carries the PREVIOUS release's text onto this version, so production
# users read notes for a build they are not getting. That is the same defect the v0.22.3
# notes shipped (see docs/releases/README.md), and it is invisible until someone reads the
# store listing. Failing here also means a missing file cannot leave a half-published
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
#
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
# testers costs nothing.
- name: Play release notes gate (tags only)
if: startsWith(github.ref, 'refs/tags/v')
run: |
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
if [ ! -f "$NOTES" ]; then
echo "ERROR: $NOTES does not exist."
echo "A production release needs its own Play 'What's new' (<=500 chars, written for"
echo "phone/TV users). Without it Play reuses the previous release's text."
echo "See docs/releases/README.md; copy docs/releases/whatsnew/TEMPLATE.txt."
exit 1
fi
# A verbatim copy of another release's file is the same bug wearing a hat: the store
# listing still describes the wrong build. Cheap to check, and only ever trips on an
# actual copy-paste that was never edited.
for other in docs/releases/whatsnew/*.txt; do
if [ "$other" != "$NOTES" ] && [ "$other" != "docs/releases/whatsnew/TEMPLATE.txt" ]; then
if cmp -s "$NOTES" "$other"; then
echo "ERROR: $NOTES is byte-identical to $other."
echo "Write notes describing THIS release, not the one before it."
exit 1
fi
fi
done
# Length is checked here as well as in play-upload.py. Not redundant: the uploader is
# the last line of defence (and the only one android-promote.yml gets), but it runs at
# step 9 — this catches an unedited TEMPLATE copy at step 1 instead of after the build.
# Must count CHARACTERS, not bytes: Play's cap is 500 chars and `•` is 3 bytes in UTF-8,
# so `wc -c` would reject a file that is comfortably legal.
python3 - "$NOTES" <<'PY'
import sys
path = sys.argv[1]
text = open(path, encoding="utf-8").read().strip()
if not text:
sys.exit(f"ERROR: {path} is empty.")
if len(text) > 500:
sys.exit(f"ERROR: {path} is {len(text)} chars; Play allows 500. Trim it.")
print(f"Play release notes OK: {path} ({len(text)}/500 chars)")
PY
# Everything below the checkout used to be four download steps (JDK, SDK,
# NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked
# into the image now. This guard only re-asserts the Android targets so a
@@ -109,6 +160,14 @@ jobs:
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
# built module) and it is the only automated cover those behaviours have.
- name: kit unit tests
working-directory: clients/android
run: ./gradlew :kit:testDebugUnitTest --stacktrace
- name: assembleDebug (cargo-ndk → jniLibs → APK)
working-directory: clients/android
env:
@@ -122,11 +181,20 @@ jobs:
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
case "$GITHUB_REF" in
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;;
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
esac
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
# On a tag the gate step above already proved this exists, so the else branch is only
# ever the canary path. See docs/releases/README.md.
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
if [ -f "$NOTES" ]; then
echo "PLAY_NOTES=$NOTES" >> "$GITHUB_ENV"
else
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
fi
echo "android version $VN -> Play track '$TRACK'"
- name: Build Release (signed AAB + universal APK)
@@ -199,15 +267,21 @@ jobs:
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
# Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
# promotion to production in the Play console.
# Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`).
#
# A tag therefore ships to real users with no further click. Two things keep that honest:
# the tag is only pushed once every platform is green, and Play reviews each production
# release before it reaches anyone. To ramp instead of going straight to 100%, this is
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
- name: Upload to Google Play
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env:
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
run: |
echo "uploading to Play track '$PLAY_TRACK'"
python3 clients/android/ci/play-upload.py \
--package io.unom.punktfunk \
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
--track "$PLAY_TRACK" --status completed
set -- --package io.unom.punktfunk \
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
--track "$PLAY_TRACK" --status completed
if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi
python3 clients/android/ci/play-upload.py "$@"
+7
View File
@@ -210,6 +210,13 @@ jobs:
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
fi
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
# per-partition-set), which is the standing packaging verdict behind the on-device
# distrobox build (scripts/steamdeck/, see scripts/steamdeck/README.md). That flow builds
# its own HDR gamescope too. packaging/arch/build-sysext.sh remains a by-hand tool for the
# Deck CLIENT image and for operators who accept the prebuilt-host trade-off.
- name: Publish to the Gitea Arch registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
+6 -5
View File
@@ -147,15 +147,16 @@ jobs:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
run: |
git config --global --add safe.directory "$PWD"
# THREE binaries ship in the client .deb, so all three are built here: the GTK shell,
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect), and
# punktfunk-cli (the headless `punktfunk` front-end). build-client-deb.sh installs all
# three; leaving punktfunk-cli out here made it fall over on `install: No such file or
# FOUR binaries ship in the client .deb, so all four are built here: the GTK shell,
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect),
# punktfunk-cli (the headless `punktfunk` front-end), and pf-update (the root helper
# behind `punktfunk-client --apply-update`). build-client-deb.sh installs all four;
# 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
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update
- 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
+55 -11
View File
@@ -1,10 +1,10 @@
# Contributing to punktfunk
# Contributing to Punktfunk
Thanks for your interest in contributing!
## Licensing of contributions (inbound = outbound)
punktfunk is dual-licensed under **MIT OR Apache-2.0**.
Punktfunk is dual-licensed under **MIT OR Apache-2.0**.
> Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
> the work by you, as defined in the Apache-2.0 license, shall be dual licensed as **MIT OR
@@ -28,6 +28,28 @@ If you add a new third-party dependency, it must be permissive (MIT / Apache-2.0
Unicode-3.0 / etc.). `about.toml` holds the accepted-license allow-list; regenerate the attribution
file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
## Prerequisites
The Rust toolchain is **pinned exactly** in `rust-toolchain.toml`; rustup installs it for you the
first time you build, so don't override it — a different rustc reformats files nobody touched.
The workspace links real system libraries, so a bare `cargo build --workspace` fails on a stock
machine. The authoritative list is what CI installs, in `ci/rust-ci.Dockerfile` — on **Ubuntu 26.04**,
which is what gets you FFmpeg 8:
```sh
sudo apt install build-essential clang libclang-dev pkg-config cmake \
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev libavdevice-dev \
libpipewire-0.3-dev libopus-dev libwayland-dev libxkbcommon-dev \
libgl-dev libegl-dev libgbm-dev \
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
libvulkan-dev
```
(The last two groups are the Linux client shell and `pf-ffvk`; skip them only if you never build
those crates. `scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway,
PipeWire — and is not a substitute for the list above.)
## Before you push
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
@@ -38,18 +60,40 @@ on formatting alone:
git config core.hooksPath scripts/git-hooks
```
Then the usual full pass:
Then the usual full pass. Use `--locked` as CI does — otherwise a silent `Cargo.lock` update can pass
locally and fail CI:
```sh
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
```
Generated artifacts are checked in and CI fails on drift: `include/punktfunk_core.h` (cbindgen) and
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
Two more gates that only apply to some changes:
See the [README's Build & test section](README.md#build--test-from-source) and
[Design invariants](README.md#design-invariants) for the full build/test/run guide, and the
[docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
- **Touched `web/` or `docs-site/`?** CI builds and typechecks both. Run, in that directory:
```sh
bun install && bun run build && bun run lint
```
Build first — it generates the API client / MDX typegen that the typecheck imports.
- **Touched Windows- or Linux-gated code from another OS?** `scripts/xcheck.sh windows` (or
`linux`) type-checks and lints that platform's `#[cfg(target_os = …)]` code in about a second,
instead of waiting for the CI job that compiles it.
Generated artifacts are checked in. `include/punktfunk_core.h` (cbindgen) is regenerated by the build
and CI fails if the committed copy drifts. `api/openapi.json` is **not** gated — nothing in CI
regenerates or diffs it, so regenerate and commit it yourself whenever you touch the management API,
and copy the snapshot the docs site serves:
```sh
cargo run -p punktfunk-host -- openapi > api/openapi.json
cp api/openapi.json docs-site/public/openapi.json
```
Match the surrounding code's comment density and naming. Commit messages end with the
`Co-Authored-By` trailer (see `git log`).
See the [README's Build & test section](README.md#build--test-from-source) for the extra dev
commands (the FEC loss harness, the standalone C-ABI proof) and
[Design invariants](README.md#design-invariants) for the rules a change is expected to hold to, and
the [docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
Generated
+52 -30
View File
@@ -947,7 +947,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2221,7 +2221,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.22.3"
version = "0.24.0"
[[package]]
name = "lazy_static"
@@ -2326,7 +2326,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"bindgen",
"cmake",
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"punktfunk-core",
]
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -2880,6 +2880,7 @@ dependencies = [
"mdns-sd",
"opus",
"pf-ffvk",
"pf-update-check",
"pipewire",
"punktfunk-core",
"pyrowave-sys",
@@ -2896,7 +2897,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2914,7 +2915,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -2935,7 +2936,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -2959,7 +2960,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"ash",
"bindgen",
@@ -2968,7 +2969,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"libc",
@@ -2980,7 +2981,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2994,11 +2995,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.22.3"
version = "0.24.0"
[[package]]
name = "pf-inject"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3027,14 +3028,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -3047,9 +3048,29 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pf-update"
version = "0.24.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "pf-update-check"
version = "0.24.0"
dependencies = [
"anyhow",
"base64",
"ring",
"serde",
"serde_json",
"ureq",
]
[[package]]
name = "pf-vdisplay"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3082,7 +3103,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3094,7 +3115,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -3302,7 +3323,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3313,7 +3334,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"android_logger",
"jni",
@@ -3329,7 +3350,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3346,7 +3367,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3361,7 +3382,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3381,7 +3402,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3413,7 +3434,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3449,6 +3470,7 @@ dependencies = [
"pf-host-config",
"pf-inject",
"pf-paths",
"pf-update-check",
"pf-vdisplay",
"pf-win-display",
"pf-zerocopy",
@@ -3497,7 +3519,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3511,7 +3533,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"anyhow",
"ksni",
@@ -3534,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.22.3"
version = "0.24.0"
dependencies = [
"bindgen",
"cmake",
+3 -1
View File
@@ -12,6 +12,8 @@ members = [
"crates/pf-ffvk",
"crates/pf-driver-proto",
"crates/pf-paths",
"crates/pf-update",
"crates/pf-update-check",
"crates/pf-host-config",
"crates/pf-gpu",
"crates/pf-zerocopy",
@@ -51,7 +53,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.22.3"
version = "0.24.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+72 -24
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="assets/punktfunk-logo.svg" alt="punktfunk" width="320" />
<img src="assets/punktfunk-logo.svg" alt="Punktfunk" width="320" />
</p>
<p align="center"><b>Low-latency desktop and game streaming with first-class Linux and Windows hosts.</b></p>
@@ -18,7 +18,7 @@ access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
🔒 **Security:** found a vulnerability? Report it privately to **security@punktfunk.com** — see
[SECURITY.md](SECURITY.md). Please don't open a public issue.
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
Punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
@@ -43,8 +43,13 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on-box,
~1.3 ms cross-machine on a LAN. (AMD/Intel encode via VAAPI, and a GPU-less software H.264
encoder exists as a fallback.)
~1.3 ms cross-machine on a LAN. (On Linux AMD/Intel, Vulkan Video for HEVC and AV1 with VAAPI for
H.264 and as the fallback; a GPU-less software H.264 encoder exists as a last resort.)
- **A library that fills itself.** Steam and non-Steam titles show up as a grid on every client, and
plugins add their own sources — ROM Manager (your ROM collection, matched to installed emulators),
Playnite, VirtualHere. Install them from the console's **Plugins** page or with
`punktfunk-host plugins add`. See
[Plugins](https://docs.punktfunk.unom.io/docs/plugins).
- **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and
native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol.
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
@@ -58,12 +63,12 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
| **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
| **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **Windows host** (Windows 11 22H2+, x64) | ✅ Beta — shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode (HEVC, and AV1 on hardware that decodes it), controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux` + `clients/session`) | ✅ Streaming live: relm4/GTK4 launcher shell that spawns a Vulkan session binary — Vulkan Video / VAAPI / software decode, PipeWire audio, SDL3 controllers, Skia console UI; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). Hardware decode and HDR10 present validated on glass on NVIDIA and Intel, including HDR pass-through on the Intel D3D11VA path |
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, game library, virtual-display presets, plugin store, GPU selection, performance capture graphs, live host logs, host updates |
Every native client also ships a tiered **stats overlay** (Compact / Normal / Detailed) with a
shared vocabulary across platforms, and the session client carries a full gamepad-driven **console
@@ -81,36 +86,76 @@ Both run from **one process**: bare `punktfunk-host serve` is the **secure nativ
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
Full milestone status: **[docs.punktfunk.unom.io/docs/status](https://docs.punktfunk.unom.io/docs/status)** ·
roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
What works where: **[the support matrix](https://docs.punktfunk.unom.io/docs/support-matrix)** ·
where it's heading: **[the roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
## Install the host
Pick your platform and install from its package registry — the per-platform guide covers adding the
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; on
SteamOS the host is built on-device by a script instead, and a Windows host ships as a signed
installer (all-vendor: NVIDIA, AMD, Intel).
| Platform | Install | Guide |
|--------|---------|-------|
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu / Debian](https://docs.punktfunk.unom.io/docs/ubuntu) · [packaging/debian](packaging/debian/README.md) |
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
| **SteamOS / Steam Deck** (on-device build) | `bash ~/punktfunk/scripts/steamdeck/install.sh` *(after cloning this repo to `~/punktfunk`)* | [SteamOS (Host)](https://docs.punktfunk.unom.io/docs/steamos-host) |
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
add `--gamestream` on a trusted LAN if you also want stock Moonlight clients), then pair from the web
console. Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
installed first:
```sh
mkdir -p ~/.config/punktfunk
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
# (on Bazzite take host.env.bazzite instead)
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
systemctl --user enable --now punktfunk-host # the streaming host
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
```
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
line before the replacement — the install guide has the snippet). Then open
`https://<host-ip>:47992` and pair.
How the virtual display and input are wired up depends on your desktop — see
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
[Sway](https://docs.punktfunk.unom.io/docs/sway).
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
nothing to run by hand — open the web console and pair. Use
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
`setup.exe` over the old install; uninstall from Add/Remove Programs.
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
The console's **Host** page also shows when a newer host is out, along with the exact command for
how *this* box was installed (or a one-click **Update now** on Windows) — see
[Updating the host](https://docs.punktfunk.unom.io/docs/updating). To remove it again, or to go back
to an earlier version, see [Uninstalling](https://docs.punktfunk.unom.io/docs/uninstall) and
[Release Channels](https://docs.punktfunk.unom.io/docs/channels#pin-a-version-or-roll-back).
## Connect a client
| Streaming to… | Use |
|---|---|
| Mac, iPhone, iPad, Apple TV | The **Apple app** (`clients/apple`) — also on TestFlight |
| Linux desktop / laptop, Steam Deck | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
| Linux desktop / laptop | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
| Steam Deck | The **Decky plugin** in Gaming Mode — it launches the client for you ([Steam Deck](https://docs.punktfunk.unom.io/docs/steam-deck)); in Desktop Mode, the Flatpak directly |
| Android phone or TV | The **Android app** (`clients/android`) |
| Windows | Native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
| Scripts, automation, another launcher | **`punktfunk`** — the headless CLI shipped in the Linux client packages (`punktfunk pair`, `punktfunk hosts list --json`, `punktfunk launch <host>`) |
| Anything else (browser, old phone, smart TV) | **Moonlight** over GameStream |
Each client discovers hosts on the network automatically and does a one-time
@@ -122,7 +167,7 @@ Each client discovers hosts on the network automatically and does a one-time
For development, or as an install fallback where no package is available:
```sh
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, probe (Linux & macOS)
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, the `punktfunk` CLI, probe (Linux & macOS)
cargo test --workspace # unit + loopback + proptest + C ABI harness
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
@@ -154,11 +199,14 @@ clients/
session/ punktfunk-session, the Vulkan streaming session (Rust · SDL3 · ash · Skia console UI) — also runs standalone (gamescope, Decky)
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
cli/ punktfunk, the headless client CLI — pair · hosts · wake · library · launch · punktfunk:// links
probe/ headless reference / measurement client for punktfunk/1
decky/ Steam Deck Decky plugin
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
web/ web console (TanStack) over the management API — status · devices · pairing · library · displays · plugins · GPUs · performance · logs · updates
api/openapi.json management-API OpenAPI spec (regenerated via `punktfunk-host openapi`, checked in)
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
sdk/ `@punktfunk/host` — TypeScript management-API client + event stream (Effect)
plugin-kit/ `@punktfunk/plugin-kit` — the plugin authoring kit (bun / TypeScript)
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite sysext + bootc · Windows installer + drivers · winget · Nix · gamescope
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
include/punktfunk_core.h cbindgen-generated C header (checked in)
tools/ latency-probe · loss-harness (measurement)
@@ -195,7 +243,7 @@ additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
### Third-party components
punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
Punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
@@ -203,7 +251,7 @@ notice ship in the installed `licenses/` folder).
### Trademarks
punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
Punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
NVIDIA, Microsoft, Sony, Valve, or the Moonlight project. "GameStream", "Moonlight", "Xbox",
"DualSense", "DualShock", and "PlayStation" are trademarks of their respective owners and are used
here only to describe interoperability.
+12 -3
View File
@@ -1,8 +1,17 @@
# Security Policy
punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
Punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
machine, so we take security reports seriously and appreciate responsible disclosure.
## Supported versions
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag; the current line is **0.22.x**) and
**canary** (built from `main`). Fixes ship as a new release on those tracks; in practice
we don't backport to older minor versions, so the supported versions are the latest stable release
and the current canary build. If you're on an older build, please check that the issue still
reproduces on the latest stable before reporting it. See
[Release Channels](https://docs.punktfunk.unom.io/docs/channels).
## Reporting a vulnerability
**Please report security issues privately by email to security@punktfunk.com.**
@@ -14,7 +23,7 @@ exposes other users before a fix exists.
The more of this you can give us, the faster we can act:
- The component and version (e.g. `punktfunk-host 0.9.0`, Windows or Linux, which client).
- The component and version (e.g. `punktfunk-host 0.22.3`, Windows or Linux, which client).
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
admin, a paired client, …).
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
@@ -98,4 +107,4 @@ pursue legal action against researchers who:
- give us reasonable time to remediate before public disclosure,
- don't exfiltrate more data than needed to demonstrate the issue.
Thank you for helping keep punktfunk and its users safe.
Thank you for helping keep Punktfunk and its users safe.
+25 -4
View File
@@ -18,6 +18,7 @@ VENDORED THIRD-PARTY SOURCE (inside first-party crates)
Vulkan-Headers (vendored, crates/pyrowave-sys) — https://github.com/KhronosGroup/Vulkan-Headers
Font Awesome Free brand icons (vendored, assets/os-icons) — https://fontawesome.com
Simple Icons (vendored, assets/os-icons) — https://simpleicons.org
Bazzite logo (vendored, assets/os-icons) — https://github.com/ublue-os/bazzite
----------------------------------------------------------------------------
MANIFEST (crate version — SPDX license — source)
@@ -2930,6 +2931,25 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------------------
The following license (bazzite.txt) applies to: Bazzite logo (vendored, assets/os-icons)
----------------------------------------------------------------------------
Bazzite — the `bazzite` mark in assets/os-icons/ is derived from the Bazzite logo in
the Bazzite source repository (repo_content/Bazzite.svg).
Copyright (c) Universal Blue (https://github.com/ublue-os/bazzite)
Licensed under the Apache License, Version 2.0,
https://www.apache.org/licenses/LICENSE-2.0.
Modifications: the logo's "b" letterform was lifted out of the surrounding badge, the
gradient and decorative overlays were dropped, and the path was translated and scaled
into a 24x24 box with a monochrome fill (fill="currentColor").
Brand icons are trademarks of their respective owners and are used for identification
purposes only; their use does not imply endorsement.
----------------------------------------------------------------------------
The following license (LICENSE) applies to: bindgen 0.72.1
----------------------------------------------------------------------------
@@ -6283,8 +6303,8 @@ the following restrictions:
----------------------------------------------------------------------------
The following license (font-awesome-brands.txt) applies to: Font Awesome Free brand icons (vendored, assets/os-icons)
----------------------------------------------------------------------------
Font Awesome Free — brand icons (windows, apple, linux, steam, ubuntu, fedora,
opensuse in assets/os-icons/) are from Font Awesome Free.
Font Awesome Free — brand icons (apple, linux, steam, ubuntu, fedora, opensuse in
assets/os-icons/) are from Font Awesome Free.
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
@@ -12997,8 +13017,9 @@ SOFTWARE.
----------------------------------------------------------------------------
The following license (simple-icons.txt) applies to: Simple Icons (vendored, assets/os-icons)
----------------------------------------------------------------------------
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).
Simple Icons — brand icons (arch, nixos, debian, cachyos, nobara 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
+298 -3
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.22.2"
"version": "0.23.0"
},
"paths": {
"/api/v1/clients": {
@@ -2170,6 +2170,51 @@
}
}
},
"/api/v1/plugins/logs": {
"post": {
"tags": [
"plugins"
],
"summary": "Ingest runner log lines",
"description": "The plugin/script runner ships its output here so the console's **Logs** page can show it.\n\nPlugins are not host child processes — the runner is a separate `bun` process that `import()`s\neach plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and\nbefore this endpoint the console's log page could not show a single plugin line. On Linux the\nfallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no\nlog file at all, so a failing plugin was diagnosable only by stopping the scheduled task and\nre-running the runner by hand. Both are shell access on the host box, which is exactly what the\nconsole exists to avoid.\n\nLines land in the same ring as the host's own, sharing one `seq` cursor, targeted\n`plugin:<source>` — so `GET /logs` needs no second cursor and the console needs no second poll.",
"operationId": "ingestPluginLogs",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginLogBatch"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "Lines ingested"
},
"400": {
"description": "Batch too large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/plugins/{id}": {
"put": {
"tags": [
@@ -3433,6 +3478,58 @@
}
}
},
"/api/v1/update/apply": {
"post": {
"tags": [
"update"
],
"summary": "Apply the available update",
"description": "Starts the one-click apply for install kinds that support it (Windows installer). The\nrequest carries no version or URL — the host installs exactly what its verified manifest\nannounced. Progress is polled via `GET /update/status` (`job`); the host restarts as part\nof the apply, and the outcome lands in `last_result` after it comes back.",
"operationId": "applyUpdate",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApplyRequest"
}
}
},
"required": true
},
"responses": {
"202": {
"description": "Apply started — poll `GET /update/status`",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"409": {
"description": "Refused: unsupported install kind, apply disabled (PUNKTFUNK_UPDATE_APPLY=0), a job already running, an active streaming session without `force`, or nothing newer to apply",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/update/check": {
"post": {
"tags": [
@@ -3443,7 +3540,7 @@
"operationId": "forceUpdateCheck",
"responses": {
"200": {
"description": "Refreshed update-check state (`last_error` carries a failed check)",
"description": "Refreshed update-check state (`last_error` carries a failed check; `not_published` an empty channel, which is not one)",
"content": {
"application/json": {
"schema": {
@@ -3848,6 +3945,15 @@
}
}
},
"ApplyRequest": {
"type": "object",
"properties": {
"force": {
"type": "boolean",
"description": "Proceed even while a streaming session is live (the stream will drop when the host\nrestarts — the console warns before sending this)."
}
}
},
"ApprovePending": {
"type": "object",
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
@@ -4913,6 +5019,29 @@
}
}
},
{
"type": "object",
"description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply.",
"required": [
"from",
"to",
"kind"
],
"properties": {
"from": {
"type": "string"
},
"kind": {
"type": "string",
"enum": [
"update.applied"
]
},
"to": {
"type": "string"
}
}
},
{
"type": "object",
"required": [
@@ -6154,6 +6283,50 @@
"gamestream"
]
},
"PluginLogBatch": {
"type": "object",
"description": "A batch of runner log lines.",
"required": [
"entries"
],
"properties": {
"entries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PluginLogLine"
}
}
}
},
"PluginLogLine": {
"type": "object",
"description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).",
"required": [
"ts_ms",
"level",
"source",
"msg"
],
"properties": {
"level": {
"type": "string",
"description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`."
},
"msg": {
"type": "string"
},
"source": {
"type": "string",
"description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:<source>`."
},
"ts_ms": {
"type": "integer",
"format": "int64",
"description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].",
"minimum": 0
}
}
},
"PluginRegistration": {
"type": "object",
"description": "Register/renew body for `PUT /plugins/{id}`.",
@@ -7155,6 +7328,44 @@
}
}
},
"UpdateJobInfo": {
"type": "object",
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
"required": [
"target_version",
"stage",
"received_bytes",
"started_unix"
],
"properties": {
"received_bytes": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"stage": {
"type": "string",
"description": "`downloading` | `verifying` | `applying` | `restarting`."
},
"started_unix": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"target_version": {
"type": "string",
"description": "The version being installed."
},
"total_bytes": {
"type": [
"integer",
"null"
],
"format": "int64",
"minimum": 0
}
}
},
"UpdateManifestInfo": {
"type": "object",
"description": "One channel's manifest facts, as much as the console renders.",
@@ -7190,6 +7401,56 @@
}
}
},
"UpdateResultInfo": {
"type": "object",
"description": "Durable outcome of the most recent apply attempt (survives the host's own restart).",
"required": [
"ok",
"from",
"to",
"finished_unix"
],
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"finished_unix": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"from": {
"type": "string"
},
"log_path": {
"type": [
"string",
"null"
],
"description": "The installer's own log file on this host, for diagnosis."
},
"ok": {
"type": "boolean"
},
"stage": {
"type": [
"string",
"null"
],
"description": "The stage that failed; absent on success."
},
"staged": {
"type": "boolean",
"description": "Applied but activates on the next reboot (rpm-ostree)."
},
"to": {
"type": "string"
}
}
},
"UpdateStatus": {
"type": "object",
"description": "The full update-check state for this host.",
@@ -7200,7 +7461,8 @@
"apply",
"channel_hint",
"check_disabled",
"available"
"available",
"not_published"
],
"properties": {
"apply": {
@@ -7231,6 +7493,17 @@
"type": "string",
"description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`."
},
"job": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/UpdateJobInfo",
"description": "The apply in flight, if any."
}
]
},
"last_checked_unix": {
"type": [
"integer",
@@ -7247,6 +7520,17 @@
],
"description": "Why the last check failed, verbatim, if it did."
},
"last_result": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/UpdateResultInfo",
"description": "Outcome of the most recent apply attempt."
}
]
},
"manifest": {
"oneOf": [
{
@@ -7257,6 +7541,17 @@
"description": "The last verified manifest, if any check has succeeded."
}
]
},
"not_published": {
"type": "boolean",
"description": "The check reached the feed and found this channel has **no release published yet** —\nan expected state (a channel nobody has announced to answers with a 404), not a\nfailure. Mutually exclusive with `last_error`, so a UI can say \"nothing published yet\"\ninstead of painting an empty feed as a broken host. Never set once a manifest has been\nseen for this channel: a feed that loses a document it used to serve stays an error."
},
"opt_in_hint": {
"type": [
"string",
"null"
],
"description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)."
}
}
}
+14
View File
@@ -0,0 +1,14 @@
Bazzite — the `bazzite` mark in assets/os-icons/ is derived from the Bazzite logo in
the Bazzite source repository (repo_content/Bazzite.svg).
Copyright (c) Universal Blue (https://github.com/ublue-os/bazzite)
Licensed under the Apache License, Version 2.0,
https://www.apache.org/licenses/LICENSE-2.0.
Modifications: the logo's "b" letterform was lifted out of the surrounding badge, the
gradient and decorative overlays were dropped, and the path was translated and scaled
into a 24x24 box with a monochrome fill (fill="currentColor").
Brand icons are trademarks of their respective owners and are used for identification
purposes only; their use does not imply endorsement.
@@ -1,5 +1,5 @@
Font Awesome Free — brand icons (windows, apple, linux, steam, ubuntu, fedora,
opensuse in assets/os-icons/) are from Font Awesome Free.
Font Awesome Free — brand icons (apple, linux, steam, ubuntu, fedora, opensuse in
assets/os-icons/) are from Font Awesome Free.
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
+3 -2
View File
@@ -1,5 +1,6 @@
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).
Simple Icons — brand icons (arch, nixos, debian, cachyos, nobara 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
+32 -10
View File
@@ -7,7 +7,7 @@ Android `ImageVector`s). One file per **icon token** of the host's OS-identity c
| token | mark | source |
|---|---|---|
| `windows` | Windows | Font Awesome Free brands (CC BY 4.0) |
| `windows` | Windows (the current four-pane mark, no perspective skew) | own geometry |
| `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) |
@@ -17,14 +17,36 @@ Android `ImageVector`s). One file per **icon token** of the host's OS-identity c
| `arch` | Arch Linux | Simple Icons (CC0 1.0) |
| `nixos` | NixOS | Simple Icons (CC0 1.0) |
| `debian` | Debian | Simple Icons (CC0 1.0) |
| `bazzite` | Bazzite | ublue-os/bazzite (Apache-2.0) |
| `cachyos` | CachyOS | Simple Icons (CC0 1.0) |
| `nobara` | Nobara | Simple Icons (CC0 1.0, slug `nobaralinux`) |
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.
The last three are **distro leaves, not families**: a chain walks most-specific-first, so
`linux/fedora/bazzite` would otherwise draw the Fedora mark. They earn their own art because
"a Bazzite box" and "a Fedora box" are different machines to the person reading the card, and
they are what this project's hosts actually run. Every other distro with no file here (Pop!_OS,
Mint, …) still degrades to its family's mark and finally to Tux — that fallback is the design,
not a gap.
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.
Windows is the one mark drawn here rather than sourced: every icon set that ships a "Windows"
brand glyph still carries the **Windows 8/10 flag with the perspective skew**, which reads as
dated next to the flat four-pane mark Microsoft has used since Windows 11. Four equal squares
(11.377 + 1.246 gap) is the current proportion.
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Because
those viewBoxes are not all square, a client must letterbox rather than stretch — see the aspect
note in `clients/android/.../components/OsIcons.kt`.
## Regenerating the per-client derivatives
`bash scripts/gen-os-icons.sh [token ...]` turns a master into the three baked forms (GTK
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
that inline it (web console, Decky plugin, Android). Adding a **new** token also means adding it
to each client's shipped-token list — the script prints that checklist too.
## 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 @@
<!-- bazzite — the Bazzite "b", from ublue-os/bazzite repo_content/Bazzite.svg (Apache-2.0), lifted out of the badge and normalized to a 24x24 box. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z"/></svg>

After

Width:  |  Height:  |  Size: 523 B

+2
View File
@@ -0,0 +1,2 @@
<!-- cachyos — from Simple Icons (CC0 1.0). See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81"/></svg>

After

Width:  |  Height:  |  Size: 438 B

+2
View File
@@ -0,0 +1,2 @@
<!-- nobara — from Simple Icons (CC0 1.0), slug "nobaralinux". See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z"/></svg>

After

Width:  |  Height:  |  Size: 681 B

+2 -2
View File
@@ -1,2 +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>
<!-- windows — the modern (Windows 11) four-pane mark: four equal squares, no perspective skew. Own geometry, see README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z"/></svg>

Before

Width:  |  Height:  |  Size: 344 B

After

Width:  |  Height:  |  Size: 323 B

File diff suppressed because it is too large Load Diff
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Sc2Capture
import kotlinx.coroutines.delay
@@ -149,13 +150,14 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
) {
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
// Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the
// capture claims even those away), so it's enumerated on the capture side — USB device
// list + bonded BLE — and re-checked on USB hot-plug.
var sc2Generation by remember { mutableIntStateOf(0) }
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
// the USB device list + bonded BLE; a Sony pad IS an InputDevice until claimed, so its
// row supplements the PadRow below with the capture status + the USB grant.
var usbGeneration by remember { mutableIntStateOf(0) }
DisposableEffect(Unit) {
val receiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
override fun onReceive(c: Context?, i: android.content.Intent?) { usbGeneration++ }
}
val filter = android.content.IntentFilter().apply {
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
@@ -170,16 +172,23 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
val sc2Probe = remember { Sc2Capture(context) }
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(sc2Generation) {
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(usbGeneration) {
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) sc2Probe.pairedBleAddress() else null
}
val sc2Present = sc2Usb != null || sc2Ble != null
val dsUsb = remember(usbGeneration) {
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
}
}
Group("Gamepads") {
if (sc2Present) Sc2Row(sc2Usb, activity)
dsUsb?.let { DsRow(it) }
if (pads.isEmpty() && !sc2Present) {
Text(
"No controller detected. punktfunk can only forward devices Android " +
@@ -319,6 +328,104 @@ private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivi
}
}
/**
* Broadcast action for the Sony-pad USB grants — fired by both the menu-time auto-ask
* ([MainActivity.maybeAskDsPermission]) and [DsRow]'s explicit button, so an open card
* refreshes whichever dialog was answered.
*/
internal const val DS_USB_PERMISSION_ACTION = "io.unom.punktfunk.DS_CONTROLLERS_USB_PERMISSION"
/**
* The Sony USB pad card — capture status + the USB grant. The grant normally arrives via the
* menu-time auto-ask the moment the pad attaches ([MainActivity.maybeAskDsPermission]); the
* button here is the recovery path after a deny (the auto-ask fires once per attach). Shown
* ALONGSIDE the pad's ordinary [PadRow] (unclaimed it is still an InputDevice); the capture
* itself only runs inside a stream, so at menu time this card is pure status.
*/
@Composable
private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
val context = LocalContext.current
val settingOn = remember { SettingsStore(context).load().dsCapture }
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
var permitted by remember(usbDev) { mutableStateOf(usbManager.hasPermission(usbDev)) }
val model = DsDevice.modelFor(usbDev.productId)
val label = when (model) {
DsDevice.Model.DUALSENSE -> "DualSense"
DsDevice.Model.DUALSENSE_EDGE -> "DualSense Edge"
DsDevice.Model.DUALSHOCK4 -> "DualShock 4"
null -> return
}
// Refresh `permitted` when the grant dialog answers (the grant itself is system-recorded;
// this receiver only updates the card).
val action = DS_USB_PERMISSION_ACTION
DisposableEffect(usbDev) {
val receiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, i: android.content.Intent?) {
if (i?.action == action) permitted = usbManager.hasPermission(usbDev)
}
}
androidx.core.content.ContextCompat.registerReceiver(
context,
receiver,
android.content.IntentFilter(action),
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
)
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("$label passthrough", style = MaterialTheme.typography.bodyLarge)
Text(
"Wired (USB)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
when {
!settingOn -> Text(
"Passthrough is disabled in Settings — enable \"DualSense / DualShock " +
"passthrough (USB)\" to capture it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
!permitted -> {
Text(
"Needs USB access — grant it now and streams capture the pad silently.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = {
usbManager.requestPermission(
usbDev,
android.app.PendingIntent.getBroadcast(
context, 3, // requestCode 3 — 0/1/2 are the SC2/stream grants
android.content.Intent(action).setPackage(context.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
android.app.PendingIntent.FLAG_MUTABLE,
),
)
}) {
Text("Grant USB access")
}
}
else -> Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/** One detected gamepad: identity, what it streams as, and a rumble test. */
@Composable
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
@@ -50,10 +50,12 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.PendingTrust
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -250,6 +252,139 @@ fun GamepadHostOptionsDialog(
}
}
/**
* The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
* carousel's unpin uses, never the profile itself and never the host's default binding.
*
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
* always what the store holds — the row can't disagree with the carousel it feeds.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GamepadPinHostsDialog(
profileName: String,
hosts: List<KnownHost>,
pinned: (KnownHost) -> Boolean,
onToggle: (KnownHost) -> Unit,
onDismiss: () -> Unit,
) {
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
// Done, so it starts focused).
var focus by remember { mutableIntStateOf(0) }
BackHandler(onBack = onDismiss)
GamepadNavEffect2D(
active = true,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < hosts.size) focus++
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
// direction can't oscillate; asking for the state it's already in is a no-op.
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
}
},
onActivate = {
val kh = hosts.getOrNull(focus)
if (kh != null) onToggle(kh) else onDismiss()
},
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"Pin “$profileName",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (hosts.isEmpty()) {
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
} else {
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
hosts.forEachIndexed { i, kh ->
PinHostRow(
label = kh.name,
on = pinned(kh),
focused = i == focus,
onClick = { onToggle(kh) },
)
}
}
Spacer(Modifier.size(4.dp))
DialogButton(
"Done",
focused = focus == hosts.size,
primary = true,
enabled = true,
onClick = onDismiss,
)
}
}
}
}
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
val visuals = animateConsoleFocus(active = focused)
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
// landscape window pulls itself into view.
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val shape = RoundedCornerShape(14.dp)
Row(
Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.weight(1f))
ConsoleSwitch(on = on, focused = focused)
}
}
/**
* 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
@@ -57,6 +57,8 @@ import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
@@ -72,6 +74,8 @@ private class GpRow(
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
)
@Composable
@@ -89,7 +93,39 @@ fun GamepadSettingsScreen(
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
// 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)
// The Profiles section's stores, constructed here the way ConnectScreen constructs its own.
// The catalog is read once per screen entry: this screen can't create or edit profiles
// (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved
// hosts DO change under it — every pin toggle writes one — so they live in state and refresh
// on each toggle, keeping the "Pinned to N hosts" counts honest.
val knownHostStore = remember { KnownHostStore(context) }
val profileStore = remember { ProfileStore(context) }
val profiles = remember { profileStore.all() }
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
// The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad
// (this screen's nav gates on it, the ConnectScreen-dialog pattern).
var pinProfile by remember { mutableStateOf<StreamProfile?>(null) }
// Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation
// only: pin appends at the end (card order), unpin removes, and the host's default binding
// (profileId) is never touched.
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()
}
// On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
// interface remote-navigably. The strings branch on it.
val tv = remember { isTvDevice(context) }
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
@@ -101,7 +137,9 @@ fun GamepadSettingsScreen(
BackHandler(onBack = onBack)
GamepadNavEffect2D(
active = navActive,
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
// drops its probes — the pattern ConnectScreen's dialogs use.
active = navActive && pinProfile == null,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
@@ -162,16 +200,41 @@ fun GamepadSettingsScreen(
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
// The legend follows the focused row (the desktop console's hints() does the same):
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
val focused = rows.getOrNull(focus)
GamepadHintBar(
listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
),
when {
focused != null && !focused.enabled -> listOf(
PadGlyph.hint('B', "Done", onClick = onBack),
)
focused != null && !focused.adjustable -> listOf(
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
else -> listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
},
hazeState = hazeState,
)
}
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
// touch UI's per-profile pin toggles in the host edit sheet.
pinProfile?.let { p ->
GamepadPinHostsDialog(
profileName = p.name,
hosts = savedHosts,
pinned = { kh -> p.id in kh.pinnedProfileIds },
onToggle = { kh -> togglePin(kh, p) },
onDismiss = { pinProfile = null },
)
}
}
}
@@ -180,8 +243,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
// focus arrives; the value colour cross-fades with them.
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
val chevronAlpha by animateFloatAsState(
if (focused && row.adjustable) 0.6f else 0f,
tween(160),
label = "chevrons",
)
val valueColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 1f else 0.6f),
tween(160),
@@ -216,7 +284,9 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
// so its detail line can still explain what would go here.
color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f),
maxLines = 1,
)
Spacer(Modifier.weight(1f))
@@ -395,12 +465,36 @@ private fun buildSettingsRows(
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
s.micEnabled,
) { update(s.copy(micEnabled = it)) },
toggle(
"echoCancel", null, "Echo cancellation",
"Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.",
s.echoCancel,
) { update(s.copy(echoCancel = it)) },
toggle(
"padForward", "Controllers", "Forward controllers",
"Send this device's controllers to the host. Turn it off when your controller " +
"already reaches the host another way — USB passthrough such as VirtualHere — " +
"so games don't see two of them.",
s.gamepadForwarding,
) { update(s.copy(gamepadForwarding = it)) },
choice(
"padType", "Controllers", "Controller type",
"padType", null, "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad,
) { update(s.copy(gamepad = it)) },
choice(
"systemButtons", null, "Guide button",
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
"sends them to the host whenever this device delivers them.",
SYSTEM_BUTTON_OPTIONS, s.systemButtons,
) { update(s.copy(systemButtons = it)) },
choice(
"guideGesture", null, "Hold Select for guide",
"Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
GUIDE_GESTURE_OPTIONS, s.guideGesture,
) { update(s.copy(guideGesture = it)) },
) + listOfNotNull(
if (hasBodyVibrator) {
toggle(
@@ -423,3 +517,62 @@ private fun buildSettingsRows(
) { update(s.copy(sc2Capture = it)) },
)
}
/**
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
* nowhere useful on a touchless device, so the strings name the actual route — the
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
*/
private fun buildProfileRows(
profiles: List<StreamProfile>,
savedHosts: List<KnownHost>,
tv: Boolean,
openPinPicker: (StreamProfile) -> Unit,
): List<GpRow> {
val createHint = if (tv) {
"To create or edit profiles on this device, turn off Controller-optimized UI above " +
"and use the standard interface."
} else {
"Profiles are created and edited in the touch interface."
}
if (profiles.isEmpty()) {
return listOf(
GpRow(
id = "noProfiles",
header = "Profiles",
label = "No profiles yet",
value = "",
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
"one-press connect cards here. " + createHint,
adjust = { false },
activate = {},
adjustable = false,
enabled = false,
),
)
}
return profiles.mapIndexed { i, p ->
// Counted straight off the host records, so it agrees with what the carousel renders.
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
GpRow(
id = "profile:${p.id}",
header = if (i == 0) "Profiles" else null,
label = p.name,
value = when (pins) {
0 -> "Not pinned"
1 -> "Pinned to 1 host"
else -> "Pinned to $pins hosts"
},
detail = "Pin this profile to a host and it appears as its own card — one press " +
"connects with it. " + createHint,
adjust = { false },
activate = { openPinPicker(p) },
adjustable = false,
)
}
}
@@ -2,6 +2,7 @@ package io.unom.punktfunk
import android.content.Context
import android.os.Build
import android.util.Log
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.VideoDecoders
@@ -45,14 +46,40 @@ suspend fun connectToHost(
// Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media
// sockets) — must be applied before connect, since sockets are tagged at creation.
NativeBridge.nativeSetLowLatencyMode(settings.lowLatencyMode)
val multiSlice = VideoDecoders.multiSliceTolerant()
val partialFrame = VideoDecoders.partialFrameCapable()
// Slice-progressive delivery: decoder truth AND the async decode loop — the legacy
// sync loop feeds whole AUs only, so parts must never arrive when it is selected.
val frameParts = settings.lowLatencyMode && partialFrame
val codecBits = VideoDecoders.decodableCodecBits()
// Automatic codec (P5, measured NP3 ↔ RTX 4090): AV1 beat HEVC by ~1.2 ms end-to-end at
// identical conditions, so under "Automatic" this device prefers AV1 when it hardware-
// decodes it (the AV1 bit is only ever set for a real, non-blocked hardware decoder) AND
// it lacks FEATURE_PartialFrame — a partial-frame device keeps HEVC, whose slice overlap
// AV1 cannot ride (AV1 has no slices; the host's chunked poll never arms). The host
// honors the preference only inside the probed shared codec set, so an AV1-less encoder
// still resolves HEVC. An explicit user choice always wins unchanged.
val preferredCodec = settings.preferredCodec().takeIf { it != 0 }
?: if (codecBits and 4 != 0 && !partialFrame) 4 else 0
// The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline
// is client-inert unless BOTH probes pass — this line says which decoder failed one.
Log.i(
"pf.caps",
VideoDecoders.capsReport() +
" → multiSlice=$multiSlice parts=$frameParts prefer=$preferredCodec" +
" (lowLatency=${settings.lowLatencyMode})",
)
NativeBridge.nativeConnect(
host, port, w, h, hz,
identity.certPem, identity.privateKeyPem, pinHex,
settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, settings.audioChannels,
hdrEnabled, multiSlice,
frameParts,
settings.audioChannels,
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
// the user's soft codec preference — the host resolves the emitted codec from both.
VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
// the soft codec preference (user choice, or the Automatic AV1 rule above) — the
// host resolves the emitted codec from both.
codecBits, preferredCodec, timeoutMs,
launch,
// The host's approval-list / trust-store label for this device — the same
// Build.MODEL convention the pairing dialogs use for nativePair.
@@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.Keymap
@@ -169,6 +170,10 @@ class MainActivity : ComponentActivity() {
private var sc2Receiver: BroadcastReceiver? = null
private var sc2PermissionAsked = false
/** Sony-pad USB grant asked this attach — a deny doesn't re-nag until a fresh attach (or the
* Controllers screen's explicit button). */
private var dsPermissionAsked = false
/**
* Compose focus hook for the SC2's synthetic D-pad (set by [onCreate]'s composition). A
* synthetic KeyEvent dispatched from OUTSIDE the real input pipeline never reaches
@@ -225,6 +230,8 @@ class MainActivity : ComponentActivity() {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
sc2PermissionAsked = false // a fresh attach may ask once again
startSc2MenuNav()
dsPermissionAsked = false
maybeAskDsPermission()
}
SC2_MENU_PERMISSION -> {
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
@@ -281,6 +288,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
startSc2MenuNav()
maybeAskDsPermission()
}
override fun onPause() {
@@ -341,6 +349,37 @@ class MainActivity : ComponentActivity() {
sc2MenuActive = false
}
/**
* Ask for USB access to an attached Sony pad the moment it appears — a fresh attach while
* the app is open, or the app coming to the foreground with one already plugged in — at most
* once per attach, so the stream-mode capture ([io.unom.punktfunk.kit.DsCapture]) engages
* silently instead of interrupting stream start with the dialog. Unlike the SC2's menu flow
* there is nothing to START on the grant: an uncaptured Sony pad is an ordinary InputDevice
* at menu time, so the grant is simply recorded (Android keeps it while the pad stays
* attached). The broadcast only refreshes the Controllers screen's card if it happens to be
* open; a deny leaves that card's explicit button as the re-ask.
*/
private fun maybeAskDsPermission() {
if (streamHandle != 0L) return // StreamScreen owns its own permission flow while streaming
if (dsPermissionAsked) return
if (!SettingsStore(this).load().dsCapture) return
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val dev = usbManager.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
} ?: return
if (usbManager.hasPermission(dev)) return
dsPermissionAsked = true
usbManager.requestPermission(
dev,
PendingIntent.getBroadcast(
this, 4, // requestCode 4 — 0..3 are the SC2 stream/menu + DS stream/card grants
Intent(DS_USB_PERMISSION_ACTION).setPackage(packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
/**
* One SC2 navigation key transition from the menu-time capture (main thread) — routed the
* same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs,
@@ -405,8 +444,8 @@ class MainActivity : ComponentActivity() {
/**
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] replaces this with
* [setStreamDisplayMode] while streaming (matched to the video, not to the panel maximum).
*/
fun setConsoleHighRefreshRate(high: Boolean) {
if (highRefreshModeId == 0) return
@@ -415,6 +454,64 @@ class MainActivity : ComponentActivity() {
}
}
/**
* Pin the panel to a display mode matching the STREAM's refresh for the session's duration —
* exact rate first, else the smallest integer multiple (120 for a 60 stream: judder-free 2:1
* pulldown), else the highest available. Same-resolution modes only.
*
* The window-level mode pin is the belt to the decoder's `ANativeWindow_setFrameRate` braces:
* the surface hint alone is advisory, and several OEM refresh governors (Nothing OS's LTPO
* logic among them) ignore it entirely for third-party apps — leaving a 120 Hz session
* presenting on a 60/90 Hz panel, which reads as judder + a refresh of extra latency. The
* preferredDisplayModeId is the one signal they all honor. [hz] ≤ 0 falls back to releasing
* the pin (the pre-pin behaviour).
*/
fun setStreamDisplayMode(hz: Int) {
if (hz <= 0) {
setConsoleHighRefreshRate(false)
return
}
val target = streamModeFor(hz) ?: return
window.attributes = window.attributes.apply { preferredDisplayModeId = target.modeId }
}
/**
* The panel refresh rate a [hz] stream runs against — [streamModeFor]'s pick, from the mode
* TABLE rather than `display.refreshRate`. The distinction matters: under a per-uid frame
* rate override (games get a 60 fps default on Android 15+) `refreshRate` reports the
* override, not the panel — observed on-glass as a 120 Hz panel reading back as 60. The
* supported-modes list is not override-filtered. `0` when unresolvable.
*/
fun streamPanelFps(hz: Int): Int =
streamModeFor(hz)?.refreshRate?.let { kotlin.math.round(it).toInt() } ?: 0
/** The same-resolution display mode [setStreamDisplayMode] pins for a [hz] stream. */
private fun streamModeFor(hz: Int): android.view.Display.Mode? {
if (hz <= 0) return null
@Suppress("DEPRECATION")
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
val current = disp?.mode ?: return null
val sameRes = disp.supportedModes.filter {
it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight
}
fun multiple(rate: Float): Int {
val k = (rate / hz).toInt()
return if (k >= 2 && kotlin.math.abs(rate - hz * k) < 1f) k else 0
}
return sameRes.minWithOrNull(
compareBy(
{
when {
kotlin.math.abs(it.refreshRate - hz) < 1f -> 0 // exact
multiple(it.refreshRate) > 0 -> 1 // integer multiple — prefer smallest
else -> 2 // no relation — prefer highest so at least nothing is halved
}
},
{ if (multiple(it.refreshRate) > 0) it.refreshRate else -it.refreshRate },
),
)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handle = streamHandle
if (handle != 0L) {
@@ -38,16 +38,23 @@ data class SettingsOverlay(
val compositor: Int? = null,
val audioChannels: Int? = null,
val micEnabled: Boolean? = null,
val echoCancel: Boolean? = null,
val touchMode: TouchMode? = null,
val mouseMode: MouseMode? = null,
val invertScroll: Boolean? = null,
val gamepad: Int? = null,
val gamepadForwarding: Boolean? = null,
val systemButtons: String? = null,
val guideGesture: String? = 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,
/** The timeline presenter's intent pair — cross-client keys, see [Settings.presentPriority]. */
val presentPriority: String? = null,
val smoothBuffer: Int? = 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
@@ -67,12 +74,18 @@ data class SettingsOverlay(
compositor = compositor ?: base.compositor,
audioChannels = audioChannels ?: base.audioChannels,
micEnabled = micEnabled ?: base.micEnabled,
echoCancel = echoCancel ?: base.echoCancel,
touchMode = touchMode ?: base.touchMode,
mouseMode = mouseMode ?: base.mouseMode,
invertScroll = invertScroll ?: base.invertScroll,
gamepad = gamepad ?: base.gamepad,
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
systemButtons = systemButtons ?: base.systemButtons,
guideGesture = guideGesture ?: base.guideGesture,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
presentPriority = presentPriority ?: base.presentPriority,
smoothBuffer = smoothBuffer ?: base.smoothBuffer,
)
/**
@@ -98,12 +111,20 @@ data class SettingsOverlay(
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,
echoCancel = if (after.echoCancel != before.echoCancel) after.echoCancel else echoCancel,
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,
gamepadForwarding =
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
else gamepadForwarding,
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
smoothBuffer = if (after.smoothBuffer != before.smoothBuffer) after.smoothBuffer else smoothBuffer,
)
/**
@@ -121,12 +142,18 @@ data class SettingsOverlay(
"compositor" -> copy(compositor = null)
"audio_channels" -> copy(audioChannels = null)
"mic_enabled" -> copy(micEnabled = null)
"echo_cancel" -> copy(echoCancel = null)
"touch_mode" -> copy(touchMode = null)
"mouse_mode" -> copy(mouseMode = null)
"invert_scroll" -> copy(invertScroll = null)
"gamepad" -> copy(gamepad = null)
"gamepad_forwarding" -> copy(gamepadForwarding = null)
"system_buttons" -> copy(systemButtons = null)
"guide_gesture" -> copy(guideGesture = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
"present_priority" -> copy(presentPriority = null)
"smooth_buffer" -> copy(smoothBuffer = null)
else -> this
}
@@ -141,12 +168,18 @@ data class SettingsOverlay(
if (compositor != null) add("compositor")
if (audioChannels != null) add("audio_channels")
if (micEnabled != null) add("mic_enabled")
if (echoCancel != null) add("echo_cancel")
if (touchMode != null) add("touch_mode")
if (mouseMode != null) add("mouse_mode")
if (invertScroll != null) add("invert_scroll")
if (gamepad != null) add("gamepad")
if (gamepadForwarding != null) add("gamepad_forwarding")
if (systemButtons != null) add("system_buttons")
if (guideGesture != null) add("guide_gesture")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
if (presentPriority != null) add("present_priority")
if (smoothBuffer != null) add("smooth_buffer")
}
/**
@@ -169,12 +202,18 @@ data class SettingsOverlay(
compositor?.let { j.put("compositor", it) }
audioChannels?.let { j.put("audio_channels", it) }
micEnabled?.let { j.put("mic_enabled", it) }
echoCancel?.let { j.put("echo_cancel", 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) }
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
systemButtons?.let { j.put("system_buttons", it) }
guideGesture?.let { j.put("guide_gesture", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
presentPriority?.let { j.put("present_priority", it) }
smoothBuffer?.let { j.put("smooth_buffer", it) }
return j
}
@@ -185,8 +224,11 @@ data class SettingsOverlay(
/** 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",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"system_buttons", "guide_gesture",
"stats_verbosity",
"low_latency_mode", "present_priority", "smooth_buffer",
)
internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay(
@@ -200,15 +242,21 @@ data class SettingsOverlay(
compositor = j.optIntOrNull("compositor"),
audioChannels = j.optIntOrNull("audio_channels"),
micEnabled = j.optBooleanOrNull("mic_enabled"),
echoCancel = j.optBooleanOrNull("echo_cancel"),
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"),
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
systemButtons = j.optStringOrNull("system_buttons"),
guideGesture = j.optStringOrNull("guide_gesture"),
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
presentPriority = j.optStringOrNull("present_priority"),
smoothBuffer = j.optIntOrNull("smooth_buffer"),
extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) },
)
}
@@ -1,6 +1,7 @@
package io.unom.punktfunk
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Build
import android.util.Log
import android.view.Display
@@ -33,6 +34,31 @@ data class Settings(
val hdrEnabled: Boolean = true,
val compositor: Int = 0,
val gamepad: Int = 0,
/**
* Forward this device's controllers to the host at all. Default on — that was the
* unconditional behaviour before this became a setting.
*
* Off is for a couch whose controller reaches the host another way: a USB passthrough tool
* (VirtualHere and friends), or a pad simply plugged into the host itself. Leaving it on
* there gives the host two controllers for one pair of hands, and games read both. It also
* stops this device CLAIMING the pad — a device held open is one a passthrough tool can't
* bind — which is why it gates the USB capture paths, not just the wire sends.
*/
val gamepadForwarding: Boolean = true,
/**
* Where the guide (Xbox/PS) and misc/share presses land while streaming — the
* cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches
* the app on most devices) | `"forward"` | `"local"`.
*/
val systemButtons: String = "auto",
/**
* The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off
* on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's
* guide, down until release (long hold = the host's long-press → a Gaming-Mode host's
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
* shell intercepts the physical guide button.
*/
val guideGesture: String = "auto",
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2,
@@ -41,6 +67,15 @@ data class Settings(
* the host resolves (AV1 is only advertised/offered when the device has a real AV1 decoder). */
val codec: String = "auto",
val micEnabled: Boolean = false,
/**
* Cancel acoustic echo on the mic uplink (plus noise suppression): the capture opens under
* the VoiceCommunication preset so the HAL's own AEC/NS process it, with the Java effects
* attached as a backstop where available. On by default — a phone/tablet plays the game audio
* out of the same device its mic hears, so without this the host hears its own stream back.
* Turn off for a headset-only setup where the untouched full-band capture sounds better.
* Only meaningful while [micEnabled] is on.
*/
val echoCancel: Boolean = true,
/**
* How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
@@ -83,6 +118,19 @@ data class Settings(
* feeds a queue that only grows.
*/
val lowLatencyMode: Boolean = true,
/**
* The timeline presenter's intent — the cross-client `present_priority` pair (the Apple
* client's "Prioritize" picker, same stored values): `"latency"` (default) = newest-wins,
* a frame reaches glass the instant the glass budget opens; `"smooth"` = a small FIFO
* drained one frame per vsync, absorbing network/decode jitter at one refresh of added
* display latency per buffered frame. Anything unrecognized resolves to latency.
*/
val presentPriority: String = "latency",
/**
* The smoothness buffer depth (`smooth_buffer`): 0 = Automatic (2 frames), else 1..3.
* Only meaningful when [presentPriority] is `"smooth"`.
*/
val smoothBuffer: Int = 0,
/**
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
@@ -110,6 +158,18 @@ data class Settings(
*/
val sc2Capture: Boolean = true,
/**
* Capture a USB-connected Sony controller (DualSense / DualSense Edge / DualShock 4) and
* drive it directly: the app claims the pad's HID interface and renders the host's feedback
* by writing USB output reports — rumble works on every phone (no kernel force-feedback
* driver needed), and adaptive triggers + lightbar + player LEDs work at all (Android has no
* platform API for any of them). ON by default — it engages only when such a pad is attached
* over USB at stream start; uncaptured (toggle off / no permission / Bluetooth) the pad stays
* on the ordinary InputDevice path. USB only: Android exposes no raw path to a Bluetooth
* Classic pad, which is also why Sony's own Remote Play has no Android trigger support.
*/
val dsCapture: Boolean = true,
/**
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
@@ -181,9 +241,13 @@ class SettingsStore(context: Context) {
hdrEnabled = prefs.getBoolean(K_HDR, true),
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
echoCancel = prefs.getBoolean(K_ECHO_CANCEL, true),
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
@@ -201,9 +265,12 @@ class SettingsStore(context: Context) {
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
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
@@ -223,17 +290,24 @@ class SettingsStore(context: Context) {
.putBoolean(K_HDR, s.hdrEnabled)
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
.putString(K_GUIDE_GESTURE, s.guideGesture)
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_ECHO_CANCEL, s.echoCancel)
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
.putString(K_PRESENT_PRIORITY, s.presentPriority)
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.apply()
@@ -248,9 +322,13 @@ class SettingsStore(context: Context) {
const val K_HDR = "hdr_enabled"
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
const val K_SYSTEM_BUTTONS = "system_buttons"
const val K_GUIDE_GESTURE = "guide_gesture"
const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
const val K_ECHO_CANCEL = "echo_cancel"
const val K_STATS_VERBOSITY = "stats_verbosity"
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
@@ -271,9 +349,12 @@ class SettingsStore(context: Context) {
* on; both stale keys are abandoned unread. The toggle stays as a per-device escape hatch.
*/
const val K_LOW_LATENCY = "low_latency_mode_v2"
const val K_PRESENT_PRIORITY = "present_priority"
const val K_SMOOTH_BUFFER = "smooth_buffer"
const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
const val K_MOUSE_MODE = "mouse_mode"
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
@@ -285,14 +366,31 @@ class SettingsStore(context: Context) {
}
}
/**
* The display to probe for capability/mode queries: the context's own display when it is already
* associated with one, else the DEFAULT display via [DisplayManager]. A `punktfunk://` deep-link
* COLD start can reach the connect before the activity is attached to its display —
* `context.display` then throws, and the old `false`/1080p60 fallbacks silently downgraded the
* whole session (no HDR advertised / non-native mode) with nothing in the log. The default
* display IS the panel on phones and TVs; the activity-display distinction only matters on
* multi-display setups, where the attached path still wins whenever it is available.
*/
private fun probeDisplay(context: Context): Display? =
runCatching { context.display }.getOrNull()
?: runCatching {
context.getSystemService(DisplayManager::class.java)
?.getDisplay(Display.DEFAULT_DISPLAY)
}.getOrNull().also {
if (it != null) Log.i("punktfunk", "display probe: context unattached — using DEFAULT_DISPLAY")
}
/**
* The device's native display mode as a landscape `(width, height, hz)` — the long edge is the
* width, since we stream a desktop. Falls back to 1920×1080@60 if the display can't be read.
* [context] must be a visual (Activity) context.
* width, since we stream a desktop. Falls back to 1920×1080@60 if no display can be read at all
* (see [probeDisplay] for the cold-start fallback that makes that a last resort).
*/
fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
// getDisplay() throws on a non-visual context rather than returning null — guard it.
val display = runCatching { context.display }.getOrNull() ?: return Triple(1920, 1080, 60)
val display = probeDisplay(context) ?: return Triple(1920, 1080, 60)
val mode = display.mode
val w = mode.physicalWidth
val h = mode.physicalHeight
@@ -307,7 +405,12 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
* capability gate the Apple/Windows clients apply.
*/
fun displaySupportsHdr(context: Context): Boolean {
val display = runCatching { context.display }.getOrNull() ?: return false
val display = probeDisplay(context)
if (display == null) {
// Distinguishable from a real SDR verdict — a silent `false` here cost an HDR session.
Log.w("punktfunk", "display HDR probe: no display reachable — advertising SDR")
return false
}
val types = buildSet {
// API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The
// deprecated Display-level hdrCapabilities can return EMPTY on Android 14+ devices
@@ -456,6 +559,15 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, Stri
}
}
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
* Android — the press reaches the app on most devices, and where the shell shows its own UI
* for it that's the shell's business. */
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
* host); "on" is for devices whose shell intercepts the physical guide button. */
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
fun Settings.preferredCodec(): Int = when (codec) {
@@ -491,6 +603,29 @@ val COMPOSITOR_OPTIONS = listOf(
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
/** [Settings.presentPriority] as the wire int `nativeStartVideo` takes (0 = latency, 1 = smooth).
* Unrecognized values resolve to latency — same rule as the Apple client. */
fun Settings.presentPriorityWire(): Int = if (presentPriority == "smooth") 1 else 0
/** (stored value, label) for the presenter-intent picker — the Apple client's table verbatim. */
val PRESENT_PRIORITY_OPTIONS = listOf(
"latency" to "Lowest latency",
"smooth" to "Smoothness",
)
/** (frames, label) for the smoothness-buffer picker; each buffered frame ≈ one refresh interval
* of jitter absorbed for one interval of added display latency ([hz] labels the cost). */
fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
val periodMs = 1000.0 / maxOf(24, hz)
fun cost(frames: Int) = "+%.0f ms".format(periodMs * frames)
return listOf(
0 to "Automatic",
1 to "1 frame (${cost(1)})",
2 to "2 frames (${cost(2)})",
3 to "3 frames (${cost(3)})",
)
}
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
@@ -515,3 +650,17 @@ val GAMEPAD_OPTIONS = listOf(
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
)
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
val SYSTEM_BUTTON_OPTIONS = listOf(
"auto" to "Automatic",
"forward" to "Send to host",
"local" to "This device",
)
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
val GUIDE_GESTURE_OPTIONS = listOf(
"auto" to "Automatic",
"on" to "On",
"off" to "Off",
)
@@ -673,12 +673,22 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
// 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 }
// Mirror the Automatic AV1 rule in HostConnect (hardware AV1 AND no partial-frame
// support) so the picker says what "Automatic" actually does on THIS device.
val autoPrefersAv1 = remember {
VideoDecoders.decodableCodecBits() and 4 != 0 && !VideoDecoders.partialFrameCapable()
}
SettingDropdown(
label = "Video codec",
options = codecOptionsFor(s.codec, av1Capable),
selected = s.codec,
field = "codec",
caption = "A preference — the host falls back if it can't encode this one.",
caption = if (autoPrefersAv1) {
"A preference — the host falls back if it can't encode this one. " +
"Automatic prefers AV1 on this device."
} else {
"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
@@ -711,6 +721,26 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
field = "low_latency_mode",
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
)
// The timeline presenter's intent — the Apple client's "Prioritize" pair, same stored
// values, so a profile written on one platform means the same thing here.
SettingDropdown(
label = "Prioritize",
options = PRESENT_PRIORITY_OPTIONS,
selected = if (s.presentPriority == "smooth") "smooth" else "latency",
field = "present_priority",
caption = "Lowest latency shows each frame the moment it can reach the panel; " +
"Smoothness buffers a little to absorb network jitter.",
) { v -> update(s.copy(presentPriority = v)) }
if (s.presentPriority == "smooth") {
SettingDropdown(
label = "Smoothness buffer",
options = smoothBufferOptions(if (s.hz > 0) s.hz else nhz),
selected = if (s.smoothBuffer in 1..3) s.smoothBuffer else 0,
field = "smooth_buffer",
caption = "Each buffered frame absorbs one refresh of jitter and adds one of " +
"display latency — the cost shown is at the session's refresh rate.",
) { v -> update(s.copy(smoothBuffer = v)) }
}
}
SettingsGroup("Host output", footer = "Display changes apply from the next session.") {
@@ -774,20 +804,59 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
field = "mic_enabled",
onCheckedChange = onMicChange,
)
ToggleRow(
title = "Echo cancellation",
subtitle = "Filters the stream's own audio out of the mic pickup",
checked = s.echoCancel,
enabled = s.micEnabled,
field = "echo_cancel",
onCheckedChange = { on -> update(s.copy(echoCancel = on)) },
)
}
}
@Composable
private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
SettingsGroup(footer = "Applies from the next session.") {
// The master switch, above everything it governs. Profileable, so it shows in both
// scopes: a "Work" profile can decline to forward what "Game" forwards.
ToggleRow(
title = "Forward controllers",
subtitle = "Send this device's controllers to the host. Turn it off when your " +
"controller already reaches the host another way — USB passthrough such as " +
"VirtualHere, or a pad plugged into the host — so games don't see two of them",
checked = s.gamepadForwarding,
field = "gamepad_forwarding",
onCheckedChange = { on -> update(s.copy(gamepadForwarding = on)) },
)
SettingDropdown(
label = "Controller type",
options = GAMEPAD_OPTIONS,
selected = s.gamepad,
field = "gamepad",
enabled = s.gamepadForwarding,
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)) }
SettingDropdown(
label = "Guide button",
options = SYSTEM_BUTTON_OPTIONS,
selected = s.systemButtons,
field = "system_buttons",
enabled = s.gamepadForwarding,
caption = "Where the guide (Xbox/PS) and share presses go while streaming. " +
"Automatic sends them to the host whenever this device delivers them.",
) { v -> update(s.copy(systemButtons = v)) }
SettingDropdown(
label = "Hold Select for guide",
options = GUIDE_GESTURE_OPTIONS,
selected = s.guideGesture,
field = "guide_gesture",
enabled = s.gamepadForwarding,
caption = "Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through, " +
"slightly delayed. For devices that intercept the real guide button.",
) { v -> update(s.copy(guideGesture = v)) }
DeviceScopeOnly {
ClickableRow(
title = "Connected controllers",
@@ -814,8 +883,19 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
subtitle = "Stream a Steam Controller 2 as-is — Steam on the host drives its " +
"trackpads, gyro and haptics directly",
checked = s.sc2Capture,
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
)
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
// the CONTROLLER's own motors/LEDs, not this device's.
ToggleRow(
title = "DualSense / DualShock passthrough (USB)",
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
"plus adaptive triggers, lightbar and gyro",
checked = s.dsCapture,
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
}
}
}
@@ -966,6 +1046,7 @@ private fun <T> SettingDropdown(
selected: T,
field: String? = null,
caption: String? = null,
enabled: Boolean = true,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
@@ -973,18 +1054,25 @@ private fun <T> SettingDropdown(
?: options.firstOrNull()?.second.orEmpty()
Column {
OverrideBadge(field)
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
ExposedDropdownMenuBox(
expanded = expanded && enabled,
onExpandedChange = { if (enabled) expanded = it },
) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
enabled = enabled,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
ExposedDropdownMenu(
expanded = expanded && enabled,
onDismissRequest = { expanded = false },
) {
options.forEach { (value, lbl) ->
DropdownMenuItem(
text = { Text(lbl) },
@@ -18,11 +18,13 @@ import kotlin.math.roundToInt
* The live stats overlay the unified HUD (`design/stats-unification.md`): headline is
* `capturedisplayed` tiled by `host+network` + `decode` + `display` when the platform delivered
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
* `capturedecoded` headline without the `display` term when it didn't. Reads the 26-double
* layout from [NativeBridge.nativeVideoStats]:
* `capturedecoded` headline without the `display` term when it didn't. Reads the 33-double
* layout from [NativeBridge.nativeVideoStats] (that KDoc is the authoritative index list):
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms]`.
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms,
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
* is length-guarded, so an older native lib simply omits the lines it can't feed.
*
* [verbosity] selects how many lines render (each tier a superset of the last see
* [StatsVerbosity]):
@@ -46,12 +48,20 @@ internal fun StatsOverlay(
* common case: no profile) the line is exactly what it always was.
*/
profileName: String? = null,
/**
* The panel's live refresh rate (0 = unknown). Shown as a warning on the first line whenever
* it sits below the stream rate the "an OEM governor ignored the mode pin" tell, which
* otherwise reads as inexplicable judder and an extra refresh of latency.
*/
panelHz: Float = 0f,
modifier: Modifier = Modifier,
) {
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
val w = s[6].toInt()
val h = s[7].toInt()
val hz = s[8].toInt()
val panelBelowStream = panelHz > 0f && hz > 0 && panelHz + 1f < hz.toFloat()
val panelTag = if (panelBelowStream) " ⚠ panel ${panelHz.roundToInt()} Hz" else ""
val latValid = s[4] != 0.0
val skew = s[5] != 0.0
val lost = s[9].toLong()
@@ -65,12 +75,12 @@ internal fun StatsOverlay(
val profileTag = profileName?.let { " · $it" }.orEmpty()
// Compact: everything the glance-value needs on one line, nothing else.
if (verbosity == StatsVerbosity.COMPACT) {
statLine(compactLine(s, latValid) + profileTag, Color.White)
statLine(compactLine(s, latValid) + profileTag + panelTag, Color.White)
return@Column
}
statLine(
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag",
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag$panelTag",
Color.White,
)
if (detailed && decoderLabel.isNotEmpty()) {
@@ -104,8 +114,47 @@ internal fun StatsOverlay(
} else {
"host+network ${"%.1f".format(s[14])}"
}
val displayTerm = if (dispValid) " + display ${"%.1f".format(s[23])}" else ""
statLine("= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm", Color.White)
// Timeline-presenter split (s[26]/s[27], when s[29] flags it active): the display
// term decomposes into pace (store + glass budget) + latch (SurfaceFlinger), and
// s[28] is the on-glass confirm count — presents ≪ fps means the presenter is
// dropping/serializing, an fps deficit is upstream.
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
val displayTerm = when {
dispValid && split ->
" + display ${"%.1f".format(s[23])} " +
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
dispValid -> " + display ${"%.1f".format(s[23])}"
else -> ""
}
val presents = if (s.size >= 30 && s[29] != 0.0) {
" · presents ${s[28].toInt()}"
} else {
""
}
// P3 decode split (s[30]/s[31]): `feed` = received→queued (hand-off + input-slot
// wait) + `codec` = queued→decoded (codec-pure) — rendered when a sample landed.
val decodeTerm = if (s.size >= 33 && (s[30] > 0 || s[31] > 0)) {
"decode ${"%.1f".format(s[15])} " +
"(feed ${"%.1f".format(s[30])} + codec ${"%.1f".format(s[31])})"
} else {
"decode ${"%.1f".format(s[15])}"
}
statLine(
"= $hostTerms + $decodeTerm$displayTerm$presents",
Color.White,
)
// Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS
// pipeline floor off its shown display/end-to-end; Android shows raw. This twin
// applies the same shave so iPhone↔Android HUD numbers compare directly.
if (dispValid && hz > 0) {
val shave = 2000.0 / hz
statLine(
"≈ Apple-HUD equiv: end-to-end " +
"${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " +
"${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (2 refresh)",
Color(0xFFA8D8B8),
)
}
}
}
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
@@ -151,12 +200,17 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
val fec = s[20].toLong()
val frames = s[21].toLong()
if (lost == 0L && skipped == 0L && fec == 0L) return null
// The overflow subset of `skipped` (s[32]): whole AUs dropped before feeding — the decoder
// fell behind. Absent (0 / old layout) the plain count keeps meaning benign pacing drops.
val overflow = if (s.size >= 33) s[32].toLong() else 0L
return buildList {
if (lost > 0) {
val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1)
add("lost $lost (${"%.1f".format(pct)}%)")
}
if (skipped > 0) add("skipped $skipped")
if (skipped > 0) {
add(if (overflow > 0) "skipped $skipped (⚠ $overflow overflow)" else "skipped $skipped")
}
if (fec > 0) add("FEC $fec")
}.joinToString(" · ")
}
@@ -9,11 +9,15 @@ import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AudioEffect
import android.media.audiofx.NoiseSuppressor
import android.net.wifi.WifiManager
import android.os.Build
import android.text.InputType
import android.util.Log
import android.view.KeyEvent
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
@@ -25,12 +29,20 @@ import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -41,6 +53,7 @@ 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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
@@ -54,6 +67,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import io.unom.punktfunk.kit.DsCapture
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.deviceBodyVibrator
@@ -62,6 +76,7 @@ import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
/**
@@ -77,7 +92,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
val micEnabled = initialSettings.micEnabled
val context = LocalContext.current
val activity = context as? MainActivity
// The View hosting this composition — the one that receives the stream's touch/pointer events
// (the gesture Box below is a Compose node inside it), so it is where unbuffered dispatch is
// requested.
val composeView = androidx.compose.ui.platform.LocalView.current
val window = activity?.window
// The negotiated stream refresh, known from the handshake (0 = unknown / older native lib) —
// drives the panel mode pin, the render-rate vote, and the presenter's latch grid.
val streamHz = remember(handle) { NativeBridge.nativeVideoSize(handle)?.getOrNull(2) ?: 0 }
val controller = remember(window) {
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
}
@@ -88,6 +110,38 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
// The Java AEC/NS pair backstopping the native VoiceCommunication capture preset, hung off the
// audio session id `nativeStartMic` returns. Attached in surfaceCreated (where the mic starts)
// and released on every path that stops the mic — the surface teardown AND the final dispose —
// so a surface recreate re-attaches to the fresh stream instead of leaking effect engines.
// All three touch points run on the main thread; a plain list is race-free.
val micEffects = remember { mutableListOf<AudioEffect>() }
// In-stream mic mute. Per SESSION and never persisted (no setting backs it): a new stream
// always starts unmuted. The authoritative flag lives on the native handle, which is why a mute
// survives the mic stop/start a surface recreate performs — this state is the UI's mirror of
// it, and survives the same recreate because the composition outlives the surface.
var micMuted by remember(handle) { mutableStateOf(false) }
// Whether a capture is actually RUNNING, not merely wanted — set from surfaceCreated on what
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute control
// rather than one that lies about a mic being heard.
var micRunning by remember(handle) { mutableStateOf(false) }
// Transient confirmation of a mic-chord toggle (null = nothing showing). Only the gamepad path
// needs it: the touch button confirms itself by changing under the finger, but a chord has no
// on-screen state of its own, and "did that register?" is exactly the doubt to answer.
var micHint by remember { mutableStateOf<String?>(null) }
LaunchedEffect(micHint) {
if (micHint != null) {
delay(1600)
micHint = null
}
}
// The one place mute is toggled — Compose state + the native flag, always together.
val setMicMuted = { muted: Boolean ->
micMuted = muted
NativeBridge.nativeSetMicMuted(handle, muted)
}
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
@@ -99,6 +153,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") }
var codecLabel by remember { mutableStateOf("") }
// The panel's LIVE refresh rate, re-read each poll — the HUD flags a session whose panel sits
// below the stream rate (an OEM governor that ignored both the mode pin and the surface hint).
var panelHz by remember { mutableStateOf(0f) }
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
val statsOn = statsVerbosity != StatsVerbosity.OFF
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
@@ -120,6 +177,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
while (true) {
delay(1000)
stats = NativeBridge.nativeVideoStats(handle)
panelHz = runCatching { context.display }.getOrNull()?.refreshRate ?: 0f
// The decoder is fixed for the session; fetch its label once it's resolved.
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
}
@@ -226,18 +284,47 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
val priorSoftInput = window?.attributes?.softInputMode
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
// Draw under the display cutout, explicitly. Android 15's SDK-35 edge-to-edge enforcement
// makes ALWAYS the immersive default, but pre-15 devices letterbox the notch as a dead
// black bar unless asked — and the stream's own letterbox is black anyway, so the cutout
// region can never show anything wrong. Captured + restored like the rest of the window
// state so the menus keep their platform-default behaviour.
val priorCutout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.attributes?.layoutInDisplayCutoutMode
} else {
null
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.let { w ->
w.attributes = w.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
}
}
}
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
// activity declares configChanges=orientation, so this re-lays out the surface in place without
// recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op.
// The prior request is captured and restored on the way out.
//
// COMPACT devices only (sw < 600 dp): on tablets/foldables/desktop windows the lock is a
// large-display anti-pattern (Play flags it; Android 16+ ignores it there outright), and the
// stream doesn't need it — the aspect-ratio letterbox renders correctly in any orientation,
// the lock is purely a phone-ergonomics choice.
val compactDevice = context.resources.configuration.smallestScreenWidthDp < 600
val priorOrientation = activity?.requestedOrientation
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
if (compactDevice) {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
activity?.streamHandle = handle // route hardware keys to this session
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
// controller (Automatic). Built here, released on dispose.
val router = GamepadRouter(context, handle, initialSettings.gamepad)
val router = GamepadRouter(
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
)
activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
@@ -248,6 +335,16 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
router.onExitArmed = { armed -> exitArming = armed }
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
// to mute, and claiming otherwise would be the lie the control exists to avoid).
router.onMicChord = {
if (micRunning) {
val next = !micMuted
setMicMuted(next)
micHint = if (next) "Microphone muted" else "Microphone live"
}
}
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
// The local cursor is hidden over the stream — the host's own cursor, composited into
@@ -304,7 +401,30 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
} else {
null
}
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Pin the panel to the stream's refresh (exact / multiple) for the session. The decoder's
// own ANativeWindow_setFrameRate hint still aligns vsync, but it is advisory — some OEM
// refresh governors ignore it outright and would leave a 120 Hz session on a 60/90 Hz
// panel. TV boxes skip the pin: the native side actively drives the HDMI mode there.
if (isTv) {
activity?.setConsoleHighRefreshRate(false) // the decoder's HDMI mode switch governs
} else {
activity?.setStreamDisplayMode(streamHz)
}
// Touch/pointer events are vsync-batched by default — up to a frame of input latency the
// stream shouldn't pay. Unbuffered dispatch delivers them the moment the kernel does.
// Undone by passing 0 on the way out (API 30+).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
composeView.requestUnbufferedDispatch(android.view.InputDevice.SOURCE_CLASS_POINTER)
}
// Vote the app's RENDER rate up to the stream's (API 35+). The mode pin above governs the
// panel, but the platform separately down-rates a quiet app's choreographer stream
// (frame-rate categories: a non-animating UI reads as "normal" = 60) — observed on-glass
// as 16.6 ms vsync callbacks on a 120 Hz panel, which would pace the presenter at half
// rate. The native side also subdivides onto the panel grid, so this vote is the belt to
// that braces. Reset to no-preference on the way out.
if (Build.VERSION.SDK_INT >= 35 && streamHz > 0) {
composeView.requestedFrameRate = streamHz.toFloat()
}
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
// index via the router; poll threads stopped + joined before the router is released and the
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
@@ -325,7 +445,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
// claim the interfaces; it resumes in onDispose once the stream releases them.
activity?.stopSc2MenuNav()
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
val sc2 = if (initialSettings.sc2Capture && initialSettings.gamepadForwarding) {
Sc2Capture(context, router)
} else {
null
}
var sc2UsbReceiver: BroadcastReceiver? = null
if (sc2 != null) {
feedback.onHidRaw = sc2::onHidRaw
@@ -367,14 +491,65 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
}
}
}
// Sony pad capture (DualSense / Edge / DualShock 4, opt-out): claim a USB-connected
// pad's HID interface and drive it directly — rumble without a kernel force-feedback
// driver, plus adaptive triggers, lightbar, player LEDs and gyro/touchpad, none of which
// the InputDevice path can render (no platform API for any of them). Uncaptured (toggle
// off / permission denied / Bluetooth) the pad stays on the ordinary InputDevice path —
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
// hands over deterministically.
val ds = if (initialSettings.dsCapture && initialSettings.gamepadForwarding) {
DsCapture(context, router)
} else {
null
}
var dsUsbReceiver: BroadcastReceiver? = null
if (ds != null) {
feedback.sink = ds
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val usbDev = ds.findUsbDevice()
when {
usbDev != null && usbManager.hasPermission(usbDev) -> ds.startUsb(usbDev)
usbDev != null -> {
// One-time system dialog; capture engages on grant (Android remembers the
// grant for as long as the device stays attached).
val action = "io.unom.punktfunk.DS_USB_PERMISSION"
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != action) return
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (ok) ds.startUsb(usbDev) else Log.i("punktfunk", "Sony pad USB permission denied")
}
}
dsUsbReceiver = receiver
ContextCompat.registerReceiver(
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
)
usbManager.requestPermission(
usbDev,
PendingIntent.getBroadcast(
context, 2, // requestCode 2 — 0/1 are the SC2 stream/menu grants
Intent(action).setPackage(context.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
}
}
onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
feedback.onHidRaw = null
feedback.sink = null
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
router.onMicChord = null // same: no mute toggle on buttons released during teardown
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
@@ -388,8 +563,19 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// Back in the menus: the SC2 (if present) resumes driving the console UI.
activity?.startSc2MenuNav()
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
composeView.requestUnbufferedDispatch(0) // back to ordinary batched dispatch
}
if (Build.VERSION.SDK_INT >= 35) {
composeView.requestedFrameRate = View.REQUESTED_FRAME_RATE_CATEGORY_DEFAULT
}
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
window?.setSoftInputMode(priorSoftInput)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && priorCutout != null) {
window?.let { w ->
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
}
}
controller?.show(WindowInsetsCompat.Type.systemBars())
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -400,6 +586,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
activity?.requestedOrientation =
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
// Leaving the stream: stop the mic + audio + decode threads and tear down the session.
releaseMicEffects(micEffects)
NativeBridge.nativeStopMic(handle)
NativeBridge.nativeStopAudio(handle)
NativeBridge.nativeStopVideo(handle)
@@ -480,12 +667,48 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
lowLatencyMode,
choice?.lowLatencyFeature ?: false,
isTv,
initialSettings.presentPriorityWire(),
initialSettings.smoothBuffer,
// The panel's own refresh — from the mode TABLE (streamPanelFps),
// because display.refreshRate reports a per-uid override, not the
// panel. Fallback: the (possibly lying) live rate.
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
.roundToInt(),
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
if (micWanted) NativeBridge.nativeStartMic(handle)
if (micWanted) {
val sessionId =
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
if (initialSettings.echoCancel) {
attachMicEffects(sessionId, micEffects)
}
// Did a capture actually open? That — not the setting — is what
// puts the mute control on screen. A restart after a surface
// recreate comes back already muted if the user muted: the flag
// lives on the session handle, so nothing has to be re-applied.
micRunning = NativeBridge.nativeMicActive(handle)
}
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
// Re-assert the frame-rate vote: a buffer-geometry change can reset
// the surface's frame-rate setting on some OEM builds, silently
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
// policy (FIXED_SOURCE; ALWAYS only on the TV low-latency path —
// phones stay seamless so a re-hint can never force a mode flicker).
if (streamHz > 0) runCatching {
holder.surface.setFrameRate(
streamHz.toFloat(),
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
if (isTv && lowLatencyMode) {
Surface.CHANGE_FRAME_RATE_ALWAYS
} else {
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
},
)
}
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
// Surface gone (backgrounding, or on the way out). Stop the threads that
@@ -493,7 +716,12 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// DisposableEffect has closed it, the handle is freed; dereferencing it
// here is the use-after-free that crashed on back-navigation.
if (!closed.get()) {
releaseMicEffects(micEffects)
NativeBridge.nativeStopMic(handle)
// No capture, no control — but the MUTE state is deliberately left
// standing (native keeps it on the handle), so the restart in
// surfaceCreated brings the user's choice back with it.
micRunning = false
NativeBridge.nativeStopAudio(handle)
NativeBridge.nativeStopVideo(handle)
}
@@ -508,6 +736,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
stats?.let {
StatsOverlay(
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
panelHz,
Modifier.align(Alignment.TopStart).padding(12.dp),
)
}
@@ -567,9 +796,106 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
}
},
)
// Mic mute, LAST in the stack — the one in-stream control, so unlike the purely visual
// overlays above it has to sit on top of the gesture layer to receive its own taps (it
// costs the stream that small corner of touch area, which is why it exists only while a
// capture actually runs). On TV it is the indicator alone: the Select + Y chord is the
// control there, and a focusable button would fight the game for the D-pad.
if (micRunning && (micMuted || !isTv)) {
MicMuteControl(
muted = micMuted,
onToggle = if (isTv) null else ({ setMicMuted(!micMuted) }),
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
)
}
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
}
}
/**
* Attach the Java echo-canceller + noise-suppressor pair to the mic stream's audio session the
* backstop for HALs whose VoiceCommunication capture path doesn't cancel on its own (the native
* side already opened the stream under that preset). [sessionId] `<= 0` means native allocated no
* session (echo cancellation off, or the preset fell back to the plain open), so there is nothing
* to hang an effect on. Created effects land in [into] for [releaseMicEffects]; `create()`
* returning null (unsupported / claimed) is quietly nothing the HAL preset still does its part.
* Needs no extra permission: the effect APIs attach to our own recording session.
*/
private fun attachMicEffects(sessionId: Int, into: MutableList<AudioEffect>) {
if (sessionId <= 0) return
if (AcousticEchoCanceler.isAvailable()) {
AcousticEchoCanceler.create(sessionId)?.let { it.setEnabled(true); into.add(it) }
}
if (NoiseSuppressor.isAvailable()) {
NoiseSuppressor.create(sessionId)?.let { it.setEnabled(true); into.add(it) }
}
}
/** Release every attached mic effect engine. Idempotent the list is cleared, and both stop
* paths (surface teardown, final dispose) may call it in either order. */
private fun releaseMicEffects(effects: MutableList<AudioEffect>) {
effects.forEach { runCatching { it.release() } }
effects.clear()
}
/**
* The in-stream mic control and its muted indicator, in one element: a dim mic glyph while the
* uplink is live, a red **Muted** badge while it isn't so the state that matters is the loud one,
* readable at couch distance and impossible to mistake for the stream's own picture.
*
* [onToggle] `null` makes it a pure indicator (the TV/gamepad surface, where the Select + Y chord
* is the control); non-null makes the badge itself the touch target. Rendering it at all is the
* caller's decision it means a capture is genuinely running.
*/
@Composable
private fun MicMuteControl(muted: Boolean, onToggle: (() -> Unit)?, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(10.dp)
Row(
modifier = modifier
.clip(shape)
.background(if (muted) Color(0xE0B3261E) else Color.Black.copy(alpha = 0.45f))
.then(if (onToggle != null) Modifier.clickable(onClick = onToggle) else Modifier)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
// Spoken state first, then the action — a talkback user needs to know they are muted
// before they need to know how to stop being muted.
contentDescription = if (muted) {
"Microphone muted. Activate to unmute."
} else {
"Microphone live. Activate to mute."
},
tint = Color.White,
modifier = Modifier.size(20.dp),
)
if (muted) {
Spacer(Modifier.width(6.dp))
Text("Muted", color = Color.White, fontSize = 14.sp)
}
}
}
/**
* Transient confirmation that the mic chord (Select + Y) registered. The badge above already says
* *muted*, but nothing on screen says *un*muted and "did that press do anything?" is the whole
* doubt a chord with no button under the finger creates. Same pill vocabulary as the other
* in-stream cues; the caller clears it after a beat.
*/
@Composable
private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
Text(
text,
modifier = modifier
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.White,
fontSize = 15.sp,
)
}
/**
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
* chord no longer quits on a quick press the router debounces it on a ~1 s hold so this confirms
@@ -14,7 +14,11 @@ private const val PEN_TOUCHING = 2f
private const val PEN_BARREL1 = 4f
private const val PEN_BARREL2 = 8f
private const val STRIDE = 10
private const val MAX_SAMPLES = 8
// Ceiling on samples per emit, NOT the wire batch size: the JNI layer splits an over-8 run into
// consecutive wire batches (never truncates — a long historical run means the UI thread hitched,
// which is exactly when dropping its head would notch the stroke). 64 samples ≈ >250 ms of
// 240 Hz history; anything past that clamp is a pathological stall, not stroke geometry.
private const val MAX_SAMPLES = 64
/**
* Android stylus the state-full pen plane (design/pen-tablet-input.md §7): pressure, tilt
@@ -117,7 +121,8 @@ internal class StylusStream(private val handle: Long) {
NativeBridge.nativeSendPen(handle, last, 1)
}
/** Historical (coalesced) samples oldest-first, then the current one — a single batch. */
/** Historical (coalesced) samples oldest-first, then the current one one emit; the JNI
* layer splits runs longer than the wire's 8-sample batch cap into consecutive sends. */
private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) {
val history = minOf(me.historySize, MAX_SAMPLES - 1)
var count = 0
@@ -105,8 +105,20 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, sw, sh)
}
c.positionChanged() ->
ids[c.id]?.let {
NativeBridge.nativeSendTouch(handle, it, 1, x, y, sw, sh)
ids[c.id]?.let { id ->
// Batched MotionEvents coalesce intermediate points into the
// historical list — forward them in order so a fast swipe keeps
// its real curvature on the host (usually empty during a stream:
// unbuffered dispatch is requested, so this costs nothing).
for (hs in c.historical) {
NativeBridge.nativeSendTouch(
handle, id, 1,
hs.position.x.roundToInt().coerceIn(0, sw - 1),
hs.position.y.roundToInt().coerceIn(0, sh - 1),
sw, sh,
)
}
NativeBridge.nativeSendTouch(handle, id, 1, x, y, sw, sh)
}
}
c.consume()
@@ -289,7 +301,10 @@ internal suspend fun PointerInputScope.streamTouchInput(
accY -= outY
}
} else {
moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
// Direct: cursor follows the finger — historical points first (batched
// MotionEvent samples), so the host cursor traces the finger's real path.
for (hs in p.historical) moveAbs(hs.position.x, hs.position.y)
moveAbs(p.position.x, p.position.y)
}
}
ev.changes.forEach { it.consume() }
@@ -6,6 +6,7 @@ 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
import kotlin.math.max
/**
* The host card's OS marks, resolved from the host's OS-identity chain (mDNS `os` TXT,
@@ -14,18 +15,19 @@ import io.unom.punktfunk.kit.discovery.osIconTokens
* 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.
* Path data is vendored from the assets/os-icons masters (per-mark provenance and licensing
* in that directory's README; `bash scripts/gen-os-icons.sh <token>` prints a master's
* viewport + path ready to paste); 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",
viewportWidth = 24f,
viewportHeight = 24f,
d = "M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z",
),
"apple" to OsGlyph(
viewportWidth = 384f,
@@ -72,8 +74,28 @@ private val GLYPHS: Map<String, OsGlyph> = mapOf(
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",
),
// The gaming distros get their own mark rather than their family's: "a Bazzite box" and
// "a Fedora box" are different machines to the person reading the card.
"bazzite" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z",
),
"cachyos" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81",
),
"nobara" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z",
),
)
/** Longest edge of a built mark, in dp — the box callers size the [Icon] to. */
private const val GLYPH_DP = 24f
private val built = mutableMapOf<String, ImageVector>()
/** The mark for a chain, or null (no icon). Vectors build lazily and cache per token. */
@@ -82,11 +104,18 @@ fun resolveOsIcon(chain: String): ImageVector? =
GLYPHS[token]?.let { glyph -> built.getOrPut(token) { glyph.build(token) } }
}
private fun OsGlyph.build(token: String): ImageVector =
ImageVector.Builder(
private fun OsGlyph.build(token: String): ImageVector {
// The intrinsic size has to carry the VIEWPORT'S ASPECT RATIO, not a fixed square:
// a VectorPainter maps the viewport onto the default size with independent x and y
// scales, so declaring a 448x512 mark as 24x24 dp stretches it horizontally — which is
// exactly how Tux and the Apple mark used to come out on a phone. Scaling the longest
// edge to GLYPH_DP instead keeps the ratio, and Icon() paints with ContentScale.Fit, so
// the mark letterboxes inside whatever box the caller sized us to.
val longest = max(viewportWidth, viewportHeight)
return ImageVector.Builder(
name = "OsIcon.$token",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
defaultWidth = (GLYPH_DP * viewportWidth / longest).dp,
defaultHeight = (GLYPH_DP * viewportHeight / longest).dp,
viewportWidth = viewportWidth,
viewportHeight = viewportHeight,
).apply {
@@ -96,3 +125,4 @@ private fun OsGlyph.build(token: String): ImageVector =
fill = SolidColor(Color.Black),
)
}.build()
}
@@ -0,0 +1,72 @@
package io.unom.punktfunk
import io.unom.punktfunk.components.resolveOsIcon
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Pure JVM test of the host card's OS marks (`components/OsIcons.kt`). Run:
* `./gradlew :app:testDebugUnitTest`.
*
* The aspect assertions are the point: a [androidx.compose.ui.graphics.vector.VectorPainter] maps
* the viewport onto the vector's default size with independent x and y scales, so a mark whose
* default size does not carry its viewport's ratio renders STRETCHED silently, with no crash and
* no warning. That is exactly how Tux and the Apple mark used to look on a phone.
*/
class OsIconsTest {
/** `ImageVector.name` is "OsIcon.<token>" — the only handle on which mark got resolved. */
private fun markOf(chain: String) = resolveOsIcon(chain)?.name?.removePrefix("OsIcon.")
@Test
fun defaultSizeCarriesTheViewportAspect() {
for (chain in listOf("windows", "linux", "opensuse", "steam", "apple", "bazzite")) {
val v = resolveOsIcon(chain)
assertNotNull("no mark for $chain", v)
v!!
assertEquals(
"$chain default size must keep the viewport ratio",
v.viewportWidth / v.viewportHeight,
v.defaultWidth.value / v.defaultHeight.value,
0.001f,
)
assertEquals(
"$chain longest edge must be the 24dp box",
24f,
maxOf(v.defaultWidth.value, v.defaultHeight.value),
0.001f,
)
}
}
@Test
fun tallMarkIsNarrowerThanItsBox() {
// Tux is 448x512, so a correct build is 21x24dp — 24x24 would be the stretched bug.
val tux = resolveOsIcon("linux")!!
assertEquals(21f, tux.defaultWidth.value, 0.001f)
assertEquals(24f, tux.defaultHeight.value, 0.001f)
}
@Test
fun gamingDistrosResolveToTheirOwnMark() {
// The whole reason these three ship art: without it they'd draw their family's mark.
assertEquals("bazzite", markOf("linux/fedora/bazzite"))
assertEquals("cachyos", markOf("linux/arch/cachyos"))
assertEquals("nobara", markOf("linux/rhel/nobara"))
}
@Test
fun unknownDistroStillDegradesThroughItsFamily() {
assertEquals("fedora", markOf("linux/fedora/somethingnew"))
assertEquals("linux", markOf("linux/frontier/chimera"))
assertEquals("steam", markOf("linux/arch/steamos")) // brand alias
assertEquals("apple", markOf("macos"))
}
@Test
fun noChainMeansNoMark() {
assertNull(resolveOsIcon(""))
assertNull(resolveOsIcon("!!!"))
}
}
@@ -366,6 +366,8 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
2.0, 1.0, 5.0, 238.0,
1.0, 0.5, 1.8, 2.6,
// Timeline-presenter split: pace + latch tile the display term; presents ≈ fps.
0.2, 0.3, 236.0, 1.0,
),
verbosity = verbosity,
decoderLabel = "c2.qti.hevc.decoder · low-latency",
+95 -16
View File
@@ -6,13 +6,23 @@ Why hand-rolled: stdlib + `openssl` only (no pip on the runner), and it prints G
error at the stage it fails instead of a catch-all. Reuses the SERVICE_ACCOUNT_JSON secret and
tolerates it being raw JSON *or* base64-encoded JSON.
Usage:
Usage (upload a new build):
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
python3 play-upload.py --package io.unom.punktfunk \
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
--no-commit: do insert -> upload -> track-update -> validate, then delete the edit (publishes
nothing). Use it to dry-run the credentials/AAB without touching the live track.
Usage (promote a build that is already on Play, no rebuild):
python3 play-upload.py --package io.unom.punktfunk \
--promote 10816 --promote-from alpha --track production
Promotion assigns an EXISTING versionCode to another track, so what ships to production is the
byte-identical artifact the testers ran rebuilding would burn a fresh versionCode and ship
something nobody has tested. --promote-from additionally asserts the code really is on that track
(catches a typo'd versionCode before it reaches production) and empties it in the SAME edit, so
the move is atomic: testers are never left pinned to a code that production also serves.
--no-commit: do insert -> upload/assign -> track-update -> validate, then delete the edit
(publishes nothing). Use it to dry-run the credentials/AAB/notes without touching the live track.
"""
import argparse, base64, json, os, subprocess, sys, tempfile, time
import urllib.request, urllib.parse, urllib.error
@@ -104,18 +114,79 @@ def access_token(sa) -> str:
return tok["access_token"]
# Play's "What's new" is capped at 500 characters per language. The cap lives in the Console
# (the REST reference does not state it) and the API rejects longer text at commit — i.e. AFTER
# the AAB has uploaded — so check it up front and print the actual count.
NOTES_MAX = 500
def load_release_notes(path, language):
with open(path, encoding="utf-8") as f:
text = f.read().strip()
if not text:
sys.exit(f"ERROR: release-notes file is empty: {path}")
if len(text) > NOTES_MAX:
sys.exit(f"ERROR: release notes are {len(text)} chars, Play allows {NOTES_MAX}: {path}")
print(f"release notes: {len(text)}/{NOTES_MAX} chars ({language})")
return [{"language": language, "text": text}]
def put_track(app, edit, tok, track, version_codes, status, user_fraction=None, notes=None):
"""PUT one track. An empty version_codes list clears the track (what promotion does to the
track it promoted OUT of)."""
release = {"status": status, "versionCodes": [str(v) for v in version_codes]}
if user_fraction is not None:
release["userFraction"] = user_fraction
if notes:
release["releaseNotes"] = notes
# Clearing a track means "no active releases", not "an empty release".
body = {"track": track, "releases": [release] if version_codes else []}
call("PUT", f"{app}/edits/{edit}/tracks/{track}", token=tok,
data=json.dumps(body).encode(), content_type="application/json")
def assert_on_track(app, edit, tok, track, vc):
"""Fail before anything is written if --promote names a versionCode that is not actually on
the track we claim to be promoting out of."""
got = call("GET", f"{app}/edits/{edit}/tracks/{track}", token=tok)
live = [c for r in got.get("releases", []) for c in r.get("versionCodes", [])]
if str(vc) not in live:
sys.exit(f"ERROR: versionCode {vc} is not on track '{track}' (it has: {live or 'nothing'})")
print(f"verified versionCode={vc} is live on '{track}'")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--package", required=True)
ap.add_argument("--aab", required=True)
ap.add_argument("--aab", help="upload this bundle (mutually exclusive with --promote)")
ap.add_argument("--promote", type=int, metavar="VERSIONCODE",
help="assign an already-uploaded versionCode instead of uploading")
ap.add_argument("--promote-from", metavar="TRACK",
help="with --promote: assert the code is on TRACK, then clear TRACK")
ap.add_argument("--track", default="internal")
ap.add_argument("--status", default="completed")
ap.add_argument("--user-fraction", type=float,
help="staged rollout fraction, 0<f<1; required by --status inProgress")
ap.add_argument("--release-notes-file", help="Play 'What's new' text (<=500 chars)")
ap.add_argument("--release-notes-language", default="en-US")
ap.add_argument("--no-commit", action="store_true")
a = ap.parse_args()
if not os.path.isfile(a.aab):
if bool(a.aab) == bool(a.promote):
sys.exit("ERROR: pass exactly one of --aab (upload) or --promote (assign an existing code)")
if a.promote_from and not a.promote:
sys.exit("ERROR: --promote-from only applies to --promote")
# inProgress without a fraction is an API error; halted/completed with one is also rejected.
if a.status == "inProgress" and a.user_fraction is None:
sys.exit("ERROR: --status inProgress requires --user-fraction")
if a.user_fraction is not None and not (0 < a.user_fraction < 1):
sys.exit(f"ERROR: --user-fraction must be strictly between 0 and 1 (got {a.user_fraction})")
if a.aab and not os.path.isfile(a.aab):
sys.exit(f"ERROR: AAB not found: {a.aab}")
notes = load_release_notes(a.release_notes_file, a.release_notes_language) \
if a.release_notes_file else None
sa = load_sa()
tok = access_token(sa)
print(f"authenticated as {sa['client_email']} (project {sa.get('project_id')})")
@@ -123,17 +194,25 @@ def main():
try:
edit = call("POST", f"{app}/edits", token=tok)["id"]
with open(a.aab, "rb") as f:
blob = f.read()
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
print(f"uploaded versionCode={vc}")
call("PUT", f"{app}/edits/{edit}/tracks/{a.track}", token=tok,
data=json.dumps({"track": a.track,
"releases": [{"status": a.status, "versionCodes": [str(vc)]}]}).encode(),
content_type="application/json")
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}")
if a.promote:
vc = a.promote
if a.promote_from:
assert_on_track(app, edit, tok, a.promote_from, vc)
else:
with open(a.aab, "rb") as f:
blob = f.read()
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
print(f"uploaded versionCode={vc}")
put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes)
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}"
+ (f" userFraction={a.user_fraction}" if a.user_fraction is not None else ""))
# Same edit as the assignment above, so the code is never active on both tracks at once.
if a.promote_from:
put_track(app, edit, tok, a.promote_from, [], a.status)
print(f"cleared track '{a.promote_from}'")
if a.no_commit:
call("POST", f"{app}/edits/{edit}:validate", token=tok)
@@ -0,0 +1,354 @@
package io.unom.punktfunk.kit
import android.content.Context
import android.hardware.usb.UsbDevice
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.InputDevice
/**
* One captured Sony pad (DualSense / DualSense Edge / DualShock 4) over USB stream mode only.
* The capture exists to fix what the InputDevice path structurally can't: rumble depends on the
* phone's kernel exposing force feedback (many don't), and adaptive triggers / lightbar / player
* LEDs have NO platform API at all. Claiming the pad's HID interface makes all of it work on any
* phone, plus gyro + touchpad the standard path never captured.
*
* Unlike [Sc2Capture] there is no raw passthrough the host's DualSense/DS4 backends consume
* only typed events and no UI mode: an UNcaptured Sony pad is a perfectly good InputDevice, so
* outside a stream the ordinary path drives the console UI and this class isn't constructed.
* That also makes the InputDevice path the automatic fallback whenever the capture doesn't
* engage (toggle off, permission denied, Bluetooth).
*
* Input: parse ([DsDevice.parseState]) typed mirror on an [GamepadRouter.ExternalPad] (buttons
* diffed, axes on-change the exit chord participates like any pad) + the rich plane (touch
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
* and freed on unplug/[stop], so indices never leak.
*
* Feedback: implements [GamepadFeedback.PadFeedbackSink] rumble / trigger / lightbar / player
* LED events addressed to this pad's wire index become USB output reports on the physical pad
* ([DsDevice] builders). Rendering runs on the feedback poll threads; [HidUsbLink.writeRaw] is
* thread-safe (bounded newest-wins queue, submitted by the reader thread). A USB pad holds its
* rumble level until written zero, so a backstop timer re-arms per command and writes the stop
* itself if the poll thread stalls the engine's explicit zeros remain the real stop mechanism.
*/
class DsCapture(
context: Context,
private val router: GamepadRouter,
) : GamepadFeedback.PadFeedbackSink {
private val usb = HidUsbLink(
context,
HidUsbLink.Config(
tag = TAG,
threadName = "pf-ds-usb",
deviceMatch = { it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS },
// No ifaceFilter: the pad's audio interfaces are not HID class, so the link's built-in
// class check already leaves them (and the pad's headset routing) to Android; the
// single HID interface is the only claim.
),
::onReport,
::onLinkClosed,
)
@Volatile private var model: DsDevice.Model? = null
@Volatile private var pad: GamepadRouter.ExternalPad? = null
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
private val state = DsDevice.State()
private var wireButtons = 0
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
private val lastTouchActive = BooleanArray(2)
private val lastTouchX = IntArray(2) { -1 }
private val lastTouchY = IntArray(2) { -1 }
// DS4 composed feedback (its writes are full-state — see DsDevice.ds4Report). Feedback threads.
// The lightbar starts at hid-sony's player-1 blue so the first composed write (usually a
// rumble, before any host Led lands) doesn't black the bar out.
@Volatile private var ds4Low = 0
@Volatile private var ds4High = 0
@Volatile private var ds4Rgb = 0x000040
// Rumble backstop: a USB pad holds its level until told zero, so a stalled poll thread would
// leave the motors running — re-armed per command, cancelled by an explicit (0,0).
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile private var backstop: Runnable? = null
/** Fired (link thread) when the capture engages or drops — the Controllers screen's status. */
@Volatile
var onActiveChanged: ((active: Boolean) -> Unit)? = null
val isActive: Boolean get() = model != null
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
fun findUsbDevice(): UsbDevice? = usb.findDevice()
/**
* Start capturing [dev] (permission already granted). Claims the HID interface the kernel
* driver detaches and the pad's InputDevice node vanishes; its router slot (if the router
* already opened one from the pre-claim InputDevice) is released HERE, at claim time, rather
* than waiting for the system's removal callback so the freed wire index is deterministic
* for this capture's ExternalPad instead of racing the first report against the callback. A
* released sibling that still exists as an InputDevice (a same-model Bluetooth pad) lazily
* reopens a slot on its next input event, so over-matching self-heals.
*/
fun startUsb(dev: UsbDevice): Boolean {
if (model != null) return false
val m = DsDevice.modelFor(dev.productId) ?: return false
if (!usb.start(dev)) return false
model = m
for (id in InputDevice.getDeviceIds()) {
val d = InputDevice.getDevice(id) ?: continue
if (d.vendorId == dev.vendorId && d.productId == dev.productId) router.releaseDevice(id)
}
// Release the firmware's lightbar animation once so host lightbar writes take effect
// (the same init hid-playstation/SDL send on open).
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
onActiveChanged?.invoke(true)
return true
}
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
val m = model
if (m != null) {
// The interfaces are about to release with the kernel driver still detached — a
// mid-rumble teardown would leave the motors running with nobody to stop them.
// EP0-direct (the reader thread is stopping; the queue would never drain).
// Nothing can retry after this point, so a failure is worth saying out loud: it is
// the difference between a quiet pad and one that buzzes until it is unplugged.
if (!usb.writeControl(stopReport(m))) Log.w(TAG, "teardown rumble stop was not written")
// Motors silenced above; this hands back the lightbar, player LEDs and adaptive
// triggers the game was holding, which outlive the link just as stubbornly.
resetRichFeedback(m)
}
disarmBackstop()
usb.stop()
val wasActive = model != null
model = null
releaseSlot()
if (wasActive) onActiveChanged?.invoke(false)
}
// ---- link callbacks (link thread) ----
private fun onReport(report: ByteArray, len: Int) {
val m = model ?: return
if (!DsDevice.parseState(m, report, len, state)) return
val p = pad ?: router.openExternal(m.pref)?.also {
pad = it
Log.i(TAG, "captured $m → wire pad ${it.index}")
} ?: return // all 16 wire indices taken — drop until one frees
mirrorTyped(p)
mirrorRich(p, m)
}
private fun onLinkClosed() {
Log.i(TAG, "Sony USB link closed (unplug)")
disarmBackstop()
val wasActive = model != null
model = null
releaseSlot()
// Release the transport too: the link only *signals* the drop, so without this an unplug
// left its connection open, its interfaces claimed and its detach receiver registered.
usb.stop()
if (wasActive) onActiveChanged?.invoke(false)
}
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
var changed = state.buttons xor wireButtons
while (changed != 0) {
val bit = changed and -changed // lowest changed bit
p.button(bit, state.buttons and bit != 0)
changed = changed and bit.inv()
}
wireButtons = state.buttons
axis(p, Gamepad.AXIS_LS_X, state.lsX)
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
axis(p, Gamepad.AXIS_RS_X, state.rsX)
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
axis(p, Gamepad.AXIS_LT, state.lt)
axis(p, Gamepad.AXIS_RT, state.rt)
}
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
if (lastAxis[id] == v) return
lastAxis[id] = v
p.axis(id, v)
}
/**
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
* on change per slot; motion forwarded every report (raw device units the wire is a unit
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
*/
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
for (f in 0 until 2) {
if (state.touchActive[f]) {
val x = (state.touchX[f].coerceIn(0, m.touchW - 1) * 65535) / (m.touchW - 1)
val y = (state.touchY[f].coerceIn(0, m.touchH - 1) * 65535) / (m.touchH - 1)
if (!lastTouchActive[f] || x != lastTouchX[f] || y != lastTouchY[f]) {
p.touch(f, true, x, y)
lastTouchActive[f] = true
lastTouchX[f] = x
lastTouchY[f] = y
}
} else if (lastTouchActive[f]) {
p.touch(f, false, lastTouchX[f], lastTouchY[f])
lastTouchActive[f] = false
}
}
p.motion(state.gyro, state.accel)
}
private fun releaseSlot() {
// Lift any still-touching finger so the host's virtual touchpad doesn't hold a contact.
val p = pad
if (p != null) {
for (f in 0 until 2) if (lastTouchActive[f]) p.touch(f, false, lastTouchX[f], lastTouchY[f])
}
p?.close()
pad = null
wireButtons = 0
lastAxis.fill(Int.MIN_VALUE)
lastTouchActive.fill(false)
lastTouchX.fill(-1)
lastTouchY.fill(-1)
}
// ---- PadFeedbackSink (feedback poll threads) ----
override fun ownsPad(pad: Int): Boolean = pad == this.pad?.index
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
val m = model ?: return
val stop = low == 0 && high == 0
if (!stop) armBackstop(backstopMs)
val sent = if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = low
ds4High = high
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high), OutReportQueue.KEY_RUMBLE)
}
if (stop) {
// Disarm only once the stop is actually on its way. Dropping the net *before* the
// write — as this used to — meant a discarded stop left the motors running with
// nothing scheduled to try again; a USB pad holds its last level until told zero.
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
}
}
override fun led(pad: Int, r: Int, g: Int, b: Int) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) {
ds4Rgb = (r shl 16) or (g shl 8) or b
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5LightbarReport(m, r, g, b))
}
}
override fun playerLeds(pad: Int, bits: Int) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no player LEDs on a DS4 (host never sends any)
usb.writeRaw(0, DsDevice.ds5PlayerLedsReport(m, bits))
}
override fun trigger(pad: Int, which: Int, effect: ByteArray) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no adaptive triggers on a DS4
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
}
// Coalescable: the DS4's write is full-state (motors AND lightbar, rebuilt from the current
// fields on every call), so a newer one supersedes an older one wholesale — nothing is lost by
// collapsing a backlog of them down to the last.
private fun writeDs4() = usb.writeRaw(
0,
DsDevice.ds4Report(
ds4Low,
ds4High,
(ds4Rgb shr 16) and 0xFF,
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
),
OutReportQueue.KEY_RUMBLE,
)
/**
* Hand the pad back neutral: adaptive triggers released, lightbar dark, player LEDs clear.
*
* Rumble stops the moment nothing renews it, but these are LATCHED in the controller's
* firmware they outlive the stream, the app, and being unplugged. Ending a session while a
* game held a weapon's trigger resistance left the physical trigger stiff afterwards, with
* nothing to release it but another game that happens to set one.
*
* EP0-direct like the rumble stop above: the reader thread is stopping, so the interrupt-OUT
* queue would never drain. Writes are best-effort the pad may already be gone.
*/
private fun resetRichFeedback(m: DsDevice.Model) {
if (m == DsDevice.Model.DUALSHOCK4) {
// No adaptive triggers or player LEDs on a DS4, and its write is full-state, so
// blacking the lightbar is a single composed report.
ds4Rgb = 0
usb.writeControl(DsDevice.ds4Report(0, 0, 0, 0, 0))
return
}
// An all-zero effect block is mode 0x00 — no effect — which is what releases the trigger.
for (which in 0..1) {
usb.writeControl(
DsDevice.ds5TriggerReport(m, which, ByteArray(DsDevice.TRIGGER_EFFECT_LEN)),
)
}
usb.writeControl(DsDevice.ds5LightbarReport(m, 0, 0, 0))
usb.writeControl(DsDevice.ds5PlayerLedsReport(m, 0))
}
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
* composed motor state and carries the current lightbar rather than blacking it out. */
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = 0
ds4High = 0
DsDevice.ds4Report(
0,
0,
(ds4Rgb shr 16) and 0xFF,
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
)
} else {
DsDevice.ds5RumbleReport(m, 0, 0)
}
/** (Re)arm the stalled-poll-thread net: write a rumble stop at the command's backstop. */
private fun armBackstop(ms: Long) {
backstop?.let { mainHandler.removeCallbacks(it) }
val r = Runnable {
backstop = null
val m = model ?: return@Runnable
// The net itself can be refused (a full queue, a connection going away). Re-arm rather
// than give up: this is the last thing between a stalled poll thread and a pad that
// buzzes until it is unplugged. It stops re-arming as soon as the link closes, which
// clears `model` and disarms.
if (!usb.writeRaw(0, stopReport(m), OutReportQueue.KEY_RUMBLE)) armBackstop(STOP_RETRY_MS)
}
backstop = r
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
}
private fun disarmBackstop() {
backstop?.let { mainHandler.removeCallbacks(it) }
backstop = null
}
private companion object {
const val TAG = "DsCapture"
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
* and the host has already moved on, so nothing else is coming to silence them. */
const val STOP_RETRY_MS = 100L
}
}
@@ -0,0 +1,340 @@
package io.unom.punktfunk.kit
/**
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
* as-is passthrough, nothing rides the wire raw here the host's DualSense/DS4 backends consume
* only typed events (`dualsense_proto.rs` discards `RichInput::HidReport`), so the client parses
* the pad's input reports into the ordinary button/axis wire + the rich touch/motion plane, and
* renders the host's feedback (rumble / adaptive triggers / lightbar / player LEDs) by composing
* USB output reports itself.
*
* Protocol ground truth: the Linux kernel's `hid-playstation` / `hid-sony` structs, SDL's
* `SDL_hidapi_ps5.c` / `SDL_hidapi_ps4.c`, mirrored host-side in `punktfunk-host`'s
* `dualsense_proto.rs` / `dualshock4_proto.rs` this file is the byte-exact inverse of those
* serializers (offsets cross-referenced below). USB only: over Bluetooth the reports shift
* (`0x31` + CRC32) AND Android exposes no raw path to a Classic pad anyway, so the BT case never
* reaches this code an uncaptured pad stays on the ordinary InputDevice path.
*/
object DsDevice {
const val VID_SONY = 0x054C
const val PID_DUALSENSE = 0x0CE6
const val PID_DUALSENSE_EDGE = 0x0DF2
const val PID_DUALSHOCK4_V1 = 0x05C4
const val PID_DUALSHOCK4_V2 = 0x09CC
val USB_PIDS = setOf(PID_DUALSENSE, PID_DUALSENSE_EDGE, PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2)
/**
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds matching
* the physical one), its output-report size (the descriptor-declared size the firmware
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
* onto the wire's 0..65535 space.
*/
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
}
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
fun modelFor(pid: Int): Model? = when (pid) {
PID_DUALSENSE -> Model.DUALSENSE
PID_DUALSENSE_EDGE -> Model.DUALSENSE_EDGE
PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2 -> Model.DUALSHOCK4
else -> null
}
/**
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
* (`Gamepad.BTN_*`) the parse maps device bits straight to the wire, the exact inverse of
* the host's `DsState::from_gamepad` (BTN_A cross, BTN_B circle, BTN_X square,
* BTN_Y triangle; positional, not glyph-order). Gyro/accel stay in raw device units the
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
*/
class State {
var buttons = 0
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
var rsX = 0; var rsY = 0
var lt = 0; var rt = 0 // 0..255
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
val accel = IntArray(3)
val touchActive = BooleanArray(2)
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
val touchY = IntArray(2)
}
// DS5 USB input report 0x01 (64 B) — offsets mirror the host serializer
// (`dualsense_proto.rs::serialize_state`): [1..7) sticks + triggers, [8] hat|face,
// [9]/[10] buttons, [16..28) gyro+accel, [33..41) two 4-byte touch points.
private const val DS5_INPUT_ID = 0x01
// report[8] high nibble (`dualsense_proto::btn0`).
private const val DS5_SQUARE = 0x10
private const val DS5_CROSS = 0x20
private const val DS5_CIRCLE = 0x40
private const val DS5_TRIANGLE = 0x80
// report[9] (`btn1`).
private const val DS5_L1 = 0x01
private const val DS5_R1 = 0x02
private const val DS5_CREATE = 0x10
private const val DS5_OPTIONS = 0x20
private const val DS5_L3 = 0x40
private const val DS5_R3 = 0x80
// report[10] (`btn2`); the FN/BACK bits exist only on the Edge.
private const val DS5_PS = 0x01
private const val DS5_TOUCHPAD = 0x02
private const val DS5_MUTE = 0x04
private const val EDGE_FN_LEFT = 0x10
private const val EDGE_FN_RIGHT = 0x20
private const val EDGE_BACK_LEFT = 0x40
private const val EDGE_BACK_RIGHT = 0x80
// DS4 USB input report 0x01 (64 B) — offsets mirror `dualshock4_proto.rs::serialize_state`:
// [1..5) sticks, [5] hat|face, [6]/[7] buttons, [8]/[9] triggers, [13..25) gyro+accel,
// [35..43) two touch points (same 4-byte packing as the DS5).
private const val DS4_L1 = 0x01
private const val DS4_R1 = 0x02
private const val DS4_SHARE = 0x10
private const val DS4_OPTIONS = 0x20
private const val DS4_L3 = 0x40
private const val DS4_R3 = 0x80
private const val DS4_PS = 0x01
private const val DS4_TOUCHPAD = 0x02
/**
* Parse one USB input report (`0x01`) into [out]. Returns false for any other report id or a
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 those never hit
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
* is long enough to carry them (it always is on glass 64-byte interrupt transfers).
*/
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
if (model == Model.DUALSHOCK4) {
parseDs4(report, len, out)
} else {
parseDs5(model, report, len, out)
}
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
out.lsX = stickX(u8(r, 1))
out.lsY = stickY(u8(r, 2))
out.rsX = stickX(u8(r, 3))
out.rsY = stickY(u8(r, 4))
out.lt = u8(r, 5)
out.rt = u8(r, 6)
val b8 = u8(r, 8)
val b9 = u8(r, 9)
val b10 = u8(r, 10)
var w = hatBits(b8 and 0x0F)
if (b8 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
if (b8 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
if (b8 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
if (b8 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
if (b9 and DS5_L1 != 0) w = w or Gamepad.BTN_LB
if (b9 and DS5_R1 != 0) w = w or Gamepad.BTN_RB
// L2/R2 digital bits ride the analog axes instead (wire convention).
if (b9 and DS5_CREATE != 0) w = w or Gamepad.BTN_BACK
if (b9 and DS5_OPTIONS != 0) w = w or Gamepad.BTN_START
if (b9 and DS5_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
if (b9 and DS5_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
if (b10 and DS5_PS != 0) w = w or Gamepad.BTN_GUIDE
if (b10 and DS5_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
if (b10 and DS5_MUTE != 0) w = w or Gamepad.BTN_MISC1
if (model == Model.DUALSENSE_EDGE) {
// Wire paddle order matches the host's `edge_paddle_bits` inverse: PADDLE1/2 =
// right/left BACK (the primary pair, Steam R4/L4 convention), PADDLE3/4 = right/left Fn.
if (b10 and EDGE_BACK_RIGHT != 0) w = w or Gamepad.BTN_PADDLE1
if (b10 and EDGE_BACK_LEFT != 0) w = w or Gamepad.BTN_PADDLE2
if (b10 and EDGE_FN_RIGHT != 0) w = w or Gamepad.BTN_PADDLE3
if (b10 and EDGE_FN_LEFT != 0) w = w or Gamepad.BTN_PADDLE4
}
out.buttons = w
if (len >= 28) {
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
}
if (len >= 41) {
unpackTouch(r, 33, out, 0)
unpackTouch(r, 37, out, 1)
}
return true
}
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
out.lsX = stickX(u8(r, 1))
out.lsY = stickY(u8(r, 2))
out.rsX = stickX(u8(r, 3))
out.rsY = stickY(u8(r, 4))
val b5 = u8(r, 5)
val b6 = u8(r, 6)
val b7 = u8(r, 7)
out.lt = u8(r, 8)
out.rt = u8(r, 9)
var w = hatBits(b5 and 0x0F)
if (b5 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
if (b5 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
if (b5 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
if (b5 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
if (b6 and DS4_L1 != 0) w = w or Gamepad.BTN_LB
if (b6 and DS4_R1 != 0) w = w or Gamepad.BTN_RB
if (b6 and DS4_SHARE != 0) w = w or Gamepad.BTN_BACK
if (b6 and DS4_OPTIONS != 0) w = w or Gamepad.BTN_START
if (b6 and DS4_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
if (b6 and DS4_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
if (b7 and DS4_PS != 0) w = w or Gamepad.BTN_GUIDE
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
out.buttons = w
if (len >= 25) {
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
}
if (len >= 43) {
unpackTouch(r, 35, out, 0)
unpackTouch(r, 39, out, 1)
}
return true
}
/** hat nibble (0=N … 7=NW, 8+=neutral) → wire dpad bits — inverse of the host's `hat()`. */
private fun hatBits(h: Int): Int = when (h) {
0 -> Gamepad.BTN_DPAD_UP
1 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT
2 -> Gamepad.BTN_DPAD_RIGHT
3 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_RIGHT
4 -> Gamepad.BTN_DPAD_DOWN
5 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_LEFT
6 -> Gamepad.BTN_DPAD_LEFT
7 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_LEFT
else -> 0
}
/**
* One 4-byte touch point (shared DS5/DS4 packing `dualsense_proto::pack_touch`): byte0
* bit7 = NOT active + contact id in bits 0..6; 12-bit x/y split across bytes 1..3.
*/
private fun unpackTouch(r: ByteArray, o: Int, out: State, slot: Int) {
val b0 = u8(r, o)
out.touchActive[slot] = b0 and 0x80 == 0
out.touchX[slot] = u8(r, o + 1) or ((u8(r, o + 2) and 0x0F) shl 8)
out.touchY[slot] = (u8(r, o + 2) shr 4) or (u8(r, o + 3) shl 4)
}
private fun u8(r: ByteArray, o: Int): Int = r[o].toInt() and 0xFF
private fun i16(r: ByteArray, o: Int): Int =
((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt()
// Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of
// the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`).
private fun stickX(raw: Int): Int = raw * 257 - 32768
private fun stickY(raw: Int): Int = (255 - raw) * 257 - 32768
// ---- Output reports ----
//
// Every write is valid-flag-selective: only the flagged channel applies, the firmware keeps
// the rest (the same contract the host's `parse_ds_output` mirrors — an unflagged parse would
// turn every rumble into a lightbar-off). The DS4 is the exception: its builder writes the
// full composed motors+LED state each time with both flags, SDL's proven-on-hardware shape.
// DS5 output report 0x02, report-relative offsets (`dualsense_proto::parse_ds_output`):
// [1] valid_flag0 (bit0 compat vibration, bit1 haptics select, bit2 R2 block, bit3 L2 block),
// [2] valid_flag1 (bit2 lightbar, bit4 player LEDs), [3]/[4] motors, [11..22) R2 effect,
// [22..33) L2 effect, [39] valid_flag2 (bit1 lightbar-setup enable, bit2 vibration2),
// [42] lightbar_setup, [44] player LEDs, [45..48) RGB.
private const val DS5_FLAG0_COMPAT_VIBRATION = 0x01
private const val DS5_FLAG0_HAPTICS_SELECT = 0x02
private const val DS5_FLAG0_R2_EFFECT = 0x04
private const val DS5_FLAG0_L2_EFFECT = 0x08
private const val DS5_FLAG1_LIGHTBAR = 0x04
private const val DS5_FLAG1_PLAYER_LEDS = 0x10
private const val DS5_FLAG2_LIGHTBAR_SETUP = 0x02
private const val DS5_FLAG2_VIBRATION2 = 0x04
private const val DS5_LIGHTBAR_SETUP_LIGHT_OUT = 0x02
/** The 11-byte adaptive-trigger effect block length (mode byte + 10 parameters). */
const val TRIGGER_EFFECT_LEN = 11
private fun newDs5(model: Model): ByteArray = ByteArray(model.outputSize).also { it[0] = 0x02 }
/**
* One-time capture-start report (DS5/Edge): release the firmware's lightbar animation
* (`LIGHTBAR_SETUP_LIGHT_OUT`) so subsequent host lightbar writes take effect the same
* init both hid-playstation and SDL send on open. No-op fields otherwise.
*/
fun ds5InitReport(model: Model): ByteArray = newDs5(model).also {
it[39] = DS5_FLAG2_LIGHTBAR_SETUP.toByte()
it[42] = DS5_LIGHTBAR_SETUP_LIGHT_OUT.toByte()
}
/**
* DS5/Edge rumble at the wire's u16 amplitudes ([low] = heavy/left motor, [high] =
* light/right the host parses `[3]` as high and `[4]` as low, mirrored here). Flags both
* the classic compat-vibration path AND `VIBRATION2` (firmware 2.24's full-range replot;
* older firmware ignores the unknown flag2 bit) the host parser accepts either.
*/
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
it[39] = DS5_FLAG2_VIBRATION2.toByte()
it[3] = amp8(high).toByte()
it[4] = amp8(low).toByte()
}
/**
* DS5/Edge adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] is the raw 11-byte
* trigger block from the wire (`HidOutput::Trigger` the game's bytes verbatim), copied to
* the same offsets the host parsed it from ([11..22) R2 / [22..33) L2).
*/
fun ds5TriggerReport(model: Model, which: Int, effect: ByteArray): ByteArray = newDs5(model).also {
val at = if (which == 1) 11 else 22
it[1] = (if (which == 1) DS5_FLAG0_R2_EFFECT else DS5_FLAG0_L2_EFFECT).toByte()
val n = effect.size.coerceAtMost(TRIGGER_EFFECT_LEN)
System.arraycopy(effect, 0, it, at, n)
}
/** DS5/Edge lightbar RGB. */
fun ds5LightbarReport(model: Model, r: Int, g: Int, b: Int): ByteArray = newDs5(model).also {
it[2] = DS5_FLAG1_LIGHTBAR.toByte()
it[45] = r.toByte()
it[46] = g.toByte()
it[47] = b.toByte()
}
/** DS5/Edge player-indicator LEDs (low 5 bits, hid-playstation pattern). */
fun ds5PlayerLedsReport(model: Model, bits: Int): ByteArray = newDs5(model).also {
it[2] = DS5_FLAG1_PLAYER_LEDS.toByte()
it[44] = (bits and 0x1F).toByte()
}
// DS4 output report 0x05 (32 B), report-relative (`dualshock4_proto::parse_ds4_output`):
// [1] valid_flag0 (bit0 motors, bit1 LED, bit2 blink), [4] weak/right motor, [5] strong/left,
// [6..9) RGB, [9]/[10] blink on/off.
private const val DS4_FLAG0_MOTORS = 0x01
private const val DS4_FLAG0_LED = 0x02
/**
* One full-state DS4 write: motors + lightbar together, both flags set the composed-state
* shape SDL uses against real hardware (per-channel selective writes are unproven on DS4
* firmware, unlike the DS5's). [DsCapture] holds the composition. Blink stays untouched.
*/
fun ds4Report(low: Int, high: Int, r: Int, g: Int, b: Int): ByteArray =
ByteArray(Model.DUALSHOCK4.outputSize).also {
it[0] = 0x05
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
it[4] = amp8(high).toByte()
it[5] = amp8(low).toByte()
it[6] = r.toByte()
it[7] = g.toByte()
it[8] = b.toByte()
}
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
// vibrator path's toAmplitude).
private fun amp8(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
}
@@ -46,12 +46,51 @@ class GamepadFeedback(
private val router: GamepadRouter?,
private val deviceVibrator: Vibrator? = null,
) {
/**
* A capture link's feedback renderer for the wire pads it owns, consulted BEFORE the
* InputDevice vibrator/lights paths. A captured controller has no [android.view.InputDevice]
* (its slot is an [GamepadRouter.ExternalPad] on a synthetic id, so [GamepadRouter.deviceForPad]
* resolves null and the platform paths no-op) the link renders instead, by composing USB
* output reports on the physical pad. This is also the ONLY route to adaptive triggers:
* Android has no platform API for them, so without a sink a Trigger event is log-and-drop.
* Invoked on the feedback poll threads; implementations must be thread-safe.
*/
interface PadFeedbackSink {
/** True when this sink renders feedback for wire pad [pad]; the render methods are only
* invoked while true. Racing a pad close is fine a late render is a harmless no-op. */
fun ownsPad(pad: Int): Boolean
/** One effective rumble command (`(0,0)` = stop now; else a one-shot at this level with
* [backstopMs] as the self-termination net see [GamepadFeedback.renderRumble]). */
fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long)
/** Lightbar RGB. */
fun led(pad: Int, r: Int, g: Int, b: Int)
/** Player-indicator LED bitmask (low 5 bits, hid-playstation layout). */
fun playerLeds(pad: Int, bits: Int)
/** One adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] = the raw DS5 trigger
* block (mode byte + parameters) exactly as the game wrote it host-side. */
fun trigger(pad: Int, which: Int, effect: ByteArray)
}
/**
* The active capture link's sink (a [DsCapture]), or null. Wired by StreamScreen alongside
* [onHidRaw]; cleared before the poll threads stop.
*/
@Volatile
var sink: PadFeedbackSink? = null
private companion object {
const val TAG = "pf.feedback"
const val TAG_LED: Byte = 0x01
const val TAG_PLAYER_LEDS: Byte = 0x02
const val TAG_TRIGGER: Byte = 0x03
const val TAG_HID_RAW: Byte = 0x05
/** Sparse-log cadence for swallowed render failures — see [noteRenderFailure]. */
const val LOG_EVERY = 128L
}
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 2830). */
@@ -89,6 +128,7 @@ class GamepadFeedback(
fun start() {
running = true
rumbleThread = Thread({
var failures = 0L
while (running) {
val ev = NativeBridge.nativeNextRumble(handle)
if (ev < 0L) continue // timeout / closed
@@ -100,26 +140,50 @@ class GamepadFeedback(
// the backstop (the hardware net under a stalled poll thread).
val pad = ((ev ushr 49) and 0xFL).toInt()
val backstopMs = ((ev ushr 32) and 0xFFFF)
renderRumble(
pad,
((ev ushr 16) and 0xFFFF).toInt(),
(ev and 0xFFFF).toInt(),
backstopMs,
)
// Rendering is binder calls into the vibrator service, and every one of them can
// throw unchecked — DeadSystemRuntimeException when system_server goes down, and
// the ordinary RuntimeException a dying service wraps its RemoteException in.
// Unguarded, ONE of those killed this thread outright: `running` stayed true, so
// nothing noticed and nothing restarted it, and rumble was gone for the rest of
// the session. Losing a single command is recoverable; losing the loop is not.
runCatching {
renderRumble(
pad,
((ev ushr 16) and 0xFFFF).toInt(),
(ev and 0xFFFF).toInt(),
backstopMs,
)
}.onFailure { failures = noteRenderFailure("rumble", it, failures) }
}
}, "pf-rumble").apply { isDaemon = true; start() }
hidoutThread = Thread({
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
val buf = ByteBuffer.allocateDirect(128)
var failures = 0L
while (running) {
val n = NativeBridge.nativeNextHidout(handle, buf)
if (n < 0) continue // timeout / closed
dispatchHidout(buf, n)
// Same hazard as the rumble loop above: lights/trigger rendering is binder and USB
// calls, and an unchecked throw here would silently end the rich-feedback plane.
runCatching { dispatchHidout(buf, n) }
.onFailure { failures = noteRenderFailure("hidout", it, failures) }
}
}, "pf-hidout").apply { isDaemon = true; start() }
}
/**
* Record a render failure the poll loop swallowed, and return the updated count. Logged on the
* first occurrence and sparsely after: a genuinely dead vibrator service fails on *every*
* command, which at a rumble plane's rate would bury the log.
*/
private fun noteRenderFailure(plane: String, t: Throwable, seen: Long): Long {
if (seen == 0L || seen % LOG_EVERY == 0L) {
Log.w(TAG, "$plane render failed (#${seen + 1}) — command dropped, poll loop alive", t)
}
return seen + 1
}
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
fun stop() {
running = false
@@ -221,13 +285,19 @@ class GamepadFeedback(
// controller 1 unconditionally rather than only motor-less pads — capability probing
// already decided the bind, and the user opted in.
if (pad == 0) renderDeviceRumble(low, high, durationMs)
// A captured pad's link renders on the physical controller itself (its slot has no
// InputDevice, so the vibrator bind below would resolve null and drop the command).
sink?.takeIf { it.ownsPad(pad) }?.let {
it.rumble(pad, low, high, durationMs)
return
}
val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
val m = bind.vm
if (m != null) {
if (lo == 0 && hi == 0) {
m.cancel() // (0,0) = stop
runCatching { m.cancel() } // (0,0) = stop
return
}
val combo = CombinedVibration.startParallel()
@@ -252,7 +322,7 @@ class GamepadFeedback(
// API 2830 legacy single-motor path: blend both motors into one effect.
val lv = bind.legacy ?: return
if (lo == 0 && hi == 0) {
lv.cancel() // (0,0) = stop
runCatching { lv.cancel() } // (0,0) = stop
return
}
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
@@ -313,23 +383,36 @@ class GamepadFeedback(
val g = buf.get().toInt() and 0xFF
val b = buf.get().toInt() and 0xFF
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null) s.led(pad, r, g, b)
else if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
}
TAG_PLAYER_LEDS -> {
val bits = buf.get().toInt() and 0x1F
val player = playerIndexForBits(bits)
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null) s.playerLeds(pad, bits)
else if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
}
TAG_TRIGGER -> {
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
val effLen = n - 3 // [pad][kind][which] header, then the effect block
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
Log.i(
TAG,
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
)
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null && effLen > 0) {
// A captured DualSense: the raw trigger block replays onto the physical pad.
val effect = ByteArray(effLen)
buf.get(effect)
Log.i(TAG, "hidout pad=$pad Trigger which=$which effLen=$effLen → captured pad") // verification line
s.trigger(pad, which, effect)
} else {
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
// No platform adaptive-trigger API — parse-validate the mode + log only.
Log.i(
TAG,
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (no adaptive-trigger renderer for this pad)".format(mode),
)
}
}
TAG_HID_RAW -> {
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
@@ -33,12 +33,52 @@ import java.util.concurrent.ConcurrentHashMap
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
* threads, so the slot table is a [ConcurrentHashMap].
*/
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
class GamepadRouter(
context: Context,
private val handle: Long,
private val setting: Int,
/**
* Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
* default true). Off is for a couch whose controller reaches the host another way USB
* passthrough such as VirtualHere, or a pad plugged into the host itself where forwarding
* as well would give the host two pads for one pair of hands.
*
* Off still opens slots and tracks held state; it only stops the wire sends. That is
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
* claimed by keeping a slot the Android input stack shares controllers unlike the USB
* capture links, which `StreamScreen` does not start at all while this is off.
*/
private val forwarding: Boolean = true,
/**
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved auto = forward on
* Android, where the press reaches the app on most devices; `local` exists for
* cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely
* with this device.
*/
private val systemForward: Boolean = true,
/**
* The hold-Select guide gesture (`Settings.guideGesture` resolved auto = off on
* Android): holding Select ALONE [GUIDE_HOLD_MS] sends the HOST's guide button, down
* until release so a long hold is the host's long-press, a Gaming-Mode host's QAM. A
* Select tap is delivered on release (delayed by up to the threshold); a Select pressed
* while other buttons are down passes through untouched, so the exit/mic chords keep
* working. pf-client-core's `SelectGesture`, on the main-thread handler.
*/
private val guideGesture: Boolean = false,
) {
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
var held = 0
// Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold
// timer / a delivered tap's owed release (both on the main handler), and whether
// the held Select was transformed into a synthetic guide.
var pendingGuide: Runnable? = null
var pendingTapUp: Runnable? = null
var selectAsGuide = false
}
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
@@ -65,6 +105,16 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
*/
var onExitArmed: ((armed: Boolean) -> Unit)? = null
/**
* Invoked (main thread) each time the mic-mute chord ([MIC_CHORD], Select + Y) is COMPLETED on
* a pad the couch equivalent of the stream's on-screen mute button, which a gamepad user
* cannot reach. `StreamScreen` wires it to the mute toggle. Unlike the exit chord this fires
* immediately: muting is the kind of thing you want to have already happened, and the on-screen
* indicator makes an accidental toggle self-evident. The buttons still go to the host the
* chord adds a meaning to them rather than swallowing them, exactly as the exit chord does.
*/
var onMicChord: (() -> Unit)? = null
private val mainHandler = Handler(Looper.getMainLooper())
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
private var pendingExit: Runnable? = null
@@ -108,16 +158,47 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/**
* One button transition on [slot] the shared body behind [onButton] and an [ExternalPad]'s
* transitions: forward the wire event, track held state, and arm/disarm the exit chord.
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
* the mic-mute chord ([MIC_CHORD]).
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
// Raw system buttons stay local under the "local" policy — no wire send and no held
// tracking, symmetric on both edges so nothing leaks into the chords either.
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
if (down) {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
if (guideGesture && send) {
// A Select pressed ALONE is held back until it resolves: a tap (delivered
// on release), a combo member (the next button flushes it as a real
// press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records
// it either way, so the exit/mic chords read as if the gesture didn't
// exist (Select+Y still fires the mic toggle: the flush sends Select's
// down before Y's).
if (bit == Gamepad.BTN_BACK && slot.held == 0) {
slot.held = slot.held or bit
armGuide(slot)
return
}
flushPendingSelect(slot)
}
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
}
val wasHeld = slot.held
slot.held = slot.held or bit
// Full chord now held on this pad → start the hold countdown (idempotent while held).
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
// Mic mute, edge-triggered on the button that COMPLETES the chord: a genuine press
// (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a chord member
// that leaves the whole chord held. Any other button pressed while Select + Y are down
// fails the middle test, so the toggle happens once per chord, not once per press.
if (wasHeld and bit == 0 && bit and MIC_CHORD != 0 && slot.held and MIC_CHORD == MIC_CHORD) {
onMicChord?.invoke()
}
} else {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
if (!owned && send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
slot.held = slot.held and bit.inv()
// A chord button lifted before the hold elapsed → cancel, unless another pad still
// holds the full chord.
@@ -127,6 +208,61 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
}
}
/** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */
private fun armGuide(slot: Slot) {
val r = Runnable {
slot.pendingGuide = null
slot.selectAsGuide = true
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index)
}
}
slot.pendingGuide = r
mainHandler.postDelayed(r, GUIDE_HOLD_MS)
}
/**
* A second button joined while Select was pending it was a real Select after all; its
* deferred down goes out before the caller sends the new button's, preserving chronology.
*/
private fun flushPendingSelect(slot: Slot) {
val r = slot.pendingGuide ?: return
mainHandler.removeCallbacks(r)
slot.pendingGuide = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
}
}
/**
* Select released with gesture state outstanding true when the gesture owned the
* release. A transformed hold lifts the synthetic guide; a pending tap delivers its
* held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair
* can fold into nothing in the host's per-pad input fold).
*/
private fun consumeSelectRelease(slot: Slot): Boolean {
if (slot.selectAsGuide) {
slot.selectAsGuide = false
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
}
return true
}
val r = slot.pendingGuide ?: return false
mainHandler.removeCallbacks(r)
slot.pendingGuide = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
val up = Runnable {
slot.pendingTapUp = null
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
}
slot.pendingTapUp = up
mainHandler.postDelayed(up, TAP_PRESS_MS)
}
return true
}
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
private fun armExit() {
if (pendingExit != null) return // already counting down
@@ -167,7 +303,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
val dev = event.device ?: return false
if (!isForwardable(dev)) return false
val slot = slotFor(dev) ?: return false
slot.mapper.onMotion(event)
if (forwarding) slot.mapper.onMotion(event)
return true
}
@@ -202,12 +338,32 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
fun axis(id: Int, value: Int) {
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
if (slot != null && forwarding) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
}
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
if (slot != null && forwarding) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
}
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
if (slot != null && forwarding) {
NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
}
}
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
* units the host passes them straight into the virtual pad's report). Per report. */
fun motion(gyro: IntArray, accel: IntArray) {
if (slot != null && forwarding) {
NativeBridge.nativeSendPadMotion(
handle, index,
gyro[0], gyro[1], gyro[2],
accel[0], accel[1], accel[2],
)
}
}
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
@@ -223,11 +379,21 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
val syntheticId = EXTERNAL_ID_BASE - index
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
return ExternalPad(syntheticId, index)
}
/**
* Close the slot (if any) for a physical controller a capture link just claimed. The claim
* detaches the kernel driver, so the system's own removal callback would close it moments
* later anyway doing it at claim time makes the freed wire index deterministic for the
* link's [ExternalPad] instead of racing the link's first report against that callback. Safe
* to over-match (a same-VID/PID sibling that still exists as an InputDevice lazily reopens a
* slot on its next input event). Main thread, like the hot-plug callbacks.
*/
fun releaseDevice(deviceId: Int) = closeSlot(deviceId)
/**
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
* the feedback poll threads are joined (they read [deviceForPad]).
@@ -270,7 +436,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
// to that type (a single global choice — matches the handshake's session-default pref).
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
slots[dev.id] = slot
return slot
@@ -283,7 +449,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
private fun closeSlot(deviceId: Int) {
val slot = slots.remove(deviceId) ?: return
releaseHeld(slot)
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
if (forwarding) NativeBridge.nativeSendGamepadRemove(handle, slot.index)
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
// Release this controller's feedback bindings (close its lights session / cancel rumble).
@@ -292,14 +458,32 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
private fun releaseHeld(slot: Slot) {
// Gesture first: a pending (never-sent) Select just drops its timer; an owed tap
// release goes out NOW (its down is already on the wire and the handle may not
// outlive this slot); a transformed guide — which is not in `held` — is lifted.
slot.pendingGuide?.let { mainHandler.removeCallbacks(it) }
slot.pendingGuide = null
slot.pendingTapUp?.let {
mainHandler.removeCallbacks(it)
slot.pendingTapUp = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
}
}
if (slot.selectAsGuide) {
slot.selectAsGuide = false
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
}
}
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
bits = bits and bit.inv()
}
slot.held = 0
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
if (forwarding) slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
}
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
@@ -323,7 +507,24 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
*/
const val EXIT_HOLD_MS = 1000L
/**
* Mic-mute chord: Select + Y. Y is deliberately NOT one of [EXIT_CHORD]'s buttons, so no
* way of reaching the exit chord can pass through this one on the way (and vice versa)
* and Select is a menu button rather than a twitch action, which makes the pair unlikely
* to occur inside real play.
*/
const val MIC_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_Y
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
const val EXTERNAL_ID_BASE = -1000
/** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */
const val GUIDE_HOLD_MS = 350L
/**
* pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by
* this much, so the pair can't coalesce into no press at all.
*/
const val TAP_PRESS_MS = 50L
}
}
@@ -0,0 +1,446 @@
package io.unom.punktfunk.kit
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.hardware.usb.UsbRequest
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicBoolean
/**
* Generic USB transport for a client-captured HID controller the device-agnostic half of what
* [Sc2UsbLink] pioneered, now shared with the Sony capture ([DsCapture]). Claims the controller
* interface(s) `force = true` detaches the kernel/OS driver, so a captured pad can't
* double-drive the ordinary InputDevice path runs a multiplexed [UsbRequest] read loop, and
* writes the host/capture's reports back to the device (interrupt-OUT when the interface has one,
* else EP0 `SET_REPORT`).
*
* Everything device-specific is [Config]: which attached device to pick, which of its interfaces
* to claim, and an optional keep-alive (feature reports re-sent on a firmware-watchdog cadence
* the SC2's lizard-mode refresh; a DualSense needs none).
*
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
* (an SC2 on-glass round tripped exactly this a 5 s silence heuristic firing on an idle pad).
* The real signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
*/
class HidUsbLink(
private val context: Context,
private val config: Config,
private val onReport: (report: ByteArray, len: Int) -> Unit,
private val onClosed: () -> Unit,
) {
/**
* The per-device knowledge this transport is parameterized by. [ifaceFilter] narrows WHICH
* HID/vendor-class interfaces get claimed (the class check itself is built in) e.g. the SC2
* Puck's controller slots, or the DualSense's single HID interface among its audio siblings.
* [keepAliveFeatures] are full feature reports (id byte first) re-sent to the streaming
* interface every [keepAliveMs] AND once at claim time; empty = no keep-alive.
*/
class Config(
val tag: String,
val threadName: String,
val deviceMatch: (UsbDevice) -> Boolean,
val ifaceFilter: (UsbDevice, UsbInterface) -> Boolean = { _, _ -> true },
val keepAliveFeatures: List<ByteArray> = emptyList(),
val keepAliveMs: Long = 0,
)
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
/** One claimed interface: its endpoints + the read state the reader thread owns. */
private class Claim(
val iface: UsbInterface,
val epIn: UsbEndpoint,
val epOut: UsbEndpoint?,
) {
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
var inReq: UsbRequest? = null
var outReq: UsbRequest? = null
var outBusy = false
var reports = 0L
}
private var connection: UsbDeviceConnection? = null
private var device: UsbDevice? = null
private var claims: List<Claim> = emptyList()
/** The claim whose IN endpoint last produced data where output/feature writes go.
* Written by the reader thread, read by the feedback thread (feature control transfers). */
@Volatile private var activeClaim: Claim? = null
/** Pending OUT reports, submitted by the reader thread only one thread may drive a
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
* request; a second waiter would steal the reader's completions). See [OutReportQueue] for
* what gets discarded when it fills, and why that is not simply "the oldest". */
private val outQueue = OutReportQueue()
private var reader: Thread? = null
private var detachReceiver: BroadcastReceiver? = null
@Volatile private var running = false
/** Latches on the first "this link is down" signal so [onClosed] fires exactly once, however
* many of the racing detectors (detach broadcast, reader error streak, failed re-queue) see
* it. Reset by [start]. */
private val down = AtomicBoolean(false)
/** First attached matching device, or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
*/
fun start(dev: UsbDevice): Boolean {
if (!usb.hasPermission(dev)) {
Log.e(config.tag, "no USB permission for ${dev.deviceName}")
return false
}
val conn = usb.openDevice(dev) ?: run {
Log.e(config.tag, "openDevice failed for ${dev.deviceName}")
return false
}
val claimed = claimControllerInterfaces(dev, conn)
if (claimed.isEmpty()) {
Log.e(config.tag, "no claimable interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
conn.close()
return false
}
connection = conn
device = dev
claims = claimed
down.set(false)
running = true
Log.i(
config.tag,
"USB link up: PID=0x%04x ifaces=%s".format(
dev.productId,
claimed.joinToString {
"%d(in=0x%02x out=%s)".format(
it.iface.id, it.epIn.address,
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
)
},
),
)
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (gone?.deviceName == dev.deviceName) {
Log.i(config.tag, "USB detached (${dev.deviceName})")
linkDown()
}
}
}
detachReceiver = receiver
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
if (config.keepAliveFeatures.isNotEmpty()) {
claimed.forEach { sendKeepAlive(conn, it.iface.id) }
}
reader = Thread({ readLoop(conn, claimed) }, config.threadName).apply {
isDaemon = true
start()
}
return true
}
/**
* Claim every candidate controller interface: HID (or vendor-class) interfaces that pass the
* config's [Config.ifaceFilter], with an INT/BULK IN endpoint (OUT optional the fallback is
* EP0 `SET_REPORT`). `force = true` detaches the kernel/OS driver, so the pad also vanishes
* from Android's own input stack while captured.
*/
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
val out = mutableListOf<Claim>()
for (i in 0 until dev.interfaceCount) {
val iface = dev.getInterface(i)
if (!config.ifaceFilter(dev, iface)) continue
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
iface.interfaceClass == 0xFF
if (!hidOrVendor) continue
var inEp: UsbEndpoint? = null
var outEp: UsbEndpoint? = null
for (e in 0 until iface.endpointCount) {
val ep = iface.getEndpoint(e)
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
if (!usable) continue
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
}
if (inEp == null) continue
if (conn.claimInterface(iface, true)) {
out.add(Claim(iface, inEp, outEp))
} else {
Log.w(config.tag, "could not claim iface ${iface.id}")
}
}
return out
}
/**
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
*/
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
val live = claims.filter { c ->
val req = UsbRequest()
if (!req.initialize(conn, c.epIn)) {
Log.w(config.tag, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
return@filter false
}
req.clientData = c
c.inReq = req
c.epOut?.let { ep ->
val o = UsbRequest()
if (o.initialize(conn, ep)) {
o.clientData = c
c.outReq = o
} else {
Log.w(config.tag, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
}
}
c.inBuf.clear()
req.queue(c.inBuf)
}
if (live.isEmpty()) {
Log.e(config.tag, "no IN request could be queued")
finishReader(claims)
// `start` already returned true, so without this the owner would sit waiting on a
// capture that never streams and never reports itself dead.
linkDown()
return
}
val scratch = ByteArray(64)
var lastKeepAlive = android.os.SystemClock.elapsedRealtime()
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
try {
while (running) {
val now = android.os.SystemClock.elapsedRealtime()
if (config.keepAliveFeatures.isNotEmpty() && config.keepAliveMs > 0 &&
now - lastKeepAlive >= config.keepAliveMs
) {
// Refresh the firmware settings on the streaming interface (else every live
// one, before a streaming interface is known) — replaying also repairs state
// some other consumer changed after capture started.
val target = activeClaim
if (target != null) sendKeepAlive(conn, target.iface.id)
else live.forEach { sendKeepAlive(conn, it.iface.id) }
lastKeepAlive = now
}
// Submit the next pending OUT report on the active (else first) interface.
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
if (outTarget != null) {
outQueue.poll()?.let { data ->
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
}
}
val done = try {
conn.requestWait(READ_TIMEOUT_MS)
} catch (_: TimeoutException) {
// A quiet controller is NOT an unplug — keep listening indefinitely; the
// detach broadcast is the real signal.
errorsSince = 0L
continue
}
if (done == null) {
// Hard error. On a real unplug these storm continuously (the detach
// broadcast usually beats us to it); tolerate transient ones.
if (errorsSince == 0L) errorsSince = now
if (now - errorsSince >= ERROR_UNPLUG_MS) {
Log.i(config.tag, "USB request errors persisting ${now - errorsSince} ms — treating as unplug")
break
}
continue
}
errorsSince = 0L
val claim = done.clientData as? Claim ?: continue
if (done === claim.inReq) {
val n = claim.inBuf.position()
if (n > 0) {
claim.inBuf.flip()
claim.inBuf.get(scratch, 0, n)
if (claim.reports++ == 0L) {
Log.i(
config.tag,
"first report on iface %d: id=0x%02x len=%d".format(
claim.iface.id, scratch[0].toInt() and 0xFF, n,
),
)
}
activeClaim = claim
onReport(scratch, n)
}
claim.inBuf.clear()
if (!claim.inReq!!.queue(claim.inBuf)) {
Log.i(config.tag, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
break
}
} else if (done === claim.outReq) {
claim.outBusy = false
}
}
} finally {
finishReader(claims)
}
linkDown()
}
/**
* Report the link down, exactly once, from whichever detector noticed first the detach
* broadcast (main thread) or the reader thread on its way out.
*
* This only *signals*; releasing the connection and the interfaces stays the owner's job, via
* the [stop] its `onClosed` handler calls. Previously nothing released them on this path: the
* detach receiver flipped a flag and fired the callback, so an unplug left the connection open,
* the interfaces claimed (the pad could not return to Android's own input stack) and the
* receiver still registered and a re-plug overwrote the field holding it, leaking a receiver
* that stayed live for the process's lifetime.
*/
private fun linkDown() {
running = false
if (down.compareAndSet(false, true)) onClosed()
}
private fun finishReader(claims: List<Claim>) {
for (c in claims) {
runCatching { c.inReq?.cancel(); c.inReq?.close() }
runCatching { c.outReq?.cancel(); c.outReq?.close() }
c.inReq = null
c.outReq = null
}
}
/**
* Write one raw report to the device: kind 0 = output report (the active interface's
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
*
* [coalesce] tells the pending-OUT queue whether a newer report of the same kind may replace
* this one [OutReportQueue.KEY_RUMBLE] for motor levels, the default [OutReportQueue.NO_COALESCE]
* for one-shots (lightbar, player LEDs, trigger effects) the sender will not repeat.
*
* Returns whether the report reached the device or is queued for it. A caller that is writing
* a **stop** needs this: a discarded stop has nothing behind it, so it must not be mistaken
* for one that landed.
*/
fun writeRaw(kind: Int, data: ByteArray, coalesce: Int = OutReportQueue.NO_COALESCE): Boolean {
if (data.isEmpty()) return false
return when (kind) {
0 -> {
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
// Interrupt-OUT rides UsbRequests submitted by the reader thread.
outQueue.offer(data, coalesce)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> setReport(REPORT_TYPE_FEATURE, data)
else -> false
}
}
private fun setReport(type: Int, data: ByteArray): Boolean {
val conn = connection ?: return false
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return false
return sendReport(conn, ifId, type, data)
}
/**
* Write one output report EP0-direct (`SET_REPORT(Output)`), bypassing the interrupt-OUT
* queue for a teardown write that must land while the reader thread is stopping and the
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
* thread: EP0 control transfers are independent of the reader's `requestWait`.
*/
fun writeControl(data: ByteArray): Boolean =
data.isNotEmpty() && setReport(REPORT_TYPE_OUTPUT, data)
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
}
/**
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
*/
private fun sendReport(
conn: UsbDeviceConnection,
ifaceId: Int,
type: Int,
data: ByteArray,
): Boolean {
val id = data[0].toInt() and 0xFF
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
// controlTransfer returns the byte count, or a negative value on failure — a failed write
// must be reported as such, not swallowed (a dropped rumble stop has nothing behind it).
val n = runCatching {
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}.getOrDefault(-1)
return n >= 0
}
/**
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
*
* Safe to call from the `onClosed` handler itself that is how an unplug now gets cleaned up,
* and it arrives on the reader thread, which must not try to join itself.
*/
fun stop() {
running = false
// Claim the down-latch so the reader's own exit does not report a close the owner asked for.
down.set(true)
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
detachReceiver = null
if (reader !== Thread.currentThread()) {
runCatching { reader?.join(1000) }
// Only forget the thread once it is actually gone: clearing it while it still runs
// would let a later stop() skip the join and free the connection under it.
reader = null
}
outQueue.clear()
activeClaim = null
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
claims = emptyList()
runCatching { connection?.close() }
connection = null
device = null
}
private companion object {
const val READ_TIMEOUT_MS = 100L
const val WRITE_TIMEOUT_MS = 250
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
const val ERROR_UNPLUG_MS = 2000L
const val REPORT_TYPE_OUTPUT = 0x02
const val REPORT_TYPE_FEATURE = 0x03
}
}
@@ -47,6 +47,14 @@ object NativeBridge {
compositorPref: Int,
gamepadPref: Int,
hdrEnabled: Boolean,
/** Every decoder this device would use tolerates multi-slice AUs
* ([VideoDecoders.multiSliceTolerant]) advertises `VIDEO_CAP_MULTI_SLICE`; false keeps
* the host at single-slice frames (the safe pre-0.17 wire shape). */
multiSliceOk: Boolean,
/** Every decoder this device would use accepts partial-frame input
* ([VideoDecoders.partialFrameCapable]) opts into slice-progressive delivery (the
* decode loop then feeds slices with `BUFFER_FLAG_PARTIAL_FRAME` as they arrive). */
framePartsOk: Boolean,
audioChannels: Int,
/** `quic::CODEC_*` bitfield of codecs this device decodes ([VideoDecoders.decodableCodecBits]);
* `0` falls back to H.264|HEVC. The host resolves the emitted codec from this its GPU. */
@@ -184,10 +192,12 @@ object NativeBridge {
external fun nativeVideoMime(handle: Long): String
/**
* The negotiated video mode as `[width, height]`, or `null` on a `0` handle. Resolved at the
* handshake, so it is known before the first frame the stream view sizes itself to THIS
* aspect rather than stretching the picture to the panel's. Fixed for the session; read once.
* Cheap; UI-safe.
* The negotiated video mode as `[width, height, refreshHz]`, or `null` on a `0` handle.
* Resolved at the handshake, so it is known before the first frame the stream view sizes
* itself to THIS aspect rather than stretching the picture to the panel's, and pins the
* panel's display mode to the stream refresh. The trailing `refreshHz` was appended later
* (an older native lib returns only `[width, height]` index defensively). Fixed for the
* session; read once. Cheap; UI-safe.
*/
external fun nativeVideoSize(handle: Long): IntArray?
@@ -204,11 +214,13 @@ object NativeBridge {
* entirely in Rust (NDK AMediaCodec ANativeWindow) no per-frame JNI. [decoderName] is the
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
* the MIME what the pre-overhaul client always did); [lowLatencyMode] is the user's
* "Low-latency mode (experimental)" toggle (off, the default, runs the original decode
* pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether
* "Low-latency mode" master toggle (ON by default: async loop + per-SoC tuning; off runs the
* original synchronous pipeline as the per-device escape hatch); [lowLatencyFeature] is whether
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
* mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless
* hint otherwise). No-op if already started.
* hint otherwise). [presentPriority]/[smoothBuffer] are the timeline presenter's intent
* (0 = lowest latency / 1 = smoothness; buffer 0 = automatic, else 1..3 frames) the Apple
* client's `present_priority`/`smooth_buffer` pair. No-op if already started.
*/
external fun nativeStartVideo(
handle: Long,
@@ -217,6 +229,11 @@ object NativeBridge {
lowLatencyMode: Boolean,
lowLatencyFeature: Boolean,
isTv: Boolean,
presentPriority: Int,
smoothBuffer: Int,
/** The display mode's own refresh rate (0 = unknown) the latch grid the presenter
* subdivides onto when the platform down-rates the app's choreographer stream. */
panelFps: Int,
)
/** Stop + join the decode thread without closing the session. No-op on `0`. */
@@ -231,11 +248,12 @@ object NativeBridge {
/**
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
* Returns 26 doubles (unified stats spec, `design/stats-unification.md`):
* Returns 33 doubles (unified stats spec, `design/stats-unification.md`):
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
* e2eDispP50Ms, e2eDispP95Ms]`
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
* feedP50Ms, codecP50Ms, skippedOverflowWindow]`
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capturedecoded headline; 1013
* describe the negotiated video feed bit depth 8/10, CICP primaries/transfer, and the HEVC
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline
@@ -246,7 +264,12 @@ object NativeBridge {
* `display` stage from the OnFrameRendered render timestamps when `dispValid` is 1.0 the
* headline becomes the directly-measured capturedisplayed pair at 24/25, tiled by
* `host+network` + `decode` + `display` (23), and when 0.0 the HUD falls back to the
* capturedecoded headline at 2/3 without the `display` term).
* capturedecoded headline at 2/3 without the `display` term; 2629 split the `display`
* term the timeline presenter owns `pace` = decodedrelease, `latch` = releasedisplayed,
* the window's on-glass confirm count, and whether the presenter is active at all; 30/31
* split `decode` (15) the same way `feed` = receivedqueued (hand-off + input-slot wait),
* `codec` = queueddecoded, the decoder's own time; 32 is the parked-AU overflow subset of
* `skipped` (19), i.e. the decoder falling behind rather than benign newest-wins pacing).
* Poll ~1 Hz; each call resets the measurement window.
*/
external fun nativeVideoStats(handle: Long): DoubleArray?
@@ -271,15 +294,53 @@ object NativeBridge {
external fun nativeStopAudio(handle: Long)
/**
* Start mic uplink: AAudio input Opus (48 kHz stereo, 20 ms) host (`send_mic` / 0xCB), all in
* Rust. No-op if already running. The caller MUST hold RECORD_AUDIO; otherwise the AAudio input
* stream fails to open and the rest of the session keeps streaming.
* Start mic uplink: AAudio input Opus (48 kHz mono, 10 ms) host (`send_mic` / 0xCB), all in
* Rust. [echoCancel] opens the capture under the VoiceCommunication preset (the HAL's own echo
* canceller / noise suppressor) and allocates an audio session id; the return value is that id
* (`> 0`) so the caller can attach the Java [android.media.audiofx.AcousticEchoCanceler] /
* [android.media.audiofx.NoiseSuppressor] as a backstop `0` when none was allocated
* (echoCancel off, the device refused the preset and the open fell back to the plain path, or
* the mic failed entirely). No-op if already running (returns the running capture's id). The
* caller MUST hold RECORD_AUDIO; otherwise the AAudio input stream fails to open and the rest
* of the session keeps streaming.
*/
external fun nativeStartMic(handle: Long)
external fun nativeStartMic(handle: Long, echoCancel: Boolean): Int
/** Stop + join the mic thread and close the AAudio input stream. No-op on `0`. */
/**
* Stop + join the mic thread and close the AAudio input stream. No-op on `0`. Leaves the
* session's mute state ([nativeSetMicMuted]) alone a surface recreate stops and restarts the
* mic, and a user who muted must stay muted through it.
*/
external fun nativeStopMic(handle: Long)
/**
* Mute/unmute the mic uplink mid-stream. Muting does NOT stop the capture: the AAudio input
* stream, the input preset it settled on and its primed buffers stay as they are, and the
* encode loop drops each 10 ms frame instead of encoding + sending it so room audio is never
* encoded and nothing goes on the wire, while a toggle costs an atomic store and takes effect
* on the next 10 ms boundary (a stop/start would re-run the preset fallback ladder and re-prime
* buffers every time).
*
* Sticky for the SESSION the flag lives on the handle, not on the capture so the mic
* restart a surface recreate performs comes back muted, with no window for an unmuted frame to
* escape; a fresh session always starts unmuted. Nothing here is persisted. No-op on `0`.
* Cheap (one atomic store); UI-safe.
*
* One honest consequence of keeping the stream open: the platform's own recording indicator
* stays lit while muted, because the mic really is still open. What stops is the encode and the
* send no captured audio leaves the process.
*/
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
/**
* Is a mic capture actually RUNNING i.e. did [nativeStartMic] open a stream, and has
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
* on the user's setting: a device that refused every AAudio input rung (or a missing
* RECORD_AUDIO grant) then shows no control instead of a lie about a mic being heard. `false`
* on a `0` handle. Cheap; UI-safe.
*/
external fun nativeMicActive(handle: Long): Boolean
// ---- Input: Kotlin captures, Rust forwards to the host (send_input) ----
/** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */
@@ -407,6 +468,30 @@ object NativeBridge {
*/
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
/**
* One touchpad contact from a client-captured controller (the Sony USB capture), forwarded on
* the rich-input plane (`RichInput::Touchpad`). [finger] is the contact slot (0/1); [x]/[y]
* are normalized 0..65535 in SCREEN convention (+y down the wire's fixed meaning); active
* false lifts the finger. Send on change only the host holds per-slot state.
*/
external fun nativeSendPadTouch(handle: Long, pad: Int, finger: Int, active: Boolean, x: Int, y: Int)
/**
* One motion-sensor sample from a client-captured controller (`RichInput::Motion`): gyro
* pitch/yaw/roll + accel, each a raw signed-16 value in the pad's own units the host passes
* them straight into the virtual DualSense report. Called at the pad's report rate.
*/
external fun nativeSendPadMotion(
handle: Long,
pad: Int,
gyroPitch: Int,
gyroYaw: Int,
gyroRoll: Int,
accelX: Int,
accelY: Int,
accelZ: Int,
)
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
/**
@@ -0,0 +1,89 @@
package io.unom.punktfunk.kit
/**
* The pending interrupt-OUT reports for a captured controller: a bounded FIFO whose overflow
* policy knows which reports may be thrown away and which may not.
*
* The queue exists because only one thread may drive a connection's `UsbRequest`s, so writes from
* the feedback threads are handed to the reader thread rather than submitted directly. It has to
* be bounded a stalled or unplugged device would otherwise grow it without limit and the
* question is what to discard when it fills.
*
* The old policy was "newest wins": drop from the head until there is room. That is right for
* rumble, which is *level-styled* the host re-sends it continuously, so a dropped frame is
* replaced milliseconds later and nothing is permanently lost. It is wrong for everything else.
* A lightbar colour, a player-LED mask and an adaptive-trigger effect are **one-shots**: the host
* sends them on change and never repeats them. Dropping one leaves the pad wrong until the next
* time that value happens to change, which may be never.
*
* So eviction is driven by an explicit [key] supplied by the caller, not by inspecting the bytes.
* That distinction cannot be recovered from the report itself: every DualSense output report
* carries the *same* report id and differs only in its `valid_flag` bytes, so an id-keyed policy
* would happily let a rumble supersede a lightbar the very bug this replaces, relocated.
*
* Two rules:
* - A report offered with a coalescing key **replaces** the pending report with that key, in
* place. A burst of rumble collapses to its latest value and never displaces anything else.
* - Only when the queue is full does anything get dropped, and then the oldest *coalescable*
* report goes first. A one-shot is discarded only if the queue is full of nothing but
* one-shots which needs [cap] distinct one-shots outstanding, far beyond what a real pad
* produces.
*
* Thread-safe: offered by the feedback threads, drained by the reader thread.
*/
internal class OutReportQueue(private val cap: Int = CAP) {
private class Entry(val key: Int, val data: ByteArray)
private val items = ArrayDeque<Entry>()
/**
* Queue [data] for submission. [key] is [NO_COALESCE] for a one-shot, or a caller-chosen
* constant identifying a level-styled stream whose newer values supersede older ones.
*
* Returns false only if the report had to be dropped outright the caller can then treat the
* write as failed rather than assuming it is on its way.
*/
fun offer(data: ByteArray, key: Int = NO_COALESCE): Boolean = synchronized(items) {
if (key != NO_COALESCE) {
val at = items.indexOfFirst { it.key == key }
if (at >= 0) {
// Supersede in place: keeping the queue position stops a fast rumble stream from
// repeatedly jumping the one-shots queued ahead of it.
items[at] = Entry(key, data)
return true
}
}
if (items.size >= cap) {
val victim = items.indexOfFirst { it.key != NO_COALESCE }
if (victim >= 0) {
items.removeAt(victim)
} else if (key != NO_COALESCE) {
// Nothing coalescable to sacrifice and this report is itself replaceable — drop it
// rather than a one-shot that will never come again.
return false
} else {
items.removeFirst()
}
}
items.addLast(Entry(key, data))
return true
}
/** The next report to submit, or null when nothing is pending. */
fun poll(): ByteArray? = synchronized(items) { items.removeFirstOrNull()?.data }
fun clear() = synchronized(items) { items.clear() }
val size: Int get() = synchronized(items) { items.size }
companion object {
/** This report is a one-shot: never superseded, evicted only as a last resort. */
const val NO_COALESCE = 0
/** Motor levels — re-sent continuously, so only the newest is worth keeping. */
const val KEY_RUMBLE = 1
/** Deep enough to absorb a burst, small enough that a stalled device cannot bloat us. */
const val CAP = 32
}
}
@@ -273,10 +273,20 @@ class Sc2Capture(
private fun onLinkClosed() {
Log.i(TAG, "SC2 link closed (unplug / power-off)")
// Both transports share this callback, so read which one was live BEFORE clearing it —
// releasing the other would tear down a link that never dropped.
val dropped = activeLink
activeLink = LINK_NONE
dongleLink = false
releaseSlot()
releaseUiKeys()
// Release the transport too — see the note in DsCapture.onLinkClosed. The Puck makes this
// worse than a single leak: it is the pad that gets power-cycled, so the same process can
// round-trip a link many times in one session.
when (dropped) {
LINK_USB -> usb.stop()
LINK_BLE -> ble.stop()
}
onActiveChanged?.invoke(false)
}
@@ -1,28 +1,13 @@
package io.unom.punktfunk.kit
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.hardware.usb.UsbRequest
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeoutException
/**
* USB transport for a Steam Controller 2 wired (`28DE:1302`) or through the wireless Puck
* dongle (`1304`/`1305`). Claims the controller interface(s) detaching the OS input stack, so
* the pad can't double-drive the ordinary InputDevice path runs a multiplexed [UsbRequest]
* read loop, keeps lizard mode off on the firmware watchdog cadence, and replays the host's raw
* writes (Steam's rumble output reports / settings feature reports) back to the device.
* dongle (`1304`/`1305`). The SC2 specialization of the shared [HidUsbLink] transport (which owns
* the claim, read loop, write queue, and unplug handling); this class contributes only what is
* SC2-specific:
*
* **The Puck claims ALL controller interfaces (2..5):** the dongle hosts up to four pads, one
* HID interface each, and there is no way to know which slot a controller bonded to claiming
@@ -30,350 +15,50 @@ import java.util.concurrent.TimeoutException
* on-glass symptom: the pad surfaced as a generic InputDevice Xbox360). Whichever interface
* streams state becomes the write target for rumble/settings.
*
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
* (round 2's wired disconnect was the 5 s silence heuristic firing on an idle pad). The real
* signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
* **Lizard keep-alive:** the firmware watchdog re-enables lizard mode (built-in kb/mouse
* emulation) after a few seconds of silence, so [Sc2Device.DISABLE_LIZARD] +
* [Sc2Device.NORMALIZE_JOYSTICKS] are re-sent on SDL's cadence the generic link's keep-alive.
*/
class Sc2UsbLink(
private val context: Context,
private val onReport: (report: ByteArray, len: Int) -> Unit,
private val onClosed: () -> Unit,
context: Context,
onReport: (report: ByteArray, len: Int) -> Unit,
onClosed: () -> Unit,
) {
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
/** One claimed interface: its endpoints + the read state the reader thread owns. */
private class Claim(
val iface: UsbInterface,
val epIn: UsbEndpoint,
val epOut: UsbEndpoint?,
) {
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
var inReq: UsbRequest? = null
var outReq: UsbRequest? = null
var outBusy = false
var reports = 0L
}
private var connection: UsbDeviceConnection? = null
private var device: UsbDevice? = null
private var claims: List<Claim> = emptyList()
/** The claim whose IN endpoint last produced data where rumble/settings writes go.
* Written by the reader thread, read by the feedback thread (feature control transfers). */
@Volatile private var activeClaim: Claim? = null
/** Pending OUT reports (Steam's forwarded haptics), submitted by the reader thread only
* one thread may drive a connection's [UsbRequest]s ([UsbDeviceConnection.requestWait]
* returns ANY completed request; a second waiter would steal the reader's completions). */
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
private var reader: Thread? = null
private var detachReceiver: BroadcastReceiver? = null
@Volatile private var running = false
private val link = HidUsbLink(
context,
HidUsbLink.Config(
tag = "Sc2UsbLink",
threadName = "pf-sc2-usb",
deviceMatch = {
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
},
// Wired: every HID/vendor interface; dongle: only the controller slots 2..5.
ifaceFilter = { dev, iface ->
dev.productId == Sc2Device.PID_WIRED || iface.id in Sc2Device.DONGLE_IFACES
},
keepAliveFeatures = listOf(Sc2Device.DISABLE_LIZARD, Sc2Device.NORMALIZE_JOYSTICKS),
keepAliveMs = Sc2Device.LIZARD_REFRESH_MS,
),
onReport,
onClosed,
)
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull {
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
}
fun findDevice(): UsbDevice? = link.findDevice()
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
*/
fun start(dev: UsbDevice): Boolean {
if (!usb.hasPermission(dev)) {
Log.e(TAG, "no USB permission for ${dev.deviceName}")
return false
}
val conn = usb.openDevice(dev) ?: run {
Log.e(TAG, "openDevice failed for ${dev.deviceName}")
return false
}
val claimed = claimControllerInterfaces(dev, conn)
if (claimed.isEmpty()) {
Log.e(TAG, "no claimable SC2 interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
conn.close()
return false
}
connection = conn
device = dev
claims = claimed
running = true
Log.i(
TAG,
"SC2 USB link up: PID=0x%04x ifaces=%s".format(
dev.productId,
claimed.joinToString {
"%d(in=0x%02x out=%s)".format(
it.iface.id, it.epIn.address,
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
)
},
),
)
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (gone?.deviceName == dev.deviceName) {
Log.i(TAG, "SC2 USB detached (${dev.deviceName})")
if (running) {
running = false
onClosed()
}
}
}
}
detachReceiver = receiver
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
claimed.forEach { configureInputMode(conn, it.iface.id) }
reader = Thread({ readLoop(conn, claimed) }, "pf-sc2-usb").apply {
isDaemon = true
start()
}
return true
}
/**
* Claim every candidate controller interface: the wired pad's single HID interface, or ALL
* of a Puck's controller slots (interfaces 2..5 the controller may be bonded to any of
* them). `force = true` detaches the kernel/OS driver, so the pad also vanishes from
* Android's own input stack while captured.
*/
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
val dongle = dev.productId != Sc2Device.PID_WIRED
val out = mutableListOf<Claim>()
for (i in 0 until dev.interfaceCount) {
val iface = dev.getInterface(i)
if (dongle && iface.id !in Sc2Device.DONGLE_IFACES) continue
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
iface.interfaceClass == 0xFF
if (!hidOrVendor) continue
var inEp: UsbEndpoint? = null
var outEp: UsbEndpoint? = null
for (e in 0 until iface.endpointCount) {
val ep = iface.getEndpoint(e)
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
if (!usable) continue
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
}
if (inEp == null) continue
if (conn.claimInterface(iface, true)) {
out.add(Claim(iface, inEp, outEp))
} else {
Log.w(TAG, "could not claim iface ${iface.id}")
}
}
return out
}
/**
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
*/
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
val live = claims.filter { c ->
val req = UsbRequest()
if (!req.initialize(conn, c.epIn)) {
Log.w(TAG, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
return@filter false
}
req.clientData = c
c.inReq = req
c.epOut?.let { ep ->
val o = UsbRequest()
if (o.initialize(conn, ep)) {
o.clientData = c
c.outReq = o
} else {
Log.w(TAG, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
}
}
c.inBuf.clear()
req.queue(c.inBuf)
}
if (live.isEmpty()) {
Log.e(TAG, "no IN request could be queued")
finishReader(claims)
return
}
val scratch = ByteArray(64)
var lastLizard = android.os.SystemClock.elapsedRealtime()
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
try {
while (running) {
val now = android.os.SystemClock.elapsedRealtime()
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
// Refresh both required firmware modes. The raw-joystick setting is normally
// persistent, but replaying it also repairs a host/driver that enabled ADC
// coordinates after capture started.
val target = activeClaim
if (target != null) configureInputMode(conn, target.iface.id)
else live.forEach { configureInputMode(conn, it.iface.id) }
lastLizard = now
}
// Submit the next pending OUT report on the active (else first) interface.
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
if (outTarget != null) {
outQueue.poll()?.let { data ->
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
}
}
val done = try {
conn.requestWait(READ_TIMEOUT_MS)
} catch (_: TimeoutException) {
// A quiet controller is NOT an unplug — keep listening indefinitely; the
// detach broadcast is the real signal.
errorsSince = 0L
continue
}
if (done == null) {
// Hard error. On a real unplug these storm continuously (the detach
// broadcast usually beats us to it); tolerate transient ones.
if (errorsSince == 0L) errorsSince = now
if (now - errorsSince >= ERROR_UNPLUG_MS) {
Log.i(TAG, "SC2 USB request errors persisting ${now - errorsSince} ms — treating as unplug")
break
}
continue
}
errorsSince = 0L
val claim = done.clientData as? Claim ?: continue
if (done === claim.inReq) {
val n = claim.inBuf.position()
if (n > 0) {
claim.inBuf.flip()
claim.inBuf.get(scratch, 0, n)
if (claim.reports++ == 0L) {
Log.i(
TAG,
"SC2 first report on iface %d: id=0x%02x len=%d".format(
claim.iface.id, scratch[0].toInt() and 0xFF, n,
),
)
}
activeClaim = claim
onReport(scratch, n)
}
claim.inBuf.clear()
if (!claim.inReq!!.queue(claim.inBuf)) {
Log.i(TAG, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
break
}
} else if (done === claim.outReq) {
claim.outBusy = false
}
}
} finally {
finishReader(claims)
}
if (running) {
running = false
onClosed()
}
}
private fun finishReader(claims: List<Claim>) {
for (c in claims) {
runCatching { c.inReq?.cancel(); c.inReq?.close() }
runCatching { c.outReq?.cancel(); c.outReq?.close() }
c.inReq = null
c.outReq = null
}
}
fun start(dev: UsbDevice): Boolean = link.start(dev)
/**
* Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80`
* rumble & friends the active interface's interrupt-OUT, else a `SET_REPORT(Output)`
* control transfer), kind 1 = feature report (`SET_REPORT(Feature)`). [data] is the full
* report, id byte first, exactly as hidapi framed it host-side.
* rumble & friends), kind 1 = feature report. [data] is the full report, id byte first,
* exactly as hidapi framed it host-side.
*/
fun writeRaw(kind: Int, data: ByteArray) {
if (data.isEmpty()) return
when (kind) {
0 -> {
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
// newest-wins: these are level-styled commands the host re-sends anyway.
while (outQueue.size >= 32) outQueue.poll()
outQueue.offer(data)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> setReport(REPORT_TYPE_FEATURE, data)
}
}
fun writeRaw(kind: Int, data: ByteArray) = link.writeRaw(kind, data)
private fun setReport(type: Int, data: ByteArray) {
val conn = connection ?: return
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
sendReport(conn, ifId, type, data)
}
private fun configureInputMode(conn: UsbDeviceConnection, ifaceId: Int) {
sendFeature(conn, ifaceId, Sc2Device.DISABLE_LIZARD)
sendFeature(conn, ifaceId, Sc2Device.NORMALIZE_JOYSTICKS)
}
private fun sendFeature(conn: UsbDeviceConnection, ifaceId: Int, data: ByteArray) {
sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, data)
}
/**
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
*/
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
val id = data[0].toInt() and 0xFF
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
fun stop() {
running = false
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
detachReceiver = null
runCatching { reader?.join(1000) }
reader = null
outQueue.clear()
activeClaim = null
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
claims = emptyList()
runCatching { connection?.close() }
connection = null
device = null
}
private companion object {
const val TAG = "Sc2UsbLink"
const val READ_TIMEOUT_MS = 100L
const val WRITE_TIMEOUT_MS = 250
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
const val ERROR_UNPLUG_MS = 2000L
const val REPORT_TYPE_OUTPUT = 0x02
const val REPORT_TYPE_FEATURE = 0x03
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire the closed callback. */
fun stop() = link.stop()
}
@@ -53,6 +53,81 @@ object VideoDecoders {
fun decodableCodecBits(): Int =
1 or 2 or (if (pickDecoder("video/av01") != null) 4 else 0)
/**
* Whether EVERY decoder this device would use tolerates multi-slice access units the
* `VIDEO_CAP_MULTI_SLICE` advertisement (decoder truth, per the 0.17.0 field regression:
* Amlogic HEVC decoders wedge the whole DEVICE on multi-slice frames Chromecast with
* Google TV, onn 4K which is why Moonlight requests single-slice for every hardware
* decoder). We advertise per-family instead: tolerant unless the pick for ANY advertised
* codec is an Amlogic decoder or can't be inspected (a null pick = the platform default
* uninspectable, so conservatively single-slice). Probed once at connect time; the host
* defaults to >1 slice only toward clients that set the bit.
*/
fun multiSliceTolerant(): Boolean {
val mimes = buildList {
add("video/avc")
add("video/hevc")
if (decodableCodecBits() and 4 != 0) add("video/av01")
}
return mimes.all { mime ->
val name = pickDecoder(mime)?.name?.lowercase() ?: return@all false
!name.startsWith("omx.amlogic") && !name.startsWith("c2.amlogic")
}
}
/**
* True when EVERY decoder this device would stream with supports partial-frame input
* (MediaCodec `FEATURE_PartialFrame`): the client may then opt into slice-progressive
* delivery and feed slices with `BUFFER_FLAG_PARTIAL_FRAME` ahead of the AU's tail.
* Codec selection happens after connect, so all advertised mimes must qualify the same
* conservative shape as [multiSliceTolerant] (an unprobeable pick disqualifies).
*/
fun partialFrameCapable(): Boolean {
val mimes = buildList {
add("video/avc")
add("video/hevc")
if (decodableCodecBits() and 4 != 0) add("video/av01")
}
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
.getOrNull() ?: return false
return mimes.all { mime ->
val pick = pickDecoder(mime)?.name ?: return@all false
val info = infos.firstOrNull { it.name == pick } ?: return@all false
runCatching {
info.getCapabilitiesForType(mime)
.isFeatureSupported(CodecCapabilities.FEATURE_PartialFrame)
}.getOrDefault(false)
}
}
/**
* One-line per-mime probe readout for the connect log (`adb logcat -s pf.caps`): which
* decoder each advertised mime resolves to and whether it declares `FEATURE_PartialFrame`
* the P2 slice-pipeline gate that is otherwise invisible until a stream behaves differently.
*/
fun capsReport(): String {
val mimes = buildList {
add("video/avc")
add("video/hevc")
if (decodableCodecBits() and 4 != 0) add("video/av01")
}
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
.getOrNull() ?: return "codec list unavailable"
return mimes.joinToString(" ") { mime ->
val pick = pickDecoder(mime)?.name
val partial = pick?.let { p ->
infos.firstOrNull { it.name == p }?.let { info ->
runCatching {
info.getCapabilitiesForType(mime)
.isFeatureSupported(CodecCapabilities.FEATURE_PartialFrame)
}.getOrNull()
}
}
"${mime.removePrefix("video/")}=${pick ?: "platform-default"}" +
" partialFrame=${partial ?: "?"}"
}
}
fun pickDecoder(mime: String): DecoderChoice? {
if (mime.isEmpty()) return null
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
@@ -0,0 +1,224 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure JVM tests of the Sony USB report codec ([DsDevice]) the byte-exact inverse of the
* host's `dualsense_proto.rs` / `dualshock4_proto.rs` serializers (offsets cross-checked against
* those files' own tests). No Android runtime types ([Gamepad]'s BTN_* are compile-time ints).
* Run: `./gradlew :kit:testDebugUnitTest`.
*/
class DsDeviceTest {
private fun ds5Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
ByteArray(64).also {
it[0] = 0x01
// Sticks centred, hat neutral (8).
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
it[8] = 0x08
// Touch points inactive (bit7 set).
it[33] = 0x80.toByte(); it[37] = 0x80.toByte()
mutate(it)
}
private fun ds4Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
ByteArray(64).also {
it[0] = 0x01
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
it[5] = 0x08
it[35] = 0x80.toByte(); it[39] = 0x80.toByte()
mutate(it)
}
// ---- input parse ----
@Test
fun ds5ButtonsMapPositionally() {
val s = DsDevice.State()
// cross+triangle, hat NE, L1+create+L3, PS+touchpad+mute.
val r = ds5Report {
it[8] = (0x20 or 0x80 or 0x01).toByte() // cross | triangle | hat=1 (NE)
it[9] = (0x01 or 0x10 or 0x40).toByte() // L1 | create | L3
it[10] = (0x01 or 0x02 or 0x04).toByte() // PS | touchpad | mute
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
val expected = Gamepad.BTN_A or Gamepad.BTN_Y or
Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT or
Gamepad.BTN_LB or Gamepad.BTN_BACK or Gamepad.BTN_LS_CLICK or
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD or Gamepad.BTN_MISC1
assertEquals(expected, s.buttons)
}
@Test
fun ds5SticksInvertYAndCoverTheFullRange() {
val s = DsDevice.State()
// Device +y down; wire +y up. Left stick fully up-left, right stick fully down-right.
val r = ds5Report {
it[1] = 0x00; it[2] = 0x00 // lx min, ly min (up)
it[3] = 0xFF.toByte(); it[4] = 0xFF.toByte() // rx max, ry max (down)
it[5] = 0x40; it[6] = 0xFF.toByte()
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
assertEquals(-32768, s.lsX)
assertEquals(32767, s.lsY) // device up → wire +32767
assertEquals(32767, s.rsX)
assertEquals(-32768, s.rsY) // device down → wire 32768
assertEquals(0x40, s.lt)
assertEquals(0xFF, s.rt)
// Centre stays (near) centre: 0x80 → 128 wire units of bias, the u8 grid's own offset.
val c = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 64, c))
assertEquals(128, c.lsX)
assertEquals(-129, c.lsY)
}
@Test
fun ds5MotionAndTouchUnpack() {
val s = DsDevice.State()
val r = ds5Report {
// gyro pitch = 0x0102, accel z = -2 (LE i16s at 16.. / 22..).
it[16] = 0x02; it[17] = 0x01
it[26] = 0xFE.toByte(); it[27] = 0xFF.toByte()
// Touch 0 active, id 5, x=1919 (0x77F), y=1079 (0x437):
// b0=0x05, b1=0x7F, b2=(x>>8)|((y&0xF)<<4)=0x77, y>>4=0x43.
it[33] = 0x05
it[34] = 0x7F
it[35] = (0x07 or (0x07 shl 4)).toByte()
it[36] = 0x43
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
assertEquals(0x0102, s.gyro[0])
assertEquals(-2, s.accel[2])
assertTrue(s.touchActive[0])
assertEquals(1919, s.touchX[0])
assertEquals(1079, s.touchY[0])
assertFalse(s.touchActive[1])
}
@Test
fun edgePaddlesParseOnlyOnTheEdge() {
val r = ds5Report { it[10] = 0xF0.toByte() } // all four FN/BACK bits
val edge = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE_EDGE, r, 64, edge))
// Host inverse (`edge_paddle_bits`): PADDLE1/2 = right/left BACK, PADDLE3/4 = right/left Fn.
assertEquals(
Gamepad.BTN_PADDLE1 or Gamepad.BTN_PADDLE2 or Gamepad.BTN_PADDLE3 or Gamepad.BTN_PADDLE4,
edge.buttons,
)
val plain = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, plain))
assertEquals(0, plain.buttons) // a non-Edge never reports phantom paddles
}
@Test
fun ds4LayoutDiffersWhereItShould() {
val s = DsDevice.State()
val r = ds4Report {
it[5] = (0x10 or 0x04).toByte() // square | hat=4 (down)
it[6] = (0x10 or 0x20).toByte() // share | options
it[7] = 0x03 // PS | touchpad click
it[8] = 0x11 // L2 analog
it[9] = 0x99.toByte() // R2 analog
// gyro yaw at 15.. (second i16 of 13..19).
it[15] = 0x34; it[16] = 0x12
// Touch 0 active id 3 at x=100 (0x064), y=941 (0x3AD): b1=0x64, b2=0xD0, b3=0x3A.
it[35] = 0x03
it[36] = 0x64
it[37] = 0xD0.toByte()
it[38] = 0x3A
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, r, 64, s))
assertEquals(
Gamepad.BTN_X or Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_BACK or Gamepad.BTN_START or
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD,
s.buttons,
)
assertEquals(0x11, s.lt)
assertEquals(0x99, s.rt)
assertEquals(0x1234, s.gyro[1])
assertTrue(s.touchActive[0])
assertEquals(100, s.touchX[0])
assertEquals(941, s.touchY[0])
}
@Test
fun rejectsForeignAndShortReports() {
val s = DsDevice.State()
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report { it[0] = 0x31 }, 64, s))
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 8, s))
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
}
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
@Test
fun ds5RumbleReportFlagsAndMotors() {
val r = DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, low = 0xFF00, high = 0x1200)
assertEquals(48, r.size)
assertEquals(0x02, r[0].toInt())
assertEquals(0x03, r[1].toInt()) // compat vibration | haptics select
assertEquals(0x04, r[39].toInt()) // VIBRATION2 (fw ≥ 2.24)
assertEquals(0x12, r[3].toInt() and 0xFF) // high = right/small at [3]
assertEquals(0xFF, r[4].toInt() and 0xFF) // low = left/big at [4]
// A nonzero amplitude never collapses to motor 0.
assertEquals(1, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, 0x00FF, 0)[4].toInt())
// The Edge's output report is the 64-byte variant.
assertEquals(64, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE_EDGE, 0, 0).size)
}
@Test
fun ds5TriggerReportPlacesTheBlockPerSide() {
val effect = ByteArray(11) { (it + 1).toByte() }
val r2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 1, effect = effect)
assertEquals(0x04, r2[1].toInt()) // R2 valid flag
assertEquals(1, r2[11].toInt()) // block at [11..22)
assertEquals(11, r2[21].toInt())
assertEquals(0, r2[22].toInt())
val l2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 0, effect = effect)
assertEquals(0x08, l2[1].toInt()) // L2 valid flag
assertEquals(1, l2[22].toInt()) // block at [22..33)
assertEquals(11, l2[32].toInt())
// Oversized wire effects clamp to the 11-byte hardware block.
val big = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, 1, ByteArray(20) { 0x7F })
assertEquals(0, big[22].toInt())
}
@Test
fun ds5LightbarPlayerLedsAndInit() {
val led = DsDevice.ds5LightbarReport(DsDevice.Model.DUALSENSE, 1, 2, 3)
assertEquals(0x04, led[2].toInt()) // lightbar valid flag
assertEquals(1, led[45].toInt()); assertEquals(2, led[46].toInt()); assertEquals(3, led[47].toInt())
val pl = DsDevice.ds5PlayerLedsReport(DsDevice.Model.DUALSENSE, 0xFF)
assertEquals(0x10, pl[2].toInt()) // player-LED valid flag
assertEquals(0x1F, pl[44].toInt()) // masked to the 5 LEDs
val init = DsDevice.ds5InitReport(DsDevice.Model.DUALSENSE)
assertEquals(0x02, init[39].toInt()) // lightbar-setup enable
assertEquals(0x02, init[42].toInt()) // LIGHT_OUT — releases the firmware animation
}
@Test
fun ds4ReportIsAFullStateWrite() {
val r = DsDevice.ds4Report(low = 0xAB00, high = 0x0100, r = 9, g = 8, b = 7)
assertEquals(32, r.size)
assertEquals(0x05, r[0].toInt())
assertEquals(0x03, r[1].toInt()) // motors | LED, both — composed state
assertEquals(0x01, r[4].toInt()) // high = weak/right at [4]
assertEquals(0xAB, r[5].toInt() and 0xFF) // low = strong/left at [5]
assertEquals(9, r[6].toInt()); assertEquals(8, r[7].toInt()); assertEquals(7, r[8].toInt())
assertEquals(0, r[9].toInt()) // blink untouched
}
@Test
fun modelResolution() {
assertEquals(DsDevice.Model.DUALSENSE, DsDevice.modelFor(0x0CE6))
assertEquals(DsDevice.Model.DUALSENSE_EDGE, DsDevice.modelFor(0x0DF2))
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x05C4))
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x09CC))
assertEquals(null, DsDevice.modelFor(0x1234))
assertEquals(Gamepad.PREF_DUALSENSE, DsDevice.Model.DUALSENSE.pref)
assertEquals(Gamepad.PREF_DUALSENSEEDGE, DsDevice.Model.DUALSENSE_EDGE.pref)
assertEquals(Gamepad.PREF_DUALSHOCK4, DsDevice.Model.DUALSHOCK4.pref)
}
}
@@ -0,0 +1,102 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertArrayEquals
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 pending-OUT queue's overflow policy. What is being pinned here is the distinction the old
* "drop from the head until there is room" policy did not make: rumble is re-sent continuously and
* may be thrown away, while a lightbar/player-LED/trigger report is sent once and never repeated.
*/
class OutReportQueueTest {
/** A report carrying a 0..255 marker so a test can tell which one came back out. */
private fun report(marker: Int) = byteArrayOf(0x02, marker.toByte())
// Masked: the marker rides in a Byte, and Byte.toInt() sign-extends.
private fun drain(q: OutReportQueue): List<Int> =
generateSequence { q.poll() }.map { it[1].toInt() and 0xFF }.toList()
@Test
fun `rumble supersedes the pending rumble instead of queueing another`() {
val q = OutReportQueue()
assertTrue(q.offer(report(1), OutReportQueue.KEY_RUMBLE))
assertTrue(q.offer(report(2), OutReportQueue.KEY_RUMBLE))
assertTrue(q.offer(report(3), OutReportQueue.KEY_RUMBLE))
assertEquals("a rumble burst must collapse to one entry", 1, q.size)
assertArrayEquals(report(3), q.poll())
assertNull(q.poll())
}
@Test
fun `superseding keeps the queue position so a rumble stream cannot jump one-shots`() {
val q = OutReportQueue()
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
q.offer(report(10)) // a one-shot queued behind it
q.offer(report(2), OutReportQueue.KEY_RUMBLE)
// The newer rumble takes the OLD rumble's slot, so the one-shot does not get starved
// behind an endlessly-renewed entry.
assertEquals(listOf(2, 10), drain(q))
}
@Test
fun `a full queue sacrifices rumble, never a one-shot`() {
val q = OutReportQueue(cap = 4)
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
q.offer(report(10))
q.offer(report(11))
q.offer(report(12))
assertEquals(4, q.size)
// Full. The old policy dropped the head — here that is a rumble, but only by luck of
// ordering; what matters is that the one-shots all survive.
assertTrue(q.offer(report(13)))
assertEquals(listOf(10, 11, 12, 13), drain(q))
}
@Test
fun `the one-shot the host never repeats survives a rumble storm`() {
val q = OutReportQueue(cap = 4)
// The exact regression: a lightbar colour queued once, then a flood of rumble. Under the
// old newest-wins eviction the colour was dropped from the head and never came back,
// leaving the pad lit wrong until the value next happened to change.
q.offer(report(200)) // lightbar
repeat(50) { q.offer(report(it), OutReportQueue.KEY_RUMBLE) }
val out = drain(q)
assertTrue("the lightbar report must still be queued, got $out", out.contains(200))
assertEquals("rumble must not have accumulated", listOf(200, 49), out)
}
@Test
fun `a queue full of one-shots refuses a rumble rather than dropping one`() {
val q = OutReportQueue(cap = 2)
q.offer(report(10))
q.offer(report(11))
assertFalse(
"with nothing coalescable to sacrifice, the replaceable report yields",
q.offer(report(1), OutReportQueue.KEY_RUMBLE),
)
assertEquals(listOf(10, 11), drain(q))
}
@Test
fun `only a queue of nothing but one-shots drops one, and it is the oldest`() {
val q = OutReportQueue(cap = 2)
q.offer(report(10))
q.offer(report(11))
assertTrue(q.offer(report(12)))
assertEquals(listOf(11, 12), drain(q))
}
@Test
fun `clear empties the queue`() {
val q = OutReportQueue()
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
q.offer(report(10))
q.clear()
assertEquals(0, q.size)
assertNull(q.poll())
}
}
+54 -56
View File
@@ -12,10 +12,14 @@
//! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the
//! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to
//! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free`
//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and
//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain
//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and
//! grown on XRuns (Google's anti-glitch technique).
//! has unbounded tail latency); (2) the jitter ring is deeper than the other clients' and decoupled
//! from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain doesn't
//! manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and grown on
//! XRuns (Google's anti-glitch technique).
//!
//! (2) is now the SHARED `punktfunk_core::audio::JitterPolicy` at `JitterTuning::AAUDIO`, which also
//! fixed what this ring was missing: it had a hard cap but nothing that walked the depth back down,
//! so drift and arrival bursts raised latency permanently and Android settled on its ceiling.
use ndk::audio::{
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
@@ -34,26 +38,18 @@ const SAMPLE_RATE: i32 = 48_000;
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
const RING_CHUNKS: usize = 64;
// --- Jitter-ring depths, in MILLISECONDS (scaled to interleaved-f32 samples at runtime). --------
// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied
// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`.
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
const PRIME_FLOOR_MS: usize = 40;
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
const PRIME_CEIL_MS: usize = 80;
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
/// without overflowing.
const JITTER_HEADROOM_MS: usize = 80;
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
const HARD_CAP_MS: usize = 150;
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
// --- Jitter-ring depths now come from the SHARED policy (`punktfunk_core::audio::JitterTuning`). --
// They used to be four Android-only constants here. The rationale for Android being DEEPER than the
// other clients still holds and is preserved in `JitterTuning::AAUDIO`: unlike PipeWire, which
// adaptively rate-matches the stream to the graph clock and masks host↔DAC drift, AAudio hands us a
// raw callback and we own the buffer, so drift and Wi-Fi power-save bunching land as
// underruns/overflows = crackle.
//
// Two things changed with the move. The prime floor drops 40 ms → 25 ms, because the policy GROWS
// the target on the devices that actually underrun instead of every device pre-paying for the worst
// one. And the ring finally sheds: it had a hard cap but nothing that walked the depth back down, so
// any drift or burst raised latency permanently and Android converged on its 120 ms ceiling and
// stayed there — the "audio latency is too high" report.
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
const XRUN_CHECK_EVERY: u32 = 128;
@@ -104,6 +100,7 @@ struct Counters {
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
ring_depth: AtomicU64, // ring sample count at the last callback
target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns)
}
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
@@ -126,10 +123,9 @@ impl AudioPlayback {
// Interleaved f32 samples per millisecond at this layout (48 kHz × channels); the ms-
// denominated jitter-ring depths scale by it.
let ms = (SAMPLE_RATE as usize / 1000) * channels;
let prime_floor = PRIME_FLOOR_MS * ms;
let prime_ceil = PRIME_CEIL_MS * ms;
let jitter_headroom = JITTER_HEADROOM_MS * ms;
let hard_cap_max = HARD_CAP_MS * ms;
let tuning = punktfunk_core::audio::JitterTuning::AAUDIO;
// Worst transient the ring can hold before the policy trims it.
let hard_cap_max = tuning.hard_cap_ms as usize * ms;
let counters = Arc::new(Counters::default());
// One open attempt at a given sharing mode. Everything the realtime callback captures
@@ -157,8 +153,10 @@ impl AudioPlayback {
// `decode_loop`.
let mut ring: VecDeque<f32> =
VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
let mut primed = false;
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
// Shared de-jitter policy — prime depth, drift correction, de-prime hysteresis. The
// hysteresis this replaces was Android-only; Linux and Windows carried the instant
// `if ring.is_empty()` re-prime until now.
let mut policy = punktfunk_core::audio::JitterPolicy::new(tuning, channels as u8);
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
@@ -173,21 +171,25 @@ impl AudioPlayback {
ring.extend(chunk.drain(..));
let _ = free_tx.try_send(chunk);
}
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained
// drain; drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst
// `want` (tiny on the LowLatency MMAP path) so the depth doesn't collapse to a single
// quantum.
let target = (3 * want).clamp(prime_floor, prime_ceil);
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
while ring.len() > hard_cap {
ring.pop_front();
// Jitter buffer: the shared policy decides prime/silence, trims a burst, and —
// new here — sheds ONE crossfaded 5 ms frame when the depth average has sat above
// target long enough to be drift rather than jitter. Without that shed this ring
// had no way back down: it clamped at 120 ms and stayed pinned there.
let step = policy.step(ring.len(), want);
if step.drop_front > 0 {
punktfunk_core::audio::crossfade_drop(
&mut ring,
step.drop_front,
step.crossfade,
);
}
if !primed && ring.len() >= target {
primed = true;
}
if primed {
let mut ran_short = false;
if !step.silence {
for slot in out.iter_mut() {
*slot = ring.pop_front().unwrap_or(0.0);
*slot = ring.pop_front().unwrap_or_else(|| {
ran_short = true;
0.0
});
}
cb_counters
.pcm_written
@@ -196,20 +198,15 @@ impl AudioPlayback {
out.fill(0.0);
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
}
// Re-prime only after a RUN of empty callbacks, not a single transient one —
// otherwise every momentary drain costs a fresh 40 ms silence (the old behaviour,
// self-inflicted crackle on any jitter spike).
if ring.is_empty() {
empties += 1;
if empties >= DEPRIME_AFTER_CALLBACKS {
primed = false;
}
} else {
empties = 0;
}
// No-op while un-primed, so a deliberate priming silence is never counted as an
// underrun (which would otherwise drive the adaptive floor up for no reason).
policy.note_read(ran_short);
cb_counters
.ring_depth
.store(ring.len() as u64, Ordering::Relaxed);
cb_counters
.target_ms
.store(policy.target_ms() as u64, Ordering::Relaxed);
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the
// HW buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are
// both callback-safe / non-blocking, and set clamps to capacity so it self-limits.
@@ -408,10 +405,11 @@ fn decode_loop(
}
if count % 600 == 0 {
log::info!(
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} peak={window_peak:.3}",
counters.pcm_written.load(Ordering::Relaxed),
counters.underruns.load(Ordering::Relaxed),
counters.ring_depth.load(Ordering::Relaxed),
counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64,
counters.target_ms.load(Ordering::Relaxed),
);
window_peak = 0.0;
}
+383 -52
View File
@@ -16,11 +16,13 @@ use std::time::{Duration, Instant};
use super::display::{
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
};
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags, take_stamp};
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate,
};
use super::vsync::{now_monotonic_ns, VsyncClock};
use super::{
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
@@ -55,6 +57,8 @@ enum DecodeEvent {
},
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
FormatChanged,
/// A panel vsync (from the [`VsyncClock`] thread) — the presenter's retry/pacing tick.
Vsync,
/// The codec reported an error; `fatal` when neither recoverable nor transient.
Error { fatal: bool },
}
@@ -78,6 +82,9 @@ pub(super) fn run_async(
ll_feature,
low_latency_mode,
is_tv,
present_priority,
smooth_buffer,
panel_hz,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -196,9 +203,33 @@ pub(super) fn run_async(
// parked in the tracker at release; the OnFrameRendered callback pairs it with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below.
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
let meter = Arc::new(PresentMeter::new());
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone(), meter.clone());
let render_cb = install_render_callback(&codec, &tracker);
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
// glass budget, timeline-timed release. `debug.punktfunk.presenter = arrival` selects the
// legacy release-immediately path for a rebuild-free on-device A/B.
let mut presenter = if presenter_disabled_by_sysprop() {
log::info!("decode: presenter = arrival (sysprop) — legacy immediate release");
None
} else {
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
log::info!(
"decode: presenter = timeline ({})",
match priority {
PresentPriority::Latency => "lowest latency".to_string(),
PresentPriority::Smooth { buffer } => format!("smoothness, buffer {buffer}"),
}
);
Some(Presenter::new(priority))
};
stats.set_presenter_active(presenter.is_some());
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
// the same event channel. The Sender parks here until that moment.
let mut vsync: Option<VsyncClock> = None;
let mut vsync_tx = presenter.is_some().then(|| ev_tx.clone());
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
let feeder = {
@@ -238,6 +269,8 @@ pub(super) fn run_async(
let mut free_inputs: VecDeque<usize> = VecDeque::new();
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
// Phase-lock v3: per-AU arrival stamps for the circular arrival-lead report (drained 1 Hz).
let mut arrival_stamps: Vec<i128> = Vec::new();
let mut ready: Vec<OutputReady> = Vec::new();
let mut applied_ds: Option<DataSpace> = None;
let mut fed: u64 = 0;
@@ -245,6 +278,12 @@ pub(super) fn run_async(
let mut discarded: u64 = 0;
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
let mut oversized_dropped: u64 = 0;
// Slice-progressive continuity ledger (see `PartFeed`).
let mut part_open: Option<PartFeed> = None;
// Queued-instant stamps (pts → realtime ns at the AU's LAST piece entering the codec) — the
// P3 decode-split ledger: `feed` = received→queued, `codec` = queued→decoded. Always on
// (one vDSO clock read per AU); consumed by `present_ready`.
let mut queued_stamps: VecDeque<(u64, i128)> = VecDeque::new();
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
// (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
@@ -277,6 +316,7 @@ pub(super) fn run_async(
};
let work_t0 = Instant::now();
let mut fmt_dirty = false;
let mut vsync_tick = false;
let mut aus_dropped: u64 = 0;
if let Some(ev) = ev0 {
aus_dropped += u64::from(dispatch_event(
@@ -285,9 +325,11 @@ pub(super) fn run_async(
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
}
// Coalesce every other event already queued into this one work pass — correct newest-only
@@ -299,12 +341,19 @@ pub(super) fn run_async(
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
}
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
if vsync_tick {
if let Some(p) = presenter.as_mut() {
p.on_vsync();
}
}
stats.note_skipped_overflow(aus_dropped); // parked-AU overflow: skips, flagged as such
if fmt_dirty {
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
}
@@ -315,8 +364,12 @@ pub(super) fn run_async(
&mut free_inputs,
&mut fed,
&mut oversized_dropped,
&mut part_open,
&mut queued_stamps,
&mut gate,
);
let had_output = !ready.is_empty();
let rendered_before = rendered;
present_ready(
&codec,
&client,
@@ -324,16 +377,92 @@ pub(super) fn run_async(
&mut ready,
&stats,
&in_flight,
&mut queued_stamps,
&meter,
clock_offset.load(Ordering::Relaxed),
&tracker,
&mut presenter,
&mut rendered,
&mut discarded,
&mut gate,
&mut recovery_flags,
);
// The presenter's decision point runs EVERY pass — frame arrivals, vsync ticks and the
// 5 ms housekeeping wake all land here, which is what reopens the glass budget on time
// even when the choreographer clock is absent.
if let Some(p) = presenter.as_mut() {
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
if p.pump(&codec, clock, &tracker, &meter, &stats, now_monotonic_ns()) {
rendered += 1;
}
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
// CIRCULAR mean + coherence of the ARRIVAL lead — each AU's reassembly stamp
// against the panel's latch grid — because arrival is the phase the host actually
// controls; the v2 latch statistic measured downstream of the decoder pipeline,
// which absorbed the actuation (on-glass 2026-07-31). Timestamps convert
// monotonic→realtime→host — the skew offset lives client-side.
if let (Some(_), Some(c)) = (p.flush_log(&meter, clock), clock) {
let period = c.panel_period_ns().max(c.period_ns());
if period > 0 {
if let Some(t) = c.next_target(now_monotonic_ns(), 0) {
let mono_now = now_monotonic_ns();
let real_now = now_realtime_ns();
let leads_us: Vec<u64> = arrival_stamps
.iter()
.map(|&r_ns| {
let arrival_mono = mono_now as i128 - (real_now - r_ns);
((t.expected_present_ns as i128 - arrival_mono)
.rem_euclid(period as i128)
/ 1000) as u64
})
.collect();
arrival_stamps.clear();
if let Some((lead_mean_ns, coherence)) =
punktfunk_core::phase::circular_latch(&leads_us, period)
{
log::info!(
target: "pf.phase",
"arrival lead circ={:.2}ms coh={}",
lead_mean_ns as f64 / 1e6,
coherence
);
let latch_real_ns =
real_now + (t.expected_present_ns - mono_now) as i128;
let latch_host_ns = (latch_real_ns
+ clock_offset.load(Ordering::Relaxed) as i128)
.max(0) as u64;
client.report_phase(
latch_host_ns,
period.clamp(0, u32::MAX as i64) as u32,
1_000_000, // skew residual — conservative 1 ms
lead_mean_ns.min(u32::MAX as u64) as u32,
coherence,
);
}
}
}
}
}
let presented_now = rendered > rendered_before;
// Start the vsync clock LAZILY on the first decoded output (eager, it ticks the panel
// rate into a session that has no frame yet — the Apple deadline presenter's bootstrap
// lesson). A `None` from start (no choreographer surface) simply leaves ASAP targets.
if had_output && vsync.is_none() {
if let Some(tx) = vsync_tx.take() {
vsync = VsyncClock::start(
panel_hz,
Box::new(move || {
let _ = tx.send(DecodeEvent::Vsync);
}),
);
if vsync.is_none() {
log::info!("decode: no choreographer clock — presenter uses ASAP targets");
}
}
}
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
if had_output {
if presented_now {
if !hint_tried {
hint_tried = true;
let tids = client.hot_thread_ids();
@@ -420,6 +549,10 @@ pub(super) fn run_async(
}
}
if let Some(p) = presenter.as_mut() {
p.release_all(&codec); // hand every held output buffer back before the codec stops
}
drop(vsync); // stop + join the choreographer thread; its channel sends are harmless after
let _ = codec.stop();
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
if let Some(j) = feeder {
@@ -448,6 +581,9 @@ fn feeder_loop(
) {
// Received AUs awaiting their 0xCF host timing (Phase-2 split), as (pts_ns, capture→received µs).
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
// Last logged phase-lock ACK (the host's applied capture hold, from the 0xCF tail) — logged
// on change so `adb logcat -s pf.phase` shows the closed loop working (or not) at a glance.
let mut last_phase_ack: Option<i32> = None;
while !shutdown.load(Ordering::Relaxed) {
match client.next_frame(Duration::from_millis(5)) {
Ok(frame) => {
@@ -455,11 +591,15 @@ fn feeder_loop(
// invalidation request so an RFI-capable host recovers with a cheap clean P-frame
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
let gap = client.note_frame_index(frame.frame_index);
// Slice-progressive parts repeat their AU's index — note it once, on the
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
// counting AUs.
let au_first = frame.part.is_none_or(|p| p.first);
let gap = au_first && client.note_frame_index(frame.frame_index);
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
// HUD-only `received` point + host/network split stay gated on the overlay.
if stats.enabled() || measure_decode {
if (stats.enabled() || measure_decode) && frame.complete {
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
// here would fold the hand-off queue wait into the network latency figure
// (a client-side standing backlog masquerading as network). 0 = older core.
@@ -482,7 +622,10 @@ fn feeder_loop(
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
.then_some((lat_ns / 1000) as u64);
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
// On a parts stream the completing delivery carries only the AU's
// suffix — its offset restores the full AU byte count for bitrate.
let au_len = frame.part.map_or(0, |p| p.offset as usize) + frame.data.len();
stats.note_received(au_len, lat_us, clock_offset != 0);
if let Some(hostnet_us) = lat_us {
pending_split.push_back((frame.pts_ns, hostnet_us));
if pending_split.len() > PENDING_SPLIT_CAP {
@@ -490,6 +633,17 @@ fn feeder_loop(
}
}
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
// Phase-lock closed-loop readout: the host's applied hold rides the
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
// once a second). None = a host without the tail (pre-phase-lock).
if t.applied_phase_ns != last_phase_ack {
log::info!(
target: "pf.phase",
"host applied_phase={:?}us",
t.applied_phase_ns.map(|n| n / 1000)
);
last_phase_ack = t.applied_phase_ns;
}
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
{
let (_, hostnet_us) = pending_split.remove(i).unwrap();
@@ -520,9 +674,11 @@ fn dispatch_event(
free_inputs: &mut VecDeque<usize>,
ready: &mut Vec<OutputReady>,
fmt_dirty: &mut bool,
vsync_tick: &mut bool,
fatal: &mut bool,
gate: &mut ReanchorGate,
recovery_flags: &mut VecDeque<(u64, u32)>,
arrival_stamps: &mut Vec<i128>,
) -> bool {
match ev {
DecodeEvent::Au(f, gap) => {
@@ -531,9 +687,27 @@ fn dispatch_event(
if gap {
gate.arm(Instant::now());
}
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
if recovery_flags.len() > IN_FLIGHT_CAP {
recovery_flags.pop_front();
// One entry per AU (parts share the pts): the completing delivery carries it.
if f.complete {
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
if recovery_flags.len() > IN_FLIGHT_CAP {
recovery_flags.pop_front();
}
}
// Phase-lock v3 sensor: the ARRIVAL stamp (reassembly completion, realtime) — the
// phase the host actually controls. The latch-based v2 sensor measured downstream
// of the decoder pipeline, which absorbed the host's actuation (on-glass 07-31).
// Phase sensor: AU completion is the arrival the host's hold actually moves —
// prefix parts would smear the phase toward the first slice's landing.
if f.complete {
arrival_stamps.push(if f.received_ns > 0 {
f.received_ns as i128
} else {
now_realtime_ns()
});
if arrival_stamps.len() > 256 {
arrival_stamps.remove(0);
}
}
pending_aus.push_back(f);
if pending_aus.len() > FRAME_PARK_CAP {
@@ -552,6 +726,7 @@ fn dispatch_event(
decoded_ns,
}),
DecodeEvent::FormatChanged => *fmt_dirty = true,
DecodeEvent::Vsync => *vsync_tick = true,
DecodeEvent::Error { fatal: f } => {
if f {
*fatal = true;
@@ -565,10 +740,36 @@ fn dispatch_event(
false
}
/// `AMEDIACODEC_BUFFER_FLAG_PARTIAL_FRAME` (NDK ≥ 26, gated by the Kotlin
/// `FEATURE_PartialFrame` probe): this input buffer is a PIECE of an AU — the codec assembles
/// pieces until a buffer WITHOUT the flag closes the AU.
const BUFFER_FLAG_PARTIAL_FRAME: u32 = 8;
/// The slice-progressive feed's open access unit: parts already queued into the codec under
/// [`BUFFER_FLAG_PARTIAL_FRAME`], awaiting the rest. Loop-local — a codec rebuild tears the
/// whole loop down, so the state can never outlive the codec instance it fed.
pub(super) struct PartFeed {
index: u32,
/// The AU byte offset the next part must carry — a mismatch means the hand-off dropped a
/// piece (memory cap / jump-to-live clear) and the AU is unrecoverable.
expected: usize,
/// The dead-close pts: an abandoned AU is CLOSED with an empty non-PARTIAL buffer at its
/// own pts — the codec then emits (concealed garbage) at that pts, which the reanchor
/// freeze gate withholds from glass while the keyframe request recovers the chain. No
/// mid-stream codec flush needed.
pts_us: u64,
}
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
/// submitted; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
///
/// Slice-progressive deliveries ([`Frame::part`]) feed as they arrive: every piece rides
/// [`BUFFER_FLAG_PARTIAL_FRAME`] except the AU's last, all at the AU's pts. `part_open` is the
/// continuity ledger — any break (gap, orphan, oversize) abandons the AU per [`PartFeed::pts_us`]'s
/// close contract and re-syncs at the next `first`.
#[allow(clippy::too_many_arguments)] // one call site; the split ledger threads through like the gate
fn feed_ready(
codec: &MediaCodec,
client: &NativeClient,
@@ -576,14 +777,66 @@ fn feed_ready(
free_inputs: &mut VecDeque<usize>,
fed: &mut u64,
oversized_dropped: &mut u64,
part_open: &mut Option<PartFeed>,
queued_stamps: &mut VecDeque<(u64, i128)>,
gate: &mut ReanchorGate,
) {
while !pending_aus.is_empty() && !free_inputs.is_empty() {
let idx = free_inputs.pop_front().unwrap();
let frame = pending_aus.pop_front().unwrap();
let pts_us = frame.pts_ns / 1000;
let (first, last, offset) = match frame.part {
None => (true, true, 0usize),
Some(p) => (p.first, p.last, p.offset as usize),
};
// Continuity ledger. `continues` = this piece extends the open AU exactly;
// anything else with an AU open means that AU died mid-flight and must be closed
// (empty non-PARTIAL buffer at ITS pts) before this frame may touch the codec.
let continues = part_open
.as_ref()
.is_some_and(|o| frame.frame_index == o.index && offset == o.expected && !first);
if !continues {
if let Some(o) = part_open.take() {
// Spend THIS slot on the close; the current frame re-queues for the next one.
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, 0, o.pts_us, 0) {
log::warn!("decode: close of abandoned partial AU {}: {e}", o.index);
}
log::warn!(
"decode: partial AU {} abandoned mid-feed — closed empty, requesting keyframe",
o.index
);
// The close makes the codec emit concealed garbage at the dead pts — freeze it
// off the glass until the recovery keyframe re-anchors.
gate.arm(Instant::now());
let _ = client.request_keyframe();
pending_aus.push_front(frame);
continue;
}
// No AU open: an orphan non-first piece lost its head upstream — discard and
// re-sync at the next `first` (the recovery request rides the same loss).
if !first {
free_inputs.push_front(idx);
gate.arm(Instant::now());
let _ = client.request_keyframe();
continue;
}
}
let Some(dst) = codec.input_buffer(idx) else {
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
continue;
// Nothing was written and nothing was queued, so BOTH stay ours. Dropping the slot
// here leaked one of the codec's input buffers per occurrence — we forget it and the
// codec never frees what it never received, so the pipeline quietly runs out of input
// slots, `pending_aus` overflows, and the resulting drop storm reads as a decode
// fault. Dropping the AU on top of that punched a hole in the reference chain with no
// keyframe request behind it, unlike every sibling path here.
//
// `break`, not `continue`: a codec that cannot hand out an input buffer it just
// advertised is in no state to be fed the rest of the parked queue this pass, and
// retrying the same index against every parked AU would burn the whole backlog. The
// loop re-runs within the housekeeping wake (≤ 5 ms) if it was transient.
log::warn!("decode: input_buffer({idx}) returned None — retrying next pass");
free_inputs.push_front(idx);
pending_aus.push_front(frame);
break;
};
let au = &frame.data;
if au.len() > dst.len() {
@@ -597,6 +850,16 @@ fn feed_ready(
*oversized_dropped
);
let _ = client.request_keyframe();
if frame.part.is_some() {
gate.arm(Instant::now());
// Pieces already queued can't be unqueued: poison the ledger so the next
// delivery mismatches and takes the close-empty path above.
*part_open = Some(PartFeed {
index: frame.frame_index,
expected: usize::MAX,
pts_us,
});
}
continue;
}
let n = au.len();
@@ -605,21 +868,51 @@ fn feed_ready(
unsafe {
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
}
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, 0) {
let flags = if last { 0 } else { BUFFER_FLAG_PARTIAL_FRAME };
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, flags) {
log::warn!("decode: queue_input_buffer_by_index: {e}");
if frame.part.is_some() && !last {
// The piece never reached the codec — same unrecoverable-AU shape as oversize.
*part_open = Some(PartFeed {
index: frame.frame_index,
expected: usize::MAX,
pts_us,
});
}
} else {
*fed += 1;
// `fed` counts ACCESS UNITS toward the HUD's fed/decoded balance — the closing
// piece (or a whole AU) bumps it. The queued stamp marks the same instant (the AU
// is fully in the codec's hands): the P3 decode split measures `codec` from here,
// so a slice-progressive head start shows up as codec-pure shrink.
if last {
*fed += 1;
queued_stamps.push_back((pts_us, now_realtime_ns()));
if queued_stamps.len() > IN_FLIGHT_CAP {
queued_stamps.pop_front(); // stale — codec never echoed it back
}
}
*part_open = if last {
None
} else {
Some(PartFeed {
index: frame.frame_index,
expected: offset + n,
pts_us,
})
};
}
}
}
/// Present only the NEWEST ready output (render = true) and release the rest without rendering — a
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
/// receipt-map eviction stays monotonic. The presented frame's `(pts, decoded stamp)` is parked in
/// `tracker` for the OnFrameRendered callback — the `display` stage's other endpoint. `ready` is
/// drained.
/// Route the ready outputs toward glass, recording each one's decode-split + e2e first. With the
/// timeline presenter (default): fold each output
/// through the re-anchor gate in pts order, hand the approved ones to the presenter's store
/// (newest-wins / smoothing FIFO — the actual release happens in `Presenter::pump`, budgeted and
/// timeline-timed), and release withheld concealment unrendered. Legacy (`arrival` sysprop):
/// present only the NEWEST ready output immediately and release the rest unrendered — the
/// original policy. Every dequeued buffer, rendered or not, is the HUD's `decoded` measurement
/// point (it finished decoding either way); samples are recorded in pts order so the receipt-map
/// eviction stays monotonic. `ready` is drained.
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
fn present_ready(
codec: &MediaCodec,
@@ -628,8 +921,11 @@ fn present_ready(
ready: &mut Vec<OutputReady>,
stats: &crate::stats::VideoStats,
in_flight: &Mutex<VecDeque<(u64, i128)>>,
queued_stamps: &mut VecDeque<(u64, i128)>,
meter: &PresentMeter,
clock_offset: i64,
tracker: &DisplayTracker,
presenter: &mut Option<Presenter>,
rendered: &mut u64,
discarded: &mut u64,
gate: &mut ReanchorGate,
@@ -638,50 +934,85 @@ fn present_ready(
if ready.is_empty() {
return;
}
// Pair each output's decode stage (feeds the ABR decode signal always; the HUD histogram only
// while visible) — both consume the receipt map, so enter for either.
if stats.enabled() || measure_decode {
// Pair each output's decode stage (the ABR decode signal + the HUD histogram consume the
// receipt map; the P3 split's codec-pure half needs only the queued stamp, so it records
// even with both off — that keeps the 1 Hz pf.present mirror HUD-off readable).
{
let want_stage = stats.enabled() || measure_decode;
let mut g = in_flight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for o in ready.iter() {
note_decoded_pts(
client,
measure_decode,
stats,
&mut g,
clock_offset,
o.pts_us,
o.decoded_ns,
);
let received_ns = if want_stage {
note_decoded_pts(
client,
measure_decode,
stats,
&mut g,
clock_offset,
o.pts_us,
o.decoded_ns,
)
} else {
None
};
let queued = take_stamp(queued_stamps, o.pts_us);
let codec_us = queued.map(|q| ((o.decoded_ns - q).max(0) / 1000) as u64);
let feed_us = match (queued, received_ns) {
(Some(q), Some(r)) => Some(((q - r).max(0) / 1000) as u64),
_ => None,
};
// Always-on e2e for the 1 Hz pf.present mirror (same formula + clamp as the HUD's
// capture→decoded headline in `note_decoded_pts`).
let e2e_ns = o.decoded_ns + clock_offset as i128 - o.pts_us as i128 * 1000;
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
meter.note_decode(feed_us, codec_us, e2e_us);
if let Some(c) = codec_us {
stats.note_decode_split(feed_us, c);
}
}
}
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
// so the two-mark re-anchor count stays correct; a `false` verdict is withheld concealment (the
// SurfaceView keeps the last rendered frame frozen on).
let now = Instant::now();
let last = ready.len() - 1;
let mut skipped: u64 = 0;
for (i, o) in ready.drain(..).enumerate() {
let flags = take_flags(recovery_flags, o.pts_us);
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
let render = i == last && present;
match codec.release_output_buffer_by_index(o.index, render) {
Ok(()) if render => {
*rendered += 1;
if stats.enabled() {
tracker.note_rendered(o.pts_us, o.decoded_ns);
if let Some(p) = presenter.as_mut() {
for o in ready.drain(..) {
let flags = take_flags(recovery_flags, o.pts_us);
if gate.on_decoded(flags, false, now) == GateVerdict::Present {
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns);
skipped += dropped;
*discarded += dropped;
} else {
if let Err(e) = codec.release_output_buffer_by_index(o.index, false) {
log::warn!("decode: release_output_buffer_by_index({}): {e}", o.index);
}
}
Ok(()) => {
*discarded += 1;
skipped += 1;
}
Err(e) => {
log::warn!(
"decode: release_output_buffer_by_index({}, {render}): {e}",
o.index
)
}
} else {
let last = ready.len() - 1;
for (i, o) in ready.drain(..).enumerate() {
let flags = take_flags(recovery_flags, o.pts_us);
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
let render = i == last && present;
match codec.release_output_buffer_by_index(o.index, render) {
Ok(()) if render => {
*rendered += 1;
tracker.note_rendered(o.pts_us, o.decoded_ns, now_realtime_ns());
}
Ok(()) => {
*discarded += 1;
skipped += 1;
}
Err(e) => {
log::warn!(
"decode: release_output_buffer_by_index({}, {render}): {e}",
o.index
)
}
}
}
}
+29 -18
View File
@@ -35,32 +35,37 @@ pub(super) struct DisplayTracker {
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
/// keeps the leaked render-callback refcount from pinning the whole session alive.
clock_offset: Arc<AtomicI64>,
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
/// callback early-outs) while the overlay is hidden.
rendered: Mutex<VecDeque<(u64, i128)>>,
/// Always-on latch/display accumulator for the presenter's 1 Hz `pf-present` line —
/// independent of the HUD gate, so a HUD-off A/B stays measurable from logcat.
meter: Arc<super::presenter::PresentMeter>,
/// `(pts_us, decoded_real_ns, released_real_ns)` of frames released with `render = true`, in
/// release order, awaiting their callback. Pushed on EVERY render (no HUD gate — the ring is
/// a 64-tuple bound and the latch metric wants to exist when nobody is watching).
rendered: Mutex<VecDeque<(u64, i128, i128)>>,
}
impl DisplayTracker {
pub(super) fn new(
stats: Arc<crate::stats::VideoStats>,
clock_offset: Arc<AtomicI64>,
meter: Arc<super::presenter::PresentMeter>,
) -> Arc<DisplayTracker> {
Arc::new(DisplayTracker {
stats,
clock_offset,
meter,
rendered: Mutex::new(VecDeque::new()),
})
}
/// Park one just-rendered frame's `(pts, decoded stamp)` for the render callback to pair.
/// Caller gates on the HUD being visible.
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128) {
/// Park one just-rendered frame's `(pts, decoded stamp, release stamp)` for the render
/// callback to pair — the release stamp is the latch metric's start (release→displayed).
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128, released_ns: i128) {
let mut g = self
.rendered
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.push_back((pts_us, decoded_ns));
g.push_back((pts_us, decoded_ns, released_ns));
if g.len() > RENDERED_CAP {
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
}
@@ -152,36 +157,42 @@ unsafe extern "C" fn on_frame_rendered(
// `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() {
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
}
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
let pts_us = media_time_us.max(0) as u64;
// Pair the frame back to its release record, evicting older entries (their callbacks were
// dropped by the platform, or the entry predates a HUD toggle) — same monotonic-eviction
// discipline as `note_decoded_pts`.
let mut decoded_ns = None;
// dropped by the platform) — same monotonic-eviction discipline as `note_decoded_pts`.
let mut paired = None;
{
let mut g = t
.rendered
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while let Some(&(p, d)) = g.front() {
while let Some(&(p, d, r)) = g.front() {
if p > pts_us {
break; // future frame — leave it for its own callback
}
g.pop_front();
if p == pts_us {
decoded_ns = Some(d);
paired = Some((d, r));
break;
}
}
}
// Clamped to (0, 10 s) like the e2e sample: a vendor's first render callbacks can carry a
// garbage `system_nano` (observed on-glass: an epoch-sized latch max on the session's first
// window), and one such sample would poison every max/percentile it lands in.
let clamp = |v: i128| (v > 0 && v < 10_000_000_000).then_some((v / 1000) as u64);
let display_us = paired.and_then(|(d, _)| clamp(displayed_ns - d));
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
// Always-on half: the presenter's pf-present line reads these with the HUD off.
t.meter.note_latch(latch_us);
if !t.stats.enabled() {
return; // HUD hidden — skip the skew math + the stats lock
}
let e2e_ns =
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
t.stats.note_displayed(e2e_us, display_us);
t.stats.note_displayed(e2e_us, display_us, latch_us);
}
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
+21 -2
View File
@@ -20,7 +20,8 @@ pub(super) fn now_realtime_ns() -> i128 {
/// entries older than it are evicted (decode order == input order here — low-latency, no
/// B-frames — so anything before it was dropped inside the codec or stamped before a flush).
/// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's
/// stamp (async loop).
/// stamp (async loop). Returns the receipt stamp it paired (if any) so the caller can split the
/// `decode` stage further (feed wait vs codec-pure) without re-walking the map.
pub(super) fn note_decoded_pts(
client: &NativeClient,
measure_decode: bool,
@@ -29,7 +30,7 @@ pub(super) fn note_decoded_pts(
clock_offset: i64,
pts_us: u64,
decoded_ns: i128,
) {
) -> Option<i128> {
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
let mut received_ns = None;
while let Some(&(p, r)) = in_flight.front() {
@@ -61,6 +62,24 @@ pub(super) fn note_decoded_pts(
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
stats.note_decoded(e2e_us, decode_us);
}
received_ns
}
/// The queued-instant stamp for a decoded output, keyed by the echoed `presentationTimeUs` — the
/// same monotonic evict-as-you-go pairing as [`take_flags`], over an `(pts_us, realtime_ns)` map
/// (the feed side stamps each AU as its last piece enters the codec). A miss returns `None` —
/// the split is simply not recorded for that frame.
pub(super) fn take_stamp(map: &mut VecDeque<(u64, i128)>, pts_us: u64) -> Option<i128> {
while let Some(&(p, t)) = map.front() {
if p > pts_us {
break; // future frame — leave it for its own output buffer
}
map.pop_front();
if p == pts_us {
return Some(t);
}
}
None
}
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
+18
View File
@@ -9,8 +9,10 @@
mod async_loop;
mod display;
mod latency;
mod presenter;
mod setup;
mod sync_loop;
mod vsync;
use async_loop::run_async;
pub(crate) use setup::{codec_label, codec_mime};
@@ -106,6 +108,22 @@ pub(crate) struct DecodeOptions {
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
pub is_tv: bool,
/// The user's presentation intent (`present_priority` setting): 0 = lowest latency
/// (newest-wins), 1 = smoothness (a small FIFO). Resolved by
/// [`presenter::PresentPriority::resolve`]; anything else = latency.
pub present_priority: i32,
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
/// Only meaningful with `present_priority` = smooth.
pub smooth_buffer: i32,
/// SEED for the panel's refresh period — the latch grid the presenter subdivides onto when
/// the app's choreographer stream is down-rated below the panel (see `vsync.rs`). Kotlin
/// resolves it from the display mode TABLE (`MainActivity.streamPanelFps`), not
/// `display.refreshRate`, which reports a per-uid override rather than the panel. 0 = unknown.
///
/// ⚠ Only a seed: `preferredDisplayModeId` is a REQUEST the system may refuse, so the mode
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
pub panel_hz: i32,
}
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
@@ -0,0 +1,673 @@
//! The timeline presenter — Android's port of the Apple client's stage-4 deadline discipline
//! (`clients/apple/.../Stage2Pipeline.swift`, the `.deadline` pacing):
//!
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
//! queueing behind the display;
//! * a **glass budget of one**: at most one undisplayed release in flight to SurfaceFlinger,
//! reopened on the clock-predicted latch (with a 100 ms stale force-open as the liveness
//! backstop, mirroring Apple's `PresentGate.staleAfter`), and bounded underneath by what
//! `OnFrameRendered` actually confirmed reached glass ([`UNDISPLAYED_CAP`]) — because the
//! prediction is only as good as the panel grid behind it, and 0.23.0 shipped a grid that
//! could be wrong in one direction forever;
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
//! identical to the legacy path — and only the budget prediction uses the measured period.
//!
//! The legacy behaviour (release the newest ready buffer immediately, unbudgeted) remains
//! selectable at runtime: `adb shell setprop debug.punktfunk.presenter arrival` — the on-device
//! A/B needs no rebuild. The user-facing escape hatch stays the "Low-latency mode" master toggle
//! (off = the synchronous pre-overhaul loop, no presenter at all).
use ndk::media::media_codec::MediaCodec;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::Mutex;
use std::time::Instant;
use super::display::DisplayTracker;
use super::latency::now_realtime_ns;
use super::vsync::VsyncShared;
/// Submit-margin ahead of a timeline's EXPECTED PRESENT — SurfaceFlinger's own latch lead: the
/// released buffer must be in the BufferQueue by SF's wakeup for that vsync (a few ms before
/// present). A present closer than this is treated as missed and the next one is targeted. This
/// is deliberately NOT the timeline's `deadline` (which budgets for GPU rendering a video
/// buffer doesn't do — see `VsyncShared::next_target`); a too-tight gamble here presents one
/// vsync later, the exact cost the deadline gate paid on every frame.
///
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
/// here is a ms off every frame's display stage. A device that misses at the live margin shows it
/// as a measured latch beyond one panel period (see the adaptation in
/// [`Presenter::flush_log`]) — that, not a drop counter, is the signal to widen.
const LATCH_MARGIN_NS: i64 = 2_500_000;
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
/// setprop + stream restart, no rebuild. Unset/invalid = `None` = the adaptive default.
fn latch_margin_ns() -> Option<i64> {
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
let n = unsafe {
libc::__system_property_get(
c"debug.punktfunk.latch_margin_us".as_ptr(),
buf.as_mut_ptr().cast(),
)
};
if n > 0 {
if let Ok(us) = std::str::from_utf8(&buf[..n as usize])
.unwrap_or("")
.trim()
.parse::<i64>()
{
if (0..=8_000).contains(&us) {
return Some(us * 1_000);
}
}
}
None
}
/// The budget's liveness backstop: a release whose predicted latch never seems to arrive
/// (clock glitch, mode switch) force-reopens the budget this long after the release, counted in
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
const STALE_REOPEN_NS: i64 = 100_000_000;
/// Releases still unconfirmed by `OnFrameRendered` at which the presenter stops handing
/// SurfaceFlinger more work.
///
/// The reopen above is a PREDICTION off the learned panel grid. A grid finer than the panel
/// (0.23.0 could pin one permanently — see [`punktfunk_core::phase::PanelGrid`]) reopens the
/// budget before the display has consumed anything, and the presenter then releases faster than
/// the panel scans: the BufferQueue fills, MediaCodec runs out of output buffers, the decoder
/// stalls, and the no-output backstop starts begging for keyframes. The render callback is the
/// ground truth about what actually reached glass, so it bounds the prediction.
///
/// Six, not one: the platform is explicitly allowed to deliver these callbacks BATCHED, and this
/// module's own `RENDERED_CAP` note records them trailing a release by a vsync or two — so a
/// healthy device sits at 1-3 outstanding and a tight cap would throttle it for nothing (a held
/// frame in the newest-wins slot is a DROPPED frame the moment a fresher one decodes). This is
/// not a pacing knob; it is the "something is structurally wrong" rail, and a presenter genuinely
/// out-running its display climbs past any fixed cap within a second. If a device's BufferQueue
/// is shallower than this the rail simply never engages and the no-output backstop handles it,
/// exactly as before — best-effort, never worse than not having it.
const UNDISPLAYED_CAP: i32 = 6;
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
/// The user's presentation intent — the Apple client's `PresentPriority`, same resolution rules:
/// anything but an explicit "smooth" is latency; a smooth buffer outside 1..=3 becomes 2.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum PresentPriority {
/// Newest-wins, release the instant the budget opens. The default.
Latency,
/// A small FIFO (1..=3 frames) drained one per vsync: jitter absorbed at one refresh of
/// added display latency per slot, which the metrics show rather than hide.
Smooth { buffer: usize },
}
impl PresentPriority {
/// From the JNI ints (`presentPriority` 0 = latency / 1 = smooth; `smoothBuffer` 0 = auto).
pub(crate) fn resolve(priority: i32, buffer: i32) -> PresentPriority {
if priority != 1 {
return PresentPriority::Latency;
}
let b = if (1..=3).contains(&buffer) {
buffer as usize
} else {
2
};
PresentPriority::Smooth { buffer: b }
}
}
/// One decoded output buffer held for presentation.
struct HeldFrame {
index: usize,
pts_us: u64,
/// The output callback's `CLOCK_REALTIME` stamp — the pace metric's start (decoded→release).
decoded_ns: i128,
}
/// The one-in-flight glass budget.
struct InFlight {
/// Monotonic instant the budget reopens: the release target's expected present (clock), or
/// `release + period` on the fallback path.
reopen_at_ns: i64,
released_at_ns: i64,
}
/// Latch samples + display confirms recorded by the `OnFrameRendered` callback thread, drained by
/// the presenter's 1 Hz `pf-present` line. Always on (independent of the HUD) — this is what makes
/// a HUD-off wireless A/B readable from logcat.
pub(super) struct PresentMeter {
inner: Mutex<PresentMeterInner>,
/// Frames released to SurfaceFlinger that `OnFrameRendered` has not yet confirmed reached
/// glass. The presenter's structural rail (see [`UNDISPLAYED_CAP`]) and the pf-present line's
/// queue-depth readout. Lock-free because the release side runs on the decode loop and the
/// confirm side on the codec's callback thread, once per frame each.
undisplayed: AtomicI32,
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
confirms: AtomicBool,
}
struct PresentMeterInner {
latch_us: Vec<u64>,
displays: u64,
/// The `decode` stage's feed split, received→queued µs (P3 science: hand-off + input-slot
/// wait). Empty when no receipt stamp matched (HUD off and ABR not measuring decode).
feed_us: Vec<u64>,
/// The codec-pure half, queued→decoded µs, measured from the AU's LAST piece — always on,
/// so a HUD-off logcat A/B still reads the decoder's own time.
codec_us: Vec<u64>,
/// Capture→decoded end-to-end µs (skew-corrected, clamped) — always on for the same reason:
/// the wireless A/B's headline without having to reach the on-screen HUD.
e2e_us: Vec<u64>,
}
impl PresentMeter {
pub(super) fn new() -> PresentMeter {
PresentMeter {
inner: Mutex::new(PresentMeterInner {
latch_us: Vec::with_capacity(256),
displays: 0,
feed_us: Vec::with_capacity(256),
codec_us: Vec::with_capacity(256),
e2e_us: Vec::with_capacity(256),
}),
undisplayed: AtomicI32::new(0),
confirms: AtomicBool::new(false),
}
}
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
///
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
self.confirms.store(true, Ordering::Relaxed);
let _ = self
.undisplayed
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
Some((v - 1).max(0))
});
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.displays += 1;
if let Some(l) = latch_us {
if g.latch_us.len() < 4096 {
g.latch_us.push(l);
}
}
}
/// One frame handed to SurfaceFlinger, awaiting its confirm. Decode thread.
fn note_released(&self) {
self.undisplayed.fetch_add(1, Ordering::Relaxed);
}
/// Releases still unconfirmed, and whether confirms happen on this device at all.
fn outstanding(&self) -> (i32, bool) {
(
self.undisplayed.load(Ordering::Relaxed),
self.confirms.load(Ordering::Relaxed),
)
}
/// Write off the outstanding releases: the platform stopped confirming (it is allowed to
/// drop callbacks under load) or SurfaceFlinger discarded the buffers without presenting
/// them. Never stall the stream on a ledger we cannot audit.
fn forgive_outstanding(&self) {
self.undisplayed.store(0, Ordering::Relaxed);
}
/// One decoded frame's always-on measurements: the `decode`-stage split (feed =
/// received→queued when a receipt stamp matched; codec = queued→decoded when the queued
/// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof.
pub(super) fn note_decode(
&self,
feed_us: Option<u64>,
codec_us: Option<u64>,
e2e_us: Option<u64>,
) {
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(f) = feed_us {
if g.feed_us.len() < 4096 {
g.feed_us.push(f);
}
}
if let Some(c) = codec_us {
if g.codec_us.len() < 4096 {
g.codec_us.push(c);
}
}
if let Some(e) = e2e_us {
if g.e2e_us.len() < 4096 {
g.e2e_us.push(e);
}
}
}
#[allow(clippy::type_complexity)] // one caller unpacks it in place; a struct would be noise
fn drain(&self) -> (Vec<u64>, u64, Vec<u64>, Vec<u64>, Vec<u64>) {
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let displays = g.displays;
g.displays = 0;
(
std::mem::take(&mut g.latch_us),
displays,
std::mem::take(&mut g.feed_us),
std::mem::take(&mut g.codec_us),
std::mem::take(&mut g.e2e_us),
)
}
}
/// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty.
fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
if v.is_empty() {
return (0.0, 0.0);
}
v.sort_unstable();
let p50 = v[v.len() / 2] as f64 / 1000.0;
let max = *v.last().unwrap() as f64 / 1000.0;
(p50, max)
}
pub(super) struct Presenter {
/// 0 = newest-wins; 1..=3 = smoothing FIFO capacity.
fifo_capacity: usize,
frames: VecDeque<HeldFrame>,
/// FIFO preroll: `take` withholds until the buffer filled to capacity once, re-armed on a dry
/// run — the Apple `FrameStore` semantics (headroom never builds without it).
prerolled: bool,
inflight: Option<InFlight>,
/// A vsync arrived since the last release — the FIFO's one-per-refresh drain pace.
vsync_tick: bool,
// -- 1 Hz pf-present window, always on --
released: u64,
paced_drops: u64,
no_budget: u64,
forced: u64,
dry: u64,
/// Pump passes that held a frame back because too many earlier releases were still
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
/// the signature of a presenter out-running its display.
queue_waits: u64,
/// When the unconfirmed-release rail first engaged, so it can be forgiven if the confirms
/// simply stopped coming. `None` while the rail is down.
backed_up_since: Option<i64>,
pace_us: Vec<u64>,
last_flush: Instant,
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
/// release with NO lead on the NP3 — the fixed 2.5 ms was pure display latency) and
/// widens by measurement: `paced` misses in a 1 Hz window push it +500 µs toward
/// [`LATCH_MARGIN_NS`], the pre-sweep known-safe ceiling. A sysprop override PINS it.
margin_ns: i64,
/// `debug.punktfunk.latch_margin_us` was set — the margin is pinned, never adapted.
margin_pinned: bool,
}
impl Presenter {
pub(super) fn new(priority: PresentPriority) -> Presenter {
let pinned = latch_margin_ns();
let (margin_ns, margin_pinned) = match pinned {
Some(ns) => (ns, true),
None => (0, false),
};
log::info!(
"presenter: latch margin {}us{}",
margin_ns / 1_000,
if margin_pinned {
" (debug.punktfunk.latch_margin_us pin)"
} else {
" (adaptive — widens on latch misses)"
}
);
Presenter {
fifo_capacity: match priority {
PresentPriority::Latency => 0,
PresentPriority::Smooth { buffer } => buffer,
},
frames: VecDeque::new(),
prerolled: false,
inflight: None,
vsync_tick: false,
released: 0,
paced_drops: 0,
no_budget: 0,
forced: 0,
dry: 0,
queue_waits: 0,
backed_up_since: None,
pace_us: Vec::with_capacity(256),
last_flush: Instant::now(),
margin_ns,
margin_pinned,
}
}
/// A vsync pulse from the clock thread's event — the retry tick for a parked frame and the
/// FIFO's drain pace.
pub(super) fn on_vsync(&mut self) {
self.vsync_tick = true;
}
/// Accept one decoded, gate-approved output buffer. Newest-wins evicts everything older
/// (released unrendered — the explicit, counted drop); the FIFO evicts its oldest past
/// capacity. Returns how many frames were dropped by the policy (the HUD's `skipped`).
pub(super) fn submit(
&mut self,
codec: &MediaCodec,
index: usize,
pts_us: u64,
decoded_ns: i128,
) -> u64 {
let mut dropped = 0u64;
if self.fifo_capacity == 0 {
while let Some(stale) = self.frames.pop_front() {
release_unrendered(codec, stale.index);
dropped += 1;
}
}
self.frames.push_back(HeldFrame {
index,
pts_us,
decoded_ns,
});
if self.fifo_capacity > 0 && self.frames.len() > self.fifo_capacity {
if let Some(stale) = self.frames.pop_front() {
release_unrendered(codec, stale.index);
dropped += 1;
}
}
self.paced_drops += dropped;
dropped
}
/// The present decision point — run on every loop pass (frame arrivals, vsync ticks, and the
/// 5 ms housekeeping wake all land here). Releases AT MOST one frame (the budget). Returns
/// `true` when a frame was released to glass this call.
#[allow(clippy::too_many_arguments)] // one call site; the seams are the point
pub(super) fn pump(
&mut self,
codec: &MediaCodec,
clock: Option<&VsyncShared>,
tracker: &DisplayTracker,
meter: &PresentMeter,
stats: &crate::stats::VideoStats,
now_mono_ns: i64,
) -> bool {
// Budget bookkeeping first: reopen on the predicted latch, force-open on the backstop.
if let Some(f) = &self.inflight {
if now_mono_ns >= f.reopen_at_ns {
self.inflight = None;
} else if now_mono_ns - f.released_at_ns > STALE_REOPEN_NS {
self.forced += 1;
self.inflight = None;
}
}
// The measured rail beneath that prediction (see `UNDISPLAYED_CAP`). Evaluated on every
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
// rather than how often a frame happened to be ready.
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
// Pick the frame this pump may release.
let frame = if self.fifo_capacity == 0 {
self.frames.pop_back() // submit() kept it a single slot; back == the newest
} else {
// FIFO: drain exactly one frame per vsync tick, after preroll; a drain tick that
// finds the buffer dry re-arms preroll (the Apple `FrameStore` underflow semantics —
// the previous frame persists on glass, a repeat by omission, while headroom
// rebuilds). Everything is gated on the tick so an idle stream neither counts
// underflows nor churns the preroll flag 200×/s.
if !self.vsync_tick {
return false;
}
if !self.prerolled {
if self.frames.len() < self.fifo_capacity {
return false;
}
self.prerolled = true;
}
if self.frames.is_empty() {
self.prerolled = false;
self.dry += 1;
self.vsync_tick = false; // this tick's drain ran (and found nothing)
return false;
}
self.frames.pop_front()
};
let Some(frame) = frame else { return false };
if self.inflight.is_some() || backlogged {
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
// vsync tick / loop pass retries the pairing.
if backlogged {
self.queue_waits += 1;
}
self.no_budget += 1;
match self.fifo_capacity {
0 => self.frames.push_back(frame),
_ => self.frames.push_front(frame),
}
return false;
}
// Release: timeline-timed when the clock has one, ASAP otherwise.
let target = clock.and_then(|c| c.next_target(now_mono_ns, self.margin_ns));
let released = match target {
Some(t) => codec
.release_output_buffer_at_time_by_index(frame.index, t.expected_present_ns)
.map_err(|e| log::warn!("presenter: release_at_time({}): {e}", frame.index)),
None => codec
.release_output_buffer_by_index(frame.index, true)
.map_err(|e| log::warn!("presenter: release({}): {e}", frame.index)),
};
self.vsync_tick = false;
if released.is_err() {
return false; // the buffer is gone either way; nothing to book-keep
}
let period = clock.map(|c| c.period_ns()).filter(|&p| p > 0);
// Reopen at SurfaceFlinger's LATCH for the targeted vsync (expected present minus the
// latch lead) — the instant SF consumes the queued buffer and the slot frees, so the
// next release can target the NEXT refresh. Not the platform `deadline` (with the
// aggressive present gate it can already be in the past — an instant reopen would let
// two releases pile onto the same vsync) and not the present time itself (a period too
// late — it would cap the sustainable rate at roughly half the panel).
let reopen_at_ns = target
.map(|t| t.expected_present_ns - self.margin_ns)
.unwrap_or(now_mono_ns + period.unwrap_or(FALLBACK_PERIOD_NS));
self.inflight = Some(InFlight {
reopen_at_ns,
released_at_ns: now_mono_ns,
});
self.released += 1;
meter.note_released();
let release_real_ns = now_realtime_ns();
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
if self.pace_us.len() < 4096 {
self.pace_us.push(pace_us);
}
stats.note_release(pace_us);
tracker.note_rendered(frame.pts_us, frame.decoded_ns, release_real_ns);
true
}
/// Whether SurfaceFlinger is sitting on too many unconfirmed releases to be handed another.
///
/// The predicted reopen is only as good as the panel grid behind it; this is the measured
/// rail underneath it (see [`UNDISPLAYED_CAP`]). It self-clears two ways — the confirms catch
/// up, or [`STALE_REOPEN_NS`] passes with the backlog stuck, which means the ledger itself is
/// unreliable (callbacks dropped under load, or SF discarded the buffers) and is written off
/// rather than allowed to wedge the stream.
fn unconfirmed_backlog(&mut self, meter: &PresentMeter, now_ns: i64) -> bool {
let (outstanding, confirms_live) = meter.outstanding();
if !confirms_live || outstanding < UNDISPLAYED_CAP {
self.backed_up_since = None;
return false;
}
match self.backed_up_since {
Some(t) if now_ns - t > STALE_REOPEN_NS => {
meter.forgive_outstanding();
self.backed_up_since = None;
self.forced += 1;
false
}
_ => {
self.backed_up_since.get_or_insert(now_ns);
true
}
}
}
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
while let Some(f) = self.frames.pop_front() {
release_unrendered(codec, f.index);
}
self.inflight = None;
}
/// The 1 Hz `pf-present` logcat mirror (target `pf.present`) — the Apple client's Console
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
/// `qDry` (FIFO underflows) / `qWait` (pumps held back by unconfirmed releases — 0 when
/// healthy) / `unconfirmed` (releases OnFrameRendered hasn't settled) /
/// `pace` (decoded→release) / `latch` (release→displayed) /
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
/// A/B headline) / `vsync` (the measured panel period).
///
/// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
/// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal
/// (design/phase-locked-capture.md §6; the v1 median was immovable under jitter).
pub(super) fn flush_log(
&mut self,
meter: &PresentMeter,
clock: Option<&VsyncShared>,
) -> Option<(u64, u16)> {
if self.last_flush.elapsed() < std::time::Duration::from_secs(1) {
return None;
}
self.last_flush = Instant::now();
let (latch, displays, feed, codec, e2e) = meter.drain();
if self.released == 0 && displays == 0 {
return None; // idle stream — nothing worth a line
}
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us));
let (feed_p50, feed_max) = p50_max_ms(feed);
let (codec_p50, codec_max) = p50_max_ms(codec);
let (e2e_p50, e2e_max) = p50_max_ms(e2e);
let circ = clock.and_then(|c| {
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
});
let latch_samples = latch.len();
let (latch_p50, latch_max) = p50_max_ms(latch);
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
let (outstanding, _) = meter.outstanding();
log::info!(
target: "pf.present",
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
qWait={} unconfirmed={} \
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
vsyncMs={:.2} panelMs={:.2}",
self.released,
displays,
self.paced_drops,
self.no_budget,
self.forced,
self.dry,
self.queue_waits,
outstanding,
pace_p50,
pace_max,
latch_p50,
latch_max,
feed_p50,
feed_max,
codec_p50,
codec_max,
e2e_p50,
e2e_max,
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
circ.map(|(_, c)| c).unwrap_or(0),
period_ms,
panel_ns as f64 / 1e6,
);
self.released = 0;
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
// `now + margin`, so a frame that makes its vsync is on glass within one panel period of
// that margin; beyond it, SurfaceFlinger wanted more lead and the frame waited out an
// extra refresh. Widen toward the pre-sweep ceiling. One-way by design: a margin that
// once proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
//
// ⚠ NOT `paced_drops`, which 0.23.0 used: those are the newest-wins store's own policy
// evictions — a second frame decoding while one is held — which happen whenever the
// stream out-runs the panel and say nothing at all about SF's latch lead. Driving the
// margin from them widened it to the ceiling on healthy devices, re-imposing the 2.5 ms
// of pure display latency the P2e sweep had just measured away.
let latch_p50_ns = (latch_p50 * 1e6) as i64;
if !self.margin_pinned
&& self.margin_ns < LATCH_MARGIN_NS
&& panel_ns > 0
&& latch_samples >= 8
&& latch_p50_ns > panel_ns + self.margin_ns
{
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
log::warn!(
"presenter: latch p50 {:.2}ms over the {:.2}ms panel period — margin widened to {}us",
latch_p50,
panel_ns as f64 / 1e6,
self.margin_ns / 1_000
);
}
if self.queue_waits > 0 {
log::warn!(
"presenter: {} pump(s) held back — {} release(s) still unconfirmed by \
OnFrameRendered (the display is not keeping up with the release rate)",
self.queue_waits,
outstanding
);
}
self.paced_drops = 0;
self.no_budget = 0;
self.forced = 0;
self.dry = 0;
self.queue_waits = 0;
circ
}
}
fn release_unrendered(codec: &MediaCodec, index: usize) {
if let Err(e) = codec.release_output_buffer_by_index(index, false) {
log::warn!("presenter: release_output_buffer({index}, false): {e}");
}
}
/// `debug.punktfunk.presenter` sysprop: `arrival` = the legacy release-immediately path,
/// anything else / unset = the timeline presenter. The rebuild-free on-device A/B lever.
pub(super) fn presenter_disabled_by_sysprop() -> bool {
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
let n = unsafe {
libc::__system_property_get(
c"debug.punktfunk.presenter".as_ptr(),
buf.as_mut_ptr().cast(),
)
};
n > 0 && &buf[..n as usize] == b"arrival"
}
+14 -6
View File
@@ -180,8 +180,14 @@ pub(super) fn boost_thread_priority() {
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
/// phone. Falls through to the 2-arg hint on API 30.
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) — the seamless-preferred hint for
/// phones/tablets and the universal fallback.
///
/// Both paths pass `compatibility = FIXED_SOURCE` (1): the stream is fixed-rate video content the
/// client cannot re-pace, which is exactly what that value declares — `DEFAULT` (0) told the
/// platform the app could adapt to whatever rate it picked, an invitation some OEM refresh
/// governors accepted by simply not switching. (The window-level `preferredDisplayModeId` pin in
/// `MainActivity.setStreamDisplayMode` is the phone-side belt to this braces.)
///
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
/// decline.
@@ -200,8 +206,10 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
if lib.is_null() {
return false;
}
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
// ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE — fixed-rate video content.
const FIXED_SOURCE: i8 = 1;
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 =
// ALWAYS). Absent on API 30 ⇒ fall through to the 2-arg hint below.
if is_tv {
let sym = libc::dlsym(
lib,
@@ -209,7 +217,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
);
if !sym.is_null() {
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
return set(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE, 1) == 0;
}
}
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
@@ -217,7 +225,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
return false; // device API < 30 — no per-surface frame-rate hint
}
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE) == 0
}
}
+10 -2
View File
@@ -44,6 +44,10 @@ pub(super) fn run_sync(
ll_feature,
low_latency_mode,
is_tv,
// The timeline presenter lives in the async loop only; this loop IS the escape hatch.
present_priority: _,
smooth_buffer: _,
panel_hz: _,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -181,7 +185,11 @@ pub(super) fn run_sync(
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below.
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
let tracker = DisplayTracker::new(
stats.clone(),
clock_offset.clone(),
std::sync::Arc::new(super::presenter::PresentMeter::new()),
);
let render_cb = install_render_callback(&codec, &tracker);
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
@@ -598,7 +606,7 @@ fn drain(
Ok(()) if held_present => {
rendered = 1;
if let Some((pts_us, decoded_ns)) = meta {
tracker.note_rendered(pts_us, decoded_ns);
tracker.note_rendered(pts_us, decoded_ns, super::latency::now_realtime_ns());
}
}
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
+458
View File
@@ -0,0 +1,458 @@
//! The vsync clock behind the timeline presenter: an `AChoreographer` thread publishing the
//! panel's vsync grid + upcoming frame timelines, and pulsing the decode loop's event channel so
//! a frame parked on a closed glass budget gets its retry tick.
//!
//! On API 33+ the thread rides `AChoreographer_postVsyncCallback`, whose callback payload carries
//! the platform's FRAME TIMELINES — for each upcoming refresh, when SurfaceFlinger expects to
//! present and the deadline by which a frame must be submitted to make it. That pair is exactly
//! what `AMediaCodec_releaseOutputBufferAtTime` wants as its target. On 31/32 the older
//! `postFrameCallback64` supplies only the vsync instant; the presenter then releases ASAP
//! (identical to the legacy path) and uses the measured period purely to predict the latch for
//! its glass budget.
//!
//! Every `AChoreographer_*` symbol is dlsym-resolved from `libandroid.so` (mirrors
//! [`super::setup::try_set_frame_rate`]): several sit above the crate's API floor, and one hard
//! import of a too-new symbol fails `System.loadLibrary` on every older device.
//!
//! Started LAZILY on the first decoded frame (the Apple deadline presenter's bootstrap lesson:
//! an eagerly started clock ticks uselessly for the whole connect window), stopped + joined via
//! [`VsyncClock`]'s `Drop`.
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// `CLOCK_MONOTONIC` now in nanoseconds — the clock AChoreographer stamps its timelines on and
/// the one `AMediaCodec_releaseOutputBufferAtTime` compares against (`System.nanoTime` basis).
/// Distinct from the stats path's `CLOCK_REALTIME`: presenter scheduling stays monotonic.
pub(super) fn now_monotonic_ns() -> i64 {
let mut ts = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
// Explicit widening: timespec's fields are 32-bit on armv7 (time_t/c_long).
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
}
/// One upcoming frame timeline (API 33+ payload): when SurfaceFlinger expects to present the
/// frame, and the last instant it can be submitted to make that present. Monotonic ns.
#[derive(Clone, Copy)]
pub(super) struct FrameTimeline {
pub expected_present_ns: i64,
pub deadline_ns: i64,
}
/// State the choreographer thread publishes and the decode loop reads. All monotonic ns.
pub(super) struct VsyncShared {
stop: AtomicBool,
/// The latest vsync callback's frame time (0 = no callback yet).
last_vsync_ns: AtomicI64,
/// Estimated vsync period (EMA over callback deltas / timeline spacing; 0 = unmeasured).
///
/// ⚠ This is the APP's render rate, not necessarily the panel's: Android down-rates a
/// process's vsync stream (frame-rate categories / per-uid overrides), so a quiet UI can be
/// served 60 Hz callbacks while the panel scans at 120 (observed on-glass, A024). Pacing
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
/// [`Self::next_target`].
period_ns: AtomicI64,
/// The panel's own refresh period — the grid SurfaceFlinger actually latches on (0 = unknown).
/// Seeded from the display mode Kotlin resolved at stream start and then corrected by
/// measurement; the learner itself is [`punktfunk_core::phase::PanelGrid`], owned by the
/// choreographer thread (see [`CallbackCtx::panel`]) and published here for the decode loop.
panel_period_ns: AtomicI64,
/// Callback count, for the one-shot cadence diagnostic log.
ticks: std::sync::atomic::AtomicU32,
/// The latest callback's upcoming timelines, soonest first. Empty on the 31/32 fallback.
timelines: Mutex<Vec<FrameTimeline>>,
}
impl VsyncShared {
/// The measured vsync period, or 0 while unmeasured.
pub(super) fn period_ns(&self) -> i64 {
self.period_ns.load(Ordering::Relaxed)
}
/// The panel's own refresh period (0 = unknown) — for the pf-present line's decomposition.
pub(super) fn panel_period_ns(&self) -> i64 {
self.panel_period_ns.load(Ordering::Relaxed)
}
/// The release target for a frame submitted at `now`: the earliest stored timeline whose
/// EXPECTED PRESENT is still `margin` away, extrapolated forward by whole periods once the
/// stored set has aged out (timelines refresh once per vsync callback; a frame can decode
/// anywhere inside that window). `None` on the 31/32 fallback — the caller releases ASAP.
///
/// Gated on `expected_present`, NOT the timeline's `deadline`, on purpose: the deadline
/// budgets for GPU rendering the app has yet to submit (`presDeadline` — 11.3 ms on the
/// A024, more than a full 120 Hz period), but a video buffer is already fully rendered —
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller's
/// `margin` represents. Targeting by deadline cost every frame an extra refresh of waiting
/// (measured: latch p50 ~21 ms vs the ~2-interval floor); a mis-gamble here just means the
/// frame presents one vsync later — exactly what the conservative gate always paid.
///
/// The picked target is then SUBDIVIDED onto the panel grid: the platform reports timelines
/// at the app's assigned render rate, but the panel latches at its own — when the app is
/// down-rated (60 Hz callbacks on a 120 Hz panel) the reported timelines are a whole panel
/// period apart or more, and pacing to them would cap the video. Pulling the target earlier
/// by whole panel periods (while its present still clears the margin) restores the true
/// grid; when callbacks run at the panel rate the pull condition is never true and this is
/// a no-op.
pub(super) fn next_target(&self, now_ns: i64, margin_ns: i64) -> Option<FrameTimeline> {
let mut t = {
let g = self
.timelines
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let found = g
.iter()
.find(|t| t.expected_present_ns > now_ns + margin_ns)
.copied();
match found {
Some(t) => t,
None => {
let last = g.last().copied()?;
let period = self.period_ns();
if period <= 0 {
return None;
}
// All stored timelines have passed — step the last one forward whole
// periods until its present clears `now + margin` again.
let behind = (now_ns + margin_ns).saturating_sub(last.expected_present_ns);
let k = behind / period + 1;
FrameTimeline {
expected_present_ns: last.expected_present_ns + k * period,
deadline_ns: last.deadline_ns + k * period,
}
}
}
};
let panel = self.panel_period_ns.load(Ordering::Relaxed);
if panel > 0 {
while t.expected_present_ns - panel > now_ns + margin_ns {
t.deadline_ns -= panel;
t.expected_present_ns -= panel;
}
}
Some(t)
}
}
// ---- dlsym'd AChoreographer surface ----
type PostFrameCallback64 =
unsafe extern "C" fn(*mut c_void, unsafe extern "C" fn(i64, *mut c_void), *mut c_void);
type PostVsyncCallback = unsafe extern "C" fn(
*mut c_void,
unsafe extern "C" fn(*const c_void, *mut c_void),
*mut c_void,
);
struct ChoreoApi {
get_instance: unsafe extern "C" fn() -> *mut c_void,
/// API 33: vsync callback with frame-timeline payload. Preferred.
post_vsync: Option<PostVsyncCallback>,
/// API 29 fallback: frame callback with only the vsync instant.
post_frame64: Option<PostFrameCallback64>,
// AChoreographerFrameCallbackData accessors (API 33; present iff `post_vsync` is).
fcd_frame_time: Option<unsafe extern "C" fn(*const c_void) -> i64>,
fcd_timelines_len: Option<unsafe extern "C" fn(*const c_void) -> usize>,
fcd_preferred_index: Option<unsafe extern "C" fn(*const c_void) -> usize>,
fcd_expected_present: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
fcd_deadline: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
}
impl ChoreoApi {
/// Resolve from `libandroid.so`. `None` when even the baseline symbols are missing.
fn resolve() -> Option<ChoreoApi> {
// SAFETY: dlopen of the always-mapped libandroid.so (refcount bump, never closed); each
// dlsym is null-checked before the transmute to its fn-pointer type.
unsafe {
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
if lib.is_null() {
return None;
}
let sym = |name: &std::ffi::CStr| {
let p = libc::dlsym(lib, name.as_ptr());
(!p.is_null()).then_some(p)
};
let get_instance = sym(c"AChoreographer_getInstance")?;
let post_vsync = sym(c"AChoreographer_postVsyncCallback");
let post_frame64 = sym(c"AChoreographer_postFrameCallback64");
post_vsync.or(post_frame64)?; // neither post entry point — no clock on this device
Some(ChoreoApi {
get_instance: std::mem::transmute::<
*mut c_void,
unsafe extern "C" fn() -> *mut c_void,
>(get_instance),
post_vsync: post_vsync.map(|p| std::mem::transmute::<*mut c_void, PostVsyncCallback>(p)),
post_frame64: post_frame64
.map(|p| std::mem::transmute::<*mut c_void, PostFrameCallback64>(p)),
fcd_frame_time: sym(c"AChoreographerFrameCallbackData_getFrameTimeNanos").map(|p| {
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> i64>(p)
}),
fcd_timelines_len: sym(c"AChoreographerFrameCallbackData_getFrameTimelinesLength")
.map(|p| {
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(
p,
)
}),
fcd_preferred_index: sym(
c"AChoreographerFrameCallbackData_getPreferredFrameTimelineIndex",
)
.map(|p| {
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(p)
}),
fcd_expected_present: sym(
c"AChoreographerFrameCallbackData_getFrameTimelineExpectedPresentationTimeNanos",
)
.map(|p| {
std::mem::transmute::<
*mut c_void,
unsafe extern "C" fn(*const c_void, usize) -> i64,
>(p)
}),
fcd_deadline: sym(c"AChoreographerFrameCallbackData_getFrameTimelineDeadlineNanos")
.map(|p| {
std::mem::transmute::<
*mut c_void,
unsafe extern "C" fn(*const c_void, usize) -> i64,
>(p)
}),
})
}
}
}
/// Everything a callback invocation needs. Owned by the choreographer thread's stack; callbacks
/// only ever fire inside that thread's looper poll, so the borrow can't outlive the thread.
struct CallbackCtx {
api: ChoreoApi,
choreographer: *mut c_void,
shared: Arc<VsyncShared>,
on_tick: Box<dyn Fn() + Send>,
/// The panel-period learner. `Cell` rather than an atomic because it is touched from exactly
/// one thread — callbacks only ever fire inside this thread's looper poll (see the struct
/// doc) — and its streak state is nobody else's business; only the settled period is
/// published, to `shared.panel_period_ns`.
panel: std::cell::Cell<punktfunk_core::phase::PanelGrid>,
}
impl CallbackCtx {
/// Common tail of both callback flavours: update the grid estimate, publish, pulse, re-arm.
fn tick(&self, frame_time_ns: i64, timelines: Vec<FrameTimeline>) {
let prev = self
.shared
.last_vsync_ns
.swap(frame_time_ns, Ordering::Relaxed);
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and therefore the
// only honest witness to what the panel is doing — the configured mode is not (under a
// per-uid frame-rate override `Display.getRefreshRate` REPORTS THE OVERRIDE, observed
// on-glass: a 120 Hz panel read back as 60 while its timelines ran at 8.28 ms), and
// neither is the mode Kotlin *requested* (`preferredDisplayModeId` is a hint the system
// may refuse). Both directions matter and the asymmetry lives in `PanelGrid`.
if timelines.len() >= 2 {
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
let mut grid = self.panel.get();
if grid.observe(spacing) {
self.shared
.panel_period_ns
.store(grid.period_ns(), Ordering::Relaxed);
log::info!(
"vsync: panel grid now {:.2}ms",
grid.period_ns() as f64 / 1e6
);
}
self.panel.set(grid);
}
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
// panel period is exactly the down-rating question, and this line answers it on-glass.
if self.shared.ticks.fetch_add(1, Ordering::Relaxed) == 2 {
let spacing = if timelines.len() >= 2 {
timelines[1].expected_present_ns - timelines[0].expected_present_ns
} else {
0
};
log::info!(
"vsync: cadence Δ={:.2}ms timelines={} spacing={:.2}ms panel={:.2}ms",
if prev > 0 {
(frame_time_ns - prev) as f64 / 1e6
} else {
0.0
},
timelines.len(),
spacing as f64 / 1e6,
self.shared.panel_period_ns.load(Ordering::Relaxed) as f64 / 1e6,
);
}
// Period: prefer timeline spacing (exact, straight from the platform), else the delta of
// successive callbacks (jittery — EMA'd), clamped to sane panel rates (24..500 Hz).
let mut period = 0i64;
if timelines.len() >= 2 {
period = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
} else if prev > 0 {
period = frame_time_ns - prev;
}
if (2_000_000..=42_000_000).contains(&period) {
let old = self.shared.period_ns.load(Ordering::Relaxed);
let smoothed = if old > 0 {
(old * 7 + period) / 8
} else {
period
};
self.shared.period_ns.store(smoothed, Ordering::Relaxed);
}
if !timelines.is_empty() {
let mut g = self
.shared
.timelines
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*g = timelines;
}
(self.on_tick)();
if !self.shared.stop.load(Ordering::Relaxed) {
self.repost();
}
}
fn repost(&self) {
// SAFETY: `choreographer` is this thread's instance; the ctx pointer stays valid for the
// thread's life and callbacks only fire on this thread (see the struct doc).
unsafe {
let ud = self as *const CallbackCtx as *mut c_void;
if let Some(post) = self.api.post_vsync {
post(self.choreographer, on_vsync, ud);
} else if let Some(post) = self.api.post_frame64 {
post(self.choreographer, on_frame64, ud);
}
}
}
}
/// API 33+ trampoline: harvest the frame timelines, then the common tick. Panic-free (an unwind
/// out of an `extern "C"` fn aborts).
unsafe extern "C" fn on_vsync(data: *const c_void, ud: *mut c_void) {
// SAFETY: `ud` is the thread's `CallbackCtx`, alive for the whole poll loop (see struct doc).
let ctx = unsafe { &*(ud as *const CallbackCtx) };
let api = &ctx.api;
let (mut frame_time, mut timelines) = (now_monotonic_ns(), Vec::new());
// SAFETY: `data` is the platform's callback payload, valid for this invocation; the accessors
// were resolved together with `post_vsync` (same API level) and are only called when present.
unsafe {
if let Some(f) = api.fcd_frame_time {
frame_time = f(data);
}
if let (Some(len_f), Some(pref_f), Some(exp_f), Some(dl_f)) = (
api.fcd_timelines_len,
api.fcd_preferred_index,
api.fcd_expected_present,
api.fcd_deadline,
) {
let len = len_f(data).min(8);
// From the PREFERRED index on: earlier timelines are ones the platform already
// considers missed for a frame starting now.
let start = pref_f(data).min(len);
timelines = (start..len)
.map(|i| FrameTimeline {
expected_present_ns: exp_f(data, i),
deadline_ns: dl_f(data, i),
})
.collect();
}
}
ctx.tick(frame_time, timelines);
}
/// API 29 fallback trampoline: vsync instant only.
unsafe extern "C" fn on_frame64(frame_time_ns: i64, ud: *mut c_void) {
// SAFETY: `ud` is the thread's `CallbackCtx` (see `on_vsync`).
let ctx = unsafe { &*(ud as *const CallbackCtx) };
ctx.tick(frame_time_ns, Vec::new());
}
/// The clock: a dedicated looper thread the choreographer calls back on. Dropping stops + joins.
pub(super) struct VsyncClock {
shared: Arc<VsyncShared>,
join: Option<std::thread::JoinHandle<()>>,
}
impl VsyncClock {
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
/// `panel_hz` SEEDS the panel-grid learner (0 = unknown) — the latch grid that
/// [`VsyncShared::next_target`] subdivides onto. A seed, not a fact: it names the display
/// mode Kotlin *requested*, and the observed timeline spacing is what settles it. `None` when the platform surface is missing
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
/// budget).
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
let api = ChoreoApi::resolve()?;
let timelines_live = api.post_vsync.is_some();
let shared = Arc::new(VsyncShared {
stop: AtomicBool::new(false),
last_vsync_ns: AtomicI64::new(0),
period_ns: AtomicI64::new(0),
panel_period_ns: AtomicI64::new(
punktfunk_core::phase::PanelGrid::seeded(panel_hz).period_ns(),
),
ticks: std::sync::atomic::AtomicU32::new(0),
timelines: Mutex::new(Vec::new()),
});
let thread_shared = shared.clone();
let join = std::thread::Builder::new()
.name("pf-vsync".into())
.spawn(move || {
let looper = ndk::looper::ThreadLooper::prepare();
// SAFETY: getInstance on a thread with a prepared looper returns this thread's
// choreographer (never null once a looper exists).
let choreographer = unsafe { (api.get_instance)() };
if choreographer.is_null() {
log::warn!("vsync: AChoreographer_getInstance returned null — no clock");
return;
}
let ctx = CallbackCtx {
api,
choreographer,
shared: thread_shared,
on_tick,
panel: std::cell::Cell::new(punktfunk_core::phase::PanelGrid::seeded(panel_hz)),
};
ctx.repost();
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
// case teardown waits one timeout out. Callbacks fire inside poll_once_timeout.
while !ctx.shared.stop.load(Ordering::Relaxed) {
let _ = looper.poll_once_timeout(Duration::from_millis(250));
}
// `ctx` drops here — after the loop, so no queued callback can outlive it (they
// only ever fire inside this thread's poll).
})
.ok()?;
log::info!(
"vsync: choreographer clock started ({})",
if timelines_live {
"frame timelines"
} else {
"frame callback fallback"
}
);
Some(VsyncClock {
shared,
join: Some(join),
})
}
pub(super) fn shared(&self) -> &Arc<VsyncShared> {
&self.shared
}
}
impl Drop for VsyncClock {
fn drop(&mut self) {
self.shared.stop.store(true, Ordering::Relaxed);
if let Some(j) = self.join.take() {
let _ = j.join();
}
}
}
+178 -46
View File
@@ -1,16 +1,22 @@
//! Android microphone uplink (android-only): capture mic PCM via AAudio (LowLatency **input**),
//! Opus-encode 20 ms stereo frames, and push them to the host over the connector's mic plane
//! Opus-encode 10 ms mono frames, and push them to the host over the connector's mic plane
//! (`send_mic` → 0xCB datagram). The mirror of [`crate::audio`] in reverse: AAudio's realtime input
//! callback hands captured interleaved f32 to a channel; a worker thread we own does the Opus
//! encode + send (encoding is too heavy for the realtime callback, exactly as decode is on the
//! playback side). Like the playback path, the realtime callback is allocation-free: captured
//! bursts are copied into pre-allocated buffers from a recycle free-list (pool empty = drop the
//! chunk, never allocate on the capture thread). Format matches the host decoder + the Linux
//! client: 48 kHz **stereo**, 20 ms, Opus VOIP.
//! callback hands captured f32 to a channel; a worker thread we own does the Opus encode + send
//! (encoding is too heavy for the realtime callback, exactly as decode is on the playback side).
//! Like the playback path, the realtime callback is allocation-free: captured bursts are copied
//! into pre-allocated buffers from a recycle free-list (pool empty = drop the chunk, never
//! allocate on the capture thread). Format: 48 kHz **mono**, 10 ms, Opus VOIP with in-band FEC —
//! the host decodes any Opus frame ≤ 120 ms with its stereo decoder (mono packets upmix), so this
//! needs no protocol change; speech gains nothing from stereo, and the shorter frame shaves a
//! buffering interval off the uplink.
//!
//! **Mute** is a flag the encode loop reads per 10 ms frame, never a stream teardown: the AAudio
//! input stream, the input-preset ladder it settled on and its primed buffers all survive a
//! mute/unmute untouched, so toggling costs an atomic load and nothing else.
use ndk::audio::{
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode,
AudioStream, AudioStreamBuilder,
AudioCallbackResult, AudioDirection, AudioFormat, AudioInputPreset, AudioPerformanceMode,
AudioSharingMode, AudioStream, AudioStreamBuilder, SessionId,
};
use punktfunk_core::client::NativeClient;
use std::collections::VecDeque;
@@ -20,31 +26,57 @@ use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender, TryS
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const CHANNELS: usize = 2;
const CHANNELS: usize = 1;
const SAMPLE_RATE: i32 = 48_000;
/// 20 ms per channel @ 48 kHz — the Linux client's frame; the host accepts ≤ 120 ms.
const FRAME_SAMPLES: usize = 960;
/// 10 ms per channel @ 48 kHz — half the desktop clients' 20 ms frame, trading a little Opus
/// header overhead for one less buffered interval; the host accepts ≤ 120 ms.
const FRAME_SAMPLES: usize = 480;
/// Captured-chunk hand-off depth (each ~ one burst); drops on overflow (best-effort uplink).
/// Bursts are sized in frames, so the wall-time depth is unchanged by the stereo→mono move.
const RING_CHUNKS: usize = 64;
/// Free-list buffer capacity, in interleaved f32 samples: comfortably above a LowLatency input
/// burst (typically ≤ ~480 frames). A device with larger bursts costs each buffer a one-time grow
/// on the capture thread, after which the steady state is allocation-free again.
const CHUNK_CAP_SAMPLES: usize = 1920; // 20 ms stereo
/// Opus VOIP target bitrate (speech; tunable).
const MIC_BITRATE: i32 = 64_000;
/// burst (typically ≤ ~480 frames — mono, so samples = frames). A device with larger bursts costs
/// each buffer a one-time grow on the capture thread, after which the steady state is
/// allocation-free again.
const CHUNK_CAP_SAMPLES: usize = 960; // 20 ms mono — the same wall-time as the old stereo value
/// Opus VOIP target bitrate (mono speech; tunable).
const MIC_BITRATE: i32 = 48_000;
/// Encode-side self-heal threshold, in queued 10 ms frames (~60 ms): waking to more than this
/// means the uplink stalled — and because the capture callback drops the NEWEST chunk when the
/// channel is full, a stall otherwise converts to standing mic delay that never drains (real-time
/// playback host-side never makes time back up). Skip to the newest few frames instead.
const BACKLOG_MAX_FRAMES: usize = 6;
/// What a self-heal keeps: ~20 ms of the freshest audio (one audible blip, live again).
const BACKLOG_KEEP_FRAMES: usize = 2;
/// Owned by [`crate::session::SessionHandle`]: the live AAudio input stream + the encode thread.
pub struct MicCapture {
_stream: AudioStream, // dropping it stops + closes the AAudio input stream
/// The audio-session id AAudio allocated (`> 0`) when echo cancellation asked for one — the
/// hook Kotlin hangs the Java `AcousticEchoCanceler`/`NoiseSuppressor` on. `0` = none.
session_id: i32,
shutdown: Arc<AtomicBool>,
join: Option<std::thread::JoinHandle<()>>,
}
impl MicCapture {
/// Open AAudio (LowLatency, 48 kHz/stereo/f32) for **input** with a realtime callback that
/// forwards captured PCM to a channel, then spawn the Opus encode + uplink thread. `None` on
/// failure (the caller leaves the rest of the session streaming).
pub fn start(client: Arc<NativeClient>) -> Option<MicCapture> {
/// Open AAudio (LowLatency, 48 kHz/mono/f32) for **input** with a realtime callback that
/// forwards captured PCM to a channel, then spawn the Opus encode + uplink thread. With
/// `echo_cancel` the stream opens under the `VoiceCommunication` input preset the HAL's own
/// echo canceller / noise suppressor on the capture path (the default `VoiceRecognition`
/// preset deliberately bypasses them, which is why the host used to hear its own stream back
/// from a speaker-playing phone) — and allocates an audio session id for Kotlin's Java-effect
/// backstop. `None` on failure (the caller leaves the rest of the session streaming).
///
/// `muted` is the SESSION's live mic-mute flag (owned by `SessionHandle`, not by this capture),
/// honoured per frame by [`encode_loop`]. Sharing it rather than owning it is what makes mute
/// survive the mic stop/start a surface recreate performs — and means a capture started while
/// muted never encodes its first frame, so there is no window for one to escape.
pub fn start(
client: Arc<NativeClient>,
echo_cancel: bool,
muted: Arc<AtomicBool>,
) -> Option<MicCapture> {
let captured = Arc::new(AtomicU64::new(0));
// Chunks discarded on the capture thread (free-list empty / encoder lagging); logged
// throttled from the encode worker.
@@ -52,7 +84,9 @@ impl MicCapture {
// One open attempt at a given sharing mode (same pattern as [`crate::audio`]: `open_stream`
// consumes the builder AND the callback, so each try rebuilds the channels it captures).
let try_open = |sharing: AudioSharingMode| -> ndk::audio::Result<(
let try_open = |sharing: AudioSharingMode,
voice: bool|
-> ndk::audio::Result<(
AudioStream,
Receiver<Vec<f32>>,
SyncSender<Vec<f32>>,
@@ -99,13 +133,25 @@ impl MicCapture {
AudioCallbackResult::Continue
};
let stream = AudioStreamBuilder::new()?
// NOTE: no `.frames_per_data_callback(...)`: AAudio's own docs call leaving it unset
// the lowest-latency path (the callback then runs at the device's optimal burst,
// while pinning a size inserts an adaptation buffer), and the encode side re-chunks
// to 10 ms frames regardless of how the bursts arrive.
let mut builder = AudioStreamBuilder::new()?
.direction(AudioDirection::Input)
.sample_rate(SAMPLE_RATE)
.channel_count(CHANNELS as i32)
.format(AudioFormat::PCM_Float)
.performance_mode(AudioPerformanceMode::LowLatency)
.sharing_mode(sharing)
.sharing_mode(sharing);
if voice {
// VoiceCommunication routes the capture through the HAL's AEC/NS; the allocated
// session id (`None` = allocate) is what Kotlin attaches the Java effects to.
builder = builder
.input_preset(AudioInputPreset::VoiceCommunication)
.session_id(None);
}
let stream = builder
.data_callback(Box::new(callback))
.error_callback(Box::new(|_s, e| {
log::warn!("mic: AAudio error (device reroute/disconnect?): {e:?}");
@@ -114,21 +160,52 @@ impl MicCapture {
Ok((stream, rx, free_tx))
};
// Exclusive first — MMAP-exclusive is AAudio's lowest-latency path — falling back to Shared
// when the device refuses (no MMAP, mic claimed, …). The started-log below prints the mode
// the device actually GRANTED (`share=`).
let (stream, rx, free_tx) = match try_open(AudioSharingMode::Exclusive) {
Ok(opened) => opened,
Err(e) => {
log::info!("mic: Exclusive open failed ({e}) — retrying Shared");
match try_open(AudioSharingMode::Shared) {
Ok(opened) => opened,
Err(e) => {
log::error!("mic: open_stream (RECORD_AUDIO granted?): {e}");
return None;
}
// Exclusive first — MMAP-exclusive is AAudio's lowest-latency path — falling back to
// Shared when the device refuses (no MMAP, mic claimed, …); and each sharing mode with
// the voice preset before without it, because some HALs reject VoiceCommunication (or a
// session id) outright and a mic without echo cancellation still beats no mic. The
// ladder's last rungs are exactly the preset-less open this always did. The started-log
// below prints what the device actually GRANTED (`share=`/`session=`).
let attempts: &[(AudioSharingMode, bool)] = if echo_cancel {
&[
(AudioSharingMode::Exclusive, true),
(AudioSharingMode::Shared, true),
(AudioSharingMode::Exclusive, false),
(AudioSharingMode::Shared, false),
]
} else {
&[
(AudioSharingMode::Exclusive, false),
(AudioSharingMode::Shared, false),
]
};
let mut opened = None;
for &(sharing, voice) in attempts {
match try_open(sharing, voice) {
Ok(o) => {
opened = Some(o);
break;
}
Err(e) => log::info!(
"mic: open {sharing:?}{} failed ({e}) — trying the next fallback",
if voice { "+VoiceCommunication" } else { "" },
),
}
}
let (stream, rx, free_tx) = match opened {
Some(o) => o,
None => {
log::error!("mic: open_stream (RECORD_AUDIO granted?): every mode refused");
return None;
}
};
// The session id AAudio actually allocated (only a voice rung asks for one): `> 0` is the
// handle Kotlin hangs the Java AcousticEchoCanceler/NoiseSuppressor off as the HAL
// preset's backstop; `0` = none, nothing to attach.
let session_id = match stream.session_id() {
SessionId::Allocated(id) => id.get(),
SessionId::None => 0,
};
if let Err(e) = stream.request_start() {
@@ -136,7 +213,7 @@ impl MicCapture {
return None;
}
log::info!(
"mic: AAudio input started rate={} ch={} fmt={:?} share={:?}",
"mic: AAudio input started rate={} ch={} fmt={:?} share={:?} session={session_id}",
stream.sample_rate(),
stream.channel_count(),
stream.format(),
@@ -147,15 +224,21 @@ impl MicCapture {
let sd = shutdown.clone();
let join = std::thread::Builder::new()
.name("pf-mic".into())
.spawn(move || encode_loop(client, rx, free_tx, sd, captured, dropped))
.spawn(move || encode_loop(client, rx, free_tx, sd, muted, captured, dropped))
.ok();
Some(MicCapture {
_stream: stream,
session_id,
shutdown,
join,
})
}
/// The audio-session id AAudio allocated (`> 0`; see [`MicCapture::start`]), `0` = none.
pub fn session_id(&self) -> i32 {
self.session_id
}
}
impl Drop for MicCapture {
@@ -168,20 +251,29 @@ impl Drop for MicCapture {
}
}
/// Consumer: drain captured f32 → accumulate → Opus `encode_float` 20 ms stereo frames → `send_mic`.
/// Consumer: drain captured f32 → accumulate → Opus `encode_float` 10 ms mono frames → `send_mic`.
/// Drained chunk buffers go back to the callback's free-list; the encode scratch is reused across
/// frames (only the packet Vec handed to `send_mic` is allocated per frame — it's sent away owned).
///
/// While `muted` is set a formed frame is dropped instead of encoded (see the frame loop) — the
/// capture side keeps running exactly as it does unmuted, so nothing about the stream, its ring or
/// its backlog behaviour changes across a toggle.
fn encode_loop(
client: Arc<NativeClient>,
rx: Receiver<Vec<f32>>,
free_tx: SyncSender<Vec<f32>>,
shutdown: Arc<AtomicBool>,
muted: Arc<AtomicBool>,
captured: Arc<AtomicU64>,
dropped: Arc<AtomicU64>,
) {
// Fold this Opus-encode/uplink thread into the client's hot-thread set so the ADPF session the
// decode thread opens keeps mic encode on a fast core too (the playback side's decode_loop
// does the same). No-op below API 33.
client.register_hot_thread();
let mut enc = match opus::Encoder::new(
SAMPLE_RATE as u32,
opus::Channels::Stereo,
opus::Channels::Mono,
opus::Application::Voip,
) {
Ok(e) => e,
@@ -191,13 +283,21 @@ fn encode_loop(
}
};
let _ = enc.set_bitrate(opus::Bitrate::Bits(MIC_BITRATE));
// Speech tuning: complexity 5 roughly halves encode cost for no audible loss at this rate,
// and in-band FEC at an assumed 10% loss lets the host's decoder reconstruct a dropped
// datagram from its successor instead of playing a hole (the uplink is fire-and-forget).
let _ = enc.set_complexity(5);
let _ = enc.set_inband_fec(true);
let _ = enc.set_packet_loss_perc(10);
let frame = FRAME_SAMPLES * CHANNELS;
let mut ring: VecDeque<f32> = VecDeque::with_capacity(frame * 4);
let mut pcm = vec![0f32; frame]; // reusable encode scratch (one 20 ms frame)
let mut out = vec![0u8; 4000]; // max Opus packet for a 20 ms frame fits easily
let mut pcm = vec![0f32; frame]; // reusable encode scratch (one 10 ms frame)
let mut out = vec![0u8; 4000]; // max Opus packet for a 10 ms frame fits easily
let mut seq: u32 = 0;
let mut sent: u64 = 0;
let mut stale: u64 = 0; // frames shed by the backlog self-heal (see BACKLOG_MAX_FRAMES)
let mut muted_frames: u64 = 0; // frames dropped unencoded because the user muted
let mut peak = 0f32; // loudest |sample| since the last log — tells speech from silence
while !shutdown.load(Ordering::Relaxed) {
@@ -207,11 +307,41 @@ fn encode_loop(
// callback's free-list (dropped only if the pool is momentarily full).
ring.extend(chunk.drain(..));
let _ = free_tx.try_send(chunk);
// Drain whatever else queued while we were away, so a post-stall backlog lands as
// ONE lump the self-heal below can size up — chunk-at-a-time it would be encoded
// (and inflicted on the host as standing delay) before it ever looked deep.
while let Ok(mut chunk) = rx.try_recv() {
ring.extend(chunk.drain(..));
let _ = free_tx.try_send(chunk);
}
}
Err(RecvTimeoutError::Timeout) => continue, // wake to re-check shutdown
Err(RecvTimeoutError::Disconnected) => break,
}
// Self-heal the latency ratchet: a stall (scheduler hiccup, a slow send) queues stale
// audio, and every ms of it would ride the stream as mic delay for the rest of the
// session. Jump to the newest ~20 ms (one audible blip), counting the shed.
if ring.len() > BACKLOG_MAX_FRAMES * frame {
let excess = ring.len() - BACKLOG_KEEP_FRAMES * frame;
ring.drain(..excess);
stale += (excess / frame) as u64;
}
while ring.len() >= frame {
// Muted: drop the frame at the last point before it would become an Opus packet —
// room audio is never encoded and nothing goes on the wire. `seq` does NOT advance:
// it numbers the datagrams the host de-jitters, and that side reads a seq jump as
// loss (conceal + a counted gap) where a mute is a pause. Freezing it means the
// frame after an unmute continues the chain, which is what the host's own
// `reset_stream` doc calls for and what the desktop uplink does. (Encoding silence
// instead would keep a pointless uplink and a host-side ring alive for the whole
// mute.) `peak` is the loudest sample the UPLINK carried since the last log, so a
// dropped frame resets rather than raises it.
if muted.load(Ordering::Relaxed) {
ring.drain(..frame);
muted_frames += 1;
peak = 0.0;
continue;
}
for (dst, src) in pcm.iter_mut().zip(ring.drain(..frame)) {
*dst = src;
}
@@ -227,9 +357,10 @@ fn encode_loop(
let _ = client.send_mic(seq, pts, out[..len].to_vec());
seq = seq.wrapping_add(1);
sent += 1;
if sent % 250 == 0 {
if sent % 500 == 0 {
log::info!(
"mic: sent={sent} captured_frames={} dropped_chunks={} peak={peak:.3}",
"mic: sent={sent} captured_frames={} dropped_chunks={} \
stale_frames={stale} muted_frames={muted_frames} peak={peak:.3}",
captured.load(Ordering::Relaxed),
dropped.load(Ordering::Relaxed),
);
@@ -241,7 +372,8 @@ fn encode_loop(
}
}
log::info!(
"mic: stopped (sent={sent} captured_frames={} dropped_chunks={})",
"mic: stopped (sent={sent} captured_frames={} dropped_chunks={} stale_frames={stale} \
muted_frames={muted_frames})",
captured.load(Ordering::Relaxed),
dropped.load(Ordering::Relaxed),
);
+65 -5
View File
@@ -83,6 +83,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLaten
punktfunk_core::transport::set_dscp_default(enabled != 0);
}
/// `debug.punktfunk.force_parts` = 1: arm slice-progressive parts delivery even when the
/// Kotlin `FEATURE_PartialFrame` probe said no — the rebuild-free on-glass experiment for a
/// decoder that may accept `BUFFER_FLAG_PARTIAL_FRAME` without declaring the feature (the NP3's
/// c2.qti decoders declare nothing). Android-only; everywhere else the probe verdict stands.
#[cfg(target_os = "android")]
fn force_parts_sysprop() -> bool {
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
let n = unsafe {
libc::__system_property_get(
c"debug.punktfunk.force_parts".as_ptr(),
buf.as_mut_ptr().cast(),
)
};
n > 0 && std::str::from_utf8(&buf[..n as usize]).unwrap_or("").trim() == "1"
}
#[cfg(not(target_os = "android"))]
fn force_parts_sysprop() -> bool {
false
}
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch,
/// deviceName): Long`.
@@ -115,6 +137,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
compositor_pref: jint,
gamepad_pref: jint,
hdr_enabled: jboolean,
multi_slice_ok: jboolean,
frame_parts_ok: jboolean,
audio_channels: jint,
video_codecs: jint,
preferred_codec: jint,
@@ -153,6 +177,24 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
} else {
Some((cert, key))
};
// Slice-progressive parts, by decoder truth (Kotlin's FEATURE_PartialFrame probe) — with a
// sysprop escape hatch for the on-glass science question the probe can't answer: does the
// decoder ACTUALLY choke on BUFFER_FLAG_PARTIAL_FRAME input, or does it merely not declare
// the feature? (`adb shell setprop debug.punktfunk.force_parts 1` + stream restart; a codec
// that can't take parts errors recoverably and the reanchor gate + keyframe path recovers.)
let force_parts = force_parts_sysprop();
let frame_parts = frame_parts_ok != 0 || force_parts;
// The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline is
// inert client-side unless BOTH probes pass — this line is the one place that says which.
log::info!(
target: "pf.caps",
"decoder caps: multi_slice={} partial_frame={}{} hdr={} codec_bits={:#x}",
multi_slice_ok != 0,
frame_parts_ok != 0,
if force_parts { " (FORCED by sysprop)" } else { "" },
hdr_enabled != 0,
video_codecs,
);
let pin: Option<[u8; 32]> = if pin_hex.is_empty() {
None
} else {
@@ -182,11 +224,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// sends a proper 8-bit BT.709 stream rather than PQ the panel would mis-tone-map. AMediaCodec
// decodes Main10 from the SPS and the decode loop signals the Surface HDR dataspace + static
// metadata (see crate::decode).
if hdr_enabled != 0 {
// 10-bit/HDR by panel truth (above) + multi-slice by DECODER truth: Kotlin probes every
// decoder this device would use (`VideoDecoders.multiSliceTolerant` — Amlogic wedges the
// whole device on multi-slice AUs, the 0.17.0 field regression) and only then may the
// host default to >1 slice per frame (its sub-frame readback / the P2 slice pipeline).
(if hdr_enabled != 0 {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
} else {
0
},
}) | (if multi_slice_ok != 0 {
punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
} else {
0
}),
// Requested surround layout (2 = stereo / 6 = 5.1 / 8 = 7.1). The host clamps to what it can
// capture and echoes the resolved count in `connector.audio_channels`, which drives the
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
@@ -213,9 +263,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
None,
// No non-video caps: this client does not render the host cursor locally (no shape/state
// planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less.
0,
// No CLIENT_CAP_CURSOR: this client does not render the host cursor locally (no
// shape/state planes in the jni surface) advertising it would stream cursor-less.
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
// should say what the client does).
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
// loop feeds them with BUFFER_FLAG_PARTIAL_FRAME.
frame_parts,
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
@@ -233,6 +291,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
audio: Mutex::new(None),
#[cfg(target_os = "android")]
mic: Mutex::new(None),
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
Box::into_raw(Box::new(handle)) as jlong
}
+113 -42
View File
@@ -185,7 +185,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupport
/// Floats per sample in the `nativeSendPen` flat array.
const PEN_JNI_STRIDE: usize = 10;
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus batch of STATE-FULL
/// Sample ceiling per `nativeSendPen` call: over-cap runs are SPLIT into consecutive ≤8-sample
/// `send_pen` batches (the send_pen contract — never truncated), so this only bounds the stack
/// buffer. 64 samples ≈ >250 ms of 240 Hz history = a pathological UI-thread stall.
const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus emit of STATE-FULL
/// samples, `count` × [`PEN_JNI_STRIDE`] floats, oldest first:
/// `[state, tool, x, y, pressure, distance, tilt_deg, azimuth_deg, roll_deg, dt_us]`.
/// `state` = the wire `PEN_*` bits; `tool` 0=pen 1=eraser; `x`/`y`/`pressure`/`distance`
@@ -203,53 +208,56 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
if handle == 0 || count <= 0 {
return;
}
let count = (count as usize).min(PEN_BATCH_MAX);
let mut buf = [0f32; PEN_BATCH_MAX * PEN_JNI_STRIDE];
let count = (count as usize).min(PEN_JNI_MAX_SAMPLES);
let mut buf = [0f32; PEN_JNI_MAX_SAMPLES * PEN_JNI_STRIDE];
let flat = &mut buf[..count * PEN_JNI_STRIDE];
if env.get_float_array_region(&samples, 0, flat).is_err() {
return; // short array — a bridge bug, never worth a crash on the input path
}
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
for (slot, s) in batch.iter_mut().zip(flat.chunks_exact(PEN_JNI_STRIDE)) {
if !s[2].is_finite() || !s[3].is_finite() {
return; // never forward a NaN coordinate
}
*slot = PenSample {
state: s[0] as u8,
tool: if s[1] as u8 == 1 {
PenTool::Eraser
} else {
PenTool::Pen
},
x: s[2].clamp(0.0, 1.0),
y: s[3].clamp(0.0, 1.0),
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
distance: if s[5] < 0.0 {
PEN_DISTANCE_UNKNOWN
} else {
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
},
tilt_deg: if s[6] < 0.0 {
PEN_TILT_UNKNOWN
} else {
(s[6].clamp(0.0, 90.0)) as u8
},
azimuth_deg: if s[7] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[7] as u16) % 360
},
roll_deg: if s[8] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[8] as u16) % 360
},
dt_us: s[9].clamp(0.0, 65535.0) as u16,
};
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_pen(&batch[..count]);
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
for run in flat.chunks(PEN_BATCH_MAX * PEN_JNI_STRIDE) {
let n = run.len() / PEN_JNI_STRIDE;
for (slot, s) in batch.iter_mut().zip(run.chunks_exact(PEN_JNI_STRIDE)) {
if !s[2].is_finite() || !s[3].is_finite() {
return; // never forward a NaN coordinate
}
*slot = PenSample {
state: s[0] as u8,
tool: if s[1] as u8 == 1 {
PenTool::Eraser
} else {
PenTool::Pen
},
x: s[2].clamp(0.0, 1.0),
y: s[3].clamp(0.0, 1.0),
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
distance: if s[5] < 0.0 {
PEN_DISTANCE_UNKNOWN
} else {
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
},
tilt_deg: if s[6] < 0.0 {
PEN_TILT_UNKNOWN
} else {
(s[6].clamp(0.0, 90.0)) as u8
},
azimuth_deg: if s[7] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[7] as u16) % 360
},
roll_deg: if s[8] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[8] as u16) % 360
},
dt_us: s[9].clamp(0.0, 65535.0) as u16,
};
}
let _ = h.client.send_pen(&batch[..n]);
}
}
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
@@ -406,3 +414,66 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
data,
});
}
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
/// from a client-captured controller (the Sony USB capture), forwarded on the rich-input plane
/// (`RichInput::Touchpad`, 0xCC). `finger`: contact slot 0/1; `x`/`y`: normalized 0..=65535 in
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
/// the capture diffs, the host holds per-slot state.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
finger: jint,
active: jboolean,
x: jint,
y: jint,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::Touchpad {
pad: (pad as u32 & 0xF) as u8,
finger: (finger as u32 & 0x1) as u8,
active: active != 0,
x: (x as i64).clamp(0, 65535) as u16,
y: (y as i64).clamp(0, 65535) as u16,
});
}
/// `NativeBridge.nativeSendPadMotion(handle, pad, gp, gy, gr, ax, ay, az)` — one motion sample
/// from a client-captured controller (`RichInput::Motion`, 0xCC): gyro pitch/yaw/roll + accel,
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
/// controller's report rate.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
gyro_pitch: jint,
gyro_yaw: jint,
gyro_roll: jint,
accel_x: jint,
accel_y: jint,
accel_z: jint,
) {
if handle == 0 {
return;
}
let c = |v: jint| (v as i64).clamp(i64::from(i16::MIN), i64::from(i16::MAX)) as i16;
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::Motion {
pad: (pad as u32 & 0xF) as u8,
gyro: [c(gyro_pitch), c(gyro_yaw), c(gyro_roll)],
accel: [c(accel_x), c(accel_y), c(accel_z)],
});
}
@@ -61,6 +61,13 @@ pub(crate) struct SessionHandle {
audio: Mutex<Option<crate::audio::AudioPlayback>>,
#[cfg(target_os = "android")]
mic: Mutex<Option<crate::mic::MicCapture>>,
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
/// for the same reason the stats gate is: the mic stops and restarts across a surface
/// recreate, and a mute the user set must come back with it — with no window in which the
/// fresh capture could send an unmuted frame. Per session and never persisted: a new session
/// starts unmuted.
pub mic_muted: Arc<AtomicBool>,
}
struct VideoThread {
+133 -25
View File
@@ -10,11 +10,13 @@ use jni::JNIEnv;
use super::{jni_guard, SessionHandle};
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature)`
/// — wrap the SurfaceView's `Surface` as an `ANativeWindow` and start the decode thread rendering
/// onto it. `decoderName` is the codec Kotlin ranked from `MediaCodecList` (`""` = let the platform
/// resolve the default for the MIME); `lowLatencyMode` is the user's master toggle;
/// `lowLatencyFeature` is whether that decoder advertised `FEATURE_LowLatency` (HUD label only).
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
/// and start the decode thread rendering onto it. `decoderName` is the codec Kotlin ranked from
/// `MediaCodecList` (`""` = let the platform resolve the default for the MIME); `lowLatencyMode`
/// is the user's master toggle; `lowLatencyFeature` is whether that decoder advertised
/// `FEATURE_LowLatency` (HUD label only); `presentPriority`/`smoothBuffer` are the timeline
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
/// No-op if already started.
#[cfg(target_os = "android")]
#[no_mangle]
@@ -27,6 +29,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
low_latency_mode: jboolean,
ll_feature: jboolean,
is_tv: jboolean,
present_priority: jni::sys::jint,
smooth_buffer: jni::sys::jint,
panel_fps: jni::sys::jint,
) {
use super::VideoThread;
use std::sync::atomic::AtomicBool;
@@ -70,6 +75,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
ll_feature: ll_feature != 0,
low_latency_mode: low_latency_mode != 0,
is_tv: is_tv != 0,
present_priority,
smooth_buffer,
panel_hz: panel_fps,
};
let join = std::thread::Builder::new()
.name("pf-decode".into())
@@ -169,11 +177,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
}
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
/// (unified stats spec, `design/stats-unification.md`). Returns 26 doubles
/// (unified stats spec, `design/stats-unification.md`). Returns 33 doubles
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
/// e2eDispP50Ms, e2eDispP95Ms]`
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
/// feedP50Ms, codecP50Ms, skippedOverflowWindow]`
/// (the flags are 1.0/0.0; indexes 021 match the previous 22-double layout — 013 the original
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
@@ -186,7 +195,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
/// OnFrameRendered render timestamps — when `dispValid` is 1.0 the HUD headline becomes the
/// directly-measured capture→displayed pair at 24/25 with `display` = decoded→displayed p50 at 23
/// closing the equation, and when 0.0 — no render callback landed this window — it falls back to
/// the capture→decoded headline at 2/3), or `null` when no decode thread is running.
/// the capture→decoded headline at 2/3; 2629 are the timeline presenter's split of the `display`
/// term — `pace` = decoded→release (store + glass budget) p50 at 26, `latch` =
/// release→displayed (SurfaceFlinger) p50 at 27, the window's on-glass confirm count at 28
/// (`presents` vs `fps` is the presenter-health pair), and 29 = 1.0 while the timeline presenter
/// is active this session; 30/31 are the `decode` stage's split p50s — `feed` =
/// received→queued (hand-off + input-slot wait) at 30 and `codec` = queued→decoded (codec-pure,
/// from the AU's last piece) at 31, both 0.0 when no sample landed (sync loop); 32 is the
/// parked-AU overflow subset of the window's `skipped` at 19 (decoder fell behind, vs benign
/// newest-wins pacing)), or `null` when no decode thread is running.
/// Poll ~1 Hz from the UI; each call
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
/// the host build too (Kotlin only ever calls it on device).
@@ -210,7 +227,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
let mode = h.client.mode();
let color = h.client.color;
let buf: [f64; 26] = [
let buf: [f64; 33] = [
snap.fps,
snap.mbps,
snap.e2e_p50_ms,
@@ -251,6 +268,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
snap.display_p50_ms,
snap.e2e_disp_p50_ms,
snap.e2e_disp_p95_ms,
// Timeline-presenter split of the `display` term (pace = decoded→release, latch =
// release→displayed), the window's on-glass confirm count, and whether the presenter
// is active at all (0.0 = legacy release-immediately path — split reads 0 too).
snap.pace_p50_ms,
snap.latch_p50_ms,
snap.presents as f64,
if h.stats.presenter_active() { 1.0 } else { 0.0 },
// The `decode` stage's split (P3 science): feed = received→queued (hand-off +
// input-slot wait), codec = queued→decoded (codec-pure) — and the parked-AU
// overflow subset of `skipped` (decoder-health vs benign pacing drops).
snap.feed_p50_ms,
snap.codec_p50_ms,
snap.skipped_overflow as f64,
];
let arr = match env.new_double_array(buf.len() as jsize) {
Ok(a) => a,
@@ -264,10 +294,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
}
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
/// `[width, height]`. Resolved at the handshake (Welcome), so it is known before a single frame
/// arrives: the UI sizes the video surface to the STREAM's aspect rather than stretching it to the
/// panel's. `null` on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links
/// on the host build too. Cheap; safe on the UI thread.
/// `[width, height, refreshHz]`. Resolved at the handshake (Welcome), so it is known before a
/// single frame arrives: the UI sizes the video surface to the STREAM's aspect rather than
/// stretching it to the panel's, and pins the panel's display mode to the stream refresh. The
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
/// build too. Cheap; safe on the UI thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
env: JNIEnv,
@@ -281,7 +313,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mode = h.client.mode();
let buf: [i32; 2] = [mode.width as i32, mode.height as i32];
let buf: [i32; 3] = [
mode.width as i32,
mode.height as i32,
mode.refresh_hz as i32,
];
let arr = match env.new_int_array(buf.len() as jsize) {
Ok(a) => a,
Err(_) => return std::ptr::null_mut(),
@@ -365,33 +401,49 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
})
}
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
/// No-op if already running or on a `0` handle. Caller MUST hold RECORD_AUDIO; a failure (e.g. no
/// permission) leaves the rest of the session streaming.
/// `NativeBridge.nativeStartMic(handle, echoCancel): Int` — start mic capture (AAudio input →
/// Opus → host `send_mic`). `echoCancel` opens the capture under the `VoiceCommunication` preset
/// (the HAL's echo canceller / noise suppressor) and allocates an audio session id; the return
/// value is that id (`> 0`), so Kotlin can attach the Java `AcousticEchoCanceler`/`NoiseSuppressor`
/// as a backstop — `0` when none was allocated (echoCancel off, the preset fell back to the plain
/// open, a `0` handle, or the mic failed entirely). Already running (a surface recreate) returns
/// the running capture's id. Caller MUST hold RECORD_AUDIO; a failure (e.g. no permission) leaves
/// the rest of the session streaming.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) {
echo_cancel: jboolean,
) -> jni::sys::jint {
if handle == 0 {
return;
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = h.mic.lock().unwrap();
if guard.is_some() {
return; // already capturing
if let Some(m) = guard.as_ref() {
return m.session_id(); // already capturing — same stream, same session
}
match crate::mic::MicCapture::start(h.client.clone()) {
Some(m) => *guard = Some(m),
None => log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)"),
// The capture SHARES the session's mute flag, so one started while muted stays muted (and
// sends nothing) from its very first frame — see `SessionHandle::mic_muted`.
match crate::mic::MicCapture::start(h.client.clone(), echo_cancel != 0, h.mic_muted.clone()) {
Some(m) => {
let session_id = m.session_id();
*guard = Some(m);
session_id
}
None => {
log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)");
0
}
}
}
/// `NativeBridge.nativeStopMic(handle)` — stop + join the mic thread and close the AAudio input
/// stream (without closing the session). No-op on `0`.
/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a
/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
@@ -407,3 +459,59 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
}
})
}
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
///
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
/// it settled on and its primed buffers all stay exactly as they are, and the encode loop simply
/// drops each 10 ms frame instead of encoding + sending it. A stop/start would re-run the preset
/// fallback ladder and re-prime buffers on every toggle — hundreds of ms, and possibly a different
/// rung (echo cancellation silently lost). This way a toggle costs one atomic store here and one
/// relaxed load per frame there, and takes effect on the very next 10 ms boundary.
///
/// Sticky for the SESSION (the flag lives on the handle, not on the capture), so the mic restart a
/// surface recreate performs comes back muted with no window for an unmuted frame to escape; a
/// fresh session always starts unmuted. No-op on `0`. Not android-gated — pure `jni` + an atomic
/// store, so it links on the host build too.
///
/// One honest consequence of keeping the stream open: the platform's own recording indicator stays
/// lit while muted, because the mic really is still open. What stops is the encode and the send —
/// no captured audio leaves the process.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
_env: JNIEnv,
_this: JObject,
handle: jlong,
muted: jboolean,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.mic_muted
.store(muted != 0, std::sync::atomic::Ordering::Relaxed);
}
})
}
/// `NativeBridge.nativeMicActive(handle): Boolean` — is a mic capture actually RUNNING? `true` only
/// between a `nativeStartMic` that opened a stream and the matching `nativeStopMic`. The in-stream
/// mute control is offered on this evidence rather than on the user's setting, so a device that
/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a
/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock).
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) -> jboolean {
jni_guard(0, || {
if handle == 0 {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
jboolean::from(h.mic.lock().unwrap().is_some())
})
}
+148 -5
View File
@@ -28,6 +28,9 @@ pub struct VideoStats {
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
/// Off until Kotlin shows the HUD.
enabled: AtomicBool,
/// Whether the timeline presenter is active this session (async loop, not sysprop-disabled) —
/// stats index 29, so the HUD can label the display split it is (or isn't) getting.
presenter_active: AtomicBool,
/// The resolved decoder identity for the HUD: the codec's actual `AMediaCodec` name (e.g.
/// `c2.qti.avc.decoder`) and whether it advertised `FEATURE_LowLatency`. Set once when the
/// decode thread creates the codec (`set_decoder`), read one-shot by `nativeVideoDecoderLabel`.
@@ -67,9 +70,29 @@ struct Inner {
/// `end-to-end` = capture→displayed samples, µs (skew-corrected) — the spec's headline,
/// measured directly (not summed from stages). Empty under the same fallback as `display_us`.
e2e_disp_us: Vec<u64>,
/// The `display` stage's presenter split, decoded→release (pace wait: store + glass budget),
/// µs. Empty on the legacy release-immediately path.
pace_us: Vec<u64>,
/// The other half of the split, release→displayed (SurfaceFlinger's latch + scanout), µs —
/// from the `OnFrameRendered` render timestamps. `pace + latch ≈ display` per frame.
latch_us: Vec<u64>,
/// The `decode` stage's feed split, received→queued (hand-off + input-slot wait), µs. Empty
/// when no receipt stamp matched (HUD off and ABR not measuring decode).
feed_us: Vec<u64>,
/// The other half, queued→decoded (codec-pure: the decoder's own time on the AU, measured
/// from its LAST piece so a slice-progressive head start shows up as a shrink here), µs.
codec_us: Vec<u64>,
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks) — the `presents`-vs-
/// `fps` health pair: presents ≪ fps means the presenter is dropping/serializing; an fps
/// deficit is upstream.
presents: u64,
/// Client-side newest-wins/pacing drops this window (decoded frames released without
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
skipped: u64,
/// The subset of `skipped` that was parked-AU OVERFLOW (the decoder fell behind and whole
/// AUs were dropped before feeding) — a decoder-health signal, vs the benign newest-wins
/// pacing majority. Always ≤ `skipped`.
skipped_overflow: u64,
/// Baselines for windowing the session-cumulative connector counters: the unrecoverable-drop
/// and FEC-recovered totals as of the last drain (or the enable that opened the window), so
/// each snapshot reports only THIS window's `lost` / `FEC` (spec line 4).
@@ -101,6 +124,18 @@ pub struct Snapshot {
/// Whether any capture→displayed sample landed this window — gates the HUD's headline endpoint
/// (`capture→displayed` vs the capture→decoded fallback) and the equation's `display` term.
pub disp_valid: bool,
/// The `display` stage's presenter split p50s (ms): `pace` = decoded→release (store + glass
/// budget), `latch` = release→displayed (SurfaceFlinger). 0.0 when no sample landed (legacy
/// path / no render callbacks).
pub pace_p50_ms: f64,
pub latch_p50_ms: f64,
/// The `decode` stage's split p50s (ms): `feed` = received→queued (hand-off + input-slot
/// wait), `codec` = queued→decoded (codec-pure, from the AU's last piece). 0.0 when no
/// sample landed (sync loop / no receipt stamps).
pub feed_p50_ms: f64,
pub codec_p50_ms: f64,
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks).
pub presents: u64,
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
pub host_p50_ms: f64,
@@ -115,6 +150,8 @@ pub struct Snapshot {
pub lost: u64,
/// Client-side newest-wins/pacing drops this window (spec `skipped`).
pub skipped: u64,
/// The parked-AU overflow subset of `skipped` (decoder fell behind; ≤ `skipped`).
pub skipped_overflow: u64,
/// FEC shards recovered this window (spec `FEC`, windowed from the cumulative counter).
pub fec: u64,
}
@@ -132,6 +169,7 @@ impl VideoStats {
pub fn new() -> VideoStats {
VideoStats {
enabled: AtomicBool::new(false),
presenter_active: AtomicBool::new(false),
decoder: Mutex::new(None),
inner: Mutex::new(Inner {
window_start: Instant::now(),
@@ -144,7 +182,13 @@ impl VideoStats {
decode_us: Vec::with_capacity(256),
display_us: Vec::with_capacity(256),
e2e_disp_us: Vec::with_capacity(256),
pace_us: Vec::with_capacity(256),
latch_us: Vec::with_capacity(256),
feed_us: Vec::with_capacity(256),
codec_us: Vec::with_capacity(256),
presents: 0,
skipped: 0,
skipped_overflow: 0,
last_dropped_total: 0,
last_fec_total: 0,
skew_corrected: false,
@@ -160,6 +204,18 @@ impl VideoStats {
self.enabled.load(Ordering::Relaxed)
}
/// Record whether the timeline presenter runs this session (decode thread, once at start).
// Set only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn set_presenter_active(&self, on: bool) {
self.presenter_active.store(on, Ordering::Relaxed);
}
/// Whether the timeline presenter is active (stats index 29).
pub fn presenter_active(&self) -> bool {
self.presenter_active.load(Ordering::Relaxed)
}
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
@@ -181,7 +237,13 @@ impl VideoStats {
g.decode_us.clear();
g.display_us.clear();
g.e2e_disp_us.clear();
g.pace_us.clear();
g.latch_us.clear();
g.feed_us.clear();
g.codec_us.clear();
g.presents = 0;
g.skipped = 0;
g.skipped_overflow = 0;
g.last_dropped_total = dropped_total;
g.last_fec_total = fec_total;
}
@@ -275,6 +337,45 @@ impl VideoStats {
g.skipped += n;
}
/// Record parked-AU OVERFLOW drops (whole AUs dropped before feeding — the decoder fell
/// behind). Counts into `skipped` too, plus the overflow-only counter, so the HUD can tell
/// benign newest-wins pacing from a decoder that can't keep up.
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note_skipped_overflow(&self, n: u64) {
if n == 0 || !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock
}
// Poison-proof for the same reason as `note_received`.
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.skipped += n;
g.skipped_overflow += n;
}
/// Record one decoded frame's `decode`-stage split: `feed` = received→queued (hand-off +
/// input-slot wait; absent when no receipt stamp matched) and `codec` = queued→decoded
/// (codec-pure, measured from the AU's LAST piece — a slice-progressive head start shows
/// as a shrink here), both µs.
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note_decode_split(&self, feed_us: Option<u64>, codec_us: u64) {
if !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock
}
// Poison-proof for the same reason as `note_received`.
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(f) = feed_us {
g.feed_us.push(f);
}
g.codec_us.push(codec_us);
}
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
/// this pts predates the HUD being shown).
@@ -298,13 +399,19 @@ impl VideoStats {
}
/// Record one displayed frame (the `OnFrameRendered` render timestamp, re-based to the
/// realtime clock): its capture→displayed `end-to-end` sample and its decoded→displayed
/// `display` stage sample (either may be absent — the e2e clamp rejected an out-of-range
/// value, or the decoded stamp for this pts was already evicted/pre-HUD). Fired from the
/// codec's render-callback thread, not the decode thread — the lock makes that safe.
/// realtime clock): its capture→displayed `end-to-end` sample, its decoded→displayed
/// `display` stage sample, and the presenter split's `latch` half (release→displayed) — any
/// may be absent (the e2e clamp rejected an out-of-range value, or the release record for
/// this pts was already evicted). Fired from the codec's render-callback thread, not the
/// decode thread — the lock makes that safe.
// Driven only by the android-only decode path; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note_displayed(&self, e2e_us: Option<u64>, display_us: Option<u64>) {
pub fn note_displayed(
&self,
e2e_us: Option<u64>,
display_us: Option<u64>,
latch_us: Option<u64>,
) {
if !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock (the callback already skipped the clock reads)
}
@@ -313,12 +420,32 @@ impl VideoStats {
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.presents += 1;
if let Some(l) = e2e_us {
g.e2e_disp_us.push(l);
}
if let Some(l) = display_us {
g.display_us.push(l);
}
if let Some(l) = latch_us {
g.latch_us.push(l);
}
}
/// Record one released frame's pace-wait (decoded→release: the presenter's store + glass
/// budget), µs — the `display` stage's other half. Decode-thread only.
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note_release(&self, pace_us: u64) {
if !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock
}
// Poison-proof for the same reason as `note_received`.
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.pace_us.push(pace_us);
}
/// Compute the window's rates + latency percentiles, then reset for the next window.
@@ -341,6 +468,10 @@ impl VideoStats {
g.decode_us.sort_unstable();
g.display_us.sort_unstable();
g.e2e_disp_us.sort_unstable();
g.pace_us.sort_unstable();
g.latch_us.sort_unstable();
g.feed_us.sort_unstable();
g.codec_us.sort_unstable();
let snap = Snapshot {
fps,
mbps,
@@ -352,6 +483,11 @@ impl VideoStats {
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
display_p50_ms: pctl_ms(&g.display_us, 0.50),
disp_valid: !g.e2e_disp_us.is_empty(),
pace_p50_ms: pctl_ms(&g.pace_us, 0.50),
latch_p50_ms: pctl_ms(&g.latch_us, 0.50),
feed_p50_ms: pctl_ms(&g.feed_us, 0.50),
codec_p50_ms: pctl_ms(&g.codec_us, 0.50),
presents: g.presents,
host_p50_ms: pctl_ms(&g.host_us, 0.50),
net_p50_ms: pctl_ms(&g.net_us, 0.50),
lat_valid: !g.e2e_us.is_empty(),
@@ -359,6 +495,7 @@ impl VideoStats {
frames: g.frames,
lost: dropped_total.saturating_sub(g.last_dropped_total),
skipped: g.skipped,
skipped_overflow: g.skipped_overflow,
fec: fec_total.saturating_sub(g.last_fec_total),
};
g.window_start = Instant::now();
@@ -371,7 +508,13 @@ impl VideoStats {
g.decode_us.clear();
g.display_us.clear();
g.e2e_disp_us.clear();
g.pace_us.clear();
g.latch_us.clear();
g.feed_us.clear();
g.codec_us.clear();
g.presents = 0;
g.skipped = 0;
g.skipped_overflow = 0;
g.last_dropped_total = dropped_total;
g.last_fec_total = fec_total;
snap
+4 -3
View File
@@ -34,9 +34,10 @@ let package = Package(
// Geist (SIL OFL 1.1) the brand typeface, shared with punktfunk-website.
// Registered with Core Text at first use; see BrandFont.swift.
.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.
// The host cards' OS marks (template vector imagesets generated from the
// assets/os-icons masters by scripts/gen-os-icons.sh per-mark provenance and
// licensing in that README). `.process` compiles the catalog; loaded via
// OsIcon.swift.
.process("Resources/OsIcons.xcassets"),
],
linkerSettings: [
+5 -1
View File
@@ -121,7 +121,11 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
host's virtual pad.
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
every main push. See the script header for details.
every main push. See the script header for details. The script's `SCENES` array is the listing
set, in listing order; override it (`SCENES="06-gamepad-home 10-edithost" tools/screenshots.sh ios`)
to capture any of the other scenes in `ShotScenes.all`. Mock data — hosts, adverts, profiles — is
seeded in `ShotMock` so a capture is byte-for-byte deterministic and never browses the real LAN
(a stranger's hostname reached the live listing that way once).
- Deeper design notes live in the internal planning repo (punktfunk-planning:
`apple-stage2-presenter.md`).
@@ -315,7 +315,15 @@ struct ContentView: View {
clipboardAvailable: model.connection?.hostSupportsClipboard == true,
clipboardOn: model.clipboardEnabled,
toggleClipboard: { model.toggleClipboardSync() },
micAvailable: model.micAvailable,
micMuted: model.micMuted,
toggleMicMute: { model.toggleMicMute() },
disconnect: { model.disconnect() }))
// A fired while input was CAPTURED (InputCapture's chord path posts it the menu's
// identical equivalent can't reach a captured stream). Same toggle either way.
.onReceive(NotificationCenter.default.publisher(for: .punktfunkToggleMicMute)) { _ in
model.toggleMicMute()
}
#endif
#if os(macOS)
// Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
@@ -724,31 +732,48 @@ struct ContentView: View {
}
.animation(.smooth(duration: 0.28), value: statsVerbosity)
}
#if os(macOS) || os(tvOS)
// The start-of-stream shortcut banner (Windows-client parity): the platform's
// reserved controls on a glass pill, bottom-centre, for the first 6 seconds of
// every session independent of the stats HUD, so the keys are discoverable
// even with statistics off. The banner's own task drops it (cancelled cleanly
// if the session view goes away first). On tvOS it carries the ONLY exits
// Menu/B is swallowed during a session (the `.onExitCommand {}` in the tvOS
// session branch), so the hold gestures must be told to the user.
// The bottom-centre stack: the muted-microphone badge over the start-of-stream
// shortcut banner. ONE overlay for both, so the two can never land on top of each
// other in the seconds where they overlap.
.overlay(alignment: .bottom) {
if captureEnabled && showShortcutHint {
Text(Self.shortcutHintText)
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassBackground(Capsule())
.padding(.bottom, 24)
.transition(.opacity)
.task {
try? await Task.sleep(for: .seconds(6))
withAnimation(.easeOut(duration: 0.6)) { showShortcutHint = false }
}
VStack(spacing: 8) {
#if !os(tvOS)
// Shown for as long as the mic is muted, at every stats tier and with the
// overlay off see MicMutedBadge. tvOS has no microphone to mute.
if captureEnabled && model.micMuted {
MicMutedBadge { model.setMicMuted(false) }
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
#endif
#if os(macOS) || os(tvOS)
// The start-of-stream shortcut banner (Windows-client parity): the
// platform's reserved controls on a glass pill for the first 6 seconds of
// every session independent of the stats HUD, so the keys are
// discoverable even with statistics off. The banner's own task drops it
// (cancelled cleanly if the session view goes away first). On tvOS it
// carries the ONLY exits Menu/B is swallowed during a session (the
// `.onExitCommand {}` in the tvOS session branch), so the hold gestures
// must be told to the user.
if captureEnabled && showShortcutHint {
Text(shortcutHintText)
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassBackground(Capsule())
.transition(.opacity)
.task {
try? await Task.sleep(for: .seconds(6))
withAnimation(.easeOut(duration: 0.6)) {
showShortcutHint = false
}
}
}
#endif
}
.padding(.bottom, 24)
.animation(.easeOut(duration: 0.2), value: model.micMuted)
}
#endif
#if os(iOS)
// Touch users have no menu / D, so when the HUD's Disconnect button isn't on
// screen the overlay off, or the compact pill (which carries no button)
@@ -766,21 +791,24 @@ struct ContentView: View {
.overlay(alignment: .topLeading) {
if captureEnabled,
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
Button { model.disconnect() } label: {
Image(systemName: "xmark")
.font(.headline.weight(.semibold))
.frame(width: 36, height: 36)
// Floating glass disc over the frame (26+, material fallback).
// interactive: the disc IS the tap target, so the glass reacts
// to press.
.glassBackground(Circle(), interactive: true)
// Match the hit region to the visible disc so every tap also
// triggers the interactive-glass press highlight.
.contentShape(Circle())
HStack(spacing: 10) {
Button { model.disconnect() } label: { touchDisc("xmark") }
.buttonStyle(.plain)
.accessibilityLabel("Disconnect")
// The mic toggle rides the same discs, for the same reason: in these
// tiers the HUD carries no buttons (compact is a stat pill, off is
// nothing), so this is a touch-only user's ONLY way to mute. Absent
// not greyed when the session sends no microphone at all.
if model.micAvailable {
Button { model.toggleMicMute() } label: {
touchDisc(model.micMuted ? "mic.slash.fill" : "mic.fill")
}
.buttonStyle(.plain)
.accessibilityLabel(
model.micMuted ? "Unmute microphone" : "Mute microphone")
}
}
.buttonStyle(.plain)
.padding(12)
.accessibilityLabel("Disconnect")
.transition(.opacity)
.task {
guard statsVerbosity == .off else { return }
@@ -794,14 +822,34 @@ struct ContentView: View {
}
}
#if os(iOS)
/// One touch-control disc: an SF Symbol on a floating glass disc over the frame (26+,
/// material fallback), sized as a comfortable tap target. `interactive`: the disc IS the tap
/// target, so the glass reacts to press, and the hit region is matched to the visible disc so
/// every tap triggers that press highlight.
private func touchDisc(_ symbol: String) -> some View {
Image(systemName: symbol)
.font(.headline.weight(.semibold))
.frame(width: 36, height: 36)
.glassBackground(Circle(), interactive: true)
.contentShape(Circle())
}
#endif
#if os(macOS)
private static let shortcutHintText =
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
/// The reserved combos, told once per session. The mute segment appears only when the session
/// actually sends a microphone teaching a shortcut for a mic that isn't on would be a lie.
private var shortcutHintText: String {
let base =
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base
}
private static let shortcutHintFont: CGFloat = 12
#elseif os(tvOS)
private static let shortcutHintText =
private var shortcutHintText: String {
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
}
private static let shortcutHintFont: CGFloat = 22 // read from the couch
#endif
@@ -135,7 +135,7 @@ struct GamepadHomeView: View {
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
#if os(macOS)
.sheet(isPresented: $showSettings) {
GamepadSettingsView()
GamepadSettingsView(store: store)
.frame(width: 720, height: 640)
}
.sheet(isPresented: $showAddHost) {
@@ -144,7 +144,7 @@ struct GamepadHomeView: View {
}
.frame(minWidth: 640, minHeight: 420)
#else
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() }
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
.fullScreenCover(isPresented: $showAddHost) {
GamepadAddHostView { store.add($0) }
}
@@ -176,18 +176,33 @@ struct GamepadHomeView: View {
// MARK: - Chrome
private var titleBar: some View {
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) {
// Which pad is driving this UI (name + battery) quiet, and only where there's
// room; a compact-height phone gives the pixels to the carousel instead.
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.padding(.trailing, 20)
}
}
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
// optically centred AND clear of the chip at every width; the title shrinks a little
// before it would ever truncate.
HStack(spacing: 12) {
statusChip(hidden: true)
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
statusChip(hidden: false)
}
.padding(.horizontal, 20)
}
/// Which pad is driving this UI (name + battery) quiet, and only where there's room; a
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
/// chip purely as a width reserve.
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.opacity(hidden ? 0 : 1)
.accessibilityHidden(hidden)
}
}
private var cardSpacing: CGFloat {
@@ -24,6 +24,13 @@ import ImageIO
@MainActor
enum ScreenshotMode {
/// This process was launched to capture a screenshot. Cheap enough to consult from the
/// stores' persistence paths (`HostStore` / `ProfileStore`), which must NOT write their
/// mock contents back into a real user's App Group when the harness runs on a dev Mac.
static var isActive: Bool {
!(ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? "").isEmpty
}
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
static var requestedScene: ShotScene? {
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
@@ -41,8 +48,11 @@ struct ScreenshotHostView: View {
scene.make()
.environment(\.colorScheme, scene.colorScheme)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.ignoresSafeArea()
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
// line was unreadable in every 6.9" capture); scenes that genuinely want full bleed
// the streamed frame itself ignore it themselves.
.background(Color.black.ignoresSafeArea())
#if os(macOS)
.background(MacShotWindowConfigurator(scene: scene))
#elseif os(iOS)
@@ -129,18 +139,64 @@ enum MacSelfCapture {
#endif
#if os(iOS)
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
/// for chrome). Requires the app to allow those orientations in Info.plist.
/// Orientation lock for the requested scene (landscape for the stream hero, portrait for chrome).
/// Requires the app to allow those orientations in Info.plist it does, for both.
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
let orientation: ShotOrientation
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
func makeUIViewController(context: Context) -> ShotOrientationController {
ShotOrientationController(mask: mask)
}
func updateUIViewController(_ vc: UIViewController, context: Context) {
guard let scene = vc.view.window?.windowScene else { return }
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
func updateUIViewController(_ vc: ShotOrientationController, context: Context) {
vc.mask = mask
vc.applyGeometry()
}
private var mask: UIInterfaceOrientationMask {
orientation == .landscape ? .landscapeRight : .portrait
}
}
/// Asks the window scene to rotate, from a place where there IS a window.
///
/// The previous version made the request inside `updateUIViewController`, where `view.window` is
/// still nil: SwiftUI makes exactly one update pass for a representable mounted as a `.background`,
/// before the hierarchy is in a window, so the `guard` fell through and nothing ever asked again.
/// Every scene declared `.landscape` the stream hero and the trust card was therefore captured
/// in PORTRAIT at the portrait App Store size. Overriding `supportedInterfaceOrientations` as well
/// keeps the scene from rotating back if the simulator reports a device orientation change.
final class ShotOrientationController: UIViewController {
var mask: UIInterfaceOrientationMask
init(mask: UIInterfaceOrientationMask) {
self.mask = mask
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("not from a nib") }
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { mask }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
applyGeometry()
}
func applyGeometry() {
// `view.window` once mounted; the connected-scene lookup covers the first update pass,
// which still runs before this controller is in a window.
let scene = view.window?.windowScene
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
guard let scene else { return }
// Report a refusal instead of silently shipping the wrong orientation that is exactly
// how every landscape scene went out as a portrait PNG for as long as it did.
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
print("PF_SHOT_ORIENTATION_REFUSED \(error.localizedDescription)")
fflush(stdout)
}
setNeedsUpdateOfSupportedInterfaceOrientations()
}
}
#endif
@@ -81,24 +81,126 @@ enum ShotScenes {
@MainActor
enum ShotMock {
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
// Stable ids so the store, the adverts and the profile bindings all point at the same things
// across every scene and every run.
static let battlestationID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000001")!
static let livingRoomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000002")!
static let workshopID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000003")!
static let officeID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000004")!
static let editingID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000005")!
static let bedroomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000006")!
static let hdrProfileID = "a71c4e0d9f22"
static let couchProfileID = "3e88b107c4da"
/// The catalog the host cards read their chips and pinned cards from. Seeded once, on the
/// first store build `ProfileStore` is a singleton, and in shot mode its write-back is
/// suppressed, so this never reaches a real user's catalog.
static func installProfiles() {
guard !profilesInstalled else { return }
profilesInstalled = true
ProfileStore.shared.debugSet([
StreamProfile(name: "4K HDR", id: hdrProfileID, accent: "#8B7BF7"),
StreamProfile(name: "Couch 1080p", id: couchProfileID, accent: "#4FD1A5"),
])
}
private static var profilesInstalled = false
/// A populated saved-host grid: the most-recent host bound to a profile (its chip), a second
/// paired machine, and one asleep box we hold a MAC for (so its card offers Wake-on-LAN). OS
/// chains give every tile its real vendor mark instead of a letter monogram.
///
/// No PINNED host+profile card: it renders a second tile for the SAME host, which is the
/// feature working as designed but reads as a duplicate to anyone meeting the app in a store
/// listing. The binding chip carries the profile story on its own.
static func hostStore() -> HostStore {
installProfiles()
let store = HostStore()
store.hosts = [
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
pinnedSHA256: fingerprint),
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
StoredHost(
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420),
macAddresses: ["a4:b1:c2:d3:e4:f5"], profileID: hdrProfileID,
osChain: "windows/11"),
StoredHost(
id: livingRoomID, name: "Living Room PC", address: "192.168.1.41", port: 9777,
pinnedSHA256: hostFingerprint(1), lastConnected: Date().addingTimeInterval(-86_400),
macAddresses: ["b8:27:eb:11:22:33"], osChain: "linux/fedora/bazzite"),
StoredHost(
id: officeID, name: "Office NUC", address: "192.168.1.33", port: 9777,
pinnedSHA256: hostFingerprint(4), lastConnected: Date().addingTimeInterval(-259_200),
profileID: couchProfileID, osChain: "linux/ubuntu"),
StoredHost(
id: workshopID, name: "Workshop", address: "10.0.0.7", port: 9777,
pinnedSHA256: hostFingerprint(2), macAddresses: ["de:ad:be:ef:00:07"],
osChain: "linux/arch"),
StoredHost(
id: editingID, name: "Editing Rig", address: "192.168.1.62", port: 9777,
pinnedSHA256: hostFingerprint(5), lastConnected: Date().addingTimeInterval(-604_800),
osChain: "linux/nobara"),
StoredHost(
id: bedroomID, name: "Bedroom Mini", address: "192.168.1.77", port: 9777,
pinnedSHA256: hostFingerprint(6), macAddresses: ["00:1a:2b:3c:4d:5e"],
osChain: "windows/11"),
]
return store
}
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint)
/// Discovery, seeded rather than live. Two saved hosts advertise (so their cards read ONLINE
/// through the real `advertises` path, and the reachability probe skips them no network from
/// a capture), "Workshop" stays quiet so the grid shows an asleep machine, and one genuinely
/// new host populates the "On this network" section.
///
/// A live browse made the shot non-deterministic AND leaked whatever was on the capturing
/// machine's LAN into the App Store listing.
static func discovery() -> HostDiscovery {
let discovery = HostDiscovery()
discovery.debugSet([
HostDiscovery.debugAdvert(
id: "battlestation", name: "Battlestation", host: "192.168.1.20",
fingerprintHex: fingerprint.hexLower, macAddresses: ["a4:b1:c2:d3:e4:f5"],
osChain: "windows/11"),
HostDiscovery.debugAdvert(
id: "living-room", name: "Living Room PC", host: "192.168.1.41",
fingerprintHex: hostFingerprint(1).hexLower, macAddresses: ["b8:27:eb:11:22:33"],
osChain: "linux/fedora/bazzite"),
HostDiscovery.debugAdvert(
id: "office-nuc", name: "Office NUC", host: "192.168.1.33",
fingerprintHex: hostFingerprint(4).hexLower, osChain: "linux/ubuntu"),
HostDiscovery.debugAdvert(
id: "studio", name: "Studio PC", host: "192.168.1.58",
fingerprintHex: hostFingerprint(3).hexLower, requiresPairing: true, allowsTofu: false,
osChain: "windows/11"),
])
return discovery
}
static let host = StoredHost(
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, osChain: "windows/11")
/// What the pairing sheet calls THIS device. Taken from the platform, not from
/// `UIDevice.current.name` on a capture simulator that is the harness's own throwaway name
/// (`pf-shot-iphone-6.9` went out on the store listing that way).
static var clientDeviceName: String {
#if os(tvOS)
"Apple TV"
#elseif os(macOS)
"MacBook Pro"
#else
UIDevice.current.userInterfaceIdiom == .pad ? "iPad Pro" : "iPhone"
#endif
}
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
static let fingerprint = hostFingerprint(0)
/// Distinct per host `StoredHost.matches` prefers a fingerprint comparison, so sharing one
/// across the mock grid made a single advert light up every card.
static func hostFingerprint(_ seed: Int) -> Data {
Data((0..<32).map { UInt8((($0 &* 37) &+ 0x1d &+ (seed &* 91)) & 0xff) })
}
}
// MARK: - Home
@@ -106,7 +208,7 @@ enum ShotMock {
private struct ShotHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var discovery = ShotMock.discovery()
var body: some View {
#if os(macOS)
@@ -134,7 +236,7 @@ private struct ShotHome: View {
private struct ShotGamepadHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var discovery = ShotMock.discovery()
@StateObject private var waker = HostWaker()
var body: some View {
@@ -146,7 +248,9 @@ private struct ShotGamepadHome: View {
}
private struct ShotGamepadSettings: View {
var body: some View { GamepadSettingsView() }
@StateObject private var store = ShotMock.hostStore()
var body: some View { GamepadSettingsView(store: store) }
}
private struct ShotGamepadAddHost: View {
@@ -164,7 +268,7 @@ private struct ShotConnect: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var discovery = ShotMock.discovery()
@StateObject private var waker = HostWaker()
var body: some View {
@@ -241,9 +345,9 @@ private struct ShotSettings: View {
#elseif os(iOS)
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
// rendered directly a wrapping NavigationStack would nest a split view in a stack. Open
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
// the General page) instead of the bare category list.
SettingsView(initialCategory: .general)
// on Display rather than the bare category list: resolution, frame rate, bitrate, HDR and
// codec are what someone reads a streaming app's settings shot to find out.
SettingsView(initialCategory: .display)
#else
NavigationStack { SettingsView() }
#endif
@@ -253,16 +357,44 @@ private struct ShotSettings: View {
// MARK: - Pair (PIN ceremony)
private struct ShotPair: View {
/// The PIN as the host's web console shows it, and a device name that doesn't depend on what
/// the capture simulator happens to be called.
private var sheet: some View {
PairSheet(
host: ShotMock.host, shotPIN: "418 306",
shotClientName: ShotMock.clientDeviceName, onPaired: { _ in })
}
var body: some View {
#if os(iOS)
// PRESENT it, don't rebuild it. `PairSheet` is a bottom sheet on iOS it carries its own
// `.presentationDetents([.medium, .large])` and the system's Liquid Glass background, both
// of which only exist inside a real `.sheet`. Composed into a ZStack instead (what this
// scene used to do), the detents were inert, the grouped Form stretched to the full height
// of the screen, and the capture was a thin strip of content over a huge black void.
ShotHome()
.sheet(isPresented: .constant(true)) {
// Pinned to one detent. The sheet ships `[.medium, .large]` so it can grow over
// the keyboard, and the resting height leaves a wide empty band between the form
// and the button row; a capture wants the snug version.
sheet.presentationDetents([.fraction(0.52)])
}
#elseif os(tvOS)
// tvOS pushes the ceremony as a full screen (HomeView's `navigationDestination`).
NavigationStack { sheet }
#else
// macOS: a fixed-width panel (`.frame(width: 400).fixedSize()`) that hugs its content, so
// floating it over the dimmed grid matches how the window-modal sheet reads. `screencapture
// -l<windowID>` grabs one window, and an AppKit sheet is a child window a real `.sheet`
// would fall outside the capture.
ZStack {
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
PairSheet(host: ShotMock.host, onPaired: { _ in })
.frame(maxWidth: 460)
sheet
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
.clipShape(RoundedRectangle(cornerRadius: 18))
.shadow(radius: 40, y: 16)
.padding(40)
}
#endif
}
}
@@ -1,6 +1,9 @@
// Session state for the app shell: owns the connection, the input capture, the trust
// handshake phase, and the pump-thread main-actor stats relay.
// AVFoundation: AVCaptureDevice.authorizationStatus (the mic TCC grant behind `micAvailable`)
// and, on tvOS, AVPlayer.eligibleForHDRPlayback (the TV-capability HDR gate).
import AVFoundation
import Foundation
import os
import PunktfunkKit
@@ -11,9 +14,6 @@ import SwiftUI
#elseif canImport(UIKit)
import UIKit
#endif
#if os(tvOS)
import AVFoundation // AVPlayer.eligibleForHDRPlayback the TV-capability HDR gate
#endif
/// 1 Hz latency-stage line mirrored to the unified log so the stages can be read WITHOUT the
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
@@ -137,6 +137,14 @@ final class SessionModel: ObservableObject {
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
/// HUD's "click to capture" / " releases" hint).
@Published var mouseCaptured = false
/// The USER's in-stream mic mute (the HUD button, the Stream menu's A, the captured-state
/// chord, the iOS mic disc) session state, deliberately NOT persisted: a mute is for the
/// people in the room right now, so every new session starts live if the mic is on at all.
/// One of the two inputs to the effective mute; `isBackgrounded` is the other, and
/// `applyMicMute` composes them a user mute survives a trip through the background, and the
/// background's privacy mute never clears the user's choice. Local and instant: it gates
/// capture on this device, nothing is sent to the host.
@Published private(set) var micMuted = false
/// Resize overlay (design/midstream-resolution-resize.md client resize UX): true from the
/// instant a Match-window resize starts steering toward a new size until a frame at that size
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
@@ -338,7 +346,10 @@ final class SessionModel: ObservableObject {
let clientCaps: UInt8 =
(MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop ? 0x01 : 0
#else
let clientCaps: UInt8 = 0
// iOS/tvOS run the stage-4 deadline presenter, whose link thread feeds
// reportPhase advertise the vsync-aware presenter (0x02, CLIENT_CAP_PHASE_LOCK).
// macOS stays without it: the stage-2 arrival presenter has no latch grid.
let clientCaps: UInt8 = 0x02
#endif
let result = Result { try PunktfunkConnection(
host: host.address, port: host.port,
@@ -437,7 +448,7 @@ final class SessionModel: ObservableObject {
guard phase == .streaming, let conn = connection, !isBackgrounded else { return }
isBackgrounded = true
conn.setVideoDropped(true)
audio?.setMicMuted(true)
applyMicMute() // now muted for privacy on top of the user's own mute, not instead of it
// Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast,
// exactly like today's network-drop path. min 1 minute guards a nonsense setting.
let minutes = max(1, timeoutMinutes)
@@ -462,13 +473,57 @@ final class SessionModel: ObservableObject {
backgroundDeadline = nil
backgroundTimer?.cancel()
backgroundTimer = nil
audio?.setMicMuted(false)
applyMicMute() // back to the user's own choice which may well still be "muted"
if let conn = connection {
conn.setVideoDropped(false)
conn.requestKeyframe()
}
}
// MARK: - Microphone mute (in-stream, per session)
/// Whether this session has a mic uplink there is any point in muting: the mic must be on in
/// the session's RESOLVED settings (a profile can turn it on or off), the platform must have
/// an app-accessible input at all, and the OS must not have refused us one. Drives whether the
/// mute control is offered a live-looking mute button over a session that sends no
/// microphone would be a lie. Same three conditions `SessionAudio` starts an uplink on
/// (`.notDetermined` counts: the prompt is pending and a grant starts the uplink mid-session).
var micAvailable: Bool {
#if os(tvOS)
return false // no app-accessible microphone SessionAudio never opens an uplink either
#else
guard settings.micEnabled else { return false }
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized, .notDetermined: return true
default: return false // denied / restricted there is no uplink to mute
}
#endif
}
/// Flip the user's mute. The in-stream surfaces (HUD button, Stream menu, A while
/// captured, the iOS mic disc) all land here.
func toggleMicMute() {
setMicMuted(!micMuted)
}
/// Set the user's mute directly (the badge's tap-to-unmute). Ignored when the session has no
/// microphone, so a stale surface can't leave a phantom "muted" badge over a session that was
/// never sending anything.
func setMicMuted(_ muted: Bool) {
guard micAvailable, micMuted != muted else { return }
micMuted = muted
applyMicMute()
}
/// Push the EFFECTIVE mute the user's choice OR the background keep-alive's privacy mute
/// onto the audio engine. The two reasons are composed here and nowhere else: whichever one
/// changed, the other still holds, so returning from the background can't un-mute a user who
/// muted mid-stream, and a user unmuting while backgrounded (Live Activity, another window)
/// doesn't open the mic behind their back.
private func applyMicMute() {
audio?.setMicMuted(micMuted || isBackgrounded)
}
/// Follow a live stats-overlay cycle (S, the three-finger tap, the Stream menu). Those
/// surfaces write the GLOBAL setting as they always have; this moves the session's own tier
/// with it, so cycling still works in a session a profile put on a different tier.
@@ -506,6 +561,9 @@ final class SessionModel: ObservableObject {
backgroundTimer = nil
isBackgrounded = false
backgroundDeadline = nil
// The mic mute is per-session and never persisted: the next stream starts live (if the
// mic is enabled), rather than silently carrying a mute nobody remembers making.
micMuted = false
let audio = self.audio
self.audio = nil
// Gamepad capture is main-actor (releases held buttons on the wire while the
@@ -606,14 +664,24 @@ final class SessionModel: ObservableObject {
speakerUID: settings.speakerUID,
micUID: settings.micUID,
micChannel: settings.micChannel,
micEnabled: settings.micEnabled)
micEnabled: settings.micEnabled,
echoCancel: settings.echoCancel)
self.audio = audio
// Gamepads: forward every controller GamepadManager selected each on its own wire pad
// index (a pin forwards only one, Automatic forwards all) and render the host's feedback
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
// when a pad's virtual device is a DualSense). Same trust gate as audio nothing is
// forwarded during the trust prompt.
let capture = GamepadCapture(connection: conn, manager: .shared)
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
// (USB passthrough, or a pad plugged into the host) capture still runs, and still
// watches for the escape chord, but puts nothing on the wire.
// System-button routing: whether raw guide/share presses ride the wire, and whether
// hold-Select arms as the alternate guide route (auto = on everywhere but macOS
// iOS reserves the physical Home press, tvOS never delivers it).
let capture = GamepadCapture(
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding,
systemForward: settings.systemButtonsForward,
guideGesture: settings.guideGestureEnabled)
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) on tvOS the only
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
@@ -1,7 +1,8 @@
// The app's "Stream" menu (macOS menu bar + iPad hardware-keyboard shortcuts). These live at
// the Scene level so they keep working when the HUD overlay is hidden. The shortcuts are the
// CROSS-CLIENT set every punktfunk client reserves Ctrl+Alt+Shift+Q (release the captured
// mouse) / +D (disconnect) / +S (stats) and the menu is their discoverable surface on macOS
// mouse) / +D (disconnect) / +S (stats), plus +A (mute the microphone), the Apple clients'
// addition to it and the menu is their discoverable surface on macOS
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows
// keys); InputCapture's monitor detects the same combos there and performs the same actions
@@ -27,6 +28,12 @@ struct SessionFocus {
/// Clipboard sync is live (host-acked) drives the item's Stop/Share title.
var clipboardOn: Bool
var toggleClipboard: () -> Void
/// The session has a mic uplink at all (its resolved `micEnabled`) gates the mute item, so
/// it is never an enabled control over a session that sends no microphone.
var micAvailable: Bool
/// The user's mic mute is engaged drives the item's Mute/Unmute title.
var micMuted: Bool
var toggleMicMute: () -> Void
var disconnect: () -> Void
}
@@ -60,6 +67,17 @@ struct StreamCommands: Commands {
}
.keyboardShortcut("q", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true)
// Mic mute, local and instant (it gates capture on this device the host is never
// asked). Per SESSION: it starts off every time, so this item is a live toggle, not a
// setting. Greyed when the session sends no microphone at all (Settings mic off, or
// a profile that turns it off) rather than pretending there is something to mute.
// Captured, the combo is handled by InputCapture's chord path before menus see it;
// this item is the released-state path and the shortcut's documentation.
Button(session?.micMuted == true ? "Unmute Microphone" : "Mute Microphone") {
session?.toggleMicMute()
}
.keyboardShortcut("a", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true || session?.micAvailable != true)
#if os(macOS)
// Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed
// when the host doesn't advertise the cap (older host / operator policy off).
@@ -179,6 +179,18 @@ struct StreamHUDView: View {
.foregroundStyle(.secondary)
}
#endif
// Mic mute the in-stream toggle, on the same card as the other in-overlay action.
// Absent (not greyed) when the session sends no microphone: the HUD is a status card,
// and a dead control on it would read as "there is a mic, and it is on". The muted
// STATE is not this button's job the badge over the stream says that at every tier
// and with the overlay off entirely. tvOS gets no control: no microphone, and a
// focusable one would steal the controller's A press from the host.
#if !os(tvOS)
if model.micAvailable {
Button(micButtonTitle) { model.toggleMicMute() }
.font(.geist(12, relativeTo: .caption))
}
#endif
// D lives on the app's Stream menu (so it still works when the HUD is hidden)
// and in InputCapture's monitor while captured; this button is the in-overlay,
// click-to-disconnect affordance. tvOS deliberately gets NEITHER a button (a
@@ -195,6 +207,19 @@ struct StreamHUDView: View {
}
}
#if !os(tvOS)
/// The mute button's wording. macOS names the chord, exactly as its Disconnect button does;
/// iOS/iPadOS spells the action out (the HUD's buttons there carry no shortcuts, even where a
/// hardware keyboard could fire one the Stream menu is that keyboard's surface).
private var micButtonTitle: String {
#if os(macOS)
return model.micMuted ? "Unmute Mic (⌃⌥⇧A)" : "Mute Mic (⌃⌥⇧A)"
#else
return model.micMuted ? "Unmute Microphone" : "Mute Microphone"
#endif
}
#endif
// MARK: - Card metrics
/// The card's inner content padding. Roomier on tvOS the stat text auto-scales for the
@@ -242,6 +267,42 @@ struct StreamHUDView: View {
}
}
#if !os(tvOS)
/// The muted-microphone badge the mute STATE, as opposed to the buttons that flip it. It rides
/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user
/// may have cycled off, and which the compact tier reduces to a stat line): "am I muted?" is not a
/// statistic, and a mute you can't see is how people talk to nobody for a minute. Same glass
/// language as the HUD, sized like the start-of-stream banner it shares the bottom edge with.
///
/// It is also a control: tapping it unmutes. That is the guaranteed way back for a touch user who
/// muted with the overlay off, and it costs the badge nothing (it is on screen either way).
struct MicMutedBadge: View {
let onUnmute: () -> Void
var body: some View {
Button(action: onUnmute) {
HStack(spacing: 7) {
Image(systemName: "mic.slash.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.red)
Text("Microphone muted")
.font(.geist(12, .medium, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.9))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
// interactive: the badge IS the tap target, so the glass reacts to press.
.glassBackground(Capsule(), interactive: true)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
.accessibilityLabel("Microphone muted")
.accessibilityHint("Unmutes the microphone")
}
}
#endif
#if os(iOS)
/// Device display geometry the overlay needs but UIKit doesn't expose publicly.
enum DeviceMetrics {
@@ -10,6 +10,14 @@
// on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
// with one button. Toggles read left = off, right = on refusing a no-op with the same thud.
//
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker an
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
// Pins are presentation only: never the host's default binding, never the profile itself
// profiles are created and edited in the standard interface (and can't be on tvOS, whose
// per-device catalog the detail strings are honest about).
import PunktfunkKit
import SwiftUI
@@ -21,17 +29,25 @@ import CoreHaptics
struct GamepadSettingsView: View {
@Environment(\.dismiss) private var dismiss
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
/// itself (ContentView owns the instance).
@ObservedObject var store: HostStore
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
@AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto"
@AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto"
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@AppStorage(DefaultsKey.enable444) private var enable444 = false
@AppStorage(DefaultsKey.codec) private var codec = "auto"
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
@AppStorage(DefaultsKey.echoCancel) private var echoCancel = true
// The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
@@ -50,6 +66,10 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
#endif
@ObservedObject private var gamepads = GamepadManager.shared
/// The profile catalog (ProfileStore.shared, like every other surface that reads it) the
/// Profiles rows re-derive from it each render, so a rename/delete made in the standard
/// interface shows up live.
@ObservedObject private var profiles = ProfileStore.shared
#if os(iOS)
/// `.compact` in a landscape phone window tighter chrome so more rows fit.
@@ -60,6 +80,9 @@ struct GamepadSettingsView: View {
private let compact = false // no size classes on macOS; the sheet is sized generously
#endif
@State private var focusID: String?
/// The pin-to-hosts picker's profile non-nil swaps the row list for one toggle row per
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
@State private var pinTarget: StreamProfile?
/// The direction of the last value step (+1 right/forward, -1 left) picks which edge the
/// changed value slides in from, so the animation follows the user's motion.
@State private var lastAdjustDelta = 1
@@ -70,7 +93,7 @@ struct GamepadSettingsView: View {
focusID: $focusID,
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
onActivate: { activate(id: $0.id) },
onBack: { dismiss() }
onBack: { back() }
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
@@ -78,7 +101,7 @@ struct GamepadSettingsView: View {
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
Text("Settings")
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.padding(.top, gamepadTitleTopPadding(compact: compact))
@@ -94,11 +117,7 @@ struct GamepadSettingsView: View {
.foregroundStyle(.white.opacity(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
GamepadHintBar(hints: [
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
])
GamepadHintBar(hints: hints)
}
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
.padding(.leading, compact ? 12 : 18)
@@ -136,6 +155,43 @@ struct GamepadSettingsView: View {
.accessibilityLabel("Close settings")
}
/// "Settings", or "Pin Work" while the pin picker is up the title is what says which
/// layer the row list currently is.
private var title: String {
pinTarget.map { "Pin “\($0.name)" } ?? "Settings"
}
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
/// picker where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
private var hints: [GamepadHint] {
guard pinTarget != nil else {
return [
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
]
}
guard !store.hosts.isEmpty else {
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
}
return [
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
]
}
/// B peels one layer: the pin picker back to the settings rows focus returning to the
/// profile row it came from then the screen itself.
private func back() {
if let profile = pinTarget {
pinTarget = nil
focusID = "profile-\(profile.id)"
} else {
dismiss()
}
}
// MARK: - Row rendering
private func rowView(_ row: Row, focused: Bool) -> some View {
@@ -162,7 +218,7 @@ struct GamepadSettingsView: View {
HStack(spacing: 9) {
Image(systemName: "chevron.left")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
// Keyed by the value so a change slides the new option in instead of
// hard-swapping the string a QUIET horizontal slip following the user's
// motion (a right-step enters from the right), crossfading over ~14 pt.
@@ -183,7 +239,7 @@ struct GamepadSettingsView: View {
.animation(.smooth(duration: 0.22), value: row.value)
Image(systemName: "chevron.right")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
}
}
.padding(.horizontal, m.rowHPad)
@@ -217,6 +273,9 @@ struct GamepadSettingsView: View {
let value: String
/// One-line explanation shown near the hint bar while this row is focused.
let detail: String
/// Whether left/right means anything here false hides the value's chevrons (the
/// Profiles rows navigate, and the placeholder rows do nothing at all).
var adjustable = true
/// Left/right step; returns whether the value actually changed (false boundary thud).
let adjust: (Int) -> Bool
/// A cycle forward (wrapping) / flip.
@@ -236,6 +295,9 @@ struct GamepadSettingsView: View {
}
private var rows: [Row] {
// The pin picker replaces the whole list while it's up same screen, one layer deeper,
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
if let profile = pinTarget { return pinRows(for: profile) }
let resolution = resolutionOptions
let refresh = SettingsOptions.refreshRates(including: hz)
.map { (label: "\($0) Hz", tag: $0) }
@@ -316,9 +378,21 @@ struct GamepadSettingsView: View {
id: "mic", icon: "mic", label: "Microphone",
detail: "Send this device's microphone to the host's virtual mic.",
value: $micEnabled),
toggleRow(
id: "echoCancel", icon: "waveform", label: "Echo cancellation",
detail: "Cancel the audio this device plays out of the mic signal — stops "
+ "speaker setups feeding the game back to the host.",
value: $echoCancel),
toggleRow(
id: "padForward", header: "Controller", icon: "gamecontroller",
label: "Forward controllers",
detail: "Send this device's controllers to the host. Turn it off when your "
+ "controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere — so games don't see two of them.",
value: $gamepadForwarding),
choiceRow(
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
id: "pad", icon: "gamecontroller", label: "Use controller",
detail: "Which pad is forwarded to the host, as player 1.",
options: controllers, current: gamepads.preferredID
) { gamepads.preferredID = $0 },
@@ -327,6 +401,18 @@ struct GamepadSettingsView: View {
detail: "The virtual pad the host creates — Automatic matches this controller.",
options: SettingsOptions.padTypes, current: gamepadType
) { gamepadType = $0 },
choiceRow(
id: "systemButtons", icon: "house.circle", label: "Guide button",
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
+ "Automatic sends them to the host whenever this device delivers them.",
options: SettingsOptions.systemButtons, current: systemButtons
) { systemButtons = $0 },
choiceRow(
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
detail: "Hold Select alone to press the host's guide button — keep holding "
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
options: SettingsOptions.guideGestures, current: guideGesture
) { guideGesture = $0 },
choiceRow(
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
@@ -380,7 +466,98 @@ struct GamepadSettingsView: View {
at: at + 1)
}
#endif
return list
return list + profileRows
}
// MARK: - Profiles (§5.2a)
/// The trailing Profiles section: one row per catalog profile, its value how many saved
/// hosts pin it, A opening the pin-to-hosts picker. Read-only beyond that this surface
/// pins and unpins, but profiles are created and edited elsewhere (design §5.4), so
/// left/right is a boundary thud, not an editor.
private var profileRows: [Row] {
guard !profiles.profiles.isEmpty else {
return [Row(
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
label: "No profiles yet", value: "",
detail: emptyCatalogDetail,
adjustable: false,
adjust: { _ in false }, activate: {})]
}
return profiles.profiles.enumerated().map { i, profile in
let pins = store.hosts
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
return Row(
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
icon: "slider.horizontal.3", label: profile.name,
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
detail: profileDetail,
adjustable: false,
adjust: { _ in false },
activate: {
// Focus lands on the picker's first row the focus list's reconcile
// follows this id when the row set swaps underneath it.
focusID = store.hosts.first.map { "pinHost-\($0.id.uuidString)" } ?? "noHosts"
pinTarget = profile
})
}
}
/// The pin-to-hosts picker: one toggle row per SAVED host, sharing the settings rows'
/// toggle semantics (left = unpin, right = pin, A flips; asking for the state it's in is a
/// boundary thud). Writes ride `HostStore.setPinned` pin appends, unpin removes and
/// NEVER the host's default binding (`profileID`): a pin is presentation only (§5.2a).
private func pinRows(for profile: StreamProfile) -> [Row] {
guard !store.hosts.isEmpty else {
return [Row(
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
value: "",
detail: "Pair with a host first, then pin this profile to it.",
adjustable: false,
adjust: { _ in false }, activate: {})]
}
return store.hosts.map { host in
let hostID = host.id
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
return Row(
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
label: host.displayName,
value: pinned ? "Pinned" : "Off",
detail: "A pinned profile appears as its own card on the host — one press "
+ "connects with it.",
adjust: { delta in
let target = delta > 0
guard pinned != target else { return false }
store.setPinned(hostID, profileID: profile.id, pinned: target)
return true
},
activate: { store.setPinned(hostID, profileID: profile.id, pinned: !pinned) })
}
}
/// The profile rows' explainer. tvOS gets its own: the catalog is per-device (the App Group
/// suite nothing syncs it) and tvOS has no profile editor at all (§5.4), so pointing a TV
/// user at a "standard interface" would promise profiles that can never arrive there.
private var profileDetail: String {
#if os(tvOS)
return "Pin this profile to a host and it appears as its own card on the home screen — "
+ "one press connects with it."
#else
return "Pin this profile to a host and it appears as its own card — one press connects "
+ "with it. Profiles are created and edited in Punktfunk's standard interface."
#endif
}
/// What the empty catalog's placeholder explains again honest on tvOS, where profiles
/// cannot be created (on the device or anywhere that would reach its per-device catalog).
private var emptyCatalogDetail: String {
#if os(tvOS)
return "Profiles bundle stream settings for different uses. Creating them isn't "
+ "available on Apple TV yet."
#else
return "Profiles bundle stream settings for different uses. Create them in Punktfunk's "
+ "standard interface, then pin them here as one-press connect cards."
#endif
}
/// Resolution choices as "WxH" tags the current size is inserted when it's a custom mode
@@ -34,6 +34,22 @@ enum SettingsOptions {
("DualShock 4", 4),
]
/// System-button routing (the cross-client `system_buttons` key): where the guide
/// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple.
static let systemButtons: [(label: String, tag: String)] = [
("Automatic", "auto"),
("Send to host", "forward"),
("This device", "local"),
]
/// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on
/// everywhere but macOS.
static let guideGestures: [(label: String, tag: String)] = [
("Automatic", "auto"),
("On", "on"),
("Off", "off"),
]
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
@@ -98,6 +98,10 @@ enum SettingsFields {
.init(name: "mic_enabled", key: DefaultsKey.micEnabled,
overlay: \.micEnabled, effective: \.micEnabled)
}
static var echoCancel: SettingsField<Bool> {
.init(name: "echo_cancel", key: DefaultsKey.echoCancel,
overlay: \.echoCancel, effective: \.echoCancel)
}
static var touchMode: SettingsField<String> {
.init(name: "touch_mode", key: DefaultsKey.touchMode,
overlay: \.touchMode, effective: \.touchMode)
@@ -118,6 +122,18 @@ enum SettingsFields {
.init(name: "gamepad", key: DefaultsKey.gamepadType,
overlay: \.gamepadType, effective: \.gamepadType)
}
static var gamepadForwarding: SettingsField<Bool> {
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
}
static var systemButtons: SettingsField<String> {
.init(name: "system_buttons", key: DefaultsKey.systemButtons,
overlay: \.systemButtons, effective: \.systemButtons)
}
static var guideGesture: SettingsField<String> {
.init(name: "guide_gesture", key: DefaultsKey.guideGesture,
overlay: \.guideGesture, effective: \.guideGesture)
}
static var statsVerbosity: SettingsField<String> {
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
overlay: \.statsVerbosity, effective: \.statsVerbosity)
@@ -175,7 +191,9 @@ extension SettingsView {
base.compositor = compositor
base.audioChannels = audioChannels
base.micEnabled = micEnabled
base.echoCancel = echoCancel
base.gamepadType = gamepadType
base.gamepadForwarding = gamepadForwarding
base.statsVerbosity = statsVerbosityRaw
base.fullscreenWhileStreaming = fullscreenWhileStreaming
base.presentPriority = presentPriority
@@ -581,6 +581,10 @@ extension SettingsView {
field: "mic_enabled") {
Toggle("Send microphone to the host", isOn: scoped(SettingsFields.micEnabled))
}
described(echoCancelCaption, field: "echo_cancel") {
Toggle("Echo cancellation", isOn: scoped(SettingsFields.echoCancel))
.disabled(!effective.micEnabled)
}
#if os(macOS)
if !inProfileScope {
Picker("Microphone", selection: $micUID) {
@@ -619,10 +623,33 @@ extension SettingsView {
}
}
/// Honest about the macOS escape hatch: the voice processor only follows the system
/// default devices, so hand-picked endpoints silently keep the raw path (see
/// SessionAudio's topology note) better said here than discovered mid-call.
private var echoCancelCaption: String {
let base = "Voice processing cancels the audio this device plays out of the mic "
+ "signal, so a speaker setup doesn't feed the game back to the host."
#if os(macOS)
return base + " Follows the system default devices — a hand-picked speaker, "
+ "microphone or input channel streams the raw mic instead."
#else
return base
#endif
}
// MARK: - Controllers
@ViewBuilder var controllersSection: some View {
Section {
// The master switch, above everything it governs. Profileable, so it renders in
// both scopes: a "Work" profile can decline to forward what "Game" forwards.
described("Sends controllers connected to this device to the host. Turn it off when "
+ "your controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere, or a pad plugged into the host itself — so games don't see "
+ "two of them.",
field: "gamepad_forwarding") {
Toggle("Forward controllers", isOn: scoped(SettingsFields.gamepadForwarding))
}
// Which physical pad this device forwards, and what its own haptics do, are facts
// about THIS device (tier G) only the virtual pad the host creates is profileable.
if !inProfileScope {
@@ -641,6 +668,7 @@ extension SettingsView {
Text(option.label).tag(option.tag)
}
}
.disabled(!effective.gamepadForwarding)
}
}
described("The virtual pad created on the host. Automatic matches your controller "
@@ -651,6 +679,30 @@ extension SettingsView {
Text(option.label).tag(option.tag)
}
}
.disabled(!effective.gamepadForwarding)
}
described("Where the guide (Xbox/PS) and share presses go while streaming. "
+ "Automatic sends them to the host whenever this device delivers them "
+ "— the hold-Select gesture below reaches the host regardless.",
field: "system_buttons") {
Picker("Guide button", selection: scoped(SettingsFields.systemButtons)) {
Text("Automatic").tag("auto")
Text("Send to host").tag("forward")
Text("This device").tag("local")
}
.disabled(!effective.gamepadForwarding)
}
described("Hold Select on its own to press the host's guide button — keep "
+ "holding for a Gaming-Mode host's quick-access menu. A Select tap still "
+ "goes through, slightly delayed. Automatic arms it wherever the real "
+ "button can't reach the host (this device reserves it).",
field: "guide_gesture") {
Picker("Hold Select for guide", selection: scoped(SettingsFields.guideGesture)) {
Text("Automatic").tag("auto")
Text("On").tag("on")
Text("Off").tag("off")
}
.disabled(!effective.gamepadForwarding)
}
#if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
@@ -49,6 +49,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
@AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) var gamepadForwarding = true
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
SettingsOptions.presentPriorityDefault
@@ -65,6 +66,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
@AppStorage(DefaultsKey.echoCancel) var echoCancel = true
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
@AppStorage(DefaultsKey.codec) var codec = "auto"
// The overlay tier's raw string (the pickers tag by rawValue); the absent-key default runs
@@ -191,6 +191,12 @@ final class HostStore: ObservableObject {
private func persist() {
#if DEBUG
// The screenshot harness fills a store with mock hosts (ShotMock) purely to render a
// scene. On a dev Mac that store is the SAME App-Group suite the real app reads, so
// persisting would replace the tester's saved hosts with "Battlestation" & co.
if ScreenshotMode.isActive { return }
#endif
if let data = try? JSONEncoder().encode(hosts) {
defaults.set(data, forKey: Self.key)
}
@@ -20,7 +20,14 @@ final class ProfileStore: ObservableObject {
static let shared = ProfileStore()
@Published private(set) var catalog: ProfileCatalog {
didSet { catalog.save() }
didSet {
#if DEBUG
// Shot mode seeds this SINGLETON with mock profiles to populate the host cards.
// Saving would write them into the tester's real catalog see HostStore.persist().
if ScreenshotMode.isActive { return }
#endif
catalog.save()
}
}
var profiles: [StreamProfile] { catalog.profiles }
@@ -33,6 +40,14 @@ final class ProfileStore: ObservableObject {
id.flatMap { catalog.profile(id: $0) }
}
#if DEBUG
/// Shot-mode seed: replace the catalog outright so a capture shows a known set of profiles
/// rather than the tester's. Safe because `didSet` suppresses the write-back in shot mode.
func debugSet(_ profiles: [StreamProfile]) {
catalog = ProfileCatalog(profiles: profiles)
}
#endif
/// This host's default profile, dangling ids dropped a deleted profile resolves as "Default
/// settings", never an error (§4.4).
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
@@ -109,7 +109,7 @@ struct PairSheet: View {
#endif
TextField(
"Client name", text: $clientName,
prompt: Text("How the host lists this Mac"))
prompt: Text(Self.clientNamePrompt))
#if os(tvOS)
.labelsHidden() // prefilled tvOS floats the label off-center
#endif
@@ -184,6 +184,16 @@ struct PairSheet: View {
#endif
}
/// The field prompt names the device you are actually on it said "this Mac" on every
/// platform, which on an iPhone is simply wrong.
private static var clientNamePrompt: String {
#if os(macOS)
"How the host lists this Mac"
#else
"How the host lists this device"
#endif
}
private func runCeremony() {
busy = true
errorText = nil
@@ -229,3 +239,24 @@ struct PairSheet: View {
}
}
}
#if DEBUG
extension PairSheet {
/// Screenshot-harness seed (`ShotScenes`). A capture of the untouched sheet shows an empty PIN
/// field, a DISABLED "Pair & Connect", and because the client name defaults to the device's
/// own whatever the capture simulator happens to be called (`pf-shot-iphone-6.9` reached App
/// Store Connect that way). Seeding both fields captures the ceremony as a user meets it,
/// mid-entry, with a live primary button.
///
/// An extension so `PairSheet` keeps its memberwise initialiser, and THIS file so it can reach
/// the private state.
init(
host: StoredHost, shotPIN: String, shotClientName: String,
onPaired: @escaping (Data) -> Void
) {
self.init(host: host, onPaired: onPaired)
_pin = State(initialValue: shotPIN)
_clientName = State(initialValue: shotClientName)
}
}
#endif
@@ -3,28 +3,66 @@ import os
/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread render
/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming:
/// reads return silence until enough is buffered (at least `prefill`, and at least one
/// reads return silence until enough is buffered (at least the target, and at least one
/// packet more than the device's render quantum large-buffer devices would otherwise
/// chronically out-demand the prefill and oscillate prime dropout re-prime), and an
/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle.
/// chronically out-demand the prefill and oscillate prime dropout re-prime).
/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip.
///
/// **Drift correction.** Both ends run at 48 kHz but on different crystals, so backlog from a
/// network stall or plain host-vs-DAC skew never drains on its own: without correction one 300 ms
/// hiccup leaves audio 300 ms behind video for the rest of the session. This used to be handled by
/// a `highWater` shed that dropped a whole `2 × prefill` at once its own comment called that "one
/// audible blip". It is now the same two-stage scheme the Rust clients share
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
/// Keep the constants here in step with `JitterTuning.COREAUDIO`.
final class AudioRing: @unchecked Sendable {
/// Mirrors `JitterTuning::COREAUDIO` see that type for the rationale.
private static let targetMS = 20
private static let headroomMS = 30
private static let hardCapMS = 90
private static let deprimeAfter = 4
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
private static let frameMS = 5
/// Depth average must exceed target by this before drift correction fires the middle of the
/// headroom band, so the smooth shed always gets its chance BEFORE the hard cap trims.
private static let shedExcessMS = 15
/// and must stay there for this much consumed audio. Long, because a shed is the only thing
/// here a listener could notice; it must never fire on a transient.
private static let shedSustainMS = 2_000
private static let crossfadeMS = 2
/// Time constant of the depth average.
private static let ewmaTauMS = 1_000
private var buf: [Float]
private var readIdx = 0
private var writeIdx = 0
private var primed = false
private var renderQuantum = 0
private let prefill: Int
private let highWater: Int
private var emptyReads = 0
private var depthAvg: Double = 0
private var overRun = 0
/// Reported, not acted on: short reads that actually starved the callback, and smooth drift
/// corrections. A rising underrun count means the ring is being starved (network or CPU),
/// which is a different problem from the depth being wrong.
private var underrunCount = 0
private var shedCount = 0
private let channels: Int
private let perMS: Int
private let lock = OSAllocatedUnfairLock()
/// `capacity`/`prefill` in samples (interleaved `channels` per frame, both whole frames).
init(capacity: Int, prefill: Int, channels: Int) {
/// `capacity` in samples (interleaved `channels` per frame, a whole number of frames).
/// The de-jitter depth is the ring's own business (`targetMS`), not a caller's prefill.
init(capacity: Int, channels: Int) {
buf = [Float](repeating: 0, count: capacity)
self.prefill = prefill
self.channels = channels
highWater = prefill * 4
perMS = 48 * channels
}
/// Live target depth in interleaved samples, lifted so it can always serve one device quantum
/// plus a packet (a large-buffer device cannot sustain a target below its own quantum).
private var target: Int {
max(Self.targetMS * perMS, renderQuantum + Self.frameMS * perMS)
}
func write(_ samples: UnsafePointer<Float>, count: Int) {
@@ -42,12 +80,12 @@ final class AudioRing: @unchecked Sendable {
buf[(writeIdx + i) % capacity] = samples[i]
}
writeIdx += count
// Latency clamp: both ends run at 48 kHz, so backlog from a network stall (or
// creeping host-vs-DAC clock skew) never drains on its own without this, one
// 300 ms hiccup leaves audio 300 ms behind video for the rest of the session.
// Shedding down to 2× prefill costs one audible blip instead.
if writeIdx - readIdx > highWater {
readIdx = writeIdx - prefill * 2
// Backstop only: the smooth shed in `read` is what normally holds the depth down.
let cap = min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS)
if writeIdx - readIdx > cap {
readIdx = writeIdx - cap
depthAvg = Double(cap)
overRun = 0
}
}
@@ -57,16 +95,37 @@ final class AudioRing: @unchecked Sendable {
defer { lock.unlock() }
renderQuantum = max(renderQuantum, count)
let available = writeIdx - readIdx
// Depth average, weighted by the callback size so its time constant is independent of the
// device quantum.
let alpha = min(1.0, Double(count) / Double(Self.ewmaTauMS * perMS))
depthAvg += (Double(available) - depthAvg) * alpha
if !primed {
// One 5 ms host packet (240 frames × channels) of slack beyond the device's demand.
if available >= max(prefill, renderQuantum + 240 * channels) {
if available >= target {
primed = true
emptyReads = 0
} else {
for i in 0..<count { out[i] = 0 }
return
}
}
let n = min(available, count)
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
if depthAvg > Double(target + Self.shedExcessMS * perMS) {
overRun += count
if overRun >= Self.shedSustainMS * perMS {
overRun = 0
shedOneFrame()
shedCount += 1
depthAvg = Double(writeIdx - readIdx)
}
} else {
overRun = 0
}
let n = min(writeIdx - readIdx, count)
let capacity = buf.count
for i in 0..<n {
out[i] = buf[(readIdx + i) % capacity]
@@ -74,9 +133,63 @@ final class AudioRing: @unchecked Sendable {
readIdx += n
if n < count {
for i in n..<count { out[i] = 0 }
primed = false // underrun re-prime before resuming
// De-prime only after a RUN of short reads: a single transient drain must not
// manufacture a whole target's worth of fresh silence.
emptyReads += 1
underrunCount += 1
if emptyReads >= Self.deprimeAfter { primed = false }
} else {
emptyReads = 0
}
}
/// Drop one protocol frame from the front, linearly crossfading the seam so the correction is
/// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds
/// the lock.
private func shedOneFrame() {
let drop = Self.frameMS * perMS
let available = writeIdx - readIdx
guard available > drop else { return }
let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop))
let capacity = buf.count
if fade > 0 {
// The tail of what we discard fades out into the head of what survives.
for i in 0..<fade {
let old = buf[(readIdx + drop - fade + i) % capacity]
let new = buf[(readIdx + drop + i) % capacity]
let t = Float(i + 1) / Float(fade + 1)
buf[(readIdx + drop + i) % capacity] = old * (1 - t) + new * t
}
}
readIdx += drop
}
/// Current buffered depth in milliseconds for the stats overlay and the drain thread's
/// periodic log.
var bufferedMS: Int {
lock.lock()
defer { lock.unlock() }
return (writeIdx - readIdx) / max(perMS, 1)
}
/// One consistent snapshot of the ring's vitals, taken under a single lock so the numbers in
/// a log line describe the same instant. Mirrors what the three Rust clients report.
struct Stats {
let bufferedMS: Int
let targetMS: Int
let underruns: Int
let sheds: Int
}
var stats: Stats {
lock.lock()
defer { lock.unlock() }
return Stats(
bufferedMS: (writeIdx - readIdx) / max(perMS, 1),
targetMS: target / max(perMS, 1),
underruns: underrunCount,
sheds: shedCount)
}
}
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
@@ -1,7 +1,9 @@
// Opus PCM through CoreAudio's built-in codec (kAudioFormatOpus, macOS 10.13+ / iOS
// 11+) no bundled libopus. The host's audio plane is raw Opus packets (48 kHz stereo,
// one frame per packet); AVAudioConverter handles them as single-packet
// AVAudioCompressedBuffers with explicit packet descriptions.
// one frame per packet); the mic uplink is 48 kHz MONO packets (one microphone bus
// the host's decoder upmixes, so duplicating it into a second channel only cost bits).
// AVAudioConverter handles both as single-packet AVAudioCompressedBuffers with explicit
// packet descriptions.
//
// Both classes are single-threaded by contract (one per direction, owned by their
// drain/capture pipelines).
@@ -14,16 +16,16 @@ enum OpusCodecError: Error {
case convertFailed(String)
}
/// 48 kHz stereo float32 interleaved the PCM side of both converters and the layout
/// of the playback ring buffer.
/// 48 kHz stereo float32 interleaved the decoder's PCM side (the host plane's shape).
func opusPCMFormat() -> AVAudioFormat? {
AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 2, interleaved: true)
}
/// The compressed side: raw Opus, `framesPerPacket` nominal samples per packet at 48 kHz
/// (240 = the host's 5 ms audio plane; 960 = the 20 ms packets the encoder emits).
private func opusFormat(framesPerPacket: UInt32) -> AVAudioFormat? {
/// (240 = the host's 5 ms audio plane; 480 = the 10 ms packets the encoder emits) and
/// `channels` (2 = the host plane, 1 = the mic uplink).
private func opusFormat(framesPerPacket: UInt32, channels: UInt32) -> AVAudioFormat? {
var desc = AudioStreamBasicDescription(
mSampleRate: 48_000,
mFormatID: kAudioFormatOpus,
@@ -31,7 +33,7 @@ private func opusFormat(framesPerPacket: UInt32) -> AVAudioFormat? {
mBytesPerPacket: 0,
mFramesPerPacket: framesPerPacket,
mBytesPerFrame: 0,
mChannelsPerFrame: 2,
mChannelsPerFrame: channels,
mBitsPerChannel: 0,
mReserved: 0)
return AVAudioFormat(streamDescription: &desc)
@@ -45,7 +47,8 @@ final class OpusDecoder {
/// `framesPerPacket`: the sender's packet duration in samples (host audio = 240).
init(framesPerPacket: UInt32) throws {
guard let pcm = opusPCMFormat(), let opus = opusFormat(framesPerPacket: framesPerPacket),
guard let pcm = opusPCMFormat(),
let opus = opusFormat(framesPerPacket: framesPerPacket, channels: 2),
let converter = AVAudioConverter(from: opus, to: pcm)
else { throw OpusCodecError.unavailable }
self.converter = converter
@@ -90,24 +93,43 @@ final class OpusDecoder {
}
final class OpusEncoder {
/// The encoder's packet duration: 960 samples = 20 ms, CoreAudio's default Opus
/// framing. The host's mic service decodes any Opus frame size up to 120 ms.
static let framesPerPacket: AVAudioFrameCount = 960
/// The encoder's packet duration in samples: 480 = 10 ms, halving the packetization
/// latency of the old 20 ms framing. CoreAudio honors it mFramesPerPacket 480/mono
/// creates a converter that truly emits 10 ms CELT packets (TOC config 30), one per
/// 480-frame chunk, verified by inspection of the emitted TOC bytes and per-packet
/// frame accounting. 960 stays as the fallback should an older codec refuse 480.
/// The host's mic service decodes any Opus frame size up to 120 ms, so either is
/// wire-compatible.
let framesPerPacket: AVAudioFrameCount
/// 48 kHz MONO float32 interleaved the uplink carries one microphone bus.
let pcmFormat: AVAudioFormat
private let converter: AVAudioConverter
private let outBuf: AVAudioCompressedBuffer
let pcmFormat: AVAudioFormat
init() throws {
guard let pcm = opusPCMFormat(),
let opus = opusFormat(framesPerPacket: UInt32(Self.framesPerPacket)),
let converter = AVAudioConverter(from: pcm, to: opus)
guard let pcm = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 1,
interleaved: true)
else { throw OpusCodecError.unavailable }
converter.bitRate = 96_000
self.converter = converter
self.pcmFormat = pcm
var made: (converter: AVAudioConverter, fpp: AVAudioFrameCount)?
for fpp: AVAudioFrameCount in [480, 960] {
if let opus = opusFormat(framesPerPacket: UInt32(fpp), channels: 1),
let converter = AVAudioConverter(from: pcm, to: opus) {
made = (converter, fpp)
break
}
}
guard let made else { throw OpusCodecError.unavailable }
// 48 kbps: transparent for mono voice the old 96 kbps budget was sized for
// the duplicated-stereo framing this encoder no longer emits.
made.converter.bitRate = 48_000
converter = made.converter
framesPerPacket = made.fpp
pcmFormat = pcm
outBuf = AVAudioCompressedBuffer(
format: opus, packetCapacity: 4, maximumPacketSize: 1500)
format: made.converter.outputFormat, packetCapacity: 4, maximumPacketSize: 1500)
}
/// Encode exactly `framesPerPacket` frames of `pcmFormat` audio; returns the encoded
@@ -5,15 +5,22 @@
// AVAudioSourceNode pulls from the ring (silence on underrun with re-priming, so a
// network gap costs one dip, not permanent crackle).
//
// mic host: a second AVAudioEngine taps the input device, folds it to one mono bus (the
// chosen channel of a multi-channel interface, or a sum of all channels), resamples to 48 kHz
// stereo, slices 20 ms chunks, Opus-encodes, and sendMic()s each packet the host feeds them
// into a virtual PipeWire source.
// mic host: a tap on the input node folds the capture to one mono bus (the chosen channel
// of a multi-channel interface, or a sum of all channels), resamples to 48 kHz mono, slices
// 10 ms chunks, Opus-encodes, and sendMic()s each packet the host feeds them into a
// virtual PipeWire source.
//
// Engine topology. With the mic enabled and echo cancellation on (both defaults), BOTH
// directions run on ONE AVAudioEngine with the system voice processor engaged
// (`setVoiceProcessingEnabled`) AEC needs render and capture on the same unit so it can
// subtract what the speaker is playing from what the mic hears; without it, a loudspeaker
// client feeds the host's own game audio straight back to it (the primary reported echo
// source). The voice processor can only follow the system DEFAULT devices, so explicit
// endpoint choices fall back to the old two-engine topology see `wantsCombined` for the
// exact decision, and `startCapture` for why two engines handle arbitrary device pairs.
//
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
// concrete device and follows default-device changes). Two engines, not one a single
// AVAudioEngine ties input+output to one aggregate clock, separate engines keep
// arbitrary mic/speaker combinations trivial.
// concrete device and follows default-device changes).
import AVFoundation
import os
@@ -40,7 +47,21 @@ public final class SessionAudio {
private let stateLock = NSLock()
private var playbackEngine: AVAudioEngine?
private var captureEngine: AVAudioEngine?
/// The one engine running BOTH directions when the voice processor is engaged;
/// `playbackEngine`/`captureEngine` stay nil while this is set.
private var combinedEngine: AVAudioEngine?
private var drainStarted = false
/// The mute LATCH: the effective mute the owner last asked for (see `setMicMuted`). Held
/// because the uplink engine can appear LATER than the request the mic permission prompt
/// is answered at the user's leisure, and the engine is built on the grant so a mute set
/// in the meantime must be waiting for it. Applied by whichever start path wins the race.
/// Guarded by `stateLock`, like the engines it applies to.
private var micMuted = false
/// The playback jitter ring created by whichever engine starts playback first and KEPT
/// across an engine rebuild (the permission-grant upgrade in `startEngines` swaps engines,
/// not the ring, so the drain thread never has to be re-pointed). Main-thread confined,
/// like every start path.
private var ring: AudioRing?
#if !os(macOS)
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
/// they must not run on the main thread (UI stall AVFoundation warns about it). PROCESS-WIDE
@@ -69,11 +90,15 @@ public final class SessionAudio {
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
/// a later main-queue hop (gated by `!flag.isStopped`) so playback is live shortly after, not
/// on return. The mic may start later still if the permission prompt is pending.
public func start(speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool) {
/// `echoCancel` picks the engine topology see the header note and `wantsCombined`.
public func start(
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
) {
#if os(macOS)
// No AVAudioSession on macOS start the engines directly (caller's thread, as before).
startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel, micEnabled: micEnabled)
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
#else
// Configure + activate the session OFF the main thread (it blocks on the audio server),
// then start the engines back on the main thread once it's active engine routing/format
@@ -85,7 +110,7 @@ public final class SessionAudio {
guard let self, !self.flag.isStopped else { return }
self.startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled)
micEnabled: micEnabled, echoCancel: echoCancel)
}
}
#endif
@@ -104,6 +129,12 @@ public final class SessionAudio {
try session.setCategory(
.playAndRecord, mode: .default,
options: [.allowBluetoothA2DP, .defaultToSpeaker])
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
// quantum is most of the mic path's burst latency). Best-effort the hardware
// has the final word (a Bluetooth route will ignore both), and whatever quantum
// is actually granted, the capture tap handles the buffers it gets.
try? session.setPreferredIOBufferDuration(0.005)
try? session.setPreferredSampleRate(48_000)
} else {
try session.setCategory(.playback, mode: .default)
}
@@ -117,32 +148,80 @@ public final class SessionAudio {
}
#endif
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
/// Build + start the engines combined (voice-processed) or split, per `wantsCombined`
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
/// iOS/tvOS the session is already active by the time this runs.
private func startEngines(
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
) {
startPlayback(speakerUID: speakerUID)
#if os(tvOS)
// No app-accessible microphone input on tvOS playback only.
startPlayback(speakerUID: speakerUID)
#else
guard micEnabled else { return }
guard micEnabled else {
startPlayback(speakerUID: speakerUID)
return
}
let combined = wantsCombined(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
echoCancel: echoCancel)
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
startCapture(micUID: micUID, micChannel: micChannel)
if combined {
startCombined(speakerUID: speakerUID, micUID: micUID, micChannel: micChannel)
} else {
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
}
case .notDetermined:
// Playback must not wait out the permission prompt (the user answers at their
// leisure) start it now, and on a grant either bolt the capture engine on
// (split) or swap the playback engine for the combined one (the ring and its
// drain thread carry over see `makePlaybackChain`).
startPlayback(speakerUID: speakerUID)
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
DispatchQueue.main.async {
guard let self, granted, !self.flag.isStopped else { return }
self.startCapture(micUID: micUID, micChannel: micChannel)
if combined {
self.stateLock.lock()
let playback = self.playbackEngine
self.playbackEngine = nil
self.stateLock.unlock()
playback?.stop()
self.startCombined(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel)
} else {
self.startCapture(micUID: micUID, micChannel: micChannel)
}
}
}
default:
startPlayback(speakerUID: speakerUID)
log.warning("microphone access denied — mic uplink disabled (System Settings → Privacy)")
}
#endif
}
#if !os(tvOS)
/// One engine or two: the voice processor requires render + capture on one unit, and that
/// unit can only follow the system DEFAULT devices so echo cancellation gets the combined
/// engine only while nothing is explicitly pinned. On macOS a chosen speaker/mic UID or a
/// picked input channel (the voice processor's capture side is its own mono mix a
/// per-channel pick can't survive it) keeps today's two-engine path, AEC-less but honoring
/// the exact endpoints the user named. On iOS routes are session-managed and the UIDs are
/// ignored, so the toggle alone decides.
private func wantsCombined(
speakerUID: String, micUID: String, micChannel: Int, echoCancel: Bool
) -> Bool {
guard echoCancel else { return false }
#if os(macOS)
return speakerUID.isEmpty && micUID.isEmpty && micChannel == 0
#else
return true
#endif
}
#endif
/// Stop both directions. Safe from any thread; waits the drain thread out ( its
/// poll timeout) so the caller can close the connection right after.
public func stop() {
@@ -152,6 +231,8 @@ public final class SessionAudio {
captureEngine = nil
let playback = playbackEngine
playbackEngine = nil
let combined = combinedEngine
combinedEngine = nil
let wasDraining = drainStarted
drainStarted = false
stateLock.unlock()
@@ -160,6 +241,10 @@ public final class SessionAudio {
capture.stop()
}
playback?.stop()
if let combined {
combined.inputNode.removeTap(onBus: 0)
combined.stop()
}
#if !os(macOS)
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
// activation, setActive is synchronous/blocking run it on the shared serial session queue
@@ -180,15 +265,38 @@ public final class SessionAudio {
}
}
/// Background keep-alive: silence the mic uplink while backgrounded (privacy no room audio
/// leaves the device) and restore it on return. Pauses/resumes the capture engine; a no-op when
/// there's no uplink (playback-only / tvOS / mic disabled). The audio SESSION stays active for
/// background playback, so iOS may keep showing the recording indicator until a full reconfigure
/// this stops the actual capture, which is the privacy-relevant part. Main thread.
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting
/// mechanism: the owner composes its reasons the user's in-stream mute and the background
/// keep-alive's privacy mute into one effective state and passes that here, so neither can
/// clear the other (see `SessionModel.applyMicMute`).
///
/// Two-engine sessions pause/resume the capture engine; a combined session instead mutes the
/// voice processor's input (playback shares that engine and must keep running, so the engine
/// itself never pauses the mute zeroes the mic at the IO unit, and the tap encodes silence).
/// Local and instant either way: nothing is negotiated with the host, and the packets that do
/// leave carry silence. A no-op when there's no uplink (playback-only / tvOS / mic disabled),
/// except that the state is LATCHED for an uplink that starts later. The audio SESSION stays
/// active for background playback, so iOS may keep showing the recording indicator until a
/// full reconfigure either path stops room audio leaving the device, which is the
/// privacy-relevant part. Main thread.
public func setMicMuted(_ muted: Bool) {
stateLock.lock()
micMuted = muted
let capture = captureEngine
let combined = combinedEngine
stateLock.unlock()
apply(micMuted: muted, capture: capture, combined: combined)
}
/// Push the latched mute onto whichever engine carries the uplink. Split out from
/// `setMicMuted` because the start paths call it too, with the engine they just started
/// that's how a mute requested before the permission grant lands on the engine the grant
/// creates. Never resumes a stopped session's engine.
private func apply(micMuted muted: Bool, capture: AVAudioEngine?, combined: AVAudioEngine?) {
if let combined {
combined.inputNode.isVoiceProcessingInputMuted = muted
return
}
guard let capture else { return }
if muted {
capture.pause()
@@ -199,28 +307,21 @@ public final class SessionAudio {
// MARK: - Playback (host speaker)
private func startPlayback(speakerUID: String) {
/// The playback jitter ring + the source node draining it shared by the plain playback
/// engine and the combined voice-processing engine, and REUSED across an engine rebuild
/// (same session, same ring: the drain thread keeps writing right through the swap). nil
/// when the host's channel layout can't be expressed (already logged). Main thread.
private func makePlaybackChain()
-> (ring: AudioRing, source: AVAudioSourceNode, format: AVAudioFormat)?
{
// Build the playback layout from the host-RESOLVED channel count (never the request):
// 2 = stereo / 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
let channels = Int(connection.resolvedAudioChannels)
// 1 s interleaved capacity, ~20 ms prefill (four 5 ms host packets of jitter absorption
// before the first sample plays), both scaled by the channel count.
let ring = AudioRing(
capacity: 48_000 * channels, prefill: 960 * channels, channels: channels)
let engine = AVAudioEngine()
#if os(macOS)
if !speakerUID.isEmpty {
if let dev = AudioDevices.deviceID(forUID: speakerUID),
let unit = engine.outputNode.audioUnit {
if !Self.setDevice(dev, on: unit) {
log.error("could not select speaker \(speakerUID) — using default")
}
} else {
log.warning("speaker \(speakerUID) not present — using default")
}
}
#endif
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
// rather than a prefill passed in here.
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
self.ring = ring
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
// uses an explicit wire-order channel layout; the mixer downmixes to the output device when
@@ -234,7 +335,7 @@ public final class SessionAudio {
}
guard let format else {
log.error("could not build \(channels)-channel audio format — audio disabled")
return
return nil
}
let scratch = ScratchBuffer() // block-owned; freed with the closure
let source = AVAudioSourceNode(format: format) { _, _, frameCount, abl -> OSStatus in
@@ -252,6 +353,24 @@ public final class SessionAudio {
}
return noErr
}
return (ring, source, format)
}
private func startPlayback(speakerUID: String) {
guard let (ring, source, format) = makePlaybackChain() else { return }
let engine = AVAudioEngine()
#if os(macOS)
if !speakerUID.isEmpty {
if let dev = AudioDevices.deviceID(forUID: speakerUID),
let unit = engine.outputNode.audioUnit {
if !Self.setDevice(dev, on: unit) {
log.error("could not select speaker \(speakerUID) — using default")
}
} else {
log.warning("speaker \(speakerUID) not present — using default")
}
}
#endif
engine.attach(source)
engine.connect(source, to: engine.mainMixerNode, format: format)
engine.prepare()
@@ -272,12 +391,19 @@ public final class SessionAudio {
startDrain(into: ring)
}
/// Idempotent the permission-grant engine swap reaches here a second time with the
/// drain thread already feeding the (carried-over) ring.
private func startDrain(into ring: AudioRing) {
stateLock.lock()
if drainStarted {
stateLock.unlock()
return
}
drainStarted = true
stateLock.unlock()
let thread = Thread { [connection, flag, drainDone] in
defer { drainDone.signal() }
var drained = 0
// Decode happens IN-CORE (libopus multistream) AudioToolbox's Opus path is
// stereo-only and is handed back as interleaved f32 PCM in wire channel order.
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
@@ -296,6 +422,17 @@ public final class SessionAudio {
ring.write(base, count: pcm.frameCount * pcm.channels)
}
}
// Periodic vitals (~10 s at the protocol's 5 ms frames). The other three clients
// log buffer depth and underruns; without this an Apple audio report latency or
// dropout arrives with no numbers at all, which is the position every platform
// was in before the 2026-08 audio work.
drained += 1
if drained % 2_000 == 0 {
let s = ring.stats
log.info(
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds)"
)
}
return true
}
}
@@ -308,6 +445,80 @@ public final class SessionAudio {
// MARK: - Mic (mic host)
#if !os(tvOS)
/// One engine, both directions: engage the system voice processor on the shared IO unit
/// (AEC + noise suppression + AGC), hang the playback source off its render side and the
/// mic tap off its capture side. Every failure falls back to a WORKING configuration
/// the split path (no AEC) when the voice processor won't engage, plain playback when the
/// mic chain can't be built a session never loses audio to the echo-cancel feature.
private func startCombined(speakerUID: String, micUID: String, micChannel: Int) {
let engine = AVAudioEngine()
let input = engine.inputNode
do {
// Before anything reads the input's format: the voice processor changes it (often
// to its own mono mix, sometimes at a lower rate) installMicTap reads the format
// AFTER this, so the converter chain adapts to whatever the processor emits.
try input.setVoiceProcessingEnabled(true)
} catch {
log.warning("""
voice processing unavailable (\(error.localizedDescription)) separate \
engines, no echo cancellation
""")
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
return
}
// Symmetric enable for the render side; with both directions on one engine the
// input-node enable already covers it, so a refusal here is not a failure.
try? engine.outputNode.setVoiceProcessingEnabled(true)
// This is a game stream, not a call: never duck the host's audio under the outgoing
// voice. .min is the closest to "off" the API offers, and advanced (selective)
// ducking stays off with it.
input.voiceProcessingOtherAudioDuckingConfiguration = .init(
enableAdvancedDucking: false, duckingLevel: .min)
guard let (ring, source, format) = makePlaybackChain() else {
// Playback impossible (logged) keep the uplink alive, as the split path would.
startCapture(micUID: micUID, micChannel: micChannel)
return
}
engine.attach(source)
engine.connect(source, to: engine.mainMixerNode, format: format)
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else {
// Mic chain unavailable (logged) keep the session audible on the plain playback
// engine rather than playing through an idle voice processor.
startPlayback(speakerUID: speakerUID)
return
}
engine.prepare()
do {
try engine.start()
} catch {
log.error("combined engine failed to start: \(error.localizedDescription)")
input.removeTap(onBus: 0)
startPlayback(speakerUID: speakerUID) // no echo cancellation beats no audio
return
}
stateLock.lock()
if flag.isStopped {
stateLock.unlock()
input.removeTap(onBus: 0)
engine.stop() // stop() already ran don't strand a started engine (or a hot mic)
return
}
combinedEngine = engine
let muted = micMuted // latched before this engine existed (a mute during the prompt)
stateLock.unlock()
apply(micMuted: muted, capture: nil, combined: engine)
startDrain(into: ring)
log.info("audio engines joined — voice processing (echo cancellation) active")
}
/// The split path: capture on its OWN engine, playback on another the pre-echo-cancel
/// topology, kept verbatim. Two engines, not one a single AVAudioEngine ties
/// input+output to one aggregate clock, separate engines keep arbitrary mic/speaker
/// combinations trivial. That freedom is exactly why the voice processor can't ride this
/// path (AEC needs both directions on one unit) and why explicitly pinned endpoints land
/// here see `wantsCombined`.
private func startCapture(micUID: String, micChannel: Int) {
let engine = AVAudioEngine()
let input = engine.inputNode
@@ -322,11 +533,44 @@ public final class SessionAudio {
}
}
#endif
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else { return }
engine.prepare()
do {
try engine.start()
} catch {
log.error("capture engine failed to start: \(error.localizedDescription)")
input.removeTap(onBus: 0)
return
}
stateLock.lock()
if flag.isStopped {
// stop() ran while we were starting (the permission prompt resolves at the
// user's leisure) tear the engine down ourselves, nobody else owns it now.
stateLock.unlock()
input.removeTap(onBus: 0)
engine.stop()
return
}
captureEngine = engine
let muted = micMuted // latched before this engine existed (a mute during the prompt)
stateLock.unlock()
apply(micMuted: muted, capture: engine, combined: nil)
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
}
/// Resolve the input's live format + fold plan, build the monoOpus chain, and install the
/// capture tap on `input` everything mic except engine ownership, shared verbatim by the
/// combined and split topologies. Reads `input.outputFormat(forBus:)` at call time, so the
/// chain follows whatever the node emits: the raw device format, or the voice processor's
/// own mix when that's enabled. False (logged) when no input is usable or the encoder
/// can't be built; the tap is installed on true.
private func installMicTap(
on input: AVAudioInputNode, micUID: String, micChannel: Int
) -> Bool {
let inFormat = input.outputFormat(forBus: 0)
guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else {
log.error("no usable input device — mic uplink disabled")
return
return false
}
// Multi-channel-interface handling. A pro interface exposes N discrete inputs with the mic
@@ -378,22 +622,38 @@ public final class SessionAudio {
#endif
// Encode a single mono bus (folded from `inFormat` in the tap): the resampler goes
// mono@inputSR the encoder's 48 kHz stereo, so it handles both the rate change and the
// monostereo duplication, and the wrong-channel downmix never happens.
// mono@inputSR the encoder's 48 kHz mono, so it handles the rate change and the
// wrong-channel downmix never happens. Mono end to end the host's decoder upmixes,
// so the old duplicate-into-stereo step only cost bits and cycles.
//
// `mono`/`staging` are the per-callback scratch buffers, preallocated HERE (grown only
// if a larger-than-expected device quantum ever arrives) the steady-state tap path
// allocates nothing.
let scratchFrames: AVAudioFrameCount = 8192
let stagingCapacity = { (frames: AVAudioFrameCount) -> AVAudioFrameCount in
AVAudioFrameCount(
(Double(frames) * 48_000 / inFormat.sampleRate).rounded(.up)) + 64
}
guard let monoFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: inFormat.sampleRate,
channels: 1, interleaved: false),
let encoder = try? OpusEncoder(),
let resampler = AVAudioConverter(from: monoFormat, to: encoder.pcmFormat),
let chunk = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat, frameCapacity: OpusEncoder.framesPerPacket)
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket),
let monoScratch = AVAudioPCMBuffer(
pcmFormat: monoFormat, frameCapacity: scratchFrames),
let stagingScratch = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat, frameCapacity: stagingCapacity(scratchFrames))
else {
log.error("Opus encoder unavailable — mic uplink disabled")
return
return false
}
// Tap-thread-confined state: resample into `staging`, accumulate in `fifo`,
// slice 960-frame chunks for the encoder.
// Tap-thread-confined state: fold into `mono`, resample into `staging`, accumulate in
// `fifo`, slice `framesPerPacket` (10 ms) chunks for the encoder.
var mono = monoScratch
var staging = stagingScratch
var fifo: [Float] = []
fifo.reserveCapacity(48_000)
var seq: UInt32 = 0
@@ -412,14 +672,26 @@ public final class SessionAudio {
var inputPeak: Float = 0
var levelReported = false
input.installTap(onBus: 0, bufferSize: 2048, format: inFormat) { buffer, _ in
// 480 frames = 10 ms, matching the packet duration. Advisory CoreAudio delivers the
// device quantum whatever we ask (the old 2048 request came back as 42.7 ms bursts, most
// of the uplink's latency) but where the system honors it, the tap fires per-packet.
input.installTap(onBus: 0, bufferSize: 480, format: inFormat) { buffer, _ in
if flag.isStopped { return }
let frames = Int(buffer.frameLength)
guard frames > 0, let src = buffer.floatChannelData,
let mono = AVAudioPCMBuffer(
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
let dst = mono.floatChannelData?[0]
else { return }
guard frames > 0, let src = buffer.floatChannelData else { return }
if frames > Int(mono.frameCapacity) {
// A quantum larger than the scratch (bufferSize is advisory both ways) regrow
// once to the new high-water mark; the steady state stays allocation-free.
guard let biggerMono = AVAudioPCMBuffer(
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
let biggerStaging = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat,
frameCapacity: stagingCapacity(buffer.frameLength))
else { return }
mono = biggerMono
staging = biggerStaging
}
guard let dst = mono.floatChannelData?[0] else { return }
mono.frameLength = buffer.frameLength
// Fold the multi-channel input down to the one mono bus we encode.
@@ -451,11 +723,6 @@ public final class SessionAudio {
}
}
let ratio = 48_000 / inFormat.sampleRate
let outCapacity = AVAudioFrameCount((Double(frames) * ratio).rounded(.up) + 64)
guard let staging = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat, frameCapacity: outCapacity)
else { return }
var fed = false
var convError: NSError?
let status = resampler.convert(to: staging, error: &convError) { _, outStatus in
@@ -469,16 +736,20 @@ public final class SessionAudio {
}
guard status != .error, let p = staging.floatChannelData?[0] else { return }
fifo.append(contentsOf: UnsafeBufferPointer(
start: p, count: Int(staging.frameLength) * 2))
start: p, count: Int(staging.frameLength)))
let samplesPerChunk = Int(OpusEncoder.framesPerPacket) * 2
while fifo.count >= samplesPerChunk {
chunk.frameLength = OpusEncoder.framesPerPacket
// Consume whole chunks through a head index, then drop the eaten prefix in ONE
// move of the sub-chunk remainder. The old per-chunk removeFirst memmoved the
// entire backlog for every packet O(n) on the render-adjacent tap thread.
let samplesPerChunk = Int(encoder.framesPerPacket)
var head = 0
while fifo.count - head >= samplesPerChunk {
chunk.frameLength = encoder.framesPerPacket
fifo.withUnsafeBufferPointer { src in
chunk.floatChannelData![0].update(
from: src.baseAddress!, count: samplesPerChunk)
from: src.baseAddress! + head, count: samplesPerChunk)
}
fifo.removeFirst(samplesPerChunk)
head += samplesPerChunk
guard let packets = try? encoder.encode(chunk) else { continue }
for packet in packets {
connection.sendMic(
@@ -486,28 +757,9 @@ public final class SessionAudio {
seq &+= 1
}
}
if head > 0 { fifo.removeFirst(head) } // keeps capacity no realloc
}
engine.prepare()
do {
try engine.start()
} catch {
log.error("capture engine failed to start: \(error.localizedDescription)")
input.removeTap(onBus: 0)
return
}
stateLock.lock()
if flag.isStopped {
// stop() ran while we were starting (the permission prompt resolves at the
// user's leisure) tear the engine down ourselves, nobody else owns it now.
stateLock.unlock()
input.removeTap(onBus: 0)
engine.stop()
return
}
captureEngine = engine
stateLock.unlock()
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
return true
}
/// Fold `channels` of input (`floatChannelData` layout: `interleaved` one buffer strided by
@@ -59,6 +59,9 @@ public final class HostDiscovery: ObservableObject {
/// Start browsing `_punktfunk._udp`. Idempotent a second call while live is a no-op.
public func start() {
#if DEBUG
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
#endif
guard browser == nil else { return }
let browser = NWBrowser(
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
@@ -92,6 +95,35 @@ public final class HostDiscovery: ObservableObject {
for conn in connections.values { conn.cancel() }
}
#if DEBUG
/// A seeded advert set is in force `start()` must not replace it with the live browse.
private var debugPinned = false
/// Screenshot/preview seam, the discovery counterpart to `HostWaker.debugSet`: publish a FIXED
/// set of adverts and keep browsing off. Without it a capture shows whatever happens to be on
/// the machine's LAN the App Store screenshots shipped a stranger's hostname more than once
/// and every mock host reads Offline because nothing advertises it.
public func debugSet(_ adverts: [DiscoveredHost]) {
stop()
debugPinned = true
hosts = adverts
}
/// Builds one advert. `DiscoveredHost`'s memberwise init is internal (a public struct's is), and
/// making it public would expose a wire-shaped model's construction to every consumer just to
/// serve the harness.
public static func debugAdvert(
id: String, name: String, host: String, port: UInt16 = 9777,
fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true,
macAddresses: [String] = [], osChain: String = ""
) -> DiscoveredHost {
DiscoveredHost(
id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex,
requiresPairing: requiresPairing, allowsTofu: allowsTofu,
macAddresses: macAddresses, osChain: osChain)
}
#endif
private func restart() {
stop()
start()
@@ -761,6 +761,21 @@ public final class PunktfunkConnection {
return out
}
/// Report the display-latch grid + circular arrival-phase statistic so the host can
/// phase-lock its capture tick (design/phase-locked-capture.md). Fire-and-forget; call
/// ~1 Hz from a vsync-aware presenter. `nextLatchHostNs` must already be HOST clock
/// convert with `clockOffsetNs` (host client). No-op toward a host that never armed.
public func reportPhase(
nextLatchHostNs: UInt64, latchPeriodNs: UInt32, uncertaintyNs: UInt32,
arrivalLeadNs: UInt32, coherenceMilli: UInt16
) {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
_ = punktfunk_connection_report_phase(
h, nextLatchHostNs, latchPeriodNs, uncertaintyNs, arrivalLeadNs, coherenceMilli)
}
/// The currently active session mode (updated by accepted `requestMode` switches).
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
abiLock.lock()
@@ -21,8 +21,12 @@ import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
///
/// A caller that owns a particular pad passes the location id it wants (see
/// `open(preferringLocationID:)`); the renderer takes that from the `GCController` it is bound to,
/// so with two DualSenses attached each renderer drives its own device. Without a preference the
/// lowest location id wins an arbitrary but *stable* choice, where `Set.first` was neither.
final class DualSenseHID {
private let manager: IOHIDManager
private var device: IOHIDDevice?
@@ -43,9 +47,57 @@ final class DualSenseHID {
deinit { close() }
/// Find and open the first connected DualSense. Returns false if none is present or it can't
/// be opened (caller then falls back to CoreHaptics).
func open() -> Bool {
/// The IOKit location id of the device this instance opened the handle a caller correlates
/// with its `GCController`. `nil` until a successful `open`.
private(set) var locationID: UInt32?
/// A device's location id, or `nil` if IOKit does not report one.
static func locationID(of dev: IOHIDDevice) -> UInt32? {
IOHIDDeviceGetProperty(dev, kIOHIDLocationIDKey as CFString) as? UInt32
}
/// Every connected DualSense/Edge, by location id what a caller pairs against its controllers.
static func attachedLocationIDs() -> [UInt32] {
let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
let matches = productIDs.map { pid in
[kIOHIDVendorIDKey: vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
}
IOHIDManagerSetDeviceMatchingMultiple(mgr, matches as CFArray)
guard IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
return []
}
defer { IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) }
let devices = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice> ?? []
return devices.compactMap(locationID(of:)).sorted()
}
/// Which attached device to drive, as an index into `ids` the whole selection rule, pure so
/// it can be tested without an `IOHIDDevice` (which cannot be constructed).
///
/// `IOHIDManagerCopyDevices` returns an unordered `Set`, so the previous `Set.first` was not
/// merely arbitrary it can differ between two calls in one process. With two DualSenses that
/// made each renderer's paddevice binding a coin flip: both could land on the same device
/// (one pad's rumble coming out of the other, and the two per-instance write dedupes fighting
/// over it) or split by luck. An explicit location id makes the binding deterministic; the
/// lowest-id fallback at least makes it stable. `nil` ids sort last so a device IOKit cannot
/// place never displaces one it can.
static func preferredIndex(among ids: [UInt32?], preferring wanted: UInt32?) -> Int? {
if let wanted, let hit = ids.firstIndex(where: { $0 == wanted }) { return hit }
return ids.indices.min { (ids[$0] ?? .max) < (ids[$1] ?? .max) }
}
/// Pick the device to drive from everything attached (see [`preferredIndex`]).
static func pick(_ devices: Set<IOHIDDevice>, preferring wanted: UInt32?) -> IOHIDDevice? {
let ordered = Array(devices)
guard let i = preferredIndex(among: ordered.map(locationID(of:)), preferring: wanted) else {
return nil
}
return ordered[i]
}
/// Find and open a connected DualSense, preferring the one at `preferredLocationID`. Returns
/// false if none is present or it can't be opened (caller then falls back to CoreHaptics).
func open(preferringLocationID preferred: UInt32? = nil) -> Bool {
let matches = Self.productIDs.map { pid in
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
}
@@ -55,13 +107,21 @@ final class DualSenseHID {
return false
}
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
let dev = devices.first
let dev = Self.pick(devices, preferring: preferred)
else {
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
return false
}
device = dev
locationID = Self.locationID(of: dev)
if let preferred, locationID != preferred {
// Not fatal one pad still gets rumble but with two pads attached it means this
// renderer is driving the wrong one, and it is invisible without the log line.
log.error(
"rumble: wanted DualSense at location \(preferred, privacy: .public) but opened \(self.locationID.map(String.init) ?? "unknown", privacy: .public)"
)
}
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
@@ -70,8 +130,16 @@ final class DualSenseHID {
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
/// each 0...255. (0, 0) stops.
func rumble(low: UInt8, high: UInt8) {
guard let dev = device else { return }
///
/// Returns whether the write reached the device. The caller needs this: it used to be logged
/// and swallowed, so a failed write still counted as a successful render. That matters most
/// for a **stop**, which has nothing behind it the renderer stamps its write clock even on
/// failure, the keepalive re-write only fires for non-zero levels, and the ticker is cancelled
/// once the target is `(0, 0)`. On USB there is no firmware timeout either, so a swallowed
/// stop left the motors running with nothing scheduled to try again.
@discardableResult
func rumble(low: UInt8, high: UInt8) -> Bool {
guard let dev = device else { return false }
let report = bluetooth
? Self.bluetoothReport(low: low, high: high)
: Self.usbReport(low: low, high: high)
@@ -81,7 +149,9 @@ final class DualSenseHID {
}
if rc != kIOReturnSuccess {
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
return false
}
return true
}
func close() {
@@ -67,6 +67,17 @@ public final class GamepadCapture {
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
var fingerActive: [Bool] = [false, false]
var lastMotionNs: UInt64 = 0
// Hold-Selectguide gesture state (pf-client-core's `SelectGesture`, adapted to
// this class's mask-diff model): a Select pressed ALONE is held out of the mask
// until it resolves into a tap (delivered on release) or past `guideHold` a
// synthetic guide, down until release.
var selectPending = false
var selectAsGuide = false
/// A delivered tap's release is owed (`tapTimer` scheduled) its down went out
/// outside `buttons`, so `flush` must know to lift it.
var tapReleaseOwed = false
var gestureTimer: Timer?
var tapTimer: Timer?
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
self.controller = controller
self.pad = pad
@@ -91,6 +102,16 @@ public final class GamepadCapture {
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
/// pf-client-core's `DISCONNECT_HOLD` the same 1.5 s on every client.
private static let disconnectHold: TimeInterval = 1.5
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long the HOST's guide goes
/// down (until release, so a long hold is the host's long-press a Gaming-Mode
/// host's QAM). The gesture exists because iOS reserves the physical Home press (the
/// Game Overlay; sanctioned opt-out only via the user's iOS 27+ Home-button setting)
/// and tvOS never delivers it at all.
private static let guideHold: TimeInterval = 0.35
/// pf-client-core's `TAP_PRESS`: a held-back Select tap is delivered as a press with
/// its release this far behind back-to-back transitions can fold into nothing in
/// the host's per-pad input fold.
private static let tapPress: TimeInterval = 0.05
private var chordTimer: Timer?
/// Fired ON MAIN once the escape chord has been held `disconnectHold` the session owner
/// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a
@@ -98,9 +119,40 @@ public final class GamepadCapture {
/// gameplay can't end it (see ContentView's tvOS session branch).
public var onDisconnectRequest: (() -> Void)?
public init(connection: PunktfunkConnection, manager: GamepadManager) {
/// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
/// default true). Off is for a couch whose controller reaches the host another way USB
/// passthrough such as VirtualHere, or a pad plugged into the host itself where
/// forwarding as well would give the host two pads for one pair of hands.
///
/// Off still opens slots and tracks button state; it just sends nothing (see `wire`). That
/// is deliberate, not laziness: the escape chord is read off the same slots, and on tvOS it
/// is the ONLY controller way out of a stream a session that silently lost its exit
/// because a forwarding preference was off would be a worse bug than the one this fixes.
/// Unlike pf-client-core's slots, GameController claims nothing exclusive, so holding one
/// open costs the host nothing and blocks no passthrough tool.
public let forwarding: Bool
/// The connection, or nil while forwarding is off every wire send goes through this, so
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
/// Forward the raw guide + share/QAM presses (`EffectiveSettings.systemButtonsForward`,
/// default true on Apple where the OS shows its own overlay for them, that's the OS's
/// business; local mode exists for profile parity with the Gaming-Mode clients).
public let systemForward: Bool
/// The hold-Select guide gesture (`EffectiveSettings.guideGestureEnabled` auto = on
/// everywhere but macOS). See `guideHold`.
public let guideGesture: Bool
public init(
connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true,
systemForward: Bool = true, guideGesture: Bool = false
) {
self.connection = connection
self.manager = manager
self.forwarding = forwarding
self.systemForward = systemForward
self.guideGesture = guideGesture
}
public func start() {
@@ -188,7 +240,14 @@ public final class GamepadCapture {
element.preferredSystemGestureState = .disabled
}
// The Home/PS button ( guide; the host maps it to the DualSense PS / Xbox guide bit,
// BTN_MODE on the virtual xpad the Steam-overlay button). Driven DIRECTLY from this
// BTN_MODE on the virtual xpad the Steam-overlay button). On iOS 26 the OS opens its
// Game Overlay for this press regardless of the gesture claim below (the app is
// LSApplicationCategoryType=games, which enrolls it); the sanctioned per-controller
// opt-out is the USER's iOS 27+ Home-button setting. TODO(iOS 27 SDK): read
// `GCControllerHomeButtonSettingsManager` and surface a one-time
// `openControllerHomeButtonSettings(for:)` deep-link so users can hand the button to
// the stream the class is Swift-only and 27.0+, so it needs the Xcode 27 SDK to
// even compile. Until then hold-Select is the reliable route. Driven DIRECTLY from this
// handler's pressed value (not via buttonMask), because the legacy
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
// exists. On tvOS the element is absent (reserved) nil, the whole block no-ops.
@@ -205,8 +264,8 @@ public final class GamepadCapture {
// core re-sends it a few times against datagram loss; an older host ignores it and uses
// the session-default kind. Then wake the host pad (pads are created lazily from the first
// event; a DualSense's UHID handshake + initial lightbar write only start then).
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
wire?.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
wire?.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
sync(slot, ext)
if let tp = Self.touchpad(ext) {
@@ -233,7 +292,7 @@ public final class GamepadCapture {
flush(slot)
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
connection.send(.gamepadRemove(pad: slot.pad))
wire?.send(.gamepadRemove(pad: slot.pad))
let c = slot.controller
if let ext = c.extendedGamepad {
ext.valueChangedHandler = nil
@@ -271,11 +330,18 @@ public final class GamepadCapture {
// as "changed" otherwise the first stick/button move after a guide press would emit a
// spurious guide-UP while the button is still physically held (and drop the bit from
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
var raw = Self.buttonMask(g)
// Raw system buttons stay local when passthrough is off: misc1 (share/QAM) is
// masked here, guide is gated at its own handler.
if !systemForward { raw &= ~GamepadWire.misc1 }
// The hold-Select gesture rewrites the mask: a Select pressed alone is held out
// until it resolves (tap on release / synthetic guide past the threshold).
if guideGesture { raw = gestureFiltered(slot, raw) }
let newButtons = raw | (slot.buttons & GamepadWire.guide)
let changed = newButtons ^ slot.buttons
if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 {
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
}
slot.buttons = newButtons
}
@@ -288,21 +354,117 @@ public final class GamepadCapture {
Int32(g.rightTrigger.value * 255),
]
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
wire?.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
slot.axes[i] = v
}
updateEscapeChord()
}
/// The hold-Selectguide state machine over one sync's raw mask (pf-client-core's
/// `SelectGesture` rules): Select pressed ALONE is suppressed while pending; another
/// button joining makes it real (unsuppressed the diff sends its down); released
/// inside `guideHold` it's a tap, delivered out-of-band on release with the release
/// `tapPress` behind; past the threshold `gestureHoldFired` turned it into a synthetic
/// guide, lifted here when Select physically releases.
///
/// One deliberate divergence from the Rust worker: while transformed into a guide the
/// Select stays OUT of `slot.buttons`, so the escape chord doesn't complete on top of
/// an in-flight guide-hold release Select and press the chord plainly instead (the
/// chord's four-at-once press never lingers in pending long enough to be affected).
private func gestureFiltered(_ slot: Slot, _ raw: UInt32) -> UInt32 {
let back = GamepadWire.back
let backDown = raw & back != 0
let othersDown = raw & ~back != 0
if slot.selectAsGuide {
if backDown { return raw & ~back }
slot.selectAsGuide = false
sendGuide(slot, down: false, raw: false)
return raw
}
if slot.selectPending {
if !backDown {
endPending(slot)
deliverTap(slot)
return raw
}
if othersDown {
// A combo after all Select unsuppresses and the diff sends its down.
endPending(slot)
return raw
}
return raw & ~back
}
if backDown, !othersDown, slot.buttons & back == 0 {
// Newly pressed, alone: hold it back. An owed tap release goes out first so
// the host never sees two downs in a row.
if slot.tapReleaseOwed { finishTap(slot) }
slot.selectPending = true
let timer = Timer(timeInterval: Self.guideHold, repeats: false) { [weak self, weak slot] _ in
Task { @MainActor in
if let self, let slot { self.gestureHoldFired(slot) }
}
}
RunLoop.main.add(timer, forMode: .common)
slot.gestureTimer?.invalidate()
slot.gestureTimer = timer
return raw & ~back
}
return raw
}
/// The hold threshold passed with Select still pending it IS the guide now, down
/// until the physical release (`gestureFiltered`'s `selectAsGuide` branch lifts it).
private func gestureHoldFired(_ slot: Slot) {
guard slot.selectPending else { return }
slot.selectPending = false
slot.gestureTimer = nil
slot.selectAsGuide = true
sendGuide(slot, down: true, raw: false)
}
private func endPending(_ slot: Slot) {
slot.selectPending = false
slot.gestureTimer?.invalidate()
slot.gestureTimer = nil
}
/// Deliver a held-back Select tap: the press now, its release `tapPress` behind. Both
/// sends bypass `slot.buttons` (the raw mask no longer carries Select, so the diff
/// stays consistent); `tapReleaseOwed` is what `flush` checks so the press can't
/// outlive the slot.
private func deliverTap(_ slot: Slot) {
wire?.send(.gamepadButton(GamepadWire.back, down: true, pad: slot.pad))
slot.tapReleaseOwed = true
let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self, weak slot] _ in
Task { @MainActor in
if let self, let slot { self.finishTap(slot) }
}
}
RunLoop.main.add(timer, forMode: .common)
slot.tapTimer?.invalidate()
slot.tapTimer = timer
}
private func finishTap(_ slot: Slot) {
guard slot.tapReleaseOwed else { return }
slot.tapReleaseOwed = false
slot.tapTimer?.invalidate()
slot.tapTimer = nil
wire?.send(.gamepadButton(GamepadWire.back, down: false, pad: slot.pad))
}
/// Forward the guide (Home/PS) transition directly it's kept out of `buttonMask` (the legacy
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
/// released by `flush` on focus loss / close just like the others.
private func sendGuide(_ slot: Slot, down: Bool) {
/// released by `flush` on focus loss / close just like the others. `raw: true` marks the
/// physical Home handler's calls, which the system-buttons policy can keep local; the
/// gesture's synthetic transitions pass `raw: false` and always go out.
private func sendGuide(_ slot: Slot, down: Bool, raw: Bool = true) {
if raw, !systemForward { return }
guard !suspended else { return }
let bit = GamepadWire.guide
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
guard now != slot.buttons else { return }
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
wire?.send(.gamepadButton(bit, down: down, pad: slot.pad))
slot.buttons = now
}
@@ -365,13 +527,13 @@ public final class GamepadCapture {
if lifted {
if slot.fingerActive[finger] {
slot.fingerActive[finger] = false
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
}
return
}
slot.fingerActive[finger] = true
let w = GamepadWire.touchpad(x: x, y: y)
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
}
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
@@ -394,7 +556,7 @@ public final class GamepadCapture {
}
let gs = GamepadWire.gyroLSBPerRadS
let as_ = GamepadWire.accelLSBPerG
connection.sendMotion(
wire?.sendMotion(
pad: UInt8(slot.pad),
gyro: (
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
@@ -431,16 +593,22 @@ public final class GamepadCapture {
/// (no GC calls) safe against an already-removed device. Does NOT close the slot or send
/// GamepadRemove (that's `closeSlot`).
private func flush(_ slot: Slot) {
// Gesture first: a pending (never-sent) Select just drops, an owed tap release
// goes out, and a transformed guide's bit folded into `buttons` by `sendGuide`
// is lifted by the loop below like any held button.
endPending(slot)
slot.selectAsGuide = false
if slot.tapReleaseOwed { finishTap(slot) }
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
}
slot.buttons = 0
for (i, v) in slot.axes.enumerated() where v != 0 {
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
wire?.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
slot.axes[i] = 0
}
for (f, active) in slot.fingerActive.enumerated() where active {
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
slot.fingerActive[f] = false
}
}
@@ -117,7 +117,15 @@ public final class GamepadFeedback {
reset(slot.controller)
slots[pad] = nil
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
renderer?.stop()
// OFF the main actor. `RumbleRenderer.stop()` is a `queue.sync`, and its body is a
// per-motor `CHHapticEngine.stop()` an XPC round trip to gamecontrollerd, which the
// renderer's own notes record as able to hang plus `DualSenseHID.close()`, whose
// blocking `IOHIDDeviceSetReport` goes to a device that has just departed. It also
// queues behind any in-flight `setup()`. This runs on every unplug and every pin
// change, and the main thread is what drives the presenter's CADisplayLink, so
// blocking here hitches the picture mid-stream. The renderer is already detached from
// routing above, so nothing observes it after this point.
if let renderer { Task.detached { renderer.stop() } }
}
for (pad, controller) in want {
if let slot = slots[pad] {
@@ -282,6 +290,12 @@ public final class GamepadFeedback {
private func reset(_ controller: GCController?) {
guard let c = controller else { return }
c.playerIndex = .indexUnset
// Put the lightbar out too. This class is what turned it on (see the `Led` and
// `PlayerLeds` arms), and every DS write is valid-flag-selective, so a colour the game
// set stays lit in firmware after the stream ends back at the launcher, or for a pad
// that merely left the forwarded set. A DS4 is cleared incidentally because its player
// indicator IS the lightbar; a DualSense is not.
c.light?.color = GCColor(red: 0, green: 0, blue: 0)
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
ds.leftTrigger.setModeOff()
ds.rightTrigger.setModeOff()
@@ -459,6 +459,18 @@ final class RumbleRenderer: @unchecked Sendable {
if split {
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
// HALF a split is worse than none, and it used to pass silently: only the all-nil case
// below counts as failure, so one surviving handle left `ok` true and `reportHealth(nil)`
// announced HEALTHY. What actually rendered was wrong in a direction that depends on
// which handle died lose `high` and `render` falls to the combined branch (selected
// purely by `high != nil`), playing max(low, high) on the LEFT handle at the combined
// sharpness; lose `low` and the split branch's reconcile no-ops on the nil slot, so the
// heavy motor is discarded outright. Tear the survivor down and take the combined path,
// which at least renders both motors somewhere.
if low == nil || high == nil {
log.warning("rumble: only one split-handle engine came up — falling back to combined")
teardown() // disarms handlers, stops the survivor's players + engine, nils both
}
} else {
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
}
@@ -587,7 +599,9 @@ final class RumbleRenderer: @unchecked Sendable {
#if os(macOS)
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
let hid = DualSenseHID()
guard hid.open() else { return false }
// Ask for the device this renderer's controller actually is, so two attached DualSenses
// do not both get driven through whichever one an unordered Set happened to yield first.
guard hid.open(preferringLocationID: Self.hidLocationID(for: c)) else { return false }
dualSenseHID = hid
return true
#else
@@ -595,6 +609,24 @@ final class RumbleRenderer: @unchecked Sendable {
#endif
}
#if os(macOS)
/// Correlate a `GCController` with an IOKit location id.
///
/// GameController exposes no location id, so there is no direct mapping. What it does expose is
/// a stable per-controller ordering, and IOKit's location ids are stable per port: pairing the
/// two by rank makes each renderer pick a *distinct* device, which is the property that was
/// missing. With one pad attached this is the same device it always was.
static func hidLocationID(for c: GCController) -> UInt32? {
let ids = DualSenseHID.attachedLocationIDs()
guard ids.count > 1 else { return ids.first }
let peers = GCController.controllers().filter { $0.extendedGamepad is GCDualSenseGamepad }
guard let rank = peers.firstIndex(where: { $0 === c }), rank < ids.count else {
return ids.first
}
return ids[rank]
}
#endif
/// Write the target to the DualSense over HID if that's the active backend; false not a
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
@@ -605,8 +637,20 @@ final class RumbleRenderer: @unchecked Sendable {
let keepalive = levels != (0, 0)
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
if levels != lastHidWrite.levels || keepalive {
hid.rumble(low: levels.0, high: levels.1)
lastHidWrite = (levels, .now())
if hid.rumble(low: levels.0, high: levels.1) {
lastHidWrite = (levels, .now())
} else {
// The write did not reach the device. Do NOT stamp the clock that would claim a
// render that never happened, and for a stop there is nothing behind it: the
// keepalive only re-writes non-zero levels and the ticker is cancelled once the
// target is (0, 0), so the motors would keep running with nothing scheduled.
// Drop the handle instead: the pad reverts to CoreHaptics, and a reconnect
// rebuilds it. Health is reported so the state is visible rather than silent.
log.error("rumble: HID write failed — dropping the handle, falling back")
closeHID()
reportHealth("Lost the direct connection to this DualSense; using the system path.")
return false
}
}
return true
#else
@@ -128,11 +128,19 @@ public final class InputCapture {
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the
/// captured-state delivery path; released, the events pass through and the menu handles them.
/// Q releases the captured mouse/keyboard; D disconnects; S cycles the stats
/// overlay tier (off compact normal detailed). Main queue.
/// overlay tier (off compact normal detailed). A (`onToggleMicMute`, below) rides
/// the same path. Main queue.
public var onReleaseCapture: (() -> Void)?
public var onDisconnect: (() -> Void)?
public var onCycleStats: (() -> Void)?
/// Fired on A mute/unmute the microphone uplink, the one in-stream control a captured
/// session can't otherwise reach (the HUD's button is behind a grabbed cursor). Same delivery
/// rule as the combos above: only WHILE FORWARDING, because that's when the menu's identical
/// key equivalent can't fire. M the obvious letter is long since the mouse-model flip
/// (cross-client), so A ("audio in") is the mic's. Main queue.
public var onToggleMicMute: (() -> Void)?
/// Fired on F (macOS) toggle the streaming window in/out of fullscreen. Detected in the
/// monitor only WHILE FORWARDING, for the same reason as the combos: a captured stream view
/// swallows keys, so the Stream menu's identical F equivalent never reaches it; released, the
@@ -140,13 +148,13 @@ public final class InputCapture {
public var onToggleFullscreen: (() -> Void)?
#if os(iOS)
/// Windows VKs of the three modifier classes in the Q release chord, both L/R sides:
/// Windows VKs of the three modifier classes in the chords, both L/R sides:
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
private static let chordModifierVKs: Set<UInt32> = [0xA2, 0xA3, 0xA4, 0xA5, 0xA0, 0xA1]
/// Whether Control AND Option AND Shift are all currently held (either side of each counts)
/// the modifier precondition for the iPad Q release chord.
private var hasReleaseChordModifiers: Bool {
/// the modifier precondition for the iPad chords (Q releases capture, A mutes the mic).
private var hasChordModifiers: Bool {
let m = chordModifiersDown
return (m.contains(0xA2) || m.contains(0xA3)) // control
&& (m.contains(0xA4) || m.contains(0xA5)) // option
@@ -284,6 +292,10 @@ public final class InputCapture {
self.suppressedVK = 0x53
self.onCycleStats?()
return nil
case 0 /* A */:
self.suppressedVK = 0x41
self.onToggleMicMute?()
return nil
default:
break
}
@@ -704,7 +716,7 @@ public final class InputCapture {
}
}
#if os(iOS)
// Track Control/Option/Shift for the Q release chord below in both forwarding
// Track Control/Option/Shift for the chords below in both forwarding
// states (like `cmdKeysDown`) so a modifier held before capture engaged still counts.
if Self.chordModifierVKs.contains(vk) {
if pressed { self.chordModifiersDown.insert(vk) } else { self.chordModifiersDown.remove(vk) }
@@ -732,11 +744,19 @@ public final class InputCapture {
// otherwise). The Q is latched (`suppressedVK`) so its keyUp can't type into the host;
// the modifiers were forwarded as they went down and are flushed by the release
// path (setCaptured(false) releaseAll). VK 0x51 is layout-independent (physical Q).
if pressed, vk == 0x51, self.hasReleaseChordModifiers {
if pressed, vk == 0x51, self.hasChordModifiers {
self.suppressedVK = 0x51
self.onReleaseCapture?()
return
}
// A mutes/unmutes the mic uplink same detection, same latching, and needed here
// for the same reason as on macOS: a captured iPad swallows the Stream menu's
// identical key equivalent. VK 0x41 is layout-independent (physical A).
if pressed, vk == 0x41, self.hasChordModifiers {
self.suppressedVK = 0x41
self.onToggleMicMute?()
return
}
#endif
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
// monitor never type Esc into the host while is held ( is reserved).
@@ -1,17 +1,19 @@
// The host cards' OS marks: template vector imagesets in Resources/OsIcons.xcassets
// (derived from the repo's assets/os-icons masters Font Awesome Free brands CC BY 4.0 +
// Simple Icons CC0; provenance in that directory's README), resolved from the host's
// (generated from the repo's assets/os-icons masters by scripts/gen-os-icons.sh
// per-mark provenance and licensing in that directory's README), resolved from the host's
// OS-identity chain via PunktfunkShared's `osIconTokens` walk. Template rendering means
// they tint with `foregroundStyle` like an SF Symbol.
import PunktfunkShared
import SwiftUI
/// The icon tokens this client ships art for. A distro without its own mark (Bazzite,
/// CachyOS, ...) degrades to its family's and finally to Tux via the chain walk.
/// The icon tokens this client ships art for: the families a chain can land on, plus the
/// gaming distros that earn their own mark because "a Bazzite box" and "a Fedora box" are
/// different machines to the person reading the card. A distro with no mark of its own
/// still degrades to its family's and finally to Tux via the chain walk.
private let osIconTokensShipped: Set<String> = [
"windows", "apple", "linux", "steam", "ubuntu", "fedora", "arch", "debian", "nixos",
"opensuse",
"opensuse", "bazzite", "cachyos", "nobara",
]
/// The mark for an OS-identity chain (`linux/fedora/bazzite`, ...), or nil no view at
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "bazzite.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "cachyos.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "nobara.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
File diff suppressed because it is too large Load Diff
@@ -355,6 +355,13 @@ final class SessionPresenter {
/// behavior iOS keeps a 30 Hz floor; macOS leaves the NSView link at its display's native
/// rate (it already tracks the display and must NOT be capped to the stream rate).
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
/// Pen-proximity panel-rate boost pass-through (Stage2Pipeline.setInteractionBoost):
/// deadline pacing only under arrival/glass the staged hint feeds no link, so this
/// is a no-op there. MAIN thread.
func setInteractionBoost(_ on: Bool) {
stage2?.setInteractionBoost(on)
}
private func syncFrameRate(hz: UInt32) {
guard hz > 0 else { return }
// Deadline pacing: the hint goes to the pipeline's CAMetalDisplayLink instead (staged;

Some files were not shown because too many files have changed in this diff Show More