63 Commits

Author SHA1 Message Date
enricobuehler ac706ba839 chore(release): bump workspace version to 0.7.2
audit / cargo-audit (push) Successful in 18s
apple / swift (push) Successful in 1m10s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 9m41s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 47s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 54s
ci / bench (push) Successful in 4m40s
windows-host / package (push) Successful in 7m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m22s
release / apple (push) Successful in 9m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m14s
apple / screenshots (push) Successful in 5m45s
android-screenshots / screenshots (push) Successful in 53s
android / android (push) Successful in 3m15s
decky / build-publish (push) Successful in 14s
deb / build-publish (push) Successful in 3m31s
linux-client-screenshots / screenshots (push) Successful in 2m17s
flatpak / build-publish (push) Failing after 4m6s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m7s
web-screenshots / screenshots (push) Successful in 2m44s
docker / deploy-docs (push) Successful in 17s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
Ship the flatpak PipeWire-socket audio fix (94b5f48) to the stable channel —
a tag is required (main pushes only publish the canary flatpak branch), and
0.7.1 stable users on the Deck have no client audio until this lands. Bump
[workspace.package] + the 9 Cargo.lock workspace entries (CI builds --locked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:59:26 +00:00
enricobuehler 94b5f48d0b fix(flatpak): expose native PipeWire socket so client audio works
The Linux client speaks the native PipeWire protocol (audio.rs `pw connect`),
but the manifest granted only --socket=pulseaudio, so the sandbox had just
`pulse/native` and no `pipewire-0`. Playback + mic both died with
"pw connect (is PipeWire running in this session?)" — reproduced live on a
Steam Deck in Gaming Mode (no client audio node ever appeared).

Add --socket=pipewire (canonical) + --filesystem=xdg-run/pipewire-0 (portable
bind of the same socket). Validated on-Deck via a `flatpak override
--filesystem=xdg-run/pipewire-0`: pipewire-0 then appears in the sandbox and
the client registers its "punktfunk-client" PipeWire node with no pw-connect
error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:59:11 +00:00
enricobuehler 139d032e55 docs(bazzite): fix the "rpm-ostree upgrade doesn't update punktfunk" trap
apple / swift (push) Successful in 1m11s
android / android (push) Successful in 4m18s
ci / rust (push) Successful in 4m51s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m47s
deb / build-publish (push) Successful in 2m56s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m45s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m2s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m0s
`rpm-ostree upgrade` re-resolves layered packages only when the BASE image
changes; on a frozen Bazzite base (pinned :stable tag / paused rebase) it
reports "No updates available" and never bumps the layered punktfunk even
when newer RPMs are live in the repo — observed on the .41 host stuck at
0.6.0 while 0.7.x sat in the registry.

- Add packaging/bazzite/update-punktfunk.sh: detects the layered punktfunk
  packages, refreshes rpmmd, and forces a re-resolve via
  `rpm-ostree update --uninstall <pkg> --install <pkg>` (the one-transaction
  idiom that actually pulls a new layered version on a static base).
- Document the trap + the fix in packaging/bazzite/README.md, including the
  channel gotcha: an enabled punktfunk-canary.repo (<next-minor>.0-0.ciN)
  outranks stable X.Y.Z-1, so the box silently tracks canary — enable one
  channel only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:38:52 +00:00
enricobuehler caa7a1c735 chore(release): bump workspace version to 0.7.1
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 16s
ci / web (push) Successful in 1m6s
ci / docs-site (push) Successful in 1m24s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 52s
ci / bench (push) Successful in 5m1s
android-screenshots / screenshots (push) Successful in 47s
ci / rust (push) Successful in 9m48s
windows-host / package (push) Successful in 7m10s
android / android (push) Successful in 3m53s
release / apple (push) Successful in 8m47s
decky / build-publish (push) Successful in 15s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m20s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m14s
deb / build-publish (push) Successful in 2m59s
apple / screenshots (push) Successful in 5m42s
flatpak / build-publish (push) Successful in 4m11s
linux-client-screenshots / screenshots (push) Successful in 1m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m19s
docker / deploy-docs (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
web-screenshots / screenshots (push) Successful in 2m34s
Cut 0.7.1 to test the GTK + Decky polish batch (57ae00a) on-device: the
host-click / disconnect-chord / gray-screen-recovery / leak fixes on the
Linux client, the Deck launcher perf profile, and the Decky pin/pairing
fixes. The [workspace.package] version (inherited by every crate via
version.workspace) is the release being cut; refresh the 9 workspace
entries in Cargo.lock to match (CI builds --locked). Canary derives from
the tag (scripts/ci/pf-version.sh), so cutting v0.7.1 auto-advances canary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:47:58 +00:00
enricobuehler 13dc7fc49f style(linux): rustfmt the keyframe-recovery throttle line
apple / swift (push) Successful in 1m9s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
Wrap the `last_kf_req.is_none_or(...)` guard to satisfy `cargo fmt --all
--check` (CI Format step).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:47:16 +00:00
enricobuehler 57ae00a9c8 fix(clients): GTK + Decky polish batch from live Deck/Windows testing
ci / rust (push) Failing after 41s
apple / swift (push) Successful in 1m8s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m20s
deb / build-publish (push) Successful in 2m55s
decky / build-publish (push) Successful in 27s
apple / screenshots (push) Successful in 5m46s
ci / bench (push) Successful in 5m5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
GTK Linux client:
- hosts/library: clicking a card was dead — the handler was on
  FlowBoxChild::activate (never emitted on click); bridge child-activated
  → child.activate() on the FlowBox (ui_hosts, ui_library).
- stream: the Ctrl+Alt+Shift+D/Q/S chords (and all key forwarding) were
  dropped because the key controller sat on the overlay, which loses focus
  to the header back button after nav.push+fullscreen — move it to the
  window and remove it on teardown.
- video: a mid-session VAAPI decode error rebuilt a software decoder but
  never requested a keyframe, so under the infinite GOP the picture stayed
  gray/frozen forever. Request an IDR on any VAAPI error, keep the hardware
  decoder, and demote to software only after repeated failures.
- stream: fix a per-session Capture↔overlay reference cycle that leaked the
  overlay subtree + the Arc<NativeClient> on every session end — hold the
  overlay weakly.
- stream: accumulate the fractional wheel remainder so precision-scroll
  (Deck trackpad / hi-res wheels) sub-unit deltas aren't dropped.
- gamepad library: keep the launcher smooth on the Deck — freeze the aurora
  and trim the visible card range (fewer 3D offscreen passes) on low-power.
- gamepad: log full pad identity (vid:pid:name:type:virtual) on attach to
  diagnose an empty controller list on the Deck.
- cli: --connect host:<badport> silently did nothing; default to 9777 + warn.
- css: add the missing .pf-neutral pill rule; fix the clipped most-recent
  accent (inset outline instead of a corner-clipped box-shadow bar).

Decky plugin:
- surface the on-screen library browser: label the host-row Games button.
- fix silent pin data-loss — the detached Games modal captured a frozen
  pins array, so pinning a second game clobbered the first; mirror pins in
  a ref and track the modal's pinned ids locally for a live label.
- route pair-required hosts through the pairing modal from the fullscreen
  Stream button (parity with the QAM panel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:37:04 +00:00
enricobuehler 882a3d57f6 chore(release): bump workspace version to 0.7.0
android-screenshots / screenshots (push) Successful in 2m12s
windows-host / package (push) Successful in 6m43s
android / android (push) Successful in 9m24s
release / apple (push) Successful in 9m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m8s
apple / swift (push) Successful in 1m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 53s
linux-client-screenshots / screenshots (push) Successful in 1m41s
apple / screenshots (push) Successful in 5m37s
web-screenshots / screenshots (push) Successful in 2m37s
audit / cargo-audit (push) Successful in 1m12s
ci / web (push) Successful in 1m8s
ci / docs-site (push) Successful in 1m22s
ci / rust (push) Successful in 4m34s
deb / build-publish (push) Successful in 3m0s
ci / bench (push) Successful in 4m57s
decky / build-publish (push) Successful in 12s
flatpak / build-publish (push) Successful in 4m7s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m2s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m42s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 54s
docker / deploy-docs (push) Successful in 20s
The [workspace.package] version (inherited by every crate via
version.workspace) is the release being cut. Refresh the 9 workspace entries
in Cargo.lock to match (CI builds --locked). Canary derives from the tag now
(scripts/ci/pf-version.sh), so no canary-base edit is needed — cutting v0.7.0
auto-advances canary to 0.8.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:45:45 +00:00
enricobuehler fa28fa19a0 Merge remote-tracking branch 'origin/main'
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
2026-07-03 22:41:42 +00:00
enricobuehler 42595b5558 fix(flatpak): keep both channels in the OSTree summary (fixes stable "No such ref")
The surfaced install command
  flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.flatpakref
failed with "No such ref 'app/io.unom.Punktfunk/x86_64/stable'". The stable
commit's objects are on the server, but the repo *summary* (what flatpak reads
to resolve refs) listed only canary.

Root cause: each CI run builds a fresh SINGLE-branch local OSTree repo,
build-update-repo regenerates the summary from that one branch, and rsync
uploads it without --delete. Objects for both channels accumulate, but the
summary is overwritten every run and only names that run's branch. Canary runs
on every main push, stable only on tags — so a tag published stable, then the
next canary push clobbered the summary back to canary-only.

Fix: seed the local repo from the live server (rsync repo/ DOWN) before the
build, so it carries every published branch; the build only adds this run's
commit and the regenerated+signed summary keeps both channels. Single shared
repo kept (no URL/Caddyfile change; existing installs fixed transparently).
Adds a refs log after build-update-repo as a clobber tripwire. Also adopts
scripts/ci/pf-version.sh for the canary base (see previous commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:40:35 +00:00
enricobuehler 4de543c146 ci(release): derive canary version from git tags (single source of truth)
Every release workflow hardcoded a canary base version (0.5.0 in
Apple/Android/rpm/flatpak/deb, 0.3 in windows-msix/windows-host/decky) that
had to be hand-bumped on each stable release and wasn't. With stable at
v0.6.0, every canary was a version *behind* stable — e.g. the Apple canary
showed up on TestFlight as 0.5.0 while 0.6.0 was already published.

Add scripts/ci/pf-version.{sh,ps1} (bash + pwsh twin) as the single source of
truth: stable = the vX.Y.Z tag; canary = latest stable tag with minor+1,
patch 0 (v0.6.0 -> 0.7.0), so canary is always exactly one minor ahead of the
newest release with zero maintenance. Falls back to the workspace Cargo.toml
version when no tag is fetchable. All workflows now eval/call it and format
their own channel suffix off $PF_BASE; only the canary branch changed, stable
branches and per-channel suffixes are untouched. channels.md drops the old
manual "bump the canary base" release step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:40:25 +00:00
enricobuehler 42d1c74663 fix(apple-client/audio): capture the right channel of a multi-channel mic + diagnostics
apple / swift (push) Successful in 1m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
The mic uplink handed the host pure digital silence on a multi-channel
interface: AVAudioConverter's N→stereo downmix takes channels 0/1, but a
pro interface puts the mic on ONE higher discrete channel. Fold the input
to a mono bus ourselves instead — pick the mic's channel (or sum all) and
resample that to the encoder's 48 kHz stereo, so the silent 0/1 downmix
never happens.

- New "Microphone channel" setting (macOS): Auto (sum every channel — a
  lone hot mic passes at full level) or pin 1-based channel N. Picker
  appears only for multi-channel devices, driven by the device's input
  channel count.
- Diagnostics that make this class of failure self-naming next session:
  log the actual live capture device + format + fold mode, warn on a
  silent UID fallback, and a one-shot silence tripwire on the EXTRACTED
  signal (WARN on 10 s of zeros, else peak dBFS).
- foldToMono extracted as a pure, unit-tested helper (pin / sum-clamp x
  interleaved / deinterleaved / mono / out-of-range).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 00:36:35 +02:00
enricobuehler 136f6e8f0e feat(probe): --mic-burst — real-client mic pacing for jitter-buffer regression tests
apple / swift (push) Successful in 1m8s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
The steady 5 ms mic-test cadence never trips host-side buffering bugs:
the WASAPI crackle (fixed in the previous commit) only reproduced under
a real client's bursty input tap. --mic-burst paces the tone the same
way (two 20 ms Opus packets every 40 ms), so recording the host mic and
counting silence gaps regression-tests the jitter buffer headlessly.
Validated against the fixed Windows host on the lab box: 15 s of bursty
tone, zero mid-stream gaps >=3 ms (gaps confined to the first 40 ms
priming window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:35:49 +00:00
enricobuehler 00acf5e44e fix(host/audio): WASAPI virtual mic — port the priming jitter buffer (crackling fix)
apple / swift (push) Successful in 1m8s
ci / rust (push) Successful in 1m56s
apple / screenshots (push) Successful in 5m17s
ci / bench (push) Successful in 4m41s
decky / build-publish (push) Successful in 24s
ci / web (push) Successful in 59s
android / android (push) Successful in 3m41s
ci / docs-site (push) Successful in 1m0s
windows-host / package (push) Successful in 7m6s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m39s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
deb / build-publish (push) Successful in 9m15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
Mac → Windows mic passthrough crackled heavily while the identical
stream was clean on the Linux host. Cause: clients push mic audio in
BURSTS on their own clock (the Mac input tap yields ~two 20 ms Opus
packets every ~42 ms) while the WASAPI render loop pulled a block every
~10 ms device period and greedily drained whatever was queued, padding
the rest with zeros — the queue sat near-empty and most periods
inserted mid-stream silence. The Linux backend has absorbed this since
day one with its priming jitter buffer; the WASAPI loop had none.

Port the same semantics: emit silence until ~48 ms is buffered (covers
the worst inter-burst gap), then play from the cushion (zero-filling
only a momentary shortfall), re-prime only after a genuine full drain
(client went quiet). Queue cap raised 80 → 120 ms for burst headroom;
steady-state added latency ≈ the 48 ms cushion.

Diagnosed live on .173: probe tone recording from CABLE Output proved
the endpoint wiring, then the burst-vs-period math explained the
crackle. Build-verified on Windows; on-glass listen pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:31:18 +00:00
enricobuehler 38078fe7ee feat(linux-client): gamepad library launcher — console-style coverflow (--browse)
ci / rust (push) Successful in 1m54s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m2s
windows-host / package (push) Successful in 6m43s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m12s
ci / bench (push) Successful in 4m47s
apple / swift (push) Successful in 1m9s
android / android (push) Successful in 3m33s
deb / build-publish (push) Successful in 4m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
decky / build-publish (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
release / apple (push) Successful in 8m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m15s
flatpak / build-publish (push) Successful in 4m4s
apple / screenshots (push) Successful in 5m31s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m48s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m20s
A controller-driven, chrome-less library launcher for the Steam Deck flow
(the Decky plugin's "Open library on screen" + pinned games, 8470419):
`--browse host[:port]` opens a paired host's game library as a coverflow
over a drifting aurora — A streams the focused title (the id rides the
Hello), session end returns to the launcher, B quits back to Gaming Mode.
`--connect` gains `--launch <id>` for direct-to-game starts; `--mgmt`
overrides the library port. Scope is deliberately library-only: host
selection/settings stay in the touch UI, pairing stays in the plugin (no
dialog can map under gamescope — every state renders in-page).

- gamepad.rs menu mode: the worker holds the active pad open while idle
  (WITHOUT the Valve HIDAPI drivers — Deck lizard mode survives) and
  translates it through a pure MenuNav state machine: edge-triggered
  buttons, held-state snapshot on entry/detach (the escape chord that ends
  a stream can't ghost-fire in the menu), 380/160 ms stick/dpad repeat,
  menu rumble ticks. Keyboard fallback (arrows/Enter/Esc) drives the same
  handler — fully usable with no pad, no host (PUNKTFUNK_FAKE_LIBRARY).
- Coverflow: ±38° corridor-facing tilt under per-card perspective
  (gsk rotate_3d), dense overlapping side shelves with paint-order
  restacking (gtk::Fixed draws in child order), opaque card faces + a
  darkening veil for the recede (translucency would bleed the stack
  through). The strip lives in an External-policy ScrolledWindow because
  a bare gtk::Fixed measures its TRANSFORMED children and inflates the
  page min-width past the window.
- Spring-driven motion: semi-implicit Euler in ≤8 ms substeps (a raw
  50 ms frame leaves the stiff recoil spring ringing at ω·dt ≈ 1.2 —
  regression-tested), ζ≈0.85 cursor chase + ζ≈0.55 boundary wobble;
  velocity carries across retargets so held-repeat scrolling glides.
- Shot scene `gamepad-library` (GTK animations force-disabled in shot mode
  — nav transitions froze mid-slide in headless captures); shared poster
  fetch extracted to library::spawn_art_fetch.

Verified here: 21 unit tests (MenuNav, cursor stepping, spring
convergence/stability), clippy -D warnings clean, screenshot scene
pixel-checked, --browse smoke runs (fake-library + unpaired) on the
headless session. On-Deck validation pending (virtual-pad input, lizard
mode, rumble via Steam Input, full Decky→browse→stream→launcher loop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:41:43 +00:00
enricobuehler 69609945a3 feat(clients): host/network split in every stats HUD (stats phase 2, client side)
Consumes the 0xCF host-timing plane (449a67c) on all four GUI clients: each
keeps a bounded pending ring of receipt samples keyed by pts, matches the
host's per-AU capture→sent reports against it, and the HUD equation becomes

  = host 3.1 + network 6.7 + decode 2.1 + display 2.3

falling back to the combined `= host+network …` term whenever no timing
matched the window (old host / datagram loss) — same total, one split
fewer, never a misleading zero. Apple additionally gains the split as the
only equation line under the stage-1 fallback presenter (receipt is
presenter-independent), a `nextHostTiming` wrapper with its own plane lock,
and a unit-tested `HostNetworkSplitter`; Android extends the JNI stats
array 16→18 doubles (0–15 unchanged); Windows/Linux thread the split
through `Stats` into the HUD and the headless/debug logs.

Docs updated: design/stats-unification.md Phase 2 → implemented (wire
format, fallback semantics), and the docs-site matrix's Sunshine "Host
processing latency" row is now a direct match (ours includes the paced
send; avg vs p50).

Verified here: linux client clippy -D warnings green on the live tree,
windows stub check + hand-verified diff, android cargo-ndk arm64 check
green, apple loopback test extended (needs the rebuilt xcframework + swift
test on the mac). On-glass: pending on all platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:31:49 +00:00
enricobuehler 8470419433 feat(decky): pinned-games library + self-update robustness; fix gamepad tab-nav
Decky client batch:
- Pinned games / library picker: per-host game grid (GamePickerModal),
  pin/unpin, one-tap streams surfaced on the Hosts tab and QAM
  (usePins/streamPin/resolvePinHost, new src/library.tsx).
- Self-update + client-update plumbing (main.py check_update, hooks.ts
  applyUpdate) with a CA-bundle-resolving SSL context and per-channel
  manifest polling; steam.ts / punktfunkrun.sh launch tweaks.
- scripts/test-backend.py harness for the backend RPCs; README refresh.

Fix: the fullscreen page wrapped <Tabs> in an overflow-visible box, so
Valve's L1/R1 tab slide + autoFocusContents scrollIntoView panned
#GamepadUI itself — the whole Steam UI slid left until a tab was clicked.
Clip the Tabs wrapper (overflow:hidden), matching Valve's own Tabs
containers. (On-glass verification pending — Deck offline this session.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:25:07 +00:00
enricobuehler 449a67ce8d feat(protocol): per-AU host-timing plane (0xCF) — split host+network latency (stats phase 2)
The unified-stats equation's host+network stage was one opaque number
because the wire carried nothing but pts_ns. Now the host reports its own
share per frame: when the client's Hello sets VIDEO_CAP_HOST_TIMING (0x08),
the send thread emits a 13-byte 0xCF datagram — [tag][pts_ns u64][host_us
u32] — right after the AU's last packet leaves the socket, so host_us =
capture→fully-sent (capture read/convert, encode, FEC+seal, paced send)
against the same anchor the wire pts carries. Clients correlate by pts_ns
and derive network = (received + clock_offset − pts) − host_us; the two
terms tile per frame by construction.

Back-compat is free in all four combinations: old clients ignore unknown
datagram tags, old hosts ignore unknown cap bits (client keeps the combined
stage). The hardened data-plane format is untouched — this rides the
established QUIC side-plane pattern (0xC8…0xCE). NativeClient ORs the bit
in unconditionally and exposes next_host_timing(); the C ABI gains
PunktfunkHostTiming + punktfunk_connection_next_host_timing (additive).
The synthetic host emits 0xCF too, so pure-loopback protocol tests cover
the plane.

The probe reports the split (host_p50/p95_us · net_p50/p95_us) and is our
direct analogue of Sunshine's "host processing latency" — ours additionally
includes the paced send.

Validated on loopback (synthetic host + probe, debug build): 240/240 AUs
matched, host_p50 6.5 ms + net_p50 6.4 ms ≈ capture→received p50 13.0 ms.
Core suite + new 0xCF roundtrip/truncation test green; host+core+probe
clippy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:22:12 +00:00
enricobuehler 09a5957c6d feat(clients): unified stats vocabulary across every client + Moonlight comparison docs
One stat model everywhere (design/stats-unification.md): four measurement
points (capture/received/decoded/displayed), three stages that tile the
interval exactly, and a HUD that shows the addition explicitly —

  end-to-end 14.2 ms p50 · 19.8 p95 · capture→on-glass
  = host+network 9.8 + decode 2.1 + display 2.3

replacing each client's ad-hoc mix of overlapping absolutes (the Apple HUD's
three arrow lines that looked sequential but weren't), mean-vs-median decode
times (Windows/Linux), missing same-host-clock flags (Windows/Linux), and
three different names for the same capture→received measurement (probe's
"reassembled", Apple/Android's "client", Windows/Linux's post-decode "lat").

Per client: Apple threads receivedNs through the VT decode via the frame
refcon bit pattern so the decode stage exists at all (stage-1 fallback
honestly degrades to a capture→received headline); Windows carries
FrameTimes through the existing frame channel to the render thread and adds
e2e p50/p95 post-Present; Linux stamps received at AU pop and rides
decoded_ns on DecodedFrame to the paintable-set site; Android pairs receipt
stamps with MediaCodec output buffers via the codec's pts round-trip (JNI
stats array 14→16 doubles, indexes 0-13 unchanged). fps now uniformly counts
received AUs; lost/(received+lost) per window, hidden at zero.

docs-site gains "Understanding the Stats Overlay": what each line means, why
the equation only approximately sums (percentiles), and a line-by-line
Moonlight/Sunshine matrix — including that Moonlight has no end-to-end
number and its "network latency" is an ENet control RTT, so punktfunk's
headline must not be compared against any single Moonlight line.

Verified here: linux client + probe + core check/clippy/fmt green, android
native cargo-ndk arm64 check green. Pending: Windows CI + on-glass, swift
test on the mac, on-device Android.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:01:29 +00:00
enricobuehler c7630ff5dc fix(host/audio): mic pump — open handshake on Linux + rapid-death backoff
apple / swift (push) Successful in 1m8s
apple / screenshots (push) Successful in 5m18s
android / android (push) Successful in 3m21s
windows-host / package (push) Successful in 6m58s
ci / rust (push) Successful in 1m58s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m1s
ci / bench (push) Successful in 4m49s
deb / build-publish (push) Successful in 4m37s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m59s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m34s
Found by a live boot-order test (host started before the user session's
PipeWire): PwMicSource::open returned Ok before the daemon connection was
attempted, so a PipeWire that wasn't running surfaced as an instantly-dead
instance instead of an open failure — and the pump churned
open→die→reopen at heartbeat rate (1 Hz "virtual mic ready" log spam)
instead of backing off.

- PwMicSource::open now has a bring-up handshake (mirrors the Windows
  backend): ready only after connect + stream connect succeed, so a
  down daemon is an open ERROR and the pump's backoff engages.
- The pump triages deaths: an instance that lived >= 5 s (a one-off
  daemon restart) reopens immediately with the backoff reset; one that
  died right after opening counts as a failed open and backs off
  (2 s → 60 s cap). New pump test rapid_death_backs_off.

Re-validated live: host started with PipeWire stopped → throttled
"unavailable" warns, zero churn; daemon started → mic node up on the
next retry; exactly one pump + one loop thread (no leak).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 20:58:06 +00:00
enricobuehler 2c7ded0f3c fix(host/audio): rebuild mic passthrough — eager, self-healing virtual mic on both hosts
apple / swift (push) Successful in 1m7s
ci / rust (push) Successful in 1m57s
ci / web (push) Successful in 59s
android / android (push) Successful in 3m19s
ci / docs-site (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m12s
windows-host / package (push) Successful in 7m2s
ci / bench (push) Successful in 4m52s
decky / build-publish (push) Successful in 14s
deb / build-publish (push) Successful in 4m37s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m14s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m40s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m28s
Mic passthrough silently died on real hosts. Root causes, all fixed:

- No liveness anywhere: a PipeWire restart (Linux) or any WASAPI device
  error (Windows) killed the backend worker; push() fed the dead queue
  for the rest of the host's life. VirtualMic now has a liveness
  contract (push -> bool, alive(), discard()) and the new shared
  audio::MicPump reopens with backoff, probing on an idle heartbeat so
  the mic heals BETWEEN sessions too. Validated live: systemctl restart
  pipewire -> node back in ~0.5 s, tone flows through the reopened
  backend.

- Lazy creation: the mic device didn't exist until the first 0xCB
  frame, but games bind their capture device at launch and never
  re-follow. The pump opens eagerly at host start (node exists with
  zero clients, elected default source).

- Windows headless dead-end: with VB-CABLE as the ONLY render endpoint
  (exactly what the installer ships), the anti-echo guard rejected the
  cable as the default render endpoint -> mic permanently dead. The new
  wiring_plan (pure, unit-tested on every platform) assigns the mic its
  endpoint FIRST (cable reserved for the mic), points the loopback at a
  DIFFERENT endpoint, and the capture side now yields (explicit
  endpoint or honest error) instead of the mic dying. Plan recomputed
  per (re)open — endpoints churn at boot/logon/driver installs.

- Stale bursts: buffered audio from a previous session played into a
  newly-attached recorder (observed live). Timestamped chunks + a
  consumer-gap check in the process callback age everything past 1 s.

The Linux node mechanism stays the stream-based Audio/Source with
RT_PROCESS + priority.session: the canonical null-audio-sink adapter
recipe was tested on this box (PipeWire 1.6.2) and never gets a clock
(QUANT 0 -> pure silence), and WirePlumber reroutes a feeder targeting
it to the default sink (echo). Decision documented in the module docs.

Live-validated on this box (synthetic host + probe --mic-test,
pw-record): eager node, both attach orderings, PipeWire-restart
self-heal, post-session silence. Windows side compile/CI + on-glass
validation pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 20:41:19 +00:00
enricobuehler b7048446c4 fix(windows-host): IDD-push compose kick — idle desktop no longer fails the attach gate
windows-drivers / probe-and-proto (push) Successful in 24s
apple / swift (push) Successful in 1m8s
ci / rust (push) Successful in 1m42s
windows-drivers / driver-build (push) Successful in 1m45s
ci / web (push) Successful in 54s
android / android (push) Successful in 3m39s
ci / docs-site (push) Successful in 1m8s
deb / build-publish (push) Successful in 4m40s
ci / bench (push) Successful in 4m58s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
decky / build-publish (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
release / apple (push) Successful in 8m9s
windows-host / package (push) Successful in 7m35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m27s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 54s
flatpak / build-publish (push) Successful in 4m26s
apple / screenshots (push) Successful in 5m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m46s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m21s
DWM presents a display only when something dirties it. On an idle desktop a
perfectly healthy session sat at E_PENDING: the driver attached but no
first frame ever landed, so wait_for_attach's 4 s gate failed the open (and
a mid-session ring recreate hit the same stall against the 3 s
recover-or-drop). A real client escaped only because its own input soon
dirtied the desktop; a headless probe / input-less connect never did.

kick_dwm_compose() injects two net-zero 1 px relative mouse moves via
SendInput — pf-vdisplay has no hardware-cursor plane, so a cursor move is
composited into the frame, a guaranteed real present onto the IDD
swap-chain (the mechanism --input-test always relied on; the pointer ends
where it started). Wired into wait_for_attach (first kick at 600 ms, then
every 800 ms) and, rate-limited, into the GB1 recovery window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 19:24:57 +00:00
enricobuehler 3039626b87 design(host): add vrr design doc 2026-07-03 19:22:19 +00:00
enricobuehler 3f33ed30ae fix(windows-host): claim the vdisplay single-instance guard eagerly at serve startup
On-glass the lazy (first-session) claim let a second host started while the
freshly-restarted service sat idle win the mutex and ADD a monitor on the
real driver — priority backwards. The claim is now a process-global,
retryable slot (a failed claim is not memoized, so it heals once the other
instance exits), and `serve` claims it before any client can connect;
ensure_device keeps the lazy claim for standalone punktfunk1-host runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:57:47 +00:00
enricobuehler 7e31020c1c fix(windows-host): second-host guard — classify ACCESS_DENIED on the instance mutex as in-use
On-glass the SCM service creates Global\punktfunk-vdisplay-manager as
SYSTEM, so a second elevated-admin host's CreateMutexW fails ACCESS_DENIED
(the implicit open is checked against the SYSTEM DACL) before the
ALREADY_EXISTS branch can fire — right refusal, wrong message. Map it to
the same loud "another instance is live" error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:53:54 +00:00
enricobuehler fe54aff658 fix(windows-host): cross-plane IDD serialization, linger-expiry race, second-host guard
Batch C of the audit's medium tier (M7+M8+M9):

- M7: GameStream sessions now run the same begin_idd_setup dance as
  punktfunk/1 before creating the shared monitor. A GS connect could
  previously ADD/reconfigure the monitor while a native session was
  mid-build (and vice versa), and its sealed-channel delivery replaced the
  native ring (newest-wins) — each plane could freeze the other. GS has no
  cooperative stop plumbing, so it registers a flag nobody reads: a later
  session signals it, waits the 3 s grace, then force-preempts — the
  intended handover.
- M8: the linger-expiry teardown now runs UNDER the state lock. Running it
  outside let a concurrent acquire see Idle and ADD+isolate while the old
  monitor's pinger-join / CCD-restore / REMOVE were still in flight — a
  failed or de-isolated session exactly at the expiry boundary. A racing
  acquire now waits the few teardown seconds instead. Lock order stays
  state → device everywhere; the pinger takes only the device lock.
- M9: a named mutex (Global\punktfunk-vdisplay-manager) makes a SECOND host
  process fail its vdisplay open loudly instead of firing a startup
  CLEAR_ALL that razes the live host's monitors mid-stream (the admin
  footgun the shared watchdog then masked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:28:22 +00:00
enricobuehler b46aa15afb fix(windows-drivers): pf-vdisplay robustness — AdapterInitStatus gate, pooled-device TDR check, MMCSS-optional worker
Batch B of the audit's medium tier (M4+M5+M6):

- M4: adapter_init_finished now reads AdapterInitStatus (was ignored) and
  only stashes the adapter on NT_SUCCESS, per the MS sample. A failed async
  init previously produced a HUSK adapter: monitors created on it arrive
  but the OS never assigns a swap-chain — every session black-screens with
  no visible cause (the exact signature live fault-injection produced after
  a WUDFHost kill). Unset adapter → ADD fails cleanly (host-retryable) and
  a re-entrant D0 retries the init; the status is now in the debug log.
- M5: pooled_device checks GetDeviceRemovedReason on a cache hit — a TDR'd
  device was returned for its LUID forever (SetDevice fail-loop, black
  virtual display until device teardown); now it falls through to a fresh
  create.
- M6: an AvSetMmThreadCharacteristicsW failure no longer aborts the worker
  before draining (which stalled the monitor and leaked the WDF swap-chain
  object) — continue unprioritized like the MS sample; revert only if MMCSS
  actually engaged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:20:48 +00:00
enricobuehler 058630f542 feat(decky): visible branded Steam shortcut, one-tap client updates, fullscreen-page polish
- The "Punktfunk" shortcut is no longer hidden: it now ships committed
  artwork (grid/wide/hero/logo/icon, generated by scripts/gen-steam-art.py
  — a pure-stdlib SDF renderer drawing the lens mark + a monoline
  "punktfunk" wordmark) applied via SetCustomArtworkForApp /
  SetShortcutIcon. Existing installs are unhidden and re-arted once per
  ART_VERSION; relaunching the library entry streams to the last host.
- Updates cover the flatpak CLIENT too: check_update compares the
  user-scope installed commit against its remote, applyUpdate runs
  `flatpak update --user` first (awaited) and the plugin reinstall —
  which reloads the panel — last; docs spell out the sudo-less --user
  update ("sudo flatpak update" silently skips per-user installs).
- Fullscreen page: DialogButton stretches to 100% width in the gamepad
  UI, so the Stream/Pair/Refresh/… actions filled whole rows — sized to
  content + right-aligned now; the header drops its Update button (About
  tab + QAM banner keep the flow) and the back button gets a real 40px
  hit target.
- Settings: the disable-Steam-Input note also shows for Automatic — on a
  Deck that now forwards the built-in controller as a Steam Deck pad
  (paddles/trackpads/gyro), which needs Steam Input off for the shortcut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:16:40 +00:00
enricobuehler e9c1f4083a fix(client-linux): Deck Gaming Mode — auto pad type, real chrome-less fullscreen, leave-to-Gaming-Mode, colour bisect
- "Automatic" gamepad type resolves to the virtual Steam Deck pad on Deck
  hardware (env SteamDeck / DMI Jupiter|Galileo): the built-in 28DE:1205
  identity is invisible at Hello time — the Valve HIDAPI drivers run
  in-session only and Steam Input shadows the pad with its virtual X360 —
  so auto always fell through to Xbox 360. "steamdeck" is now also
  selectable in Settings.
- Chrome-less launches flatten the window CSS (border-radius/box-shadow)
  and fullscreen at startup: gamescope never ACKs the xdg fullscreen
  state, so adwaita kept the floating-CSD rounded corners + shadow
  visible over the stream.
- Gaming-Mode --connect launches quit on session end, so Steam ends the
  "game" and the Deck returns to Gaming Mode — previously the app popped
  to its own hosts page, stranding the user fullscreen and making the
  escape chord read as broken.
- The capture hint is controller-aware; the chromeless hint teaches the
  hold-chord ("hold L1+R1+Start+Select to leave") and a quick chord press
  re-flashes it.
- Colour bisect for the reported off-colours on the VAAPI dmabuf path:
  graphics offload defaults OFF under gamescope (a subsurface hands the
  NV12 CSC to the compositor), PUNKTFUNK_OFFLOAD=1|0 overrides, and each
  colour-signaling change logs whether GDK accepted the BT.709-narrow
  color state (fallback = GDK's BT.601 dmabuf default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:16:26 +00:00
enricobuehler 20f0d2802f feat(client/android): Snapdragon latency tuning — ADPF pipeline hints, game mode, max-clock decode
Three levers to lower and steady decode latency on Snapdragon (Adreno) devices:

- ADPF (Adaptive Performance Framework): a new dlsym-resolved hint session
  (native/src/adpf.rs; API-33+, resolved at runtime so there's no build-time
  link dependency and libpunktfunk_android.so still loads on API 31/32) tells
  the CPU governor the video pipeline runs a per-frame real-time workload, so it
  keeps those threads on fast cores at high clocks. It now covers all three
  latency-critical threads — the pf-decode feed/drain/present loop, the core
  data-plane pump (UDP receive + FEC reassembly), and the audio thread — via a
  new generic hot-thread registry on NativeClient (register_hot_thread /
  hot_thread_ids; the pump self-registers). The session is built lazily on the
  first presented frame, since ADPF createSession rejects a set containing any
  not-yet-live tid.

- operating-rate -> Short.MAX ("as fast as possible"): pushes the Qualcomm
  decoder to run each frame at max clocks instead of merely sustaining the
  display rate at a power-saving clock that adds per-frame decode latency.

- appCategory="game": makes the app eligible for OEM Game Mode / Game Dashboard
  performance profiles.

The core registry is cross-platform (gettid on Linux/Android, a no-op
elsewhere) — no Android-specific pollution of the shared core. Host workspace +
64 core tests green; Android arm64-v8a + x86_64 (platform 31) build + clippy
clean. On-device Snapdragon validation pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 17:16:11 +00:00
enricobuehler 6f8fb15c9b fix(windows-host): self-heal the hostless-zombie pf-vdisplay device (adapter cycle + re-probe)
Fault-injection on-glass showed a killed/crashed WUDFHost leaves the devnode
"started" but HOSTLESS: PnP Status OK, no WUDFHost process, zero device-
interface instances — is_available() then fails every future session at the
vdisplay::open gate (and a reopen inside VdisplayDriver::open finds nothing),
until something cycles the device. Port reset-pf-vdisplay.ps1's adapter
disable→enable step in-process (restart_vdisplay_device): the open gate now
uses ensure_available() (cycle once + bounded re-probe; a genuinely
uninstalled driver — no adapter devnode — still fails fast), and
VdisplayDriver::open retries open_device over a short arrival window after a
cycle, covering the manager's reopen path too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:12:43 +00:00
enricobuehler 89455032a0 fix(windows-host): IDD-push resilience — driver-death recovery, reopenable control device, full interface discovery
Batch A of the audit's medium tier (M1+M2+M3):

- M1 driver-death detection: a dead WUDFHost stops publishing, which at the
  ring is indistinguishable from an idle desktop — SDR sessions streamed a
  frozen frame forever (next_frame's 20 s bail is unreachable once anything
  presented). The ChannelBroker's process handle now doubles as a liveness
  probe (SYNCHRONIZE at OpenProcess); while no fresh frame arrives,
  try_consume polls it (rate-limited) and fails the capturer, landing in the
  session's bounded in-place rebuild.
- M2 reopenable control device: the manager's OnceLock-cached handle is now
  a retire/reopen DeviceSlot — a gone-classified IOCTL failure (driver
  upgrade / WUDFHost restart; pinger, create, or REMOVE) retires the handle
  and the next use reopens + re-handshakes. Retired handles are deliberately
  kept alive forever: bare-HANDLE holders (pinger, ChannelBroker) rely on
  never-closed, and a retired handle only fails IOCTLs. CLEAR_ALL runs on
  the FIRST open only (a reopen races live-ish sessions); acquire retries
  the monitor create once after a reopen. The JOIN path now probes the
  active monitor's WUDFHost pid and preempts a DEAD monitor instead of
  handing the rebuilding session its stale target — without this the whole
  recovery chain starved to the rebuild budget.
- M3 interface discovery: enumerate ALL interface instances with an
  SPINT_ACTIVE filter (a Code-10 devnode at index 0 no longer shadows the
  live interface), HDEVINFO behind RAII (error paths leaked one per probe),
  the raw device handle wrapped before GET_INFO (leaked on handshake
  failure), and the detail-sizing result guarded before the cbSize write.
- pf-driver-proto: SetFrameChannelRequest doc now states the real
  adopt-on-success contract (the old wording invited a driver-side
  close-on-error — a cross-process double-close against the host's reap).
- install: pf_vdisplay_present() passes /connected so a phantom devnode
  can't suppress creating a live ROOT node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:04:19 +00:00
enricobuehler 0da9d8ec10 fix(windows): IDD-push audit highs — keyed-mutex timeout, two per-frame leaks, IDD_PUSH knob, pooled-device threading
Five verified findings from the IDD-push/pf-vdisplay deep audit:

- Keyed-mutex acquire (BOTH endpoints): AcquireSync returns WAIT_TIMEOUT
  (0x102) / WAIT_ABANDONED (0x80) as SUCCESS-severity HRESULTs, which the
  windows-rs Result wrapper erases — a busy slot read as "acquired", so
  driver and host could race the same ring texture (torn frames) and the
  designed busy-skip backpressure was dead code. Both sides now classify
  the raw vtable HRESULT; WAIT_ABANDONED counts as acquired (ownership
  transfers — refusing it would wedge the slot forever).
- Host SDR hot path leaked one ID3D11VideoProcessorInputView per converted
  frame: the D3D11_VIDEO_PROCESSOR_STREAM ManuallyDrop field suppressed the
  release after VideoProcessorBlt. Released by hand now, success or not.
- Driver leaked IddCx's per-acquire surface reference (from_raw_borrowed on
  a TRANSFERRED reference — the MS sample Attach/Reset's it): the swap-chain
  surface set survived swap-chain destruction, the likely true root cause of
  the ~50 MB-per-reconnect VRAM loss that device pooling only mitigated.
  Now adopted via from_raw (publisher or not) and dropped pre-Finished.
- PUNKTFUNK_IDD_PUSH removed: capture is unconditionally IDD-push, but the
  vdisplay manager still gated the lingering-monitor preempt (and render
  pin) on the knob, whose default was OFF — dev/CLI runs reused a lingering
  monitor whose IddCx swap-chain is dead (black reconnect). The preempt and
  the render-GPU pin are now unconditional; host.env comments no longer
  promise the removed DDA/WGC fallback.
- Driver D3D device: dropped D3D11_CREATE_DEVICE_SINGLETHREADED (unsound
  since DEVICE_POOL shares one device across processors) and the pooled
  immediate context is now SetMultithreadProtected — two concurrent
  monitors' workers otherwise race an unlocked context (UB in the UMD).

No wire-contract change (pf-driver-proto untouched); the driver fixes take
effect on the next pf-vdisplay redeploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:27:13 +00:00
enricobuehler fbf3fea0c8 fix(packaging/windows): Inno [Code] comment brace-nesting trap broke ISCC
apple / swift (push) Successful in 1m17s
windows-host / package (push) Successful in 7m3s
apple / screenshots (push) Successful in 5m37s
android / android (push) Successful in 3m22s
ci / web (push) Successful in 48s
ci / rust (push) Successful in 1m18s
ci / bench (push) Successful in 4m59s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 4m35s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m31s
docker / deploy-docs (push) Successful in 15s
The PublicFwParam doc comment contained a literal code-constant token; Inno's
{ } comments don't nest, so its closing brace ended the comment early and the
trailing text parsed as code ("'BEGIN' expected", compile aborted). Reworded to
avoid the literal braces + added a warning note. Verified: the [Code] section
has no other nested-brace-in-comment traps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:27:27 +00:00
enricobuehler c52ae119e1 fix: regenerate Cargo.lock for punktfunk-host's winresource build-dep
apple / swift (push) Successful in 1m10s
audit / cargo-audit (push) Successful in 1m12s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m8s
android / android (push) Successful in 3m49s
ci / web (push) Successful in 48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 45s
ci / docs-site (push) Successful in 56s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 50s
release / apple (push) Successful in 8m51s
ci / rust (push) Successful in 5m51s
ci / bench (push) Successful in 4m51s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
deb / build-publish (push) Successful in 4m46s
apple / screenshots (push) Successful in 5m34s
flatpak / build-publish (push) Successful in 5m33s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m52s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m43s
docker / deploy-docs (push) Failing after 18s
windows-host / package (push) Has been cancelled
The "Punktfunk Host" identity work added winresource to the host crate but
didn't update the lock, so every --locked CI job failed to resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:17:31 +00:00
enricobuehler 5d7aabe8f0 fix(scripts/windows): keep deploy-host.ps1 pure ASCII (em-dash -> hyphen)
apple / swift (push) Successful in 1m8s
ci / rust (push) Failing after 50s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m8s
android / android (push) Successful in 3m24s
deb / build-publish (push) Failing after 45s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
windows-host / package (push) Failing after 6m18s
apple / screenshots (push) Successful in 5m35s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m25s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m26s
docker / deploy-docs (push) Successful in 17s
Two comment em-dashes I added tripped the installer-run ASCII guard (PS 5.1
mis-parses non-ASCII on non-UTF-8 locales).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:09:17 +00:00
enricobuehler f204a89cef perf(encode/windows): AMF quality=speed + bf=0; drop the useless poll spin
ci / rust (push) Failing after 48s
windows-host / package (push) Failing after 10s
apple / swift (push) Successful in 1m6s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m8s
android / android (push) Successful in 3m20s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
apple / screenshots (push) Successful in 5m10s
ci / bench (push) Successful in 4m43s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m25s
deb / build-publish (push) Failing after 44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m21s
On-box A/B on the .173 Ryzen 7000 iGPU (720p60, real composition via input
injection — an idle virtual desktop composes ~1 fps and gives meaningless
encode timings): the encode-time-first `quality=speed` preset + explicit `bf=0`
cut host-side encode_us from ~36 ms to ~19.5 ms.

The blocking-poll idea from the prior commit was WRONG and is reverted to a
single non-blocking receive (default PUNKTFUNK_FFWIN_POLL_MS=0): libavcodec's
hevc_amf holds ~2 frames before releasing the oldest (needs frame N+2 to flush
N), so a spin between submits provably never yields the owed AU — verified with
a 150 ms cap pegging at exactly 150 ms across every usage preset and pipeline
depth. That ~2-frame buffer is inherent to the libavcodec wrapper, not host
scheduling; the real latency lever is a direct AMF SDK encoder (the AMF
analogue of the direct-NVENC path), tracked as the next AMD work item. The
env knob is retained for a future VCN/driver where a bounded spin can help.

Also measured and rejected: PUNKTFUNK_ZEROCOPY=1 on AMF is ~2x WORSE (68 ms vs
36 ms) — the D3D11 import path adds sync overhead beyond the readback it saves,
so the system-memory default stays. GPU-priority elevation is already
process-wide (dxgi.rs), so it covers the iGPU encode session with no change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:57:39 +00:00
enricobuehler 24fa018c70 chore(encode/windows): AMF forensics knobs — PUNKTFUNK_AMF_USAGE + PUNKTFUNK_FFWIN_POLL_MS
apple / swift (push) Successful in 1m6s
ci / web (push) Successful in 53s
deb / build-publish (push) Failing after 44s
windows-host / package (push) Failing after 10s
ci / rust (push) Failing after 49s
android / android (push) Successful in 3m33s
apple / screenshots (push) Successful in 5m18s
ci / docs-site (push) Successful in 57s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 5m0s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m27s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m18s
The blocking poll landed but wait_us pegs at exactly the 2-frame-period cap:
AMF holds the AU ~2 frame periods regardless of retrieval. Field knobs to
bisect on-box (usage preset × poll cap) without rebuild cycles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:39:36 +00:00
enricobuehler 51a6ca7e02 fix(encode/windows): AMF latency — honor the loop's blocking-poll contract + preset polish
apple / swift (push) Successful in 1m6s
windows-drivers / driver-build (push) Successful in 1m34s
windows-drivers / probe-and-proto (push) Successful in 20s
ci / rust (push) Failing after 47s
ci / web (push) Successful in 52s
windows-host / package (push) Failing after 11s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m20s
deb / build-publish (push) Failing after 46s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 43s
apple / screenshots (push) Successful in 5m11s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m27s
ci / bench (push) Successful in 4m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m24s
The session loop's pipeline deferral was designed around direct NVENC, whose
poll() BLOCKS in lock_bitstream; libavcodec's AMF wrapper is truly async
(EAGAIN until the ASIC finishes), so a single non-blocking receive quantized AU
retrieval to the submit cadence: +1–2 frame periods flat (~43 ms p50 at 720p60
on the Ryzen iGPU vs ~3.5 ms of actual encode). FfmpegWinEncoder now tracks
in-flight frames and, while an AU is owed, spin-polls with short sleeps bounded
to ~2 frame periods (an overloaded encoder degrades to next-tick pickup instead
of stalling capture). Also: quality=speed (latency-first, iGPU-class VCN),
explicit bf=0 (h264_amf defaults >0 on RDNA3+), AMF low-latency submission
mode (FFmpeg ≥6.1, ignored on older).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:32:41 +00:00
enricobuehler b9fde03f1e feat(security): finish Windows firewall Public opt-in wiring + vuln-disclosure + doc cleanup
Firewall (the service.rs core landed in efb1ba2): scope the web-console rule
(TCP 47992) to Domain+Private by default with a `--allow-public-network` opt-in
that deletes-then-re-adds the rule, and add the installer "Allow connections on
Public networks" task (unchecked) forwarding the flag to `service install` and
`web setup`. Default is now trusted-networks-only; Public is explicit.

Vulnerability disclosure: SECURITY.md (report to security@punktfunk.com, scope,
SLAs, safe harbor), a Gitea issue-template contact link, a README security line,
and a Reporting section on the docs Security page.

Docs: the Security page now documents the Private/Domain firewall default (and
how to fix a misclassified-Public network / opt in); removed internal design-doc
and CLAUDE.md links from the user-facing docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:08:17 +00:00
enricobuehler efb1ba26d7 fix(windows): opt-in pad-driver file logs + size-capped service log rotation
Two disk-write fixes:

- pf-xusb/pf-dualsense no longer write C:\Users\Public\pf*-driver.log
  unconditionally — the file log is now opt-in (debug builds, or the
  PFXUSB_DEBUG_LOG / PFDS_DEBUG_LOG system env var), mirroring the audit-§4.4
  fix pf-vdisplay already got: a release driver never writes the world-writable
  Public file (info-leak/DoS surface), and the per-report OUTPUT/SET_STATE hex
  dumps stop being a sustained per-rumble disk-write path during gameplay.
  OutputDebugStringA stays unconditional; the host's driver-silence WARN and
  the gamepad-driver-health failure-mode table now say the log is opt-in.

- service.log/host.log get one-generation rotation: at each (re)open a file
  over 10 MB is renamed to .old, so a crash-restart loop or a RUST_LOG=debug
  left in host.env can't grow the append-forever logs without bound. Rotation
  runs only before an open (never under a live appender — host.log's handle
  lacks FILE_SHARE_DELETE, so a racing rename harmlessly fails).

Windows CI compile/clippy pending (drivers workspace + host are not
Linux-cross-checkable); rides along with the next pad-driver redeploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:03:32 +00:00
enricobuehler 1320e3dc66 fix(scripts/windows): deploy-host.ps1 builds all-vendor when an FFmpeg tree exists
windows-host / package (push) Failing after 20s
apple / swift (push) Successful in 1m9s
apple / screenshots (push) Successful in 5m26s
ci / rust (push) Failing after 48s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m22s
deb / build-publish (push) Failing after 43s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m40s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m27s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m18s
The dev deploy built --features nvenc only, so a web-console GPU preference
pointing at an AMD/Intel adapter made every session die at encoder open
(NV_ENC_ERR_NO_ENCODE_DEVICE) — the exact "can't connect" just hit on the RTX
box's Ryzen iGPU. The script now enables amf-qsv when FFMPEG_DIR (machine env,
process env, or C:\Users\Public\ffmpeg) has a dev tree, and copies the FFmpeg
runtime DLLs next to the exe after a successful build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:52:55 +00:00
enricobuehler 1be83575b6 feat(host/windows): "Punktfunk Host" identity in Task Manager (icon + version info)
punktfunk-host.exe embedded no icon or version resources, so Task Manager and
Explorer showed a bare lowercase exe name with a generic icon. build.rs now
embeds the branded .ico + FileDescription "Punktfunk Host" / ProductName
"Punktfunk" via winresource (same pattern as the Windows client and the tray;
Linux packaging builds skip the block). The tray gets a matching "Punktfunk
Tray" description, and the SCM display name moves off lowercase
"punktfunk streaming host" to "Punktfunk Host" (applied idempotently by
`service install` on upgrade).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:52:55 +00:00
enricobuehler 4d1d20f832 chore: untrack CLAUDE.md
apple / swift (push) Successful in 1m14s
apple / screenshots (push) Successful in 5m45s
ci / rust (push) Successful in 1m15s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m5s
android / android (push) Successful in 3m25s
deb / build-publish (push) Successful in 4m35s
ci / bench (push) Successful in 4m55s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m27s
docker / deploy-docs (push) Successful in 18s
Local per-box assistant instructions (incl. internal environment detail) don't
belong in the published tree; the file stays on disk, now gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:22:23 +00:00
enricobuehler 6e875fea44 fix(apple/ios): the ACTUAL type-checker bomb was pointerSection's footer ternary chain
apple / swift (push) Successful in 1m15s
release / apple (push) Successful in 8m36s
apple / screenshots (push) Successful in 5m44s
ci / rust (push) Successful in 1m31s
ci / web (push) Successful in 56s
android / android (push) Successful in 10m1s
deb / build-publish (push) Successful in 4m33s
ci / bench (push) Successful in 4m52s
ci / docs-site (push) Successful in 1m24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
decky / build-publish (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m20s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m19s
docker / deploy-docs (push) Failing after 14s
4f3cd24 split the wrong expression — act's log masking hid the real line number.
The unmasked retry pinpointed it: the pointerSection footer, a ten-segment
string + chain with an isPad ternary nesting four more, evaluated inside the
ViewBuilder. Moved the copy into a plain computed String built with +=
statements (linear to type-check); no text change. The two remaining 5-6
segment chains in Settings are compiled by the passing macOS slice, so they
are proven cheap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:17:01 +00:00
enricobuehler 4f3cd24036 fix(apple/ios): split streamModeSection — the inline iOS branch blew the type-checker budget
release / apple (push) Successful in 6m29s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m13s
apple / swift (push) Successful in 1m5s
apple / screenshots (push) Successful in 4m5s
ci / bench (push) Successful in 4m36s
ci / rust (push) Successful in 11m23s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 4m26s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m45s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m37s
android / android (push) Successful in 9m37s
docker / deploy-docs (push) Successful in 19s
The Section's iOS content (resolution wheel + 3-way refresh rows + bitrate
rows) as ONE ViewBuilder expression hit "the compiler is unable to type-check
this expression in reasonable time" — failing exactly one build slice, the iOS
archive, so swift test (macOS) and the tvOS/macOS archives never saw it and the
0.6.0 iOS TestFlight upload soft-failed. Extracted iosResolutionWheel /
iosRefreshRows / bitrateRows; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:06:48 +00:00
enricobuehler af13f0b749 chore(release): 0.6.0
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
android-screenshots / screenshots (push) Successful in 2m18s
android / android (push) Successful in 4m13s
decky / build-publish (push) Successful in 26s
windows-host / package (push) Successful in 6m36s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m50s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
release / apple (push) Successful in 7m53s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m32s
deb / build-publish (push) Successful in 9m52s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
web-screenshots / screenshots (push) Successful in 2m38s
ci / web (push) Successful in 48s
ci / rust (push) Successful in 11m43s
linux-client-screenshots / screenshots (push) Successful in 1m33s
flatpak / build-publish (push) Successful in 4m8s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m10s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m49s
docker / deploy-docs (push) Successful in 25s
ci / docs-site (push) Successful in 57s
ci / bench (push) Successful in 5m9s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:19:18 +00:00
enricobuehler d285d4a0b2 fix(tray): live-probe the web console instead of sniffing the install layout
windows-drivers / probe-and-proto (push) Successful in 29s
audit / cargo-audit (push) Successful in 1m31s
apple / swift (push) Successful in 1m8s
windows-drivers / driver-build (push) Successful in 1m35s
android / android (push) Successful in 4m45s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m0s
release / apple (push) Successful in 7m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
The "Open web console" entry was gated on {exe dir}\web\web-run.cmd (Windows)
/ the punktfunk-web unit file (Linux) — which misses consoles run from a repo
checkout (the RTX box, caught on-glass) and shows a dead entry while an
installed console is stopped. The poller now probes https://127.0.0.1:<web
port>/ each cycle (any HTTP response = up, transport failure = down) and the
menu follows live on both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:17:01 +00:00
enricobuehler 04f370999c fix(web): pin the sidebar at viewport height
Sticky h-dvh sidebar: long pages scroll the content, not the nav — the flex
stretch was pushing the language switcher below the fold; overflow-y-auto keeps
the nav usable on short viewports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:09:52 +00:00
enricobuehler 2c937855b3 fix(packaging/windows): Windows 11 22H2 floor + tray install task + stale console-port fixes
The OS floor is now enforced at install time (MinVersion=10.0.22621 with an
explanatory [Messages] override): pf-vdisplay is built against IddCx 1.10, and
on Windows 10 (incl. LTSC) / Win11 21H2 the device fails start with Code 10
STATUS_DEVICE_POWER_FAILURE (field-reported). Docs (site requirements/install/
windows-host pages + README) state the floor; new docs-site Security page.

Installer also gains the trayicon task (punktfunk-tray.exe file + HKLM Run key,
post-install launch as the signed-in user, upgrade taskkill + uninstall
--quit/taskkill choreography before file deletion), and the wizard/cleanup
text/port sweeps move off the stale :3000 web-console references to :47992
(cleanups sweep both for upgrades from old installs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:09:52 +00:00
enricobuehler 8005b11faf feat(tray): system-tray status icon for the host (Windows + Linux)
New crates/punktfunk-tray — a small per-user companion showing the host service
state at a glance (running / stopped / starting / degraded / failed + the live
session in the tooltip) with one-click actions: open web console, approve a
pending pairing request, start/stop/restart, open logs. No more digging through
logs to learn whether the service came back after a reboot or an update.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:09:35 +00:00
enricobuehler 01fcb01019 fix(encode/windows): resolve NVENC at runtime — AMD/Intel hosts no longer crash at start
The nvenc build linked nvEncodeAPI64.dll's entry points at load time, so a
--features nvenc binary hard-crashed on any box without the NVIDIA driver
(AMD/Intel). Entry points now come from a runtime LoadLibrary table
(encode/windows/nvenc.rs load_api); a missing DLL just falls through the
encoder auto-detect to AMF/QSV/software. The generated import lib and all its
plumbing (gen-nvenc-importlib.ps1, nvenc.def, PUNKTFUNK_NVENC_LIB_DIR,
setup-build-env wiring) are gone.

Live-validated on the RTX 4090 box (NVENC session, 7000+ frames).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:09:18 +00:00
enricobuehler 95a08e99c3 feat(host/windows): seal the host↔driver channels (frame + gamepad, proto v2)
Frame ring (pf-vdisplay) and both gamepad SHM channels move off named Global\
objects (openable by any sibling LocalService) to UNNAMED sections/events whose
handles the host DuplicateHandles into the driver's verified WUDFHost with least
access — frame delivery over the SYSTEM+admins-only IOCTL_SET_FRAME_CHANNEL,
pads over a 32-byte named bootstrap mailbox (pid + handle value only, DoS-bounded;
HID minidrivers have no control device). Driver-validated pad_index kills
cross-pad redirects; v1↔v2 mixes fail closed with diagnosis logs on both sides.
Sibling-LocalService denial proven empirically (design/idd-push-security.md,
design/gamepad-channel-sealing.md).

Driver-side raw ops now live behind pf-umdf-util (checked shm accessors, the
forbid(unsafe_code) ChannelClient state machine, WDF request tokens) — the pad
drivers' logic is 100% safe Rust; whole drivers workspace clippy-gated in CI.

driver install --gamepad now sweeps SWD\punktfunk phantom devnodes: a re-created
SwDevice REVIVES the old devnode with its previously-bound driver (never
re-ranks), so an upgrade otherwise leaves the old driver serving — or, across
the v1→v2 fence, a dead pad (found live on the RTX box).

On-glass validated on the RTX 4090 box: frame path 7007 frames p50 2.06 ms
cross-machine; DualSense + XUSB "sealed pad channel mapped"/proto=2 attach via
both the test harness and a real streaming session; phantom-sweep repro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:08:56 +00:00
enricobuehler a3e1ea2b44 fix(android/ci): retry transient Play API failures in play-upload.py
apple / swift (push) Successful in 1m9s
apple / screenshots (push) Successful in 4m2s
android / android (push) Successful in 11m51s
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m13s
ci / rust (push) Successful in 4m30s
deb / build-publish (push) Successful in 3m35s
ci / bench (push) Successful in 4m47s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
decky / build-publish (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m59s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m3s
docker / deploy-docs (push) Successful in 20s
The uploader only caught HTTPError — a URLError (TLS "EOF occurred in
violation of protocol", the failure that dropped two release uploads on
2026-07-02) or a Google 5xx killed the job outright. Retry those with
3/9/27 s backoff; 4xx still fails fast. The edits API is transactional
until commit, so re-sending is safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:05:27 +00:00
enricobuehler 6686fcdded fix(gamestream/tests): sender_delivers_batches flaked under CI load — burst overflowed the default socket buffer
apple / swift (push) Successful in 1m12s
apple / screenshots (push) Successful in 4m26s
windows-host / package (push) Successful in 6m25s
ci / rust (push) Successful in 5m5s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m4s
android / android (push) Failing after 10m7s
deb / build-publish (push) Successful in 3m35s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m38s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m53s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m53s
docker / deploy-docs (push) Successful in 18s
The test burst 3×100 1200 B datagrams into an undrained loopback socket: at
~2.5 KB kernel truesize each, the default ~212 KB rmem holds only ~80, so on
a starved CI runner (parallel release builds) the kernel silently dropped the
overflow and the recv loop could never reach 300 — surfacing as WouldBlock
after the 3 s timeout. Size the burst (3×20) to fit the default buffer even
with zero concurrent draining, and give recv a starvation-tolerant 10 s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:35:23 +00:00
enricobuehler 31c382fde0 chore(release): 0.5.1
audit / cargo-audit (push) Successful in 54s
apple / swift (push) Successful in 1m15s
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m1s
ci / bench (push) Successful in 4m40s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 40s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 46s
release / apple (push) Successful in 7m51s
windows-host / package (push) Successful in 6m46s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m7s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
apple / screenshots (push) Successful in 4m4s
android-screenshots / screenshots (push) Successful in 1m14s
decky / build-publish (push) Successful in 15s
deb / build-publish (push) Successful in 3m25s
flatpak / build-publish (push) Successful in 4m20s
linux-client-screenshots / screenshots (push) Successful in 6m12s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m31s
web-screenshots / screenshots (push) Successful in 2m37s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m16s
docker / deploy-docs (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / rust (push) Successful in 4m32s
android / android (push) Failing after 11m14s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:05:43 +00:00
enricobuehler d707ee4d4e feat(apple,android): three-way touch input — trackpad cursor (default), direct pointer, real multi-touch passthrough
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
The two touch clients had exactly complementary gaps: iOS forwarded fingers
ONLY as raw wire touches (no way to drive the host cursor from the touch
screen), Android had the two mouse modes but no passthrough. Both now share
one three-way "Touch input" setting: Trackpad (default) / Direct pointer /
Touch passthrough.

iOS/iPadOS: Input/TouchMouse.swift ports the Android gesture engine 1:1
(same px-based acceleration curve; tap=click, two-finger tap=right-click,
two-finger drag=scroll, tap-then-drag=held drag, three-finger tap=stats
HUD via the shared hudEnabled default); direct-pointer mode maps through
the aspect-fit letterbox; the previous always-on behavior lives on as the
passthrough option. The mode latches per gesture (a Settings change never
splits one gesture across models), touchesCancelled releases held state
without synthesizing a click, and session stop flushes a mid-drag button.
Settings picker on iPhone + iPad next to the iPad-only pointer-capture
toggle. Deliberate default change: trackpad, not passthrough.

Android: new nativeSendTouch JNI shim → wire TouchDown/Move/Up (the host
already injects real touch on every backend — libei touchscreen, wlroots,
KWin fake-input, SendInput); streamTouchPassthrough forwards every finger
with stable ids and lifts still-held contacts on teardown; the trackpadMode
Boolean becomes the TouchMode enum (old pref migrated on load, never
rewritten) with a Settings dropdown.

Verified: macOS swift build + full suite (incl. new TouchMouseTests), iOS
Simulator Swift compile, cargo check/fmt/clippy on the native crate, Kotlin
app+kit compile + unit tests. On-glass feel of the iOS ballistics and
Android passthrough against a touch-aware app still pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:02:12 +02:00
enricobuehler e8196b33b8 feat(client/linux): Steam Deck batch — idle gamepad grab, fullscreen streams, in-band HDR colors, gamescope-safe settings, pad-pin persistence
windows-host / package (push) Successful in 6m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m5s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 47s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 54s
apple / swift (push) Successful in 1m17s
audit / cargo-audit (push) Successful in 17s
android / android (push) Successful in 3m46s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 57s
release / apple (push) Successful in 8m41s
deb / build-publish (push) Has been cancelled
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / rust (push) Successful in 8m21s
Root-caused fixes from on-Deck testing (owner + first external tester):

- System input broke while the app was merely OPEN: SDL's Steam Deck HIDAPI
  driver clears the built-in controller's "lizard mode" (trackpad-mouse,
  clicky pads) at device ENUMERATION and keeps feeding the firmware watchdog
  (SDL_hidapi_steamdeck.c InitDevice/UpdateDevice) — and we enabled that
  driver at startup and held every pad open app-lifetime. The Valve HIDAPI
  hints are now enabled only while a session is attached, and only the active
  pad is opened (Settings enumerates via SDL's ID-based metadata getters, no
  open). Close/detach hands the hardware back; the watchdog restores lizard
  mode within seconds. This also unblocks click-to-capture on the Deck (the
  dead trackpad made "input not passed through" a symptom, not a cause).
- Washed-out colors from a Windows host with an HDR desktop: the host ships
  Main10 BT.2020 PQ IN-BAND (correct VUI) while the Welcome still says SDR;
  this client rendered everything as BT.709 narrow. Colour signaling is now
  read per-frame (video::ColorDesc from the AVFrame CICP fields) and drives
  the GdkDmabufTexture color state, the software path's swscale matrix/range
  plus a tagged MemoryTexture for PQ, and an "· HDR" HUD chip — GTK tone-maps
  correctly on SDR displays, mid-session SDR↔HDR flips included. Regression-
  tested against a checked-in Main10 PQ fixture (tests/pq-frame.h265).
- Streams start fullscreen by default (Settings toggle; F11 / the controller
  chord lead out, and the pointer at the top edge reveals the header while
  input isn't captured — a Deck desktop has no F11). Gaming-Mode launches
  (--fullscreen / Deck env) build the stream page with NO header bar at all:
  gamescope doesn't reliably ACK xdg_toplevel fullscreen, so anything keyed
  on is_fullscreen() could leave the title bar drawn over the stream.
- Game Mode settings were uneditable: GTK popovers are xdg_popups, which
  gamescope never maps for nested apps — every ComboRow dropdown flashed and
  died. Under gamescope the preferences dialog now uses in-window selection
  subpages (PreferencesDialog::push_subpage) via a ChoiceRow that stays a
  stock ComboRow on desktops. Covered by an in-process GTK test
  (choice_row_modes, #[ignore]d — needs a display).
- Forwarded-controller pin persists across restarts (Settings::forward_pad,
  stable vid:pid:name key — SDL instance ids are per-run) and survives
  disconnects; automatic selection skips Steam Input's sensor-less virtual
  pad (28de:11ff) so gyro doesn't silently die on Bazzite/Deck.
- "Punktfunk" branding in the About dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:43 +00:00
enricobuehler fd699b3e2c feat(decky): plugin overhaul — on-Deck update check, exec-bit-free runner, About/host-detail UI, Punktfunk branding
Fixes from live debugging on the Deck:

- check_update() was dead on-device: Decky Loader's embedded (PyInstaller)
  Python has no usable default CA paths, so every HTTPS fetch failed with
  CERTIFICATE_VERIFY_FAILED. Build the SSL context explicitly: default paths
  first, then the known system bundles (SteamOS/Arch, Debian, Fedora/Bazzite,
  openSUSE), then certifi if importable. Verification stays on; the check
  stays offline-tolerant with its 30-min cache.
- "could not chmod runner" on every use: Decky extracts plugin zips without
  exec bits into a root-owned dir the unprivileged backend can't chmod. The
  Steam shortcut now launches the runner through /bin/sh with the script as a
  %command% argument — no exec bit needed, existing shortcuts migrate on
  reuse, the chmod attempt is gone.

UI/structure:

- index.tsx (660 lines) split into page/pair/settings/hooks/boundary modules;
  PluginErrorBoundary kept guarding every surface.
- New About section/tab: visible version + channel, explicit check-for-updates
  (forces past the cache, always toasts an outcome), setup-guide link, leave-
  chord help, and a Force-stop backstop for a wedged stream.
- Host rows open a details modal (address, protocol, pairing policy, paired
  state, fingerprint). Settings gain 1280×800 (Deck native), Xbox One and
  DualShock 4 pad types, and a host-compositor picker.
- Update flows note the Decky store contact can stall a couple of minutes on
  networks that blackhole plugins.deckbrew.xyz (observed live).
- "Punktfunk" in all user-facing strings; plugin id/paths/env unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:43 +00:00
enricobuehler 79dd8f58e3 docs(readme): status refresh — Windows client streaming live, console features
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:43 +00:00
enricobuehler be879c946a fix(host/logs): mdns-sd noise gate + tracing-log target normalization in the log ring
log-crate events arrive through the tracing-log bridge under the shim target
"log" — normalize them back to the real module path (NormalizeEvent) so the
console's target column and the noise gate see mdns_sd::… , and suppress the
bridge's log.* bookkeeping fields like the stderr fmt layer does.

Gate known-chatty third-party DEBUG targets (mdns-sd DEBUG-logs every
unparseable multicast packet — one AirPlay device floods thousands of entries
per hour) to INFO-and-up in the ring, so ambient LAN noise can't evict the
tail the ring exists to preserve. stderr under RUST_LOG is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:43 +00:00
enricobuehler f3646d4e7c feat(apple/gamepad): claim controller system gestures during capture — PS button opens the Steam overlay, share/create stops screenshotting locally
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 2m1s
ci / web (push) Successful in 56s
android / android (push) Successful in 3m19s
ci / docs-site (push) Successful in 58s
deb / build-publish (push) Successful in 3m13s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
release / apple (push) Successful in 8m1s
apple / screenshots (push) Successful in 5m33s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
While a pad drives a stream, GamepadCapture now sets EVERY element's
preferredSystemGestureState to .disabled (restored to .enabled on unbind).
iOS/macOS attach system gestures to several controller buttons — share/create
took a LOCAL screenshot instead of reaching the game, and only the Home
element was opted out before. With the gestures claimed, the already-wired
chains do their job: PS/Home → wire guide → BTN_MODE on the virtual xpad
(the Steam-overlay button) / the PS bit on the virtual DualSense.

Also fold the share/create/capture element (GCInputButtonShare) into the
back/select wire bit — clone pads like the GameSir G8 expose their screenshot
button only as the share element, not buttonOptions (OR onto the same bit, so
double-exposed pads are harmless). The G8's other extra button (M) is a
firmware-local modifier (turbo/hair-trigger/swap) invisible to the OS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:36:16 +02:00
enricobuehler 396c3453f5 feat(apple/gamepad): rewrite rumble renderer — bounded divergence + iOS 27 plain-player fix
apple / swift (push) Successful in 1m8s
ci / rust (push) Successful in 1m59s
ci / web (push) Successful in 51s
android / android (push) Successful in 3m44s
ci / docs-site (push) Successful in 1m3s
deb / build-publish (push) Successful in 3m11s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m47s
release / apple (push) Successful in 8m38s
apple / screenshots (push) Successful in 5m27s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m26s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m16s
Ground-up RumbleRenderer rewrite around one principle: rumble is idempotent
state on a lossy channel, and the actuator's divergence from it must be
bounded, not best-effort. The old renderer rebuilt an infinite-duration
CHHapticAdvancedPatternPlayer per 0xCA datagram via an async stop; one stop
lost inside CoreHaptics left an unstoppable player buzzing forever (the
"entered the menu and rumble never stopped" bug).

- Finite 4 s segments, never infinite events — a leaked player self-silences;
  steady levels re-arm seamlessly ON the engine timeline (no stop/start race)
- GamepadFeedback drains the rumble plane DRY per cycle, newest-wins (was one
  datagram per 8 ms through a 16-deep drop-newest queue = lag + shed stops)
- Host 500 ms state refreshes dedupe to a liveness stamp; zero applies
  immediately; nonzero ramps throttle to one rebake/25 ms per motor
- Throwing player stop escalates to engine.stop() (kills leaked players);
  1.6 s staleness watchdog (Policy.session) force-silences on a dead channel;
  the test panel holds levels via Policy.manual
- Plain makePlayer, NEVER makeAdvancedPlayer: gamecontrollerd's controller
  haptics server advertises `adv players: 0`, and iOS 27 beta 2 hard-drops
  advanced loads with an XPC decode fault (-4811/4097, rumble silently dead).
  Live-verified on an iOS 27 beta 2 iPhone: DualSense rumble works
- Split-handle engines fall back to one combined .default engine on repeated
  failure; renderer publishes health transitions and the test panel shows
  them (a refused system service no longer reads as silent app breakage)
- Per-motor sharpness on split handles (0.3 heavy / 0.7 light); macOS
  DualSense raw-HID path gains a ~1 s keepalive re-write while nonzero
- RumbleTuningTests pin the scheduling math, tuning relations, and a
  queue/ticker teardown smoke test

Stuck-rumble streaming repro revalidation on glass still pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:06:45 +02:00
enricobuehler 6921e147dd ci(release): idempotent registry publish — survive re-tagged releases
apple / swift (push) Successful in 1m3s
ci / rust (push) Successful in 2m2s
ci / web (push) Successful in 56s
android / android (push) Successful in 3m22s
ci / docs-site (push) Successful in 58s
apple / screenshots (push) Successful in 5m38s
deb / build-publish (push) Successful in 3m12s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
flatpak / build-publish (push) Successful in 4m8s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m12s
docker / deploy-docs (push) Successful in 19s
A moved release tag re-fires the publish workflows, and the Gitea
registries reject duplicate uploads with 409 (deb pool, rpm group, and
the generic packages' versioned URLs; the channel aliases already
pre-deleted). Delete any prior copy of the exact version before
uploading (404 on first publish tolerated), so a republished tag
overwrites instead of wedging — v0.5.0's retag left stale no-port-change
artifacts published and every re-run red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:23:04 +00:00
238 changed files with 18295 additions and 4449 deletions
+9
View File
@@ -0,0 +1,9 @@
# Shown on the "new issue" chooser so security reports go to the private channel, not a public issue.
blank_issues_enabled: true
contact_links:
- name: 🔒 Report a security vulnerability
url: https://git.unom.io/unom/punktfunk/src/branch/main/SECURITY.md
about: >-
Found a security issue? Please report it privately by email to security@punktfunk.com — do not
open a public issue, so other users aren't exposed before a fix ships. See SECURITY.md for the
full policy.
+2 -1
View File
@@ -78,9 +78,10 @@ jobs:
- name: Version + channel - name: Version + channel
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
run: | run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
case "$GITHUB_REF" in 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="alpha" ;; # alpha = built-in closed testing
*) VN="0.5.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;; *) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
esac esac
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV" echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV" echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
+14 -5
View File
@@ -36,16 +36,17 @@ jobs:
- name: Version + channel - name: Version + channel
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release). # vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
# A main push -> 0.5.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts # A main push -> <next-minor>~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
# below the eventual 0.5.0 tag, it climbs monotonically by run number, and the canary base # below the eventual tag, it climbs monotonically by run number, and the canary base is
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves # derived one minor AHEAD of the latest stable tag (scripts/ci/pf-version.sh) so a
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary # stable->canary box re-point still moves forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version). # (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
run: | run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;; refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
*) V="0.5.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;; *) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
esac esac
echo "VERSION=$V" >> "$GITHUB_ENV" echo "VERSION=$V" >> "$GITHUB_ENV"
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV" echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
@@ -126,6 +127,14 @@ jobs:
run: | run: |
for DEB in dist/*.deb; do for DEB in dist/*.deb; do
echo "uploading $DEB" echo "uploading $DEB"
# A re-tagged release re-fires this workflow and the apt registry 409s on duplicate
# package versions — delete any prior copy of this exact name/version/arch first
# (404 on the first publish is fine).
NAME=$(dpkg-deb -f "$DEB" Package)
VER=$(dpkg-deb -f "$DEB" Version)
ARCH=$(dpkg-deb -f "$DEB" Architecture)
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
# PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login. # PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login.
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload" "https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
+13 -4
View File
@@ -63,7 +63,8 @@ jobs:
pnpm run build # rollup -> clients/decky/dist/index.js pnpm run build # rollup -> clients/decky/dist/index.js
- name: Version + channel + stamp - name: Version + channel + stamp
# Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.<run> # Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> <next-minor>.<run>
# (base one minor ahead of the latest stable tag via scripts/ci/pf-version.sh)
# (`canary/` alias). Decky reads a plugin's INSTALLED version from package.json (NOT # (`canary/` alias). Decky reads a plugin's INSTALLED version from package.json (NOT
# plugin.json), and the plugin's own update check (clients/decky/main.py check_update) # plugin.json), and the plugin's own update check (clients/decky/main.py check_update)
# compares against it — so the build version is STAMPED into package.json here (mirrored # compares against it — so the build version is STAMPED into package.json here (mirrored
@@ -72,9 +73,12 @@ jobs:
# (ci10 < ci9), which would break update detection; the run number is monotonic. # (ci10 < ci9), which would break update detection; the run number is monotonic.
working-directory: ${{ gitea.workspace }} working-directory: ${{ gitea.workspace }}
run: | run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_MAJOR/PF_MINOR (base one minor ahead of latest stable)
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;; refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;;
*) V="0.3.${GITHUB_RUN_NUMBER}"; ALIAS=canary ;; # Canary MUST be a plain monotonic numeric semver (see the note above): <major>.<minor>.<run>,
# where major.minor track one minor ahead of the latest stable and the run number climbs.
*) V="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"; ALIAS=canary ;;
esac esac
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE" BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
echo "VERSION=$V" >> "$GITHUB_ENV" echo "VERSION=$V" >> "$GITHUB_ENV"
@@ -122,8 +126,13 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE" BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
# 1) Immutable, versioned URL + its update manifest (the manifest's `artifact` points # 1) Versioned URL + its update manifest (the manifest's `artifact` points here, so the
# here, so the published sha256 keeps matching what Decky later downloads). # published sha256 keeps matching what Decky later downloads). A re-tagged release
# re-fires this workflow and the registry 409s on duplicate uploads — delete any
# prior copy of this version first (404 on the first publish is fine).
for f in punktfunk.zip manifest.json; do
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$VERSION/$f" || true
done
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
"$BASE/$VERSION/punktfunk.zip" "$BASE/$VERSION/punktfunk.zip"
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \
+52 -6
View File
@@ -73,15 +73,17 @@ jobs:
- name: Version + channel - name: Version + channel
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push -> # Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
# 0.5.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo # <next-minor>-ciN.g<sha> on the `canary` branch (base one minor ahead of the latest stable
# tag via scripts/ci/pf-version.sh). The two branches live side-by-side in one repo
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update` # (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
# on a stable box never jumps to a canary build. The generic-registry version string allows # on a stable box never jumps to a canary build. The generic-registry version string allows
# letters/dots/hyphens. # letters/dots/hyphens.
run: | run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;; refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
*) V="0.5.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;; *) V="${PF_BASE}-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
esac esac
echo "VERSION=$V" >> "$GITHUB_ENV" echo "VERSION=$V" >> "$GITHUB_ENV"
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV" echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
@@ -106,6 +108,40 @@ jobs:
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \ python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
-o packaging/flatpak/cargo-sources.json -o packaging/flatpak/cargo-sources.json
- name: Seed the local OSTree repo from the live server (keep BOTH channels in the summary)
# Each CI run builds only ONE branch (canary on main, stable on a tag). The deploy step's
# `flatpak build-update-repo` regenerates the repo SUMMARY from whatever refs are in the
# LOCAL repo, and the rsync publishes it (without --delete). A fresh single-branch local
# repo therefore produces a single-branch summary that CLOBBERS the other channel on the
# server — the exact bug that made `app/io.unom.Punktfunk/x86_64/stable` unresolvable
# ("No such ref") after a canary main-push overwrote the post-release summary, even though
# the stable commit's objects were still on disk. Fix: mirror the published repo DOWN first,
# so the local repo carries every existing branch; the build below then only ADDS this run's
# commit and the regenerated+signed summary keeps both channels. No-op on a fresh repo (first
# publish) or when the deploy secrets aren't set (the build still produces a valid bundle).
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_SSH_KEY:-}" ]; then
echo "::warning::DEPLOY_* not set — no seed; building a fresh single-branch repo."
exit 0
fi
install -d -m700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
mkdir -p "$PWD/repo"
# Pull the currently-published repo (all channels' objects + refs) into the repo the build
# will extend. No --delete: the local repo starts empty, so this only ADDS. A missing
# server repo (very first publish) is fine — we continue with a fresh repo.
rsync -az --info=stats1 -e "$SSH" "$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/" \
|| echo "::warning::no published repo to seed (first publish?) — continuing fresh"
echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
- name: Build the flatpak (install deps from Flathub, offline build) - name: Build the flatpak (install deps from Flathub, offline build)
run: | run: |
# --install-deps-from=flathub pulls everything the manifest declares: the GNOME 50 # --install-deps-from=flathub pulls everything the manifest declares: the GNOME 50
@@ -133,7 +169,10 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE" BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
# 1) Immutable, versioned URL. # 1) Versioned URL. A re-tagged release re-fires this workflow and the registry 409s on
# duplicate uploads — delete any prior copy first (404 on the first publish is fine).
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"$BASE/$VERSION/$BUNDLE" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
"$BASE/$VERSION/$BUNDLE" "$BASE/$VERSION/$BUNDLE"
echo "published $BASE/$VERSION/$BUNDLE" echo "published $BASE/$VERSION/$BUNDLE"
@@ -174,6 +213,10 @@ jobs:
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" --gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME"
flatpak build-update-repo --generate-static-deltas \ flatpak build-update-repo --generate-static-deltas \
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo" --gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo"
# The regenerated summary advertises exactly these refs — must include EVERY channel that
# has ever published (the seed step ensures the other channel's commit is present). If this
# ever shows only one branch on a repo that had two, the seed didn't run — investigate.
echo "published summary advertises:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
# 2) Build the install descriptors (GPGKey = the committed public key, base64). # 2) Build the install descriptors (GPGKey = the committed public key, base64).
GPGKEY="$(base64 -w0 packaging/flatpak/unom-flatpak.gpg)" GPGKEY="$(base64 -w0 packaging/flatpak/unom-flatpak.gpg)"
rm -rf site && mkdir -p site rm -rf site && mkdir -p site
@@ -185,9 +228,12 @@ jobs:
Comment=unom Flatpak applications Comment=unom Flatpak applications
GPGKey=$GPGKEY GPGKey=$GPGKEY
EOF EOF
# Two refs, one per channel — both regenerated every run and rsync'd without --delete, so # Two refs, one per channel. Both descriptor files are regenerated every run and rsync'd
# the server always offers both (the stable ref only resolves once a release has built the # without --delete; the repo SUMMARY carries both branches because the build was seeded
# `stable` branch). A box installs ONE; `flatpak update` then tracks that channel's branch. # from the live repo above (so build-update-repo below re-signs a summary listing every
# published channel, not just this run's). The stable ref resolves for good once any
# release has built the `stable` branch. A box installs ONE; `flatpak update` then tracks
# that channel's branch.
write_ref() { # <filename> <branch> <title> write_ref() { # <filename> <branch> <title>
cat > "site/$1" <<EOF cat > "site/$1" <<EOF
[Flatpak Ref] [Flatpak Ref]
+3 -2
View File
@@ -99,13 +99,14 @@ jobs:
- name: Version from tag - name: Version from tag
run: | run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc) refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
*) V="0.5.0" ;; # canary marketing version; the build number disambiguates *) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
esac esac
echo "VERSION=$V" >> "$GITHUB_ENV" echo "VERSION=$V" >> "$GITHUB_ENV"
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV" echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
echo "version $V build $GITHUB_RUN_NUMBER" echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
- name: Rust toolchain (mac + iOS + tvOS slices) - name: Rust toolchain (mac + iOS + tvOS slices)
run: | run: |
+14 -5
View File
@@ -68,16 +68,17 @@ jobs:
restore-keys: cargo-home- restore-keys: cargo-home-
- name: Version + channel - name: Version + channel
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.5.0-0.ciN.g<sha> # vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> <next-minor>-0.ciN.g<sha>
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.5.0-1 yet # in the `<base>-canary` group, whose "0." release sorts below the eventual <next-minor>-1 yet
# climbs by run number. The canary base stays one minor ahead of the latest stable so a # climbs by run number. The canary base is derived one minor ahead of the latest stable tag
# stable->canary box re-point still moves forward. The spec %build stamps # (scripts/ci/pf-version.sh) so a stable->canary box re-point still moves forward. The spec %build stamps
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance). # PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
run: | run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;; refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
*) V="0.5.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;; *) V="$PF_BASE"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
esac esac
echo "PF_VERSION=$V" >> "$GITHUB_ENV" echo "PF_VERSION=$V" >> "$GITHUB_ENV"
echo "PF_RELEASE=$R" >> "$GITHUB_ENV" echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
@@ -103,6 +104,14 @@ jobs:
for rpm in dist/*.rpm; do for rpm in dist/*.rpm; do
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
echo "uploading $rpm" echo "uploading $rpm"
# A re-tagged release re-fires this workflow and the rpm registry 409s on duplicate
# package versions — delete any prior copy of this exact name/version-release/arch
# first (404 on the first publish is fine).
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload" "https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
done done
+13 -3
View File
@@ -131,11 +131,21 @@ jobs:
# dispatched provisioning workflow landing on a different one. Path is relative to the job # dispatched provisioning workflow landing on a different one. Path is relative to the job
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present. # working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
run: ../../../scripts/ci/ensure-windows-toolchain.ps1 run: ../../../scripts/ci/ensure-windows-toolchain.ps1
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay) - name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay + gamepad drivers)
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) + # Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
# pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve # pf-vdisplay (the real IddCx driver) + pf-umdf-util (the safe UMDF primitive layer) + the two
# against IddCxStub end-to-end (M1 step 2 gate). # gamepad drivers. pf-vdisplay linking proves the IddCx call sites resolve against IddCxStub
# end-to-end (M1 step 2 gate); the gamepad drivers prove pf-umdf-util's WDF dispatch links.
run: cargo build -v run: cargo build -v
- name: cargo clippy the shipped drivers (-D warnings — enforces the unsafe-audit gates)
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
# toolchain-only probe crate and is excluded.)
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
- name: cargo fmt --check the safe-layer + gamepad drivers
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build - name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
run: | run: |
# explicit --target (.cargo/config.toml) -> output under the triple subdir. # explicit --target (.cargo/config.toml) -> output under the triple subdir.
+18 -12
View File
@@ -16,15 +16,17 @@
# Versioning (free-form; not MSIX's 4-part rule) — single project version: # Versioning (free-form; not MSIX's 4-part rule) — single project version:
# vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the # vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the
# unified Gitea Release). # unified Gitea Release).
# main push / dispatch -> 0.3.<run_number> (canary; `canary/` alias; climbs by run number). # main push / dispatch -> <next-minor>.<run_number> (canary; `canary/` alias; base one minor
# ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
# #
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them # Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
# an ephemeral self-signed cert is generated and its public .cer published next to the installer # an ephemeral self-signed cert is generated and its public .cer published next to the installer
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1. # (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
# #
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer. # GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export # - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
# .def with llvm-dlltool (no GPU/SDK at build time). # RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
# AMD/Intel-only boxes before main).
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared # - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer. # tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265). # lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
@@ -37,6 +39,7 @@ on:
paths: paths:
- 'crates/punktfunk-host/**' - 'crates/punktfunk-host/**'
- 'crates/punktfunk-core/**' - 'crates/punktfunk-core/**'
- 'crates/punktfunk-tray/**'
- 'packaging/windows/**' - 'packaging/windows/**'
- 'scripts/windows/**' - 'scripts/windows/**'
- 'web/**' - 'web/**'
@@ -100,30 +103,33 @@ jobs:
if (-not $env:VBCABLE_DIR) { if (-not $env:VBCABLE_DIR) {
"VBCABLE_DIR=C:\Users\Public\vbcable" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "VBCABLE_DIR=C:\Users\Public\vbcable" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
} }
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') { $v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
$env:GITHUB_REF_NAME -replace '^v', '' $env:GITHUB_REF_NAME -replace '^v', ''
} else { } else {
"0.3.$($env:GITHUB_RUN_NUMBER)" # Canary: <major>.<minor>.<run> — major.minor track one minor ahead of stable, run climbs monotonically.
"$($pf.PF_MAJOR).$($pf.PF_MINOR).$($env:GITHUB_RUN_NUMBER)"
} }
"HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "host version $v" Write-Output "host version $v"
- name: Generate NVENC import lib
shell: pwsh
run: |
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build (release, nvenc + amf-qsv) - name: Build (release, nvenc + amf-qsv)
shell: pwsh shell: pwsh
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR). # All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
- name: Clippy (host, Windows) - name: Build (release, status tray)
shell: pwsh
# The per-user notification-area companion the installer bundles (punktfunk-tray.exe).
run: cargo build --release -p punktfunk-tray
- name: Clippy (host + tray, Windows)
shell: pwsh shell: pwsh
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code). # First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings run: |
cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer) - name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
shell: pwsh shell: pwsh
+5 -2
View File
@@ -16,7 +16,8 @@
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX). # vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
# Published to the generic registry + the stable `latest/` alias + attached to the # Published to the generic registry + the stable `latest/` alias + attached to the
# unified Gitea Release alongside every other platform's artifact. # unified Gitea Release alongside every other platform's artifact.
# main push / dispatch -> 0.3.<run_number>.0 (canary; climbs monotonically by run number). # main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
# Published to the generic registry + the `canary/` alias. # Published to the generic registry + the `canary/` alias.
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix). # Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
# #
@@ -78,11 +79,13 @@ jobs:
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }}
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') { $parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix. # MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.') (($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
} else { } else {
@('0', '3', $env:GITHUB_RUN_NUMBER) # Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
} }
while ($parts.Count -lt 4) { $parts += '0' } while ($parts.Count -lt 4) { $parts += '0' }
$v = ($parts[0..3] -join '.') $v = ($parts[0..3] -join '.')
+3
View File
@@ -31,3 +31,6 @@ xcuserdata/
# Python bytecode (e.g. clients/android/ci tooling) # Python bytecode (e.g. clients/android/ci tooling)
__pycache__/ __pycache__/
*.pyc *.pyc
# Claude Code project instructions — local to each dev box, not part of the repo.
CLAUDE.md
-516
View File
@@ -1,516 +0,0 @@
# CLAUDE.md — punktfunk
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
[`design/implementation-plan.md`](design/implementation-plan.md). Status table: `README.md`.
## Where the work stands
- **Core (`punktfunk-core` + C ABI): complete and hardened.** FEC recovery, loopback-under-loss,
proptests, C ABI harness all green; 13 adversarial-review findings fixed +
regression-tested (`a913042`).
- **GameStream host: working end-to-end with a stock Moonlight client.** Validated live
on this box: pairing (persists across restarts), serverinfo/applist (app catalog from
`~/.config/punktfunk/apps.json` → each entry picks a compositor + nested command), RTSP, ENet
control, audio, and video at the **client's native resolution and refresh** — the host
creates a per-session virtual output via per-compositor `VirtualDisplay` backends:
**KWin** (`zkde_screencast stream_virtual_output`, needs KWin ≥ 6.5.6 headless; >60 Hz via
custom modes), **gamescope** (spawned headless at WxH@Hz, its PipeWire node captured, needs
gamescope ≥ 3.16.22 — older deadlocks on PipeWire ≥ 1.6), **Mutter** (D-Bus
`RecordVirtual` virtual monitor; validated live on headless GNOME Shell 50, zero-copy),
**Sway/wlroots** (`swaymsg create_output` + custom mode, xdpw portal capture with a
managed chooser config; validated live on sway 1.11, zero-copy).
Performance work landed and measured: GPU **zero-copy** on all paths (tiled dmabuf →
EGL/GL → CUDA; LINEAR dmabuf → **Vulkan bridge** → CUDA → NVENC), auto 2-way NVENC
split-encode above ~1 Gpix/s (5K@240), infinite GOP + RFI keyframes (killed the periodic
freeze), encode|send thread split with `sendmmsg` batching. Stable 240 fps at 5120×1440.
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
back-channel; validated live — pad created/destroyed with the session). Management REST API +
checked-in OpenAPI doc (`mgmt.rs`). **Web-console performance capture** (`stats_recorder.rs`,
design: [`design/stats-capture-plan.md`](design/stats-capture-plan.md)): the operator arms stats
recording from the web console, plays, stops, and reviews the run as graphs (per-stage latency
breakdown · fps new/repeat · goodput · loss/FEC). A shared `Arc<StatsRecorder>` ring (the hot-path
gate is a runtime `AtomicBool`, replacing the startup-only `PUNKTFUNK_PERF`) is fed by **both** the
native `virtual_stream` and the GameStream encode loop at their existing ~2 s/~1 s aggregation
boundary, and finished captures are saved as on-disk recordings
(`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page
(recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.*
**Web-console log view** (`log_capture.rs`): a `tracing` layer tees DEBUG-and-up (independent of
`RUST_LOG`) into a 4096-entry in-memory ring, served cursor-paged at `GET /api/v1/logs`
(bearer-only) → the console's **Logs** page (follow/pause · level filter · search). The Windows
gamepad drivers now stamp attach/heartbeat marks into their shm sections and the host's
`DriverAttach` watcher turns silence into a one-shot diagnosis WARN (driver-store check + CM
devnode problem code) — failure-mode table: [`design/gamepad-driver-health.md`](design/gamepad-driver-health.md).
The Android client gained Settings → **Connected controllers** (device list + VID/PID + resolved
pad type + live input test) for the client end of the same chain. *Log view + driver health:
Linux-tested; Windows/Android sides CI/device-validation pending.*
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
(inexpressible in GameStream), host creates the native virtual output at the client's
requested mode. `punktfunk1-host` is a **persistent listener** (sessions back to back;
`--max-sessions`). QUIC datagrams carry the side planes, demuxed by first byte: input
0xC8 (incl. **gamepads** — incremental events accumulated into the uinput xpad), **Opus
audio** 0xC9 (48 kHz stereo, 5 ms, host→client), **rumble** 0xCA (host→client). **Trust:**
host serves its persistent identity (`~/.config/punktfunk/cert.pem`, shared with GameStream
pairing) and logs the SHA-256 fingerprint; clients pin it, established by a **SPAKE2 PIN pairing
ceremony** (host arms pairing and displays a 4-digit PIN; a PAKE binds both cert fingerprints so an
attacker gets one online guess, no offline dictionary attack) — PIN pairing is the default for new
hosts. **TOFU on first connect** (`endpoint::client_pinned`) stays as an explicit host opt-in
(`punktfunk1-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
clients only offer the TOFU "Trust" path for a host that advertised `pair=optional`, route every
other new host straight to the PIN ceremony, and on a pinned-fingerprint change force re-pairing
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
default; `--allow-tofu`/`--open` accept unpaired clients).
**LAN auto-discovery**: both `serve` and `punktfunk1-host` advertise the native service over
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
**Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the
host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on
(validated live: one .h265 with 720p and 1080p segments). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms**
capture→…→reassembled; audio measured live (~200 pkts/s). A **wall-clock skew handshake**
(`ClockProbe`/`ClockEcho`, 8 NTP rounds after `Start`, `clock_offset_ns`) aligns the client to the
host clock, so that latency is now valid **cross-machine** (`skew_corrected=true`) — measured GNOME
box → dev box over the LAN: **p50 1.30 ms** (the 1.57 ms inter-box clock offset removed).
`punktfunk-probe` is the
working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad).
The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect`
(pin/TOFU) + `next_au`/`next_audio`/`next_rumble`/`next_hidout`/`send_input`/
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
env > uinput Xbox 360. Backends: **Xbox 360** (uinput on Linux / the pf-xusb UMDF driver on
Windows), **Xbox One/Series** (the same
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
(UMDF minidriver)** backend — `inject/windows/dualsense_windows.rs` + `inject/windows/dualshock4_windows.rs`, one
driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
(`packaging/windows/drivers/pf-xusb/`, `inject/windows/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI
(`packaging/windows/drivers/`) and installed by the Inno Setup installer via
`punktfunk-host.exe driver install --gamepad`.
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
the remaining piece.)
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends
behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx
**pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`;
DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`), GPU encode (NVENC
`--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers
(`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire
convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK;
the Windows client derives it from the scancode, NOT the layout-resolved `vkCode`) — the Windows
injector resolves them via a fixed table mirroring the Linux `vk_to_evdev` (never through a
keyboard layout: the SYSTEM service thread's layout re-reads positions as characters — the
German y↔z / ö→ü scramble), while GameStream/Moonlight VKs are layout-semantic
(`KEY_FLAG_SEMANTIC_VK`, resolved under the foreground app's layout, Sunshine's model). Linux
renders positions under the session compositor's layout (libei) or the virtual keyboard's
uploaded keymap (Sway/wlroots — honors `XKB_DEFAULT_LAYOUT` et al., default US); the Android
client reads `KeyEvent.scanCode` first so a user-selected physical-keyboard layout can't
re-map keycodes semantically. Ships as a **signed
Inno Setup installer** that registers a `LocalSystem` SCM service launching into the interactive
session for secure-desktop (UAC/lock-screen) capture (`windows/service.rs`), bundles the
pf-vdisplay driver + the FFmpeg DLLs (+ VB-CABLE for the virtual mic), and is published by
`windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`):
`PUNKTFUNK_ENCODER=auto` (the host.env default) reads the **selected render adapter's** vendor →
**NVENC** (NVIDIA, direct SDK, `encode/windows/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
(`encode/windows/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
probed per-GPU on AMF/QSV (`windows_codec_support``serverinfo`, AV1 gated; cached per selected
GPU). **Multi-GPU is first-class** (`gpu.rs`): GPU inventory + a persisted auto/manual preference
(`<config>/gpu-settings.json`, stored by stable PCI identity — LUIDs are per-boot) exposed over
`GET /api/v1/gpus` + `PUT /api/v1/gpus/preference` and a web-console GPU card (Host page: list,
Automatic/Prefer, "In use · backend" badge). One selection — precedence **console preference >
`PUNKTFUNK_RENDER_ADAPTER` > max VRAM**, graceful fallback when the preferred GPU is absent —
feeds `win_adapter::resolve_render_adapter_luid` (capture ring + IddCx render pin), the encoder
vendor auto-detect (previously DXGI adapter 0 — wrong on hybrid boxes like NVIDIA dGPU + Intel
Arc iGPU), and the NVENC 4:4:4 probe; a preference change applies to the next session. On Linux a
matched manual preference picks the VAAPI render node / NVENC-vs-VAAPI auto choice (auto mode
unchanged). *Implemented + unit-tested; not yet on-glass validated on the hybrid box.* **HDR (10-bit)**: WGC
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
host only** (the Linux host stays 8-bit, blocked upstream). **Vulkan-game HDR over the virtual
display**: NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an IddCx
indirect display (so Vulkan games — Doom: The Dark Ages, id Tech, etc. — say "device does not support
HDR"), even though the ICD happily *accepts + presents* a forced HDR swapchain there. A tiny always-on
Vulkan **implicit layer** (`packaging/windows/pf-vkhdr-layer/`, `VK_LAYER_PUNKTFUNK_hdr_inject`)
injects the `HDR10_ST2084`/scRGB surface formats into `vkGetPhysicalDeviceSurfaceFormats[2]KHR`,
self-gated on the display's actual advanced-color state (no-op on SDR / real monitors); bundled +
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
## What's left
1. **Native clients — decode + present: macOS stage 1 done, first light achieved
(2026-06-10).** PunktfunkKit compiles and is tested on macOS (AnnexB → VideoToolbox →
`AVSampleBufferDisplayLayer`, GCMouse/GCKeyboard capture, `PunktfunkClient` app shell);
validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope
EIS. The app speaks the full ABI v2 trust surface: Keychain-persisted client identity
presented on every connect, SPAKE2 PIN pairing UI (host-card context menu + the trust
prompt's "Pair with PIN instead…"), TOFU fingerprint prompt. **Gamepads (2026-06-11):**
controller discovery + selection in Settings (`GamepadManager` — exactly one pad
forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical
controller, user-overridable), capture incl. DualSense touchpad/motion
(`GamepadCapture`/`GamepadWire`), feedback rendering (rumble → CoreHaptics; lightbar /
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
motion sign/scale derived, not yet live-verified. **Gamepad UI (iOS/iPadOS + macOS,
2026-07-02 rework):** a connected pad swaps the home for a console-style launcher
(`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add
Host tile (A connect · Y library · X settings · B back), a controller-navigable
settings screen (vertical `GamepadMenuList`, left/right steps values), an add-host
flow with an on-screen controller keyboard (`GamepadKeyboard` — no touch needed
anywhere), and the coverflow library, all over an animated aurora backdrop
(`GamepadScreenBackground`, TimelineView-driven drifting blobs — pure SwiftUI ON
PURPOSE: a .metal lib only reliably bundles in one of the two build systems (SPM vs
xcodeproj synced folders) these sources compile under). Input is the polled
`GamepadMenuInput` (handlers don't fire outside a stream; on (re)start it SNAPSHOTS
held buttons so a handoff press never double-fires), haptics dual-channel (device +
`MenuHaptics` on the pad). macOS: same screens, settings/add-host as sheets (no
fullScreenCover), NSScreen-based mode lists, scroll indicators `.never` (macOS
"always show scroll bars" overrides `.hidden`); launcher/settings/add-host/keyboard
render-verified live on this Mac via `PUNKTFUNK_FORCE_GAMEPAD_UI=1` (dev hook, forces
the mode without a pad). Controller-in-hand on-glass validation still pending on all
platforms. Tests: `swift test` in
`clients/apple` (unit + real-codec round trip),
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
includes the pairing ceremony + `--require-pairing` gate),
`RemoteFirstLightTests` (full pipeline over the LAN). See
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter is now the DEFAULT**
(stage-1 is the Metal-unavailable / DEBUG fallback): explicit `VTDecompressionSession` decode →
`CAMetalLayer`, presented from the hosting view's **main-runloop `CADisplayLink`** (`renderTick` pops
the newest ready frame per vsync; macOS `displaySyncEnabled = false` is the real fullscreen-judder fix,
~11 ms p50). *(An off-main `CAMetalDisplayLink` and an off-main blocking-render present thread were
both tried and reverted — both measured slower on macOS and iPad.)* **HDR fixed**
(`design/apple-stage2-presenter.md`): the "too bright" bug was a missing reference-white anchor — the
fix keeps the PQ-passthrough shader and adds `CAEDRMetadata.hdr10(…, opticalOutputScale: 203)` +
`wantsExtendedDynamicRangeContent` on the layer (on all platforms; the old `#if os(macOS)` guard left
iOS/tvOS EDR half-engaged), routing the 0xCE mastering metadata to the layer (via `setHdrMeta`) instead
of a never-composited source buffer. **Mid-session SDR↔HDR** is handled: `render` reconciles the layer
per-frame from the decoded `frame.isHDR` (per-mode pixel format `bgra8`/`rgba16Float`), so a game
entering HDR mid-stream just reconfigures (last 0xCE grade cached + re-applied; pump drains 0xCE
unconditionally). **4:4:4 added**: decode format is a 2×2 `(chroma, HDR)` matrix
(`420v/x420/444v/x444`, all biplanar so the shaders are unchanged), advertised (`VIDEO_CAP_444`) only
behind a **hardware-required `VTDecompressionSession` probe** (`Stage444Probe`, validated on M3) with a
Settings opt-out + a bounded pump backstop for an undecodable 4:4:4 session. *HDR brightness + 4:4:4
still need on-glass validation (Windows-HDR / `PUNKTFUNK_444` host).* Next: glass-to-glass numbers via
`tools/latency-probe`.
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
against `serve` on this box: 1080p60, steady 60 fps, capture→decoded p50
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
service (pad pin UI, auto type from the physical pad, DualSense touchpad/motion 0xCC +
raw-DS5-effects trigger/player-LED replay — needs a physical pad to live-verify), mic
uplink (validated live), per-host speed test, compositor pref, native-display mode
default, saved-hosts list, .deb + RPM-subpackage CI (deb.yml/rpm.yml). **VAAPI decode
→ DRM-PRIME dmabuf → `GdkDmabufTexture`** (BT.709 color state; Tier-1 zero-copy on
Intel/AMD, `PUNKTFUNK_DECODER=software|vaapi` override) with a proven fallback ladder —
no VAAPI device (NVIDIA) or mid-session VAAPI error → software decode. **First AMD test
(Steam Deck) hit a green-screen bug, fixed:** FFmpeg's VAAPI export uses
`SEPARATE_LAYERS`, so NV12 arrives as two single-plane layers (R8 luma + GR88 chroma,
one shared fd); the mapper took `layers[0]` only → GTK got a luma-only R8 texture, chroma
read as 0 → green field / red whites. Fix derives the combined fourcc from the decoder
`sw_format` (→ `DRM_FORMAT_NV12`) and flattens all planes across all layers (mpv's
pattern); a first-frame descriptor dump logs the real layout. Awaiting Steam Deck
reconfirm. Next: the stage-2 raw-Wayland
presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) —
**wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit).
**Windows stage 1 done 2026-06-15** (`clients/windows`, binary
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain**, presented from a
**dedicated render thread** (`render.rs`, 2026-07-02 rewrite — presenting never touches or is
stalled by the XAML thread): frame-latency-waitable swapchain + `SetMaximumFrameLatency(1)`
(≤1 queued present, newest-wins drain after the wait, so a stream faster than the display drops
backlog before any GPU work), **HiDPI-correct** (pixel-sized buffers + `SetMatrixTransform`
96/DPI — DIP-sized buffers were blurry at 125/150 %), Contain-fit letterbox, WARP fallback.
**FFmpeg decode with a D3D11VA hardware path on all vendors** (`gpu.rs` shares one D3D11 device
between decoder + presenter, adapter picked by console pref `PUNKTFUNK_ADAPTER` > the window's
monitor's adapter > default; `PUNKTFUNK_D3D_DEBUG=1` adds the debug layer): the decode pool is
**decoder-only bind, sized/aligned by libavcodec itself** (get_format returns `AV_PIX_FMT_D3D11`
and lets `hw_device_ctx` drive — three hand-built-frames-context strikes are why: NVIDIA rejects
`DECODER|SHADER_RESOURCE` arrays, `BindFlags=0` fails texture creation, and Intel rejects
non-128-aligned HEVC surfaces at the first `SubmitDecoderBuffers`), a DXVA **profile probe**
before the hwdevice commits software-vs-hardware up front (no burned first IDR), and the
presenter copies the decoded slice with ONE display-size-boxed `CopySubresourceRegion` (a planar
slice is a single subresource in D3D11 — the old two-copy D3D12-style code silently no-opped =
the black screen) into its sampleable NV12/P010 texture → per-plane SRVs + YUV→RGB shaders
(NV12/BT.709, P010/BT.2020-PQ). **Software CPU decode is the fallback** (auto-selected,
`DecoderPref` override, mid-session demotion + keyframe re-request) and now feeds the SAME
shaders (swscale → NV12/P010 planes → two dynamic plane textures) so hw/sw colour math is
identical. **HDR10**: the client advertises 10-bit/HDR (Settings toggle, gated on an HDR
display), detects PQ in-band (`transfer == SMPTE2084`), and flips the swapchain to
`R10G10B10A2` + ST.2084 with HDR10 metadata (0xCE mastering metadata plumbed). **WASAPI** render
+ mic capture, **SDL3** gamepads (rumble/lightbar/DualSense), `mdns-sd` discovery, and the full
trust surface — all **in-app**: a polished WinUI shell (host tiles w/ monogram + status pills,
`InfoBar` errors/hints, `ToggleSwitch` settings, status-chip stream HUD showing GPU/CPU decode +
HDR), host list (live mDNS + saved + manual), settings (resolution/refresh/decoder/bitrate/HDR/
mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch re-pair. **Live-validated 2026-07-02
on the hybrid laptop (Intel Arc Pro iGPU + RTX 3500 Ada) against the local Windows host**:
D3D11VA hardware decode 60 fps on BOTH vendors (headless, `PUNKTFUNK_ADAPTER`-forced; NVIDIA
0.2 ms decode, Intel 0.2 ms), software path, and the GUI on glass (real decoded desktop pixels,
GPU-decode HUD chip, ~18 ms capture→decoded p50 over loopback — dominated by the host's 60 Hz
virtual-display capture cadence). HDR-on-glass still pending. **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
exposes no raw key/pointer events; native Windows VK + absolute mouse (client-rect Contain-fit) +
wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy
+ fmt green on **`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`** — the latter
**cross-compiled off the one x64 runner** (no ARM64 runner; the x64 MSVC toolset's ARM64 cross
compiler + a per-arch `FFMPEG_DIR` ARM64 tree, SDL3/libopus build-from-source cross-compile
cleanly), and both ship as signed MSIX (`windows-msix.yml` matrix → `..._x64.msix`/`..._arm64.msix`,
verified: ARM64 binaries + manifest arch). **windows-reactor is unpublished** (git
dep pinned to commit `a4f7b2cb`, bumped 2026-07-02 from `b4129fcc` for `on_pointer_entered`/
`on_pointer_exited` hover events — mechanical renames only: `SymbolGlyph``Symbol`,
`placeholder``placeholder_text`, TextBox `on_changed``on_text_changed`, ToggleSwitch
`on_changed``on_toggled`, `on_menu_item_clicked``on_item_clicked`, SwapChainPanel
`on_ready``on_mounted`; `windows` pinned to the SAME commit so `IDXGISwapChain1` unifies with
`set_swap_chain`). New-model runtime staging: reactor has NO build.rs anymore — the app's own
`build.rs` calls `windows_reactor_setup::as_framework_dependent()` (same-rev build-dep, stages
the bootstrap DLL + resources.pri that pack-msix expects) and `main` calls
`windows_reactor::bootstrap()` before `App` (packaged MSIX: a no-op, the manifest's
`Microsoft.WindowsAppRuntime.2` dependency resolves the runtime). `CARGO_WORKSPACE_DIR` is no
longer required (harmless where still set). Gotcha: `CARGO_HOME` must be an ASCII path
— the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. **Parity/cleanup
batch (2026-07-02)**: `app.rs` split into per-screen `app/` modules (mod=root/router · hosts ·
connect · pair · speed · settings · licenses · stream · style; thread-driven state lives in ROOT
`use_async_state` and flows down as props — a child's own async-state write does NOT re-render it);
"Native display" now resolves the real monitor mode at connect (`MonitorFromWindow`
`EnumDisplaySettingsW`, was hardcoded 1080p60); per-host **speed test** (saved-host card button +
`--headless --speed-test`, probe burst → recommended ≈70 % bitrate applied in one tap; bitrate
setting is now a free-form NumberBox); **forget host** (ContentDialog confirm →
`KnownHosts::remove_by_fp`); settings gained forwarded-controller picker + gamepad type + host
compositor + capture-system-shortcuts — the previously-dead `Settings.compositor`/
`inhibit_shortcuts` are now honored (off = Alt+Tab/Alt+Esc/Ctrl+Esc/Win act locally);
**click-to-recapture** after a Ctrl+Alt+Shift+Q release with the HUD hint tracking capture state;
input hook caches lock geometry (no per-move `GetClientRect`), audio jitter-ring trims via
`drain`. Validated on the bare-metal RTX box: `--discover` (3 live LAN hosts), synthetic-host
loopback E2E (TOFU connect → clock skew → HEVC negotiate → shared-D3D11 + D3D11VA init → WASAPI →
session end; synthetic payload isn't decodable so decode output stays unvalidated), speed-test
E2E. The WinUI window itself CANNOT be launched from SSH (session-0 → WinAppSDK 0x80070005,
pre-existing; needs the console session, e.g. PsExec -i 1). **UX batch (2026-07-02 evening,
UIA-smoke-tested on the hybrid laptop)**: host tiles get the WinUI pointer-over fill
(`on_pointer_entered`/`exited` → root hover state → `ControlFillSecondary`), Settings is a stock
**NavigationView** sidebar (Windows-Settings pattern: Display/Video/Input/Audio/About panes,
built-in back arrow, section in root state; the section card is **keyed by section** — an
in-place diff across sections re-sets a reused ComboBox's items, clearing WinUI's selection,
but skips `selected_index` when the values compare equal → blank selection; the key forces a
remount — and the content column rides its own section-switch slide-up tween), new
**"Show the stats overlay (HUD)"** toggle
(`Settings::show_hud`, applies mid-stream via the 400 ms HUD re-render), the Add-host modal
slides up + fades in (root margin/opacity tween, same pattern as screen navigation), and a
self-initiated disconnect (Ctrl+Alt+Shift+D → `Ended(None)`) returns to the host list silently
instead of raising the error banner.
Next: **HDR on-glass validation** (Windows host with `PUNKTFUNK_10BIT` → the HDR laptop
display), then RAWINPUT relative-mouse pointer-lock.
**Android stage 1 done** (`clients/android`, Kotlin app + `native/` Rust JNI core linking
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
Opus/AAudio audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
(`feedback.rs`), **native `mdns-sd` mDNS discovery** (`discovery.rs`, polled over JNI — the same
browse the Linux/Windows clients use, replacing the flaky per-OEM `NsdManager`; Kotlin keeps only
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish.
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~24 ms
at high res).
3. **punktfunk/1 protocol growth.** **Done:** unified host (`serve --gamestream` runs GameStream + the
punktfunk/1 QUIC host in one process; bare `serve` is the secure native-only default — GameStream is
opt-in, trusted-LAN only, security-review #5/#9) with native pairing driven over the mgmt API /
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
unpaired clients); clients render TOFU only for a `pair=optional` host and force re-pairing on a
fingerprint change. **Done:** concurrent sessions — the accept loop spawns each session
(`--max-concurrent`, default 4, an NVENC bound), each with its own virtual output + encoder, sharing
the host-lifetime input/audio/mic services (shared-desktop multi-view on kwin/mutter/wlroots).
**Done:** delegated pairing approval (§8b-1) — an unpaired device shows up as a pending request in
the web console, one click approves + pins it. Next (see roadmap): gamescope multi-user isolation
(per-session input/audio = independent desktops); §8b-2 peer-push approval from a paired device's
own app.
4. **GameStream host polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
-highbitdepth 1` already encodes Main10 from 8-bit input on this box),
reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented
and unit/live-capture tested — both still need a live Moonlight confirmation (select
AV1 in a stock client; a real 5.1/7.1 listen incl. FEC under loss).
Box one-time setup is complete: udev rule + `input` group (gamepads validated live),
gamescope 3.16.22 installed system-wide (no PATH override), gnome-shell installed (Mutter
backend validated live). All three compositor backends are live-validated.
## Build / test / run
```sh
cargo build --workspace # green on Linux and macOS
cargo test --workspace # unit + loopback + proptest + C ABI harness (~100 tests)
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
cargo run -p loss-harness # FEC loss-resilience sweep (no network needed)
bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip proof
```
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
(cbindgen from `punktfunk-core/src/abi.rs`) and `api/openapi.json` (regenerate with
`cargo run -p punktfunk-host -- openapi > api/openapi.json`; spec lives in `mgmt.rs`).
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
build+typecheck; `docker.yml` builds+pushes the web/docs/rust-ci images (host and native
clients are deliberately NOT containerized); `apple.yml` builds the xcframework and runs
`swift build`/`swift test` on the `macos-arm64` host-mode runner (home-mac-mini-1,
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner
(`home-windows-runner-1`, vmid 210, `windows-amd64:host` label). The runner is reproducible and
**owned by `unom/infra`**, not this repo, since it's shared across unom Windows projects going
forward: `unom/infra`'s `windows-runner/` Packer template bakes a generic Windows 11 template (OS
install + OpenSSH Server + VS Build Tools/NASM/CMake/LLVM + the act_runner/Node/rustup base, no
registration) on Proxmox once; `proxmox/windows-runner/` (Terraform, `bpg/proxmox`) full-clones it
(agent-based IP discovery, no pre-provisioned DHCP reservation needed) and registers the instance
over SSH remote-exec — the same bake-once/clone-fast split `proxmox/unom-1` uses for the Linux CI
host, just without a native Windows cloud-init (registration goes over `remote-exec`/SSH instead of
`initialization{}`; WinRM was tried first but is deprecated in OpenTofu, so this moved to SSH via
Windows' in-box OpenSSH Server). punktfunk layers its own extras on top of that generic runner:
`scripts/ci/provision-windows-wdk.ps1` (WDK + cargo-wdk for the UMDF drivers) and
`scripts/ci/provision-windows-punktfunk-extras.ps1` (FFmpeg x64/ARM64 trees, Inno Setup, the
`aarch64-pc-windows-msvc` rustup target) — both idempotent, and both run automatically at the start
of every Windows CI job via the shared `scripts/ci/ensure-windows-toolchain.ps1` step (a fast no-op
once already provisioned), rather than a separate manually-dispatched provisioning workflow — that
avoided a real footgun once there could be more than one `windows-amd64` runner: a manually
dispatched provisioning workflow has no way to target a *specific* runner instance, so it could
land on an already-provisioned box instead of the one that actually needed it.
## Layout
```
crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated)
crates/punktfunk-host/
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
vdisplay/linux/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
vdisplay/windows/{pf_vdisplay,manager,identity}.rs all-Rust IddCx virtual display (pf-vdisplay)
linux/zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
inject/linux/{libei,wlr,gamepad,dualsense,dualshock4,steam_*}.rs Linux input (uinput xpad · UHID pads · virtual Deck)
inject/windows/{sendinput,gamepad_windows,dualsense_windows,dualshock4_windows}.rs Windows input (UMDF shared-mem pads)
encode/linux/{mod,vaapi}.rs · encode/windows/{nvenc,ffmpeg_win}.rs · encode/sw.rs per-GPU encoders (NVENC/CUDA · VAAPI · AMF/QSV) + GPU-less openh264
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController)
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
clients/decky/ Steam Deck Decky plugin
packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI)
web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs)
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
tools/{loss-harness,latency-probe}/ measurement (plan §10)
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
include/punktfunk_core.h generated C header
```
## Design invariants — do not regress
- **One core, linked everywhere.** Protocol/FEC/crypto live only in `punktfunk-core`, behind a
stable, versioned C ABI. `tokio`/`quinn` exist only behind the `quic` feature (control
plane); **no async on the per-frame path** — native threads only.
- **Native client resolution, no scaling.** A session gets a virtual output at exactly the
client's WxH@Hz via the `VirtualDisplay` trait (`create(mode) → VirtualOutput { node_id,
remote_fd, preferred_mode, keepalive }`, RAII teardown). There is no cross-compositor
protocol for this — each compositor keeps its own backend.
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block, Moonlight-compatible) and GF(2¹⁶)
Leopard (≤65535 shards/block) — punktfunk/1 negotiates the latter, removing the ~1 Gbps
ceiling.
- **Core security hardening stays intact**: reassembler bounds attacker-controlled fields
before allocating (`ReassemblerLimits`); AES-GCM per-direction nonce salts + seq-as-AAD;
ABI `struct_size` checks. Regression tests exist — keep them green.
- **PipeWire consumer discipline**: our capture streams set `node.dont-reconnect` and tear
down promptly on negotiation timeout — one wedged link head-blocks the daemon's shared
work queue system-wide.
## Running on this box
Headless QEMU VM (Ubuntu 26.04, kernel 7.0), passthrough RTX 5070 Ti (driver 595 **open**
module — a kernel update silently drops it; reinstall `nvidia-driver-595-open`), no KMS
scanout → KWin `--drm` impossible; everything renders offscreen via `renderD128`.
```sh
# compositor session (shell 1, or the systemd unit in scripts/): full headless Plasma.
# The script sets XDG_MENU_PREFIX=plasma- & co. — without it plasmashell runs but the
# launcher menu is EMPTY (no apps, no System Settings).
bash scripts/headless/run-headless-kde.sh 1920x1080
# host (shell 2): bare `serve` is native-only (secure default); add --gamestream for Moonlight compat.
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_VIDEO_SOURCE=virtual \
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve --gamestream
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
# across sessions — bound it with --max-sessions):
cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 10 --max-sessions 1
cargo run -rp punktfunk-probe -- --mode 1280x720x120 --out /tmp/a.h265 --input-test # + --pin HEX
```
Pinned crate facts: `ashpd` 0.13 + `pipewire` 0.9 (must match ashpd's) + `ffmpeg-next` 8.x
(`ffmpeg-sys-next` auto-detects the system FFmpeg, so it builds against **FFmpeg 7.x/libavcodec 61
or 8.x/libavcodec 62** — validated live on Ubuntu 26.04 (8) and Bazzite F43 (7.1); the zero-copy
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1|0` (Linux default: ON for
VAAPI/AMD/Intel with a one-shot CPU downgrade if the dmabuf offer never negotiates, OFF/opt-in for
NVENC), `PUNKTFUNK_VAAPI_LOW_POWER=1|0` (pin the VAAPI entrypoint; auto = full-feature then VDEnc
fallback for modern Intel), `PUNKTFUNK_NV12=0` (opt OUT of the default GPU RGB→NV12 convert on the
NVIDIA tiled zero-copy path), `PUNKTFUNK_INTRA_REFRESH=1` (opt-in NVENC intra-refresh loss recovery),
`PUNKTFUNK_PIN_CLOCKS=1` (opt-in NVML GPU clock floor, root-gated), `PUNKTFUNK_GAMESCOPE_APP=...`,
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
test — injects N% wire-packet loss on BOTH the GameStream and native video paths, no netem needed), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy),
`PUNKTFUNK_444=1` (full-chroma HEVC 4:4:4, see below).
**HEVC 4:4:4 (full chroma, Range Extensions)**: opt-in via `PUNKTFUNK_444`, negotiated like 10-bit —
the host emits 4:4:4 only when the client advertised `VIDEO_CAP_444` (wire bit `0x04` + ABI
`PUNKTFUNK_VIDEO_CAP_444`), the codec is HEVC, the session is single-process, **and** a GPU probe
(`encode::can_encode_444`, run before the Welcome) confirms support — else it resolves to 4:2:0 and
`Welcome::chroma_format` reflects the real value (honest downgrade; the client reads it via
`punktfunk_connection_chroma_format`). **punktfunk/1-native only** — GameStream/Moonlight stays 4:2:0
(stock clients can't decode 4:4:4). **NVENC is the implemented path**: Linux `hevc_nvenc` feeds a
swscale'd `yuv444p` (RGB-in is always 4:2:0 — verified on the RTX 5070 Ti — so the session forces CPU
RGB capture for 4:4:4); Windows NVENC keeps ARGB input + FREXT profile + `chromaFormatIDC=3` and the
DDA capturer delivers RGB. VAAPI / AMF / QSV **decline** (probe returns false — no validated 4:4:4
hardware in the lab; they'd produce 4:2:0). Software (openh264) is 4:2:0-only. Test with
`PUNKTFUNK_CLIENT_444=1 punktfunk-probe --out x.h265` then `ffprobe x.h265` (expect `pix_fmt yuv444p`).
*Linux NVENC mechanism validated on the RTX 5070 Ti (ffmpeg CLI); Windows NVENC + 10-bit-4:4:4 not yet
on-glass validated.*
## Conventions
- Rust 2021, `rustfmt` + `clippy -D warnings` clean before commit.
- Match the surrounding code's comment density and naming.
- Commit messages end with the Co-Authored-By trailer (see `git log`).
- `pkill` caution on this box: match exact comm names (`pkill -x gamescope-wl`,
`pkill -x punktfunk-host`) — `pkill -f` self-matches the invoking shell.
Generated
+180 -8
View File
@@ -228,6 +228,67 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]] [[package]]
name = "async-recursion" name = "async-recursion"
version = "1.1.1" version = "1.1.1"
@@ -239,6 +300,30 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -434,6 +519,19 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@@ -2002,9 +2100,26 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "ksni"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da9eeb3f510b6148ae68f963af2c1fbb0de4d9e4e05f82813cfb319837c3ad2b"
dependencies = [
"async-executor",
"async-io",
"async-lock",
"futures-channel",
"futures-lite",
"futures-util",
"pastey",
"serde",
"zbus",
]
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.5.0" version = "0.7.2"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2136,7 +2251,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.5.0" version = "0.7.2"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2561,6 +2676,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pem" name = "pem"
version = "3.0.6" version = "3.0.6"
@@ -2599,6 +2720,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]] [[package]]
name = "pipewire" name = "pipewire"
version = "0.9.2" version = "0.9.2"
@@ -2654,6 +2786,20 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "polyval" name = "polyval"
version = "0.6.2" version = "0.6.2"
@@ -2729,7 +2875,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.5.0" version = "0.7.2"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -2743,7 +2889,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.5.0" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2765,7 +2911,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.5.0" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2788,7 +2934,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.5.0" version = "0.7.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -2818,7 +2964,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.5.0" version = "0.7.2"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -2839,6 +2985,7 @@ dependencies = [
"khronos-egl", "khronos-egl",
"libc", "libc",
"libloading", "libloading",
"log",
"mdns-sd", "mdns-sd",
"nvidia-video-codec-sdk", "nvidia-video-codec-sdk",
"openh264", "openh264",
@@ -2863,6 +3010,7 @@ dependencies = [
"tokio-rustls", "tokio-rustls",
"tower", "tower",
"tracing", "tracing",
"tracing-log",
"tracing-subscriber", "tracing-subscriber",
"ureq", "ureq",
"usbip-sim", "usbip-sim",
@@ -2879,13 +3027,14 @@ dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"windows-service", "windows-service",
"winreg", "winreg",
"winresource",
"x509-parser", "x509-parser",
"xkbcommon", "xkbcommon",
] ]
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.5.0" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -2897,6 +3046,23 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "punktfunk-tray"
version = "0.7.2"
dependencies = [
"anyhow",
"ksni",
"libc",
"rustls",
"serde",
"serde_json",
"sha2",
"ureq",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"windows-service",
"winresource",
]
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "1.2.3" version = "1.2.3"
@@ -5219,8 +5385,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
dependencies = [ dependencies = [
"async-broadcast", "async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion", "async-recursion",
"async-task",
"async-trait", "async-trait",
"blocking",
"enumflags2", "enumflags2",
"event-listener", "event-listener",
"futures-core", "futures-core",
+2 -1
View File
@@ -4,6 +4,7 @@ members = [
"crates/punktfunk-core", "crates/punktfunk-core",
"crates/punktfunk-host", "crates/punktfunk-host",
"crates/punktfunk-host/vendor/usbip-sim", "crates/punktfunk-host/vendor/usbip-sim",
"crates/punktfunk-tray",
"crates/pf-driver-proto", "crates/pf-driver-proto",
"clients/probe", "clients/probe",
"clients/linux", "clients/linux",
@@ -16,7 +17,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"] exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package] [workspace.package]
version = "0.5.0" version = "0.7.2"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+9 -7
View File
@@ -15,6 +15,9 @@ your local network.
💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta 💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta
access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**. 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 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 day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
@@ -49,19 +52,19 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
| **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened | | **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 | | **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 | | **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** (x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · 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) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · 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, controllers incl. DualSense, discovery, pairing, speed test | | **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch | | **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing | | **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
| **Windows client** (`clients/windows`, WinUI 3) | 🟡 Stage 1 complete, ships as signed MSIX (x64 + ARM64); D3D11VA decode + HDR present pending on-glass validation | | **Windows client** (`clients/windows`, WinUI 3) | Streaming live: D3D11VA hardware decode on all GPU vendors (NVIDIA + Intel validated on glass) with software fallback, 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 | | **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 |
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio, (RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin, and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
gamescope, Mutter, and Sway/wlroots backends), encoded with GPU **zero-copy** (dmabuf → CUDA/Vulkan → gamescope, Mutter, and Sway/wlroots backends), encoded with GPU **zero-copy** (dmabuf → CUDA/Vulkan →
NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a NVENC) up to 5120×1440@240. The native **`punktfunk/1`** protocol adds a QUIC control plane and a
GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→reassembled at 720p120), with GF(2¹⁶) Leopard-FEC + AES-GCM data plane (p50 ~0.8 ms capture→received at 720p120), with
mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines. mid-stream mode renegotiation and a wall-clock skew handshake so latency stays valid across machines.
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default** Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the (`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
@@ -82,7 +85,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
| **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) | | **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) |
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) | | **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) | | **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Windows** (x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) | | **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status). `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; After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
@@ -135,10 +138,9 @@ clients/
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio) android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
probe/ headless reference / measurement client for punktfunk/1 probe/ headless reference / measurement client for punktfunk/1
decky/ Steam Deck Decky plugin decky/ Steam Deck Decky plugin
web/ web console (TanStack) over the management API — status · devices · pairing web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
design/ design notes & deep-dive plans (index: design/README.md)
include/punktfunk_core.h cbindgen-generated C header (checked in) include/punktfunk_core.h cbindgen-generated C header (checked in)
tools/ latency-probe · loss-harness (measurement) tools/ latency-probe · loss-harness (measurement)
``` ```
+69
View File
@@ -0,0 +1,69 @@
# Security Policy
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.
## Reporting a vulnerability
**Please report security issues privately by email to security@punktfunk.com.**
Do **not** open a public issue, pull request, or chat/forum post for a suspected vulnerability — that
exposes other users before a fix exists.
### What to include
The more of this you can give us, the faster we can act:
- The component and version (e.g. `punktfunk-host 0.6.0`, 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.
- Any suggested fix or mitigation (optional).
## What to expect
We're a small team, so timelines are best-effort, but we commit to:
- **Acknowledge** your report within **3 business days**.
- Give an **initial assessment** (severity + whether we can reproduce) within about **7 days**.
- Keep you updated, and tell you when a fix ships.
- **Credit** you in the advisory / release notes when the fix is public — unless you'd rather stay
anonymous.
We practice **coordinated disclosure**: please give us reasonable time to release a fix before
publishing details. We aim to resolve valid issues within **90 days** and will agree a disclosure
date with you.
## Scope
In scope — the code in this repository:
- The host (`punktfunk-host`), its Windows drivers, and the protocol/crypto core (`punktfunk-core`).
- The native clients (Apple, Linux, Windows, Android), the web management console, and the management
API.
Known limits — documented behavior, not vulnerabilities (see
https://docs.punktfunk.unom.io/docs/security):
- **Admin/SYSTEM already on the host = out of scope.** An attacker who is already administrator or
SYSTEM on the host owns the machine regardless of punktfunk.
- **The virtual display is a real monitor** — any process already in the interactive desktop session
can capture it via the normal OS screen-capture APIs, exactly as it could a physical monitor.
- **GameStream/Moonlight compatibility** (`--gamestream`) uses legacy encryption and is documented as
opt-in, trusted-LAN-only.
- **Public-internet exposure is unsupported** — issues that only arise from exposing the host to the
WAN are expected; keep the host on a trusted LAN or a VPN.
If you're unsure whether something is in scope, report it anyway — we'd rather hear about it.
## Safe harbor
We consider good-faith security research that follows this policy to be authorized, and we won't
pursue legal action against researchers who:
- make a good-faith effort to avoid privacy violations, data loss, and service disruption,
- only test systems they own or have explicit permission to test,
- 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.
+96 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.5.0" "version": "0.6.0"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
@@ -578,6 +578,41 @@
} }
} }
}, },
"/api/v1/local/summary": {
"get": {
"tags": [
"host"
],
"summary": "Local status summary for the tray icon",
"description": "Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device\nnames). Unauthenticated, but served to loopback peers only.",
"operationId": "getLocalSummary",
"responses": {
"200": {
"description": "Non-sensitive local host status (loopback peers only)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LocalSummary"
}
}
}
},
"401": {
"description": "Non-loopback peer",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
},
"security": [
{}
]
}
},
"/api/v1/logs": { "/api/v1/logs": {
"get": { "get": {
"tags": [ "tags": [
@@ -2083,6 +2118,66 @@
} }
} }
}, },
"LocalSummary": {
"type": "object",
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
"required": [
"version",
"video_streaming",
"audio_streaming",
"paired_clients",
"native_paired_clients",
"pin_pending",
"pending_approvals"
],
"properties": {
"audio_streaming": {
"type": "boolean",
"description": "True while the audio stream thread is running."
},
"native_paired_clients": {
"type": "integer",
"format": "int32",
"description": "Number of paired native (punktfunk/1) devices.",
"minimum": 0
},
"paired_clients": {
"type": "integer",
"format": "int32",
"description": "Number of pinned (paired) GameStream client certificates.",
"minimum": 0
},
"pending_approvals": {
"type": "integer",
"format": "int32",
"description": "Native pairing knocks awaiting the operator's approval (count only).",
"minimum": 0
},
"pin_pending": {
"type": "boolean",
"description": "True while a GameStream pairing handshake is parked waiting for the user's PIN."
},
"session": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/SessionInfo",
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
}
]
},
"version": {
"type": "string",
"description": "Host version (mirrors `/health`)."
},
"video_streaming": {
"type": "boolean",
"description": "True while the video stream thread is running."
}
}
},
"LogEntry": { "LogEntry": {
"type": "object", "type": "object",
"description": "One captured log event.", "description": "One captured log event.",
@@ -27,8 +27,15 @@
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" /> <uses-feature android:name="android.hardware.gamepad" android:required="false" />
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
eligible for the vendor's game performance profile — the aggressive CPU/GPU governor and
scheduler treatment games get — which, together with the ADPF hints in the native decode
path, is what keeps clocks up for low, consistent decode latency. Also groups it correctly
under Games in battery/data usage. Advisory: devices without Game Mode ignore it. -->
<application <application
android:allowBackup="false" android:allowBackup="false"
android:appCategory="game"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
@@ -33,13 +33,19 @@ data class Settings(
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */ /** Show the live stats overlay (FPS / throughput / latency) during a stream. */
val statsHudEnabled: Boolean = true, val statsHudEnabled: Boolean = true,
/** /**
* Touch input model. `true` (default) = trackpad: the cursor stays put on touch-down and moves * Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
* by the finger's relative delta (swipe to nudge, lift and re-swipe to walk it across), tap to * the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
* click where it is. `false` = direct pointing: the cursor jumps to the finger (the old behaviour). * lift and re-swipe to walk it across), tap to click where it is. [TouchMode.POINTER]: the
* cursor jumps to the finger (direct pointing). [TouchMode.TOUCH]: real multi-touch
* passthrough — every finger reaches the host as a touchscreen contact, for apps/games that
* understand touch. Mirrors the Apple client's TouchInputMode.
*/ */
val trackpadMode: Boolean = true, val touchMode: TouchMode = TouchMode.TRACKPAD,
) )
/** [Settings.touchMode] values; persisted by name. */
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */ /** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
class SettingsStore(context: Context) { class SettingsStore(context: Context) {
private val prefs = private val prefs =
@@ -57,7 +63,10 @@ class SettingsStore(context: Context) {
codec = prefs.getString(K_CODEC, "auto") ?: "auto", codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false), micEnabled = prefs.getBoolean(K_MIC, false),
statsHudEnabled = prefs.getBoolean(K_HUD, true), statsHudEnabled = prefs.getBoolean(K_HUD, true),
trackpadMode = prefs.getBoolean(K_TRACKPAD, true), touchMode = prefs.getString(K_TOUCH_MODE, null)
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
) )
fun save(s: Settings) { fun save(s: Settings) {
@@ -73,7 +82,7 @@ class SettingsStore(context: Context) {
.putString(K_CODEC, s.codec) .putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled) .putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_HUD, s.statsHudEnabled) .putBoolean(K_HUD, s.statsHudEnabled)
.putBoolean(K_TRACKPAD, s.trackpadMode) .putString(K_TOUCH_MODE, s.touchMode.name)
.apply() .apply()
} }
@@ -89,6 +98,9 @@ class SettingsStore(context: Context) {
const val K_CODEC = "codec" const val K_CODEC = "codec"
const val K_MIC = "mic_enabled" const val K_MIC = "mic_enabled"
const val K_HUD = "stats_hud_enabled" const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode" const val K_TRACKPAD = "trackpad_mode"
} }
} }
@@ -195,6 +207,13 @@ val COMPOSITOR_OPTIONS = listOf(
"gamescope", "gamescope",
) )
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
TouchMode.POINTER to "Direct pointer",
TouchMode.TOUCH to "Touch passthrough",
)
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */ /** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
val GAMEPAD_OPTIONS = listOf( val GAMEPAD_OPTIONS = listOf(
"Automatic", "Automatic",
@@ -165,13 +165,21 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
) )
} }
SettingsGroup("Pointer") { SettingsGroup("Touch input") {
ToggleRow( SettingDropdown(
title = "Trackpad mode", label = "Touch input",
subtitle = "Relative cursor like a laptop touchpad — swipe to nudge, tap to click. " + options = TOUCH_MODE_OPTIONS,
"Off = the cursor jumps to your finger.", selected = s.touchMode,
checked = s.trackpadMode, onSelect = { mode -> update(s.copy(touchMode = mode)) },
onCheckedChange = { on -> update(s.copy(trackpadMode = on)) }, )
Text(
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger " +
"tap right-clicks, two fingers scroll, tap-then-drag holds the button. " +
"Direct pointer: the cursor jumps to your finger. Touch passthrough: real " +
"multi-touch reaches the host, for apps that understand touch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
) )
} }
@@ -15,11 +15,16 @@ import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
* The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from * The live stats overlay — the unified HUD (`design/stats-unification.md`, Android v1: headline is
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 18-double layout from
* [NativeBridge.nativeVideoStats]: * [NativeBridge.nativeVideoStats]:
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped, bitDepth, colorPrimaries, * `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc]`. The trailing four (present on a current native lib) describe the * colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms]`. Indexes 1013
* negotiated video feed and render as a codec/depth/colour/chroma line; older layouts just omit it. * (present on a current native lib) describe the negotiated video feed and render as a
* codec/depth/colour/chroma line; 14/15 render as the stage equation — split into
* `host + network + decode` when the Phase-2 terms at 16/17 are nonzero (a current host sends
* per-AU 0xCF timings; an old host leaves them 0 and the combined `host+network` term stands);
* older layouts just omit those lines.
*/ */
@Composable @Composable
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) { internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
@@ -29,7 +34,7 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
val hz = s[8].toInt() val hz = s[8].toInt()
val latValid = s[4] != 0.0 val latValid = s[4] != 0.0
val skew = s[5] != 0.0 val skew = s[5] != 0.0
val dropped = s[9].toLong() val lost = s[9].toLong()
Column( Column(
modifier = modifier modifier = modifier
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
@@ -50,17 +55,33 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
) )
} }
if (latValid) { if (latValid) {
val tag = if (skew) "" else " (same-host)" val tag = if (skew) "" else " (same-host clock)"
Text( Text(
"capture→client ${"%.1f".format(s[2])}/${"%.1f".format(s[3])} ms p50/p95$tag", "end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag",
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
if (s.size >= 16) {
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
// reported its share this window; otherwise the combined term (old host / no
// matched 0xCF timing).
val equation = if (s.size >= 18 && s[16] > 0) {
"= host ${"%.1f".format(s[16])} + network ${"%.1f".format(s[17])} + decode ${"%.1f".format(s[15])}"
} else {
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
}
Text(
equation,
color = Color.White, color = Color.White,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 12.sp, fontSize = 12.sp,
) )
} }
if (dropped > 0) { }
if (lost > 0) {
Text( Text(
"dropped $dropped", "lost $lost",
color = Color(0xFFFFB0B0), color = Color(0xFFFFB0B0),
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 12.sp, fontSize = 12.sp,
@@ -57,7 +57,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
var stats by remember { mutableStateOf<DoubleArray?>(null) } var stats by remember { mutableStateOf<DoubleArray?>(null) }
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) } var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes). // Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val trackpad = initialSettings.trackpadMode val touchMode = initialSettings.touchMode
LaunchedEffect(handle, showStats) { LaunchedEffect(handle, showStats) {
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats) NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
if (showStats) { if (showStats) {
@@ -148,11 +148,18 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
if (showStats) { if (showStats) {
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) } stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
} }
// Touch → mouse (trackpad vs. direct pointing + the shared gesture vocabulary — see // Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
// streamTouchInput in TouchInput.kt). // vocabulary) or real multi-touch passthrough — see TouchInput.kt.
Box( Box(
Modifier.fillMaxSize().pointerInput(handle, trackpad) { Modifier.fillMaxSize().pointerInput(handle, touchMode) {
streamTouchInput(handle, trackpad, onToggleStats = { showStats = !showStats }) when (touchMode) {
TouchMode.TOUCH -> streamTouchPassthrough(handle)
else -> streamTouchInput(
handle,
trackpad = touchMode == TouchMode.TRACKPAD,
onToggleStats = { showStats = !showStats },
)
}
}, },
) )
} }
@@ -2,7 +2,11 @@ package io.unom.punktfunk
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.positionChanged
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.hypot import kotlin.math.hypot
@@ -38,6 +42,54 @@ private const val ACCEL_MAX = 3.0f
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving * two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
* windows); three-finger tap = [onToggleStats] (the stats HUD). * windows); three-finger tap = [onToggleStats] (the stats HUD).
*/ */
/**
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
* contact (down/move/up with a stable per-finger id), with NO gesture interpretation — taps,
* drags and multi-finger input mean whatever the remote app decides. Coordinates are overlay
* pixels with the overlay size as the surface, exactly like the absolute-mouse path (the host
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
* contact is lifted so nothing stays stuck on the host.
*/
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
val ids = mutableMapOf<PointerId, Int>()
fun alloc(p: PointerId): Int {
var id = 0
while (ids.containsValue(id)) id++
ids[p] = id
return id
}
try {
awaitPointerEventScope {
while (true) {
val ev = awaitPointerEvent()
val sw = size.width
val sh = size.height
if (sw <= 0 || sh <= 0) continue
for (c in ev.changes) {
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
when {
c.changedToDownIgnoreConsumed() ->
NativeBridge.nativeSendTouch(handle, alloc(c.id), 0, x, y, sw, sh)
c.changedToUpIgnoreConsumed() ->
ids.remove(c.id)?.let {
NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, sw, sh)
}
c.positionChanged() ->
ids[c.id]?.let {
NativeBridge.nativeSendTouch(handle, it, 1, x, y, sw, sh)
}
}
c.consume()
}
}
}
} finally {
// Lift anything still down (composition/session teardown mid-touch).
ids.values.forEach { NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, 1, 1) }
}
}
internal suspend fun PointerInputScope.streamTouchInput( internal suspend fun PointerInputScope.streamTouchInput(
handle: Long, handle: Long,
trackpad: Boolean, trackpad: Boolean,
@@ -27,6 +27,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.unom.punktfunk.BrandDark import io.unom.punktfunk.BrandDark
import io.unom.punktfunk.Settings import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsScreen import io.unom.punktfunk.SettingsScreen
import io.unom.punktfunk.StatsOverlay import io.unom.punktfunk.StatsOverlay
import io.unom.punktfunk.components.HostCard import io.unom.punktfunk.components.HostCard
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
gamepad = 2, gamepad = 2,
micEnabled = true, micEnabled = true,
statsHudEnabled = true, statsHudEnabled = true,
trackpadMode = true, touchMode = TouchMode.TRACKPAD,
), ),
onChange = {}, onChange = {},
onBack = {}, onBack = {},
+19 -2
View File
@@ -37,13 +37,30 @@ def call(method, url, token=None, data=None, content_type=None, want_json=True):
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
if content_type: if content_type:
headers["Content-Type"] = content_type headers["Content-Type"] = content_type
# Transient-fault retries: googleapis.com occasionally drops the TLS session ("EOF
# occurred in violation of protocol" — failed two release uploads on 2026-07-02) or
# answers 5xx. Retry those with backoff; 4xx raises immediately (a real API error).
# The edits API is transactional until commit, so re-sending any of these is safe.
last = None
for attempt in range(4):
if attempt:
delay = 3**attempt
print(f"transient Play API failure ({last}); retry {attempt}/3 in {delay}s")
time.sleep(delay)
req = urllib.request.Request(url, data=data, method=method, headers=headers) req = urllib.request.Request(url, data=data, method=method, headers=headers)
try: try:
with urllib.request.urlopen(req, timeout=300) as r: with urllib.request.urlopen(req, timeout=300) as r:
body = r.read() body = r.read()
except urllib.error.HTTPError as e:
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
return json.loads(body) if (want_json and body) else body return json.loads(body) if (want_json and body) else body
except urllib.error.HTTPError as e:
if e.code >= 500:
last = f"HTTP {e.code}"
continue
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
except urllib.error.URLError as e:
last = str(getattr(e, "reason", e))
continue
sys.exit(f"ERROR: {method} {url} still failing after retries: {last}")
def load_sa(): def load_sa():
@@ -105,12 +105,17 @@ object NativeBridge {
/** /**
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs. * Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
* Returns 14 doubles: * Returns 18 doubles (unified stats spec, `design/stats-unification.md`):
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped, * `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]` * bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
* (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — bit depth * netP50Ms]`
* 8/10, CICP primaries/transfer, and the HEVC chroma_format_idc 1=4:2:0 / 3=4:4:4). Poll ~1 Hz; * (the two flags are 1.0/0.0; indexes 2/3 are the end-to-end capture→decoded headline; 1013
* each call resets the measurement window. * 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 —
* `host+network` = capture→received, `decode` = received→decoded; 16/17 split the
* `host+network` term via the host's per-AU 0xCF timings — `host` = the host's capture→sent,
* `network` = the remainder — both 0.0 when no timing matched this window, i.e. an old host).
* Poll ~1 Hz; each call resets the measurement window.
*/ */
external fun nativeVideoStats(handle: Long): DoubleArray? external fun nativeVideoStats(handle: Long): DoubleArray?
@@ -159,6 +164,22 @@ object NativeBridge {
/** One scroll step. axis: 0=vertical 1=horizontal. delta: signed, 120-scaled, +=up/right. */ /** One scroll step. axis: 0=vertical 1=horizontal. delta: signed, 120-scaled, +=up/right. */
external fun nativeSendScroll(handle: Long, axis: Int, delta: Int) external fun nativeSendScroll(handle: Long, axis: Int, delta: Int)
/**
* One REAL touchscreen transition (the touch-passthrough input mode). [kind]: 0=down 1=move
* 2=up. [id] distinguishes fingers and is reusable after up; coordinates are pixels on the
* client's touch surface — the host rescales against [surfaceWidth]×[surfaceHeight] and
* injects a real touch contact. On up only [id] matters.
*/
external fun nativeSendTouch(
handle: Long,
id: Int,
kind: Int,
x: Int,
y: Int,
surfaceWidth: Int,
surfaceHeight: Int,
)
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */ /** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int) external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
+137
View File
@@ -0,0 +1,137 @@
//! Android Adaptive Performance Framework (ADPF) — CPU performance hints for the decode thread.
//!
//! ADPF lets a latency-critical app tell the platform "these threads run a repeating workload with
//! this per-cycle deadline, and here's how long they *actually* took." The kernel's CPU governor
//! (on Qualcomm Snapdragon in particular — its ADPF backend is among the most responsive) then keeps
//! those threads on the fast cores at high clocks instead of migrating them to a little core or
//! down-clocking between frames. For a stream client the win is on the in-process hot path we
//! control — the `pf-decode` feed/drain/present loop — *not* the hardware codec itself (that decodes
//! in the mediacodec service, a separate process we can't hint); keeping our loop from being
//! scheduled late directly trims the jitter between "AU received" and "buffer released to the
//! Surface." It complements the codec-side `operating-rate`/`priority` hints, which push the codec's
//! own clocks.
//!
//! The `APerformanceHint_*` API arrived in NDK **API level 33**. minSdk is 31, so we CANNOT link the
//! symbols directly: a `libpunktfunk_android.so` carrying an unresolved
//! `APerformanceHint_createSession` import fails to load on API 31/32 devices
//! (`System.loadLibrary` throws) even if the code path is never taken. Instead we resolve the
//! entry points from `libandroid.so` with `dlsym` at runtime — absent on < 33 ⇒
//! [`HintSession::create`] returns `None` and the decode loop simply runs without hints.
use std::ffi::c_void;
use std::os::raw::c_int;
// `APerformanceHint_*` function-pointer types. The manager/session handles are opaque, so we treat
// them as `*mut c_void`.
type GetManagerFn = unsafe extern "C" fn() -> *mut c_void;
type CreateSessionFn = unsafe extern "C" fn(*mut c_void, *const i32, usize, i64) -> *mut c_void;
type ReportFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
type UpdateTargetFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
type CloseFn = unsafe extern "C" fn(*mut c_void);
/// The entry points we use, resolved once from `libandroid.so`, plus the process-wide manager.
struct Api {
create_session: CreateSessionFn,
report: ReportFn,
update_target: UpdateTargetFn,
close: CloseFn,
manager: *mut c_void,
}
/// Resolve the ADPF entry points + the process manager, or `None` on API < 33 (symbols absent) or if
/// the manager is unavailable.
fn resolve_api() -> Option<Api> {
// SAFETY: `dlopen` of an always-present system library with a NUL-terminated name; it returns
// null on failure (checked below). `libandroid.so` is already mapped into every app process, so
// this only bumps its refcount — we intentionally never `dlclose` (process-lifetime handle).
let lib = unsafe { libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW) };
if lib.is_null() {
return None;
}
// SAFETY: `dlsym` on the valid handle above with NUL-terminated symbol names; each returns null
// when the symbol is absent (device API < 33), which we check before transmuting the non-null
// pointer to its fn-pointer type (layout-compatible; a resolved symbol is a valid code address).
unsafe {
let get_manager = libc::dlsym(lib, c"APerformanceHint_getManager".as_ptr());
let create_session = libc::dlsym(lib, c"APerformanceHint_createSession".as_ptr());
let report = libc::dlsym(lib, c"APerformanceHint_reportActualWorkDuration".as_ptr());
let update_target = libc::dlsym(lib, c"APerformanceHint_updateTargetWorkDuration".as_ptr());
let close = libc::dlsym(lib, c"APerformanceHint_closeSession".as_ptr());
if get_manager.is_null()
|| create_session.is_null()
|| report.is_null()
|| update_target.is_null()
|| close.is_null()
{
return None; // device API < 33 — no ADPF
}
let get_manager = std::mem::transmute::<*mut c_void, GetManagerFn>(get_manager);
let manager = get_manager();
if manager.is_null() {
return None;
}
Some(Api {
create_session: std::mem::transmute::<*mut c_void, CreateSessionFn>(create_session),
report: std::mem::transmute::<*mut c_void, ReportFn>(report),
update_target: std::mem::transmute::<*mut c_void, UpdateTargetFn>(update_target),
close: std::mem::transmute::<*mut c_void, CloseFn>(close),
manager,
})
}
}
/// A live ADPF hint session bound to a set of thread ids. Dropping it closes the session. Holds raw
/// handles, so it is `!Send`/`!Sync` — created and used only on the `pf-decode` thread.
pub struct HintSession {
api: Api,
session: *mut c_void,
}
impl HintSession {
/// Open a session hinting `tids` with an initial per-frame target of `target_ns` nanoseconds.
/// `None` when ADPF is unavailable (device API < 33) or the platform declines — the caller then
/// runs unhinted (a no-op, not an error).
pub fn create(target_ns: i64, tids: &[i32]) -> Option<Self> {
if target_ns <= 0 || tids.is_empty() {
return None;
}
let api = resolve_api()?;
// SAFETY: `api.manager` is the live process manager returned above; `tids` is a valid slice
// of `len` i32s that `createSession` copies; it returns null on failure (checked).
let session =
unsafe { (api.create_session)(api.manager, tids.as_ptr(), tids.len(), target_ns) };
if session.is_null() {
return None;
}
Some(Self { api, session })
}
/// Report the wall-clock time the hinted thread spent producing the last displayed frame. When
/// it exceeds the session target the governor boosts the cores running the thread; when it
/// stays under, clocks may relax. No-op on a non-positive duration (the API rejects it).
pub fn report_actual(&self, actual_ns: i64) {
if actual_ns <= 0 {
return;
}
// SAFETY: `self.session` is a live session for `self`'s lifetime.
unsafe { (self.api.report)(self.session, actual_ns) };
}
/// Update the per-frame target (e.g. after a mid-session refresh-rate change). Unused today —
/// the decode thread restarts on renegotiation — but kept for that path.
#[allow(dead_code)]
pub fn update_target(&self, target_ns: i64) {
if target_ns <= 0 {
return;
}
// SAFETY: `self.session` is a live session for `self`'s lifetime.
unsafe { (self.api.update_target)(self.session, target_ns) };
}
}
impl Drop for HintSession {
fn drop(&mut self) {
// SAFETY: `self.session` was created by `createSession` and is closed exactly once, here.
unsafe { (self.api.close)(self.session) };
}
}
+4
View File
@@ -324,6 +324,10 @@ fn decode_loop(
counters: Arc<Counters>, counters: Arc<Counters>,
channels: usize, channels: usize,
) { ) {
// Fold this Opus→AAudio thread into the client's hot-thread set so the ADPF session the decode
// thread opens also keeps audio decode on a fast core (registered before the video pump's first
// frame arrives, so it's captured when that session is created). No-op below API 33.
client.register_hot_thread();
// Interleaved f32 samples per millisecond at this layout — the ring's 5 ms reserve check below. // Interleaved f32 samples per millisecond at this layout — the ring's 5 ms reserve check below.
let ms = (SAMPLE_RATE as usize / 1000) * channels; let ms = (SAMPLE_RATE as usize / 1000) * channels;
// Opus decode scratch: worst-case 120 ms frame (5760 samples/ch) × channels. // Opus decode scratch: worst-case 120 ms frame (5760 samples/ch) × channels.
+168 -9
View File
@@ -9,16 +9,27 @@
use ndk::data_space::DataSpace; use ndk::data_space::DataSpace;
use ndk::media::media_codec::{ use ndk::media::media_codec::{
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection, DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection,
OutputBuffer,
}; };
use ndk::media::media_format::MediaFormat; use ndk::media::media_format::MediaFormat;
use ndk::native_window::{FrameRateCompatibility, NativeWindow}; use ndk::native_window::{FrameRateCompatibility, NativeWindow};
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::error::PunktfunkError; use punktfunk_core::error::PunktfunkError;
use punktfunk_core::session::Frame; use punktfunk_core::session::Frame;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// Cap on the pts→received-timestamp map below: MediaCodec holds only a handful of frames in
/// flight, so anything beyond this is stale (codec flushed / HUD toggled) and gets evicted.
const IN_FLIGHT_CAP: usize = 64;
/// Cap on received AUs awaiting their 0xCF host timing (Phase 2 host/network split): the timing
/// datagram trails its AU by at most the wire, so a match lands within a frame or two — anything
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
const PENDING_SPLIT_CAP: usize = 256;
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes. /// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes.
pub fn run( pub fn run(
client: Arc<NativeClient>, client: Arc<NativeClient>,
@@ -61,7 +72,14 @@ pub fn run(
// realtime priority + the target frame rate, so vendor decoders (e.g. Qualcomm) run at full // realtime priority + the target frame rate, so vendor decoders (e.g. Qualcomm) run at full
// clocks instead of a power-saving cadence that adds dequeue latency. // clocks instead of a power-saving cadence that adds dequeue latency.
format.set_i32("priority", 0); // 0 = realtime format.set_i32("priority", 0); // 0 = realtime
format.set_i32("operating-rate", mode.refresh_hz as i32); // Operating rate = the codec's clock hint. Setting it to the display rate merely asks the
// decoder to *sustain* that cadence — a Qualcomm decoder can meet 60/120 fps at a power-saving
// clock that adds a millisecond-plus of decode latency per frame. Setting it to the AOSP
// "unbounded" sentinel (Short.MAX) instead asks the decoder to run each frame at max clocks and
// finish ASAP, minimising per-frame decode latency — the right trade for a real-time stream
// (costs power/heat; the dial to lower if a device thermally throttles over a long session).
// Ignored where unsupported.
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was // HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade. // negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
@@ -104,6 +122,25 @@ pub fn run(
); );
} }
// ADPF: hint the platform that the whole video pipeline — this pf-decode feed/drain/present
// loop, the core's data-plane pump (UDP receive + FEC reassembly), and the audio thread — runs a
// per-frame real-time workload, so the CPU governor keeps those threads on fast cores at high
// clocks instead of down-clocking between frames or parking them on a little core. Snapdragon's
// ADPF backend responds well to this. We register this thread now but create the session lazily
// on the first presented frame: by then the pump + audio threads have registered their ids too,
// and ADPF `createSession` rejects a set with any not-yet-live/dead tid. No-op below API 33.
let frame_period_ns = if mode.refresh_hz > 0 {
1_000_000_000i64 / mode.refresh_hz as i64
} else {
0
};
client.register_hot_thread(); // this decode thread → the pipeline's hot-thread set
let mut hint: Option<crate::adpf::HintSession> = None;
let mut hint_tried = false;
// Accumulates the loop's productive (feed+drain) time between displayed frames; reported to ADPF
// once per rendered frame against the frame-period target.
let mut work_accum_ns: i64 = 0;
let mut fed: u64 = 0; let mut fed: u64 = 0;
let mut rendered: u64 = 0; let mut rendered: u64 = 0;
let mut discarded: u64 = 0; let mut discarded: u64 = 0;
@@ -115,9 +152,19 @@ pub fn run(
// climbs. // climbs.
let mut last_dropped = client.frames_dropped(); let mut last_dropped = client.frames_dropped();
let mut last_kf_req: Option<Instant> = None; let mut last_kf_req: Option<Instant> = None;
// Capture→client-receipt latency uses the negotiated host-minus-client clock offset (0 if the // Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
// host didn't answer the skew handshake — then the HUD flags it "same-host"). // host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
// HUD flags it "(same-host clock)").
let clock_offset = client.clock_offset_ns; let clock_offset = client.clock_offset_ns;
// HUD stage split: 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 for the `decode` stage. Only fed while the HUD is visible.
let mut in_flight: VecDeque<(u64, i128)> = VecDeque::new();
// Phase-2 host/network split (design/stats-unification.md): received AUs awaiting their 0xCF
// host timing, as (pts_ns, capture→received µs). The timings are drained non-blockingly right
// where receipts are recorded and matched by pts; `network = hostnet host` (saturating).
// Only fed while the HUD is visible; an old host never sends a 0xCF, so entries just age out.
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
// The dataspace we've signalled on the Surface so far (None = default/SDR). Set reactively once // The dataspace we've signalled on the Surface so far (None = default/SDR). Set reactively once
// the decoder reports an HDR stream (see `drain`); avoids re-applying every format event. // the decoder reports an HDR stream (see `drain`); avoids re-applying every format event.
let mut applied_ds: Option<DataSpace> = None; let mut applied_ds: Option<DataSpace> = None;
@@ -138,15 +185,41 @@ pub fn run(
&p[..p.len().min(6)] &p[..p.len().min(6)]
); );
} }
// HUD stat: capture→client-receipt latency = client_now + (hostclient) // HUD stat, `received` point: host+network = client_now + (hostclient)
// capture_pts. Gated on the HUD being visible — `enabled` first so the hidden // capture_pts. Gated on the HUD being visible — `enabled` first so the hidden
// steady state skips the wall-clock read and the lock entirely. // steady state skips the wall-clock read and the lock entirely. The receipt
// stamp is also parked in `in_flight` (keyed by the pts the codec will echo on
// the output buffer) for the decoded-point pairing in `drain`.
if stats.enabled() { if stats.enabled() {
let lat_ns = let received_ns = now_realtime_ns();
now_realtime_ns() + clock_offset as i128 - frame.pts_ns as i128; let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000) let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
.then_some((lat_ns / 1000) as u64); .then_some((lat_ns / 1000) as u64);
stats.note(frame.data.len(), lat_us, clock_offset != 0); stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
in_flight.push_back((frame.pts_ns / 1000, received_ns));
if in_flight.len() > IN_FLIGHT_CAP {
in_flight.pop_front(); // stale — codec never echoed it back
}
// Phase-2 split: park this AU's capture→received sample, then match any
// 0xCF host timings that have arrived — host = the host's own
// capture→sent, network = our capture→received minus it (per-frame
// tiling; saturating in case of clock jitter).
if let Some(hostnet_us) = lat_us {
pending_split.push_back((frame.pts_ns, hostnet_us));
if pending_split.len() > PENDING_SPLIT_CAP {
pending_split.pop_front(); // 0xCF lost / old host — evict
}
}
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
{
let (_, hostnet_us) = pending_split.remove(i).unwrap();
stats.note_host_split(
t.host_us as u64,
hostnet_us.saturating_sub(t.host_us as u64),
);
}
}
} }
pending = Some(frame); pending = Some(frame);
} }
@@ -154,6 +227,9 @@ pub fn run(
Err(_) => break, // session closed Err(_) => break, // session closed
} }
} }
// Time the productive work (feed + drain) only — the `next_frame` poll wait above is idle
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
let work_t0 = Instant::now();
if let Some(frame) = pending.take() { if let Some(frame) = pending.take() {
if feed(&codec, &frame.data, frame.pts_ns / 1000) { if feed(&codec, &frame.data, frame.pts_ns / 1000) {
fed += 1; fed += 1;
@@ -173,10 +249,48 @@ pub fn run(
} else { } else {
Duration::ZERO Duration::ZERO
}; };
let (r, d) = drain(&codec, &window, &mut applied_ds, wait); let (r, d) = drain(
&codec,
&window,
&mut applied_ds,
wait,
&stats,
&mut in_flight,
clock_offset,
);
rendered += r; rendered += r;
discarded += d; discarded += d;
// ADPF: attribute this iteration's feed+drain time to the frame being produced, and report
// the accumulated per-frame work once one is actually presented (r > 0). Under back-pressure
// the short output-dequeue wait is included in the tally — for a latency-first client,
// biasing the governor toward "boost" is the desired behaviour. Cheap when `hint` is None
// (one `Instant` diff, no report).
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
if r > 0 {
if !hint_tried {
// First presented frame: the pump + audio threads have registered their ids by now.
// Build one ADPF session over the whole pipeline's thread set (empty below API 33,
// or where the platform declines → `None`, and the loop runs unhinted).
hint_tried = true;
let tids = client.hot_thread_ids();
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
log::info!(
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
if hint.is_some() {
"active"
} else {
"unavailable"
},
tids.len(),
);
}
if let Some(h) = &hint {
h.report_actual(work_accum_ns);
}
work_accum_ns = 0;
}
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The // Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the // reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
// reference-missing delta frames that follow and renders them without error, so keying off // reference-missing delta frames that follow and renders them without error, so keying off
@@ -271,11 +385,19 @@ fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) -> bool {
/// the caller's input is blocked so the loop waits on decoder progress instead of busy-spinning. /// the caller's input is blocked so the loop waits on decoder progress instead of busy-spinning.
/// Returns `(rendered, discarded)`. Also reacts to `OutputFormatChanged` (which can interleave /// Returns `(rendered, discarded)`. Also reacts to `OutputFormatChanged` (which can interleave
/// between buffers — handled without losing the held buffer) to signal HDR on the Surface. /// between buffers — handled without losing the held buffer) to signal HDR on the Surface.
///
/// Each dequeued buffer is also the HUD's `decoded` measurement point (rendered or not — the frame
/// finished decoding either way): end-to-end = decoded + clock_offset capture pts, and the
/// `decode` stage pairs the buffer's echoed presentationTimeUs back to the receipt stamp in
/// `in_flight` (single-clock local difference, no skew involved).
fn drain( fn drain(
codec: &MediaCodec, codec: &MediaCodec,
window: &NativeWindow, window: &NativeWindow,
applied_ds: &mut Option<DataSpace>, applied_ds: &mut Option<DataSpace>,
first_wait: Duration, first_wait: Duration,
stats: &crate::stats::VideoStats,
in_flight: &mut VecDeque<(u64, i128)>,
clock_offset: i64,
) -> (u64, u64) { ) -> (u64, u64) {
let mut held = None; // newest ready buffer so far, presented after the loop let mut held = None; // newest ready buffer so far, presented after the loop
let mut discarded: u64 = 0; let mut discarded: u64 = 0;
@@ -284,6 +406,9 @@ fn drain(
match codec.dequeue_output_buffer(wait) { match codec.dequeue_output_buffer(wait) {
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => { Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
wait = Duration::ZERO; // only the first dequeue may block wait = Duration::ZERO; // only the first dequeue may block
if stats.enabled() {
note_decoded(stats, in_flight, clock_offset, &buf);
}
if let Some(stale) = held.replace(buf) { if let Some(stale) = held.replace(buf) {
// A newer frame is ready — drop the held one without rendering. // A newer frame is ready — drop the held one without rendering.
if let Err(e) = codec.release_output_buffer(stale, false) { if let Err(e) = codec.release_output_buffer(stale, false) {
@@ -333,6 +458,40 @@ fn drain(
(rendered, discarded) (rendered, discarded)
} }
/// HUD `decoded` point for one dequeued output buffer: build the end-to-end (capture→decoded,
/// skew-corrected, clamped to (0, 10 s)) and `decode` (received→decoded, single-clock local, ≥ 0)
/// samples and hand them to [`crate::stats::VideoStats::note_decoded`]. The codec echoes the input
/// `presentationTimeUs` on the output buffer, which keys the receipt stamp in `in_flight`; entries
/// older than the echoed pts 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).
fn note_decoded(
stats: &crate::stats::VideoStats,
in_flight: &mut VecDeque<(u64, i128)>,
clock_offset: i64,
buf: &OutputBuffer<'_>,
) {
let pts_us = buf.info().presentation_time_us().max(0) as u64;
let decoded_ns = now_realtime_ns();
// 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() {
if p > pts_us {
break; // future frame — leave it for its own output buffer
}
in_flight.pop_front();
if p == pts_us {
received_ns = Some(r);
break;
}
}
// pts_us is the truncated frame.pts_ns/1000 we queued, so ×1000 re-approximates capture time
// to < 1 µs — negligible against the ms-scale figures shown.
let e2e_ns = decoded_ns + clock_offset 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 decode_us = received_ns.map(|r| ((decoded_ns - r).max(0) / 1000) as u64);
stats.note_decoded(e2e_us, decode_us);
}
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The /// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
/// integer values are the Android MediaFormat colour constants the NDK shares: COLOR_TRANSFER /// integer values are the Android MediaFormat colour constants the NDK shares: COLOR_TRANSFER
/// ST2084 = 6 (PQ/HDR10), HLG = 7; COLOR_RANGE FULL = 1, LIMITED = 2 (the host encodes limited). /// ST2084 = 6 (PQ/HDR10), HLG = 7; COLOR_RANGE FULL = 1, LIMITED = 2 (the host encodes limited).
+2
View File
@@ -25,6 +25,8 @@ use jni::objects::JObject;
use jni::sys::jint; use jni::sys::jint;
use jni::JNIEnv; use jni::JNIEnv;
#[cfg(target_os = "android")]
mod adpf;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod audio; mod audio;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
@@ -93,6 +93,34 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
send_event(handle, InputKind::MouseScroll, axis as u32, delta, 0, 0); send_event(handle, InputKind::MouseScroll, axis as u32, delta, 0, 0);
} }
/// `NativeBridge.nativeSendTouch(handle, id, kind, x, y, surfaceWidth, surfaceHeight)` — one REAL
/// touchscreen transition (`kind`: 0=down 1=move 2=up), for the touch-passthrough input mode. `id`
/// distinguishes fingers (reusable after up); coordinates are pixels on the client's touch
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
/// (libei touchscreen / wlroots / SendInput).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
_env: JNIEnv,
_this: JObject,
handle: jlong,
id: jint,
kind: jint,
x: jint,
y: jint,
surface_width: jint,
surface_height: jint,
) {
let kind = match kind {
0 => InputKind::TouchDown,
1 => InputKind::TouchMove,
_ => InputKind::TouchUp,
};
let w = (surface_width.max(0) as u32) & 0xffff;
let h = (surface_height.max(0) as u32) & 0xffff;
send_event(handle, kind, id as u32, x, y, (w << 16) | h);
}
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows /// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier /// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves). /// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
+23 -11
View File
@@ -72,14 +72,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
}) })
} }
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD. /// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
/// Returns 14 doubles /// (unified stats spec, `design/stats-unification.md`). Returns 18 doubles
/// `[fps, mbps, latP50Ms, latP95Ms, latValid, skewCorrected, width, height, refreshHz, framesDropped, /// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]` /// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
/// (the two flags are 1.0/0.0; the trailing four describe the negotiated video feed — see below), or /// netP50Ms]`
/// `null` when no decode thread is running. Poll ~1 Hz from the UI; each call resets the measurement /// (the two flags are 1.0/0.0; indexes 015 match the previous 16-double layout — 013 the original
/// window. Not android-gated — pure `jni` + connector reads, so it links on the host build too /// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
/// (Kotlin only ever calls it on device). /// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
/// window, i.e. an old host), 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).
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
env: JNIEnv, env: JNIEnv,
@@ -98,11 +103,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
let snap = h.stats.drain(); let snap = h.stats.drain();
let mode = h.client.mode(); let mode = h.client.mode();
let color = h.client.color; let color = h.client.color;
let buf: [f64; 14] = [ let buf: [f64; 18] = [
snap.fps, snap.fps,
snap.mbps, snap.mbps,
snap.lat_p50_ms, snap.e2e_p50_ms,
snap.lat_p95_ms, snap.e2e_p95_ms,
if snap.lat_valid { 1.0 } else { 0.0 }, if snap.lat_valid { 1.0 } else { 0.0 },
if snap.skew_corrected { 1.0 } else { 0.0 }, if snap.skew_corrected { 1.0 } else { 0.0 },
mode.width as f64, mode.width as f64,
@@ -117,6 +122,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
color.primaries as f64, color.primaries as f64,
color.transfer as f64, color.transfer as f64,
h.client.chroma_format as f64, h.client.chroma_format as f64,
// Stage p50s tiling the end-to-end headline (appended to keep 013 index-compatible).
snap.hostnet_p50_ms,
snap.decode_p50_ms,
// Phase-2 host/network split of the `host+network` stage (0xCF host timings): 0.0
// when no timing matched this window (old host) — the HUD keeps the combined term.
snap.host_p50_ms,
snap.net_p50_ms,
]; ];
let arr = match env.new_double_array(buf.len() as jsize) { let arr = match env.new_double_array(buf.len() as jsize) {
Ok(a) => a, Ok(a) => a,
+130 -40
View File
@@ -1,8 +1,13 @@
//! Live decode stats for the on-stream HUD (mirrors the Apple client's stats overlay): FPS, //! Live decode stats for the on-stream HUD, following the unified stats spec
//! receive throughput, and capture→client-receipt latency (p50/p95). The decode thread is the sole //! (`design/stats-unification.md`): FPS, receive throughput, and the Android v1 stage split —
//! writer (`note` per access unit); the JNI accessor `nativeVideoStats` drains a snapshot ~1 Hz and //! headline `end-to-end` = capture→decoded (p50/p95) tiled by `host+network` = capture→received
//! resets the window. Sampling is gated on the HUD actually being visible (`set_enabled`, driven by //! and `decode` = received→decoded (stage p50s). When the host emits per-AU 0xCF host timings, the
//! `nativeSetVideoStatsEnabled`) so the hidden steady state costs one relaxed atomic load per frame. //! `host+network` term further splits into `host` + `network` (Phase 2, `note_host_split`); an old
//! host emits none and the combined term stands. The decode thread is the sole writer
//! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI
//! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
//! hidden steady state costs one relaxed atomic load per frame.
//! Pure `std` so it compiles on the host build too (the decode thread is android-only, but //! Pure `std` so it compiles on the host build too (the decode thread is android-only, but
//! `SessionHandle` holds the shared handle unconditionally). //! `SessionHandle` holds the shared handle unconditionally).
@@ -13,9 +18,9 @@ use std::time::Instant;
/// Rolling per-window accumulator. Rates are computed over the actual elapsed wall-time at drain /// Rolling per-window accumulator. Rates are computed over the actual elapsed wall-time at drain
/// (robust to poll jitter), so a poll that lands at 0.9 s or 1.1 s still reports the right FPS. /// (robust to poll jitter), so a poll that lands at 0.9 s or 1.1 s still reports the right FPS.
pub struct VideoStats { pub struct VideoStats {
/// HUD gate: `note` runs on the per-frame decode path, so while the overlay is hidden it (and /// HUD gate: the samplers run on the per-frame decode path, so while the overlay is hidden
/// the caller's latency computation — see `enabled`) early-outs on this flag alone. Off until /// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
/// Kotlin shows the HUD. /// Off until Kotlin shows the HUD.
enabled: AtomicBool, enabled: AtomicBool,
inner: Mutex<Inner>, inner: Mutex<Inner>,
} }
@@ -24,23 +29,52 @@ struct Inner {
window_start: Instant, window_start: Instant,
frames: u64, frames: u64,
bytes: u64, bytes: u64,
/// capture→client-receipt latency samples for this window, in microseconds. /// `end-to-end` = capture→decoded latency samples for this window, in microseconds
lat_us: Vec<u64>, /// (skew-corrected clock base).
e2e_us: Vec<u64>,
/// `host+network` stage = capture→received samples, in microseconds (skew-corrected).
hostnet_us: Vec<u64>,
/// Phase-2 split of `host+network` (design/stats-unification.md Phase 2), fed only when the
/// host emits per-AU 0xCF timings: `host` = the host's own capture→sent duration, µs.
host_us: Vec<u64>,
/// The matching `network` term, µs: capture→received minus the host's capture→sent
/// (wire + reassembly). Always pushed in lockstep with `host_us`.
net_us: Vec<u64>,
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
decode_us: Vec<u64>,
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid). /// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
skew_corrected: bool, skew_corrected: bool,
} }
/// A drained, computed view of one window. `lat_valid` is false when no in-range latency sample /// A drained, computed view of one window. `lat_valid` is false when no in-range end-to-end sample
/// landed (then p50/p95 are 0 and the HUD hides the latency line, exactly like the Apple client). /// landed (then the latency figures are 0 and the HUD hides the latency lines, exactly like the
/// Apple client).
pub struct Snapshot { pub struct Snapshot {
pub fps: f64, pub fps: f64,
pub mbps: f64, pub mbps: f64,
pub lat_p50_ms: f64, /// Headline `end-to-end` (capture→decoded) percentiles, ms.
pub lat_p95_ms: f64, pub e2e_p50_ms: f64,
pub e2e_p95_ms: f64,
/// Stage p50s (ms): `host+network` (capture→received) and `decode` (received→decoded).
pub hostnet_p50_ms: f64,
pub decode_p50_ms: f64,
/// 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,
pub net_p50_ms: f64,
pub lat_valid: bool, pub lat_valid: bool,
pub skew_corrected: bool, pub skew_corrected: bool,
} }
/// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty.
fn pctl_ms(sorted_us: &[u64], p: f64) -> f64 {
if sorted_us.is_empty() {
return 0.0;
}
let n = sorted_us.len();
sorted_us[((n as f64 * p) as usize).min(n - 1)] as f64 / 1000.0
}
impl VideoStats { impl VideoStats {
pub fn new() -> VideoStats { pub fn new() -> VideoStats {
VideoStats { VideoStats {
@@ -49,14 +83,18 @@ impl VideoStats {
window_start: Instant::now(), window_start: Instant::now(),
frames: 0, frames: 0,
bytes: 0, bytes: 0,
lat_us: Vec::with_capacity(256), e2e_us: Vec::with_capacity(256),
hostnet_us: Vec::with_capacity(256),
host_us: Vec::with_capacity(256),
net_us: Vec::with_capacity(256),
decode_us: Vec::with_capacity(256),
skew_corrected: false, skew_corrected: false,
}), }),
} }
} }
/// Whether the HUD wants samples. The decode thread checks this BEFORE building a latency /// Whether the HUD wants samples. The decode thread checks this BEFORE building a latency
/// sample, so the per-frame wall-clock read is skipped too while hidden. /// sample, so the per-frame wall-clock reads are skipped too while hidden.
// Read only by the android-only decode thread; unreferenced on the host build — expected. // Read only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))] #[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn enabled(&self) -> bool { pub fn enabled(&self) -> bool {
@@ -75,18 +113,23 @@ impl VideoStats {
g.window_start = Instant::now(); g.window_start = Instant::now();
g.frames = 0; g.frames = 0;
g.bytes = 0; g.bytes = 0;
g.lat_us.clear(); g.e2e_us.clear();
g.hostnet_us.clear();
g.host_us.clear();
g.net_us.clear();
g.decode_us.clear();
} }
} }
/// Record one decoded access unit: its wire size and (if in range) its capture→client latency. /// Record one received access unit: its wire size and (if in range) its capture→received
/// `host+network` stage sample. Receipt is the fps/goodput counting point per the spec.
// Driven only by the android-only decode thread; unreferenced on the host build — expected. // Driven only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))] #[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note(&self, bytes: usize, lat_us: Option<u64>, skew_corrected: bool) { pub fn note_received(&self, bytes: usize, hostnet_us: Option<u64>, skew_corrected: bool) {
if !self.enabled.load(Ordering::Relaxed) { if !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock (the caller already skipped the clock read) return; // HUD hidden — skip the lock (the caller already skipped the clock read)
} }
// Poison-proof: `note` runs per-frame on the decode thread, which has no catch_unwind — // Poison-proof: this runs per-frame on the decode thread, which has no catch_unwind —
// a panic elsewhere must not turn every later lock into a second panic (the counters // a panic elsewhere must not turn every later lock into a second panic (the counters
// stay consistent regardless). // stay consistent regardless).
let mut g = self let mut g = self
@@ -96,14 +139,56 @@ impl VideoStats {
g.frames += 1; g.frames += 1;
g.bytes += bytes as u64; g.bytes += bytes as u64;
g.skew_corrected = skew_corrected; g.skew_corrected = skew_corrected;
if let Some(l) = lat_us { if let Some(l) = hostnet_us {
g.lat_us.push(l); g.hostnet_us.push(l);
}
}
/// Record one matched host/network split sample (Phase 2): the host's reported capture→sent
/// duration and our capture→received minus it, both µs — one pair per AU whose 0xCF host
/// timing arrived and matched by pts. An old host emits none, leaving the vecs empty and the
/// snapshot p50s at 0 (HUD keeps the combined `host+network` term).
// 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_host_split(&self, host_us: u64, net_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.host_us.push(host_us);
g.net_us.push(net_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).
// 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_decoded(&self, e2e_us: Option<u64>, decode_us: Option<u64>) {
if !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock (the caller already skipped the clock read)
}
// 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(l) = e2e_us {
g.e2e_us.push(l);
}
if let Some(l) = decode_us {
g.decode_us.push(l);
} }
} }
/// Compute the window's rates + latency percentiles, then reset for the next window. /// Compute the window's rates + latency percentiles, then reset for the next window.
pub fn drain(&self) -> Snapshot { pub fn drain(&self) -> Snapshot {
// Poison-proof for the same reason as `note` — a poisoned window still drains fine. // Poison-proof for the same reason as `note_received` — a poisoned window still drains
// fine.
let mut g = self let mut g = self
.inner .inner
.lock() .lock()
@@ -111,26 +196,31 @@ impl VideoStats {
let elapsed = g.window_start.elapsed().as_secs_f64().max(1e-3); let elapsed = g.window_start.elapsed().as_secs_f64().max(1e-3);
let fps = g.frames as f64 / elapsed; let fps = g.frames as f64 / elapsed;
let mbps = g.bytes as f64 * 8.0 / 1_000_000.0 / elapsed; let mbps = g.bytes as f64 * 8.0 / 1_000_000.0 / elapsed;
let (p50, p95, valid) = if g.lat_us.is_empty() { g.e2e_us.sort_unstable();
(0.0, 0.0, false) g.hostnet_us.sort_unstable();
} else { g.host_us.sort_unstable();
g.lat_us.sort_unstable(); g.net_us.sort_unstable();
let n = g.lat_us.len(); g.decode_us.sort_unstable();
let at = |p: f64| g.lat_us[((n as f64 * p) as usize).min(n - 1)] as f64 / 1000.0; let snap = Snapshot {
(at(0.50), at(0.95), true) fps,
mbps,
e2e_p50_ms: pctl_ms(&g.e2e_us, 0.50),
e2e_p95_ms: pctl_ms(&g.e2e_us, 0.95),
hostnet_p50_ms: pctl_ms(&g.hostnet_us, 0.50),
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
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(),
skew_corrected: g.skew_corrected,
}; };
let skew = g.skew_corrected;
g.window_start = Instant::now(); g.window_start = Instant::now();
g.frames = 0; g.frames = 0;
g.bytes = 0; g.bytes = 0;
g.lat_us.clear(); g.e2e_us.clear();
Snapshot { g.hostnet_us.clear();
fps, g.host_us.clear();
mbps, g.net_us.clear();
lat_p50_ms: p50, g.decode_us.clear();
lat_p95_ms: p95, snap
lat_valid: valid,
skew_corrected: skew,
}
} }
} }
@@ -326,15 +326,21 @@ struct ContentView: View {
onCaptureChange: { [weak model] captured in onCaptureChange: { [weak model] captured in
model?.mouseCaptured = captured model?.mouseCaptured = captured
}, },
onFrame: { [meter = model.meter, latency = model.latency, offset = conn.clockOffsetNs] au in onFrame: { [meter = model.meter, latency = model.latency,
split = model.latencySplit, offset = conn.clockOffsetNs] au in
meter.note(byteCount: au.data.count) meter.note(byteCount: au.data.count)
latency.record(ptsNs: au.ptsNs, offsetNs: offset) latency.record(ptsNs: au.ptsNs, offsetNs: offset)
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
// host/network split drained by the 1 s stats tick).
split.recordReceipt(
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
}, },
onSessionEnd: { [weak model] in onSessionEnd: { [weak model] in
Task { @MainActor in model?.sessionEnded() } Task { @MainActor in model?.sessionEnded() }
}, },
presentMeter: model.presentLatency, endToEndMeter: model.endToEnd,
presentTailMeter: model.presentTail decodeMeter: model.decodeStage,
displayMeter: model.displayStage
) )
.overlay(alignment: placement.alignment) { .overlay(alignment: placement.alignment) {
if captureEnabled && hudEnabled { if captureEnabled && hudEnabled {
@@ -170,7 +170,10 @@ private struct ShotHUD: View {
Text("5120×1440@240 240 fps 812.4 Mb/s") Text("5120×1440@240 240 fps 812.4 Mb/s")
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
} }
Text("capture→client 1.3/2.1 ms p50/p95") Text("end-to-end 2.9 ms p50 · 3.8 p95 · capture→on-glass")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
Text("= host+network 1.3 + decode 0.7 + display 0.9")
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#if os(macOS) #if os(macOS)
@@ -59,36 +59,62 @@ final class SessionModel: ObservableObject {
@Published var fps = 0 @Published var fps = 0
@Published var mbps = 0.0 @Published var mbps = 0.0
@Published var totalFrames = 0 @Published var totalFrames = 0
/// Captureclient-receipt latency (ms), skew-corrected across machines via the connect-time /// The unified latency stages (design/stats-unification.md), ms per 1 s window. `host+network`
/// clock offset p50/p95 for the HUD. `latencyValid` is false until the first sample drains /// = capturereceived, skew-corrected across machines via the connect-time clock offset: the
/// (and whenever no host frames arrived in the last interval). `latencySkewCorrected` = the host /// stage-2 HUD shows its p50 in the equation line; the stage-1 fallback shows p50/p95 as its
/// `capturereceived` headline. `hostNetworkValid` is false until the first sample drains (and
/// whenever no host frames arrived in the last interval). `hostNetworkSkewCorrected` = the host
/// answered the skew handshake (the number is cross-machine valid, not just same-host). /// answered the skew handshake (the number is cross-machine valid, not just same-host).
@Published var latencyP50Ms = 0.0 @Published var hostNetworkP50Ms = 0.0
@Published var latencyP95Ms = 0.0 @Published var hostNetworkP95Ms = 0.0
@Published var latencyValid = false @Published var hostNetworkValid = false
@Published var latencySkewCorrected = false @Published var hostNetworkSkewCorrected = false
/// Capturepresent (glass-to-glass, modulo the host rendercapture term) only the stage-2 /// Phase 2 of the same stage: `host+network` split into its two terms via the host's per-AU
/// presenter can stamp this (it owns decode + a CAMetalLayer/display-link present). Stays /// 0xCF timing reports (host = capturefully-sent as the host measured it, network = the
/// invalid under stage-1, where the layer presents internally with no per-frame callback. /// remainder), matched to receipts by pts in `latencySplit`. `splitValid` is false whenever
@Published var presentLatencyP50Ms = 0.0 /// no timing matched in the window an old host that never emits the plane, or heavy 0xCF
@Published var presentLatencyP95Ms = 0.0 /// loss and the HUD then falls back to the combined `host+network` term.
@Published var presentLatencyValid = false @Published var hostP50Ms = 0.0
@Published var presentLatencySkewCorrected = false @Published var networkP50Ms = 0.0
/// Decode-completionpresent (the "present tail": ring wait + render + vsync) the term the @Published var splitValid = false
/// stage-2 presenter exists to shorten. Both instants are client-side, so no skew applies. /// End-to-end = captureon-glass, measured directly per frame (never summed from the stages)
@Published var presentTailP50Ms = 0.0 /// the HUD headline. Only the stage-2 presenter can stamp it (it owns decode + a
@Published var presentTailP95Ms = 0.0 /// CAMetalLayer/display-link present); stays invalid under stage-1, where the layer presents
@Published var presentTailValid = false /// internally with no per-frame callback.
@Published var endToEndP50Ms = 0.0
@Published var endToEndP95Ms = 0.0
@Published var endToEndValid = false
@Published var endToEndSkewCorrected = false
/// The client-local stage terms of the HUD's equation line (single clock, no skew; p50 only):
/// decode = receiveddecoded, display = decodedon-glass (ring wait + render + vsync the
/// term the stage-2 presenter exists to shorten).
@Published var decodeP50Ms = 0.0
@Published var decodeValid = false
@Published var displayP50Ms = 0.0
@Published var displayValid = false
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
@Published var lostFrames = 0
@Published var lostPct = 0.0
/// Mirrors StreamView's capture state (it owns the input capture; this drives the /// Mirrors StreamView's capture state (it owns the input capture; this drives the
/// HUD's "click to capture" / " releases" hint). /// HUD's "click to capture" / " releases" hint).
@Published var mouseCaptured = false @Published var mouseCaptured = false
let meter = FrameMeter() let meter = FrameMeter()
/// Capturereceived (the host+network stage), fed per AU at receipt by the stream view's
/// onFrame under both presenters.
let latency = LatencyMeter() let latency = LatencyMeter()
/// Fed by the stage-2 presenter's display link (capturepresent). Passed to StreamView. /// The host/network split of that same stage: onFrame also records (pts, interval) receipts
let presentLatency = LatencyMeter() /// here, and the 1 s stats tick drains the connection's 0xCF host timings into it under
/// Fed by the same present stamp (decode-completionpresent). Passed to StreamView. /// both presenters (the receipt path is presenter-independent).
let presentTail = LatencyMeter() let latencySplit = HostNetworkSplitter()
/// The stage-2 meters, passed to StreamView: end-to-end (captureon-glass, stamped at
/// present), decode (receiveddecoded), display (decodedon-glass).
let endToEnd = LatencyMeter()
let decodeStage = LatencyMeter()
let displayStage = LatencyMeter()
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
private var lastFramesDropped: UInt64 = 0
private var statsTimer: Timer? private var statsTimer: Timer?
private var audio: SessionAudio? private var audio: SessionAudio?
private var gamepadCapture: GamepadCapture? private var gamepadCapture: GamepadCapture?
@@ -281,7 +307,13 @@ final class SessionModel: ObservableObject {
phase = .idle phase = .idle
fps = 0 fps = 0
mbps = 0 mbps = 0
latencyValid = false hostNetworkValid = false
splitValid = false
endToEndValid = false
decodeValid = false
displayValid = false
lostFrames = 0
lostPct = 0
mouseCaptured = false mouseCaptured = false
} }
@@ -306,6 +338,7 @@ final class SessionModel: ObservableObject {
audio.start( audio.start(
speakerUID: defaults.string(forKey: DefaultsKey.speakerUID) ?? "", speakerUID: defaults.string(forKey: DefaultsKey.speakerUID) ?? "",
micUID: defaults.string(forKey: DefaultsKey.micUID) ?? "", micUID: defaults.string(forKey: DefaultsKey.micUID) ?? "",
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true) micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
self.audio = audio self.audio = audio
// Gamepads: forward GamepadManager's active controller as pad 0 and render the // Gamepads: forward GamepadManager's active controller as pad 0 and render the
@@ -321,6 +354,8 @@ final class SessionModel: ObservableObject {
} }
private func startStatsTimer() { private func startStatsTimer() {
lastFramesDropped = 0 // a fresh connection's cumulative drop counter starts at 0
latencySplit.reset() // no stale receipts/samples from a previous session
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self else { return } guard let self else { return }
Task { @MainActor in Task { @MainActor in
@@ -328,28 +363,60 @@ final class SessionModel: ObservableObject {
self.fps = frames self.fps = frames
self.mbps = Double(bytes) * 8 / 1_000_000 self.mbps = Double(bytes) * 8 / 1_000_000
self.totalFrames = total self.totalFrames = total
// Per-window `lost` = the delta of the connector's cumulative reassembler-drop
// counter (0 after close treat a rewind as no loss rather than underflowing).
let dropped = self.connection?.framesDropped() ?? 0
let lost = dropped >= self.lastFramesDropped
? Int(dropped - self.lastFramesDropped) : 0
self.lastFramesDropped = dropped
self.lostFrames = lost
self.lostPct = lost > 0 ? Double(lost) / Double(frames + lost) * 100 : 0
if let lat = self.latency.drain() { if let lat = self.latency.drain() {
self.latencyP50Ms = lat.p50Ms self.hostNetworkP50Ms = lat.p50Ms
self.latencyP95Ms = lat.p95Ms self.hostNetworkP95Ms = lat.p95Ms
self.latencySkewCorrected = lat.skewCorrected self.hostNetworkSkewCorrected = lat.skewCorrected
self.latencyValid = true self.hostNetworkValid = true
} else { } else {
self.latencyValid = false self.hostNetworkValid = false
} }
if let p = self.presentLatency.drain() { // Phase 2: drain the window's per-AU host timings (0xCF) into the splitter
self.presentLatencyP50Ms = p.p50Ms // non-blocking, bounded (a 240 fps window is ~240 reports; the cap only guards
self.presentLatencyP95Ms = p.p95Ms // a pathological burst). `try?` flattens (SE-0230); a throw (.closed during
self.presentLatencySkewCorrected = p.skewCorrected // teardown) just ends the drain. An old host never emits any splitValid stays
self.presentLatencyValid = true // false and the HUD keeps the combined host+network term.
} else { if let conn = self.connection {
self.presentLatencyValid = false var burst = 0
while burst < 1024, let t = try? conn.nextHostTiming(timeoutMs: 0) {
self.latencySplit.noteHostTiming(ptsNs: t.ptsNs, hostUs: t.hostUs)
burst += 1
} }
if let t = self.presentTail.drain() { }
self.presentTailP50Ms = t.p50Ms if let s = self.latencySplit.drain() {
self.presentTailP95Ms = t.p95Ms self.hostP50Ms = s.hostP50Ms
self.presentTailValid = true self.networkP50Ms = s.networkP50Ms
self.splitValid = true
} else { } else {
self.presentTailValid = false self.splitValid = false
}
if let e = self.endToEnd.drain() {
self.endToEndP50Ms = e.p50Ms
self.endToEndP95Ms = e.p95Ms
self.endToEndSkewCorrected = e.skewCorrected
self.endToEndValid = true
} else {
self.endToEndValid = false
}
if let d = self.decodeStage.drain() {
self.decodeP50Ms = d.p50Ms
self.decodeValid = true
} else {
self.decodeValid = false
}
if let d = self.displayStage.drain() {
self.displayP50Ms = d.p50Ms
self.displayValid = true
} else {
self.displayValid = false
} }
} }
} }
@@ -1,5 +1,7 @@
// The streaming overlay HUD: mode + fps/throughput, the captureclient (and, under the stage-2 // The streaming overlay HUD: mode + fps/throughput, the unified latency lines
// presenter, capturepresent) latency lines, the platform input hint, and disconnect. // (design/stats-unification.md end-to-end headline + the stage equation under stage-2, the
// capturereceived headline under the stage-1 fallback), the loss counter, the platform input
// hint, and disconnect.
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
@@ -18,24 +20,46 @@ struct StreamHUDView: View {
Text("\(connection.width)×\(connection.height)@\(connection.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s") Text("\(connection.width)×\(connection.height)@\(connection.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
} }
if model.latencyValid { if model.endToEndValid {
// Captureclient-receipt (skew-corrected); excludes the layer's decode+present // Stage-2: the end-to-end headline (captureon-glass, measured directly, skew-
// see LatencyMeter. "(same-host)" when the host didn't answer the skew handshake. // corrected) "(same-host clock)" when the host didn't answer the skew handshake.
Text("capture→client \(model.latencyP50Ms, specifier: "%.1f")/\(model.latencyP95Ms, specifier: "%.1f") ms p50/p95\(model.latencySkewCorrected ? "" : " (same-host)")") Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
// The equation: the stages tiling the headline interval (per-window p50s
// they only approximately sum to the directly-measured total). With a host
// that reports per-AU timings (0xCF) the first term splits into host + network
// (phase 2); an old host keeps the combined term.
if model.hostNetworkValid && model.decodeValid && model.displayValid {
if model.splitValid {
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
} else {
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if model.presentLatencyValid { }
// Capturepresent (glass-to-glass, modulo host rendercapture) stage-2 presenter } else if model.hostNetworkValid {
// only; stage-1's layer presents internally with no per-frame stamp. // Stage-1 fallback presenter: the layer decodes + presents internally with no
Text("capture→present \(model.presentLatencyP50Ms, specifier: "%.1f")/\(model.presentLatencyP95Ms, specifier: "%.1f") ms p50/p95\(model.presentLatencySkewCorrected ? "" : " (same-host)")") // per-frame stamp, so the honest headline ends at receipt. The host/network
// split still applies there (receipt is presenter-independent) it becomes the
// only equation line; without it, host+network IS the whole measured interval.
Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
if model.splitValid {
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")")
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if model.presentTailValid { }
// Decodepresent (the client-local "present tail": ring wait + render + vsync) if model.lostFrames > 0 {
// the term the stage-2 presenter shortens; no skew applies (one clock). // Unrecoverable network drops this window; hidden while the link is clean.
Text("decode→present \(model.presentTailP50Ms, specifier: "%.1f")/\(model.presentTailP95Ms, specifier: "%.1f") ms p50/p95") // String(format:) rather than specifier interpolation: the literal % would
// otherwise land in the LocalizedStringKey's format string as a bogus conversion.
Text(String(format: "lost %d (%.1f%%)", model.lostFrames, model.lostPct))
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -255,6 +255,10 @@ struct ControllerTestView: View {
Toggle("Light motor (right)", isOn: $lightOn) Toggle("Light motor (right)", isOn: $lightOn)
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform") Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary) .font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
if let problem = tester.rumbleHealth {
Label(problem, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange)
}
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency " Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics " + "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
+ "can't reach its motors on macOS).") + "can't reach its motors on macOS).")
@@ -7,13 +7,49 @@ import SwiftUI
extension SettingsView { extension SettingsView {
// MARK: - Sections (shared) // MARK: - Sections (shared)
// NOTE: the Section content is deliberately split into the small named builders below as one
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
// type-checker budget ("unable to type-check this expression in reasonable time"), which
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
@ViewBuilder var streamModeSection: some View { @ViewBuilder var streamModeSection: some View {
Section { Section {
#if os(iOS) #if os(iOS)
// Touch-first: a rotating wheel of common resolutions (this device's own mode first) and iosResolutionWheel
// a segmented refresh-rate control the same family as the Clock/Timer pickers. The host iosRefreshRows
// renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The Button("Use this display's mode") { fillFromMainScreen() }
// last wheel row, "Custom", reveals width/height/refresh fields for an arbitrary mode. #elseif os(macOS)
HStack {
TextField("Resolution", value: $width, format: .number.grouping(.never))
Text("×")
TextField("", value: $height, format: .number.grouping(.never))
.labelsHidden()
}
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
LabeledContent("") {
Button("Use this display's mode") { fillFromMainScreen() }
}
#endif
#if !os(tvOS)
bitrateRows
#endif
} header: {
Text("Stream mode")
} footer: {
Text("The host creates a virtual output at exactly this mode — "
+ "native resolution, no scaling. \(Self.bitrateFooter)")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
#if os(iOS)
// MARK: - Stream mode (iOS wheel)
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) the
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
/// chosen mode, so these are real pixel sizes. The last wheel row, "Custom", reveals
/// width/height/refresh fields for an arbitrary mode (see `iosRefreshRows`).
@ViewBuilder private var iosResolutionWheel: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Resolution") Text("Resolution")
.font(.geist(15, relativeTo: .subheadline)) .font(.geist(15, relativeTo: .subheadline))
@@ -27,6 +63,10 @@ extension SettingsView {
.pickerStyle(.wheel) .pickerStyle(.wheel)
.frame(maxHeight: 140) .frame(maxHeight: 140)
} }
}
/// Custom W×H(+Hz) fields, a segmented refresh picker, or a static single-rate row.
@ViewBuilder private var iosRefreshRows: some View {
if isCustomResolution { if isCustomResolution {
// Arbitrary entry: type the exact width × height (and refresh) the host should drive. // Arbitrary entry: type the exact width × height (and refresh) the host should drive.
HStack { HStack {
@@ -64,50 +104,7 @@ extension SettingsView {
Text("\(hz) Hz").foregroundStyle(.secondary) Text("\(hz) Hz").foregroundStyle(.secondary)
} }
} }
Button("Use this display's mode") { fillFromMainScreen() }
#elseif os(macOS)
HStack {
TextField("Resolution", value: $width, format: .number.grouping(.never))
Text("×")
TextField("", value: $height, format: .number.grouping(.never))
.labelsHidden()
} }
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
LabeledContent("") {
Button("Use this display's mode") { fillFromMainScreen() }
}
#endif
#if !os(tvOS)
Toggle("Automatic bitrate", isOn: automaticBitrate)
if bitrateKbps != 0 {
HStack(spacing: 12) {
Slider(value: bitrateSlider, in: 0...1) {
Text("Bitrate")
}
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 76, alignment: .trailing)
}
if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.orange)
}
}
#endif
} header: {
Text("Stream mode")
} footer: {
Text("The host creates a virtual output at exactly this mode — "
+ "native resolution, no scaling. \(Self.bitrateFooter)")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
#if os(iOS)
// MARK: - Stream mode (iOS wheel)
/// Sentinel wheel tag for the "Custom" row. Real tags are "WxH" (digits + "x"), so this can't /// Sentinel wheel tag for the "Custom" row. Real tags are "WxH" (digits + "x"), so this can't
/// collide with a resolution. /// collide with a resolution.
@@ -156,6 +153,29 @@ extension SettingsView {
} }
#endif #endif
#if !os(tvOS)
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
@ViewBuilder private var bitrateRows: some View {
Toggle("Automatic bitrate", isOn: automaticBitrate)
if bitrateKbps != 0 {
HStack(spacing: 12) {
Slider(value: bitrateSlider, in: 0...1) {
Text("Bitrate")
}
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 76, alignment: .trailing)
}
if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.orange)
}
}
}
#endif
@ViewBuilder var audioSection: some View { @ViewBuilder var audioSection: some View {
Section { Section {
Picker("Audio channels", selection: $audioChannels) { Picker("Audio channels", selection: $audioChannels) {
@@ -188,6 +208,17 @@ extension SettingsView {
} }
} }
.disabled(!micEnabled) .disabled(!micEnabled)
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
if micChannelCount > 1 {
Picker("Microphone channel", selection: $micChannel) {
Text("Auto (all channels)").tag(0)
ForEach(1...micChannelCount, id: \.self) { ch in
Text("Channel \(ch)").tag(ch)
}
}
.disabled(!micEnabled)
}
#endif #endif
} header: { } header: {
Text("Audio") Text("Audio")
@@ -201,26 +232,44 @@ extension SettingsView {
} }
#if os(iOS) #if os(iOS)
/// iPad-only pointer-capture toggle: lock the mouse/trackpad for relative movement (games) vs /// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
/// forward an absolute cursor position (desktop). Empty on iPhone (no hardware-pointer lock /// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
/// the mouse path there is always the absolute fallback).
@ViewBuilder var pointerSection: some View { @ViewBuilder var pointerSection: some View {
if UIDevice.current.userInterfaceIdiom == .pad {
Section { Section {
Picker("Touch input", selection: $touchMode) {
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
}
if UIDevice.current.userInterfaceIdiom == .pad {
Toggle("Capture pointer for games", isOn: $pointerCapture) Toggle("Capture pointer for games", isOn: $pointerCapture)
}
} header: { } header: {
Text("Pointer") Text("Touch & pointer")
} footer: { } footer: {
Text("With a mouse or trackpad connected, lock the pointer and send relative " Text(pointerFooterText)
+ "movement — the expected behavior for games (mouse-look). Turn this off for "
+ "desktop use to keep the pointer free and send its absolute position instead. "
+ "The lock needs the stream full-screen and frontmost; it falls back to the "
+ "absolute pointer automatically (Stage Manager, Slide Over). Finger touch is "
+ "unaffected. Applies from the next session.")
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
/// Footer copy for `pointerSection`, built in plain `+=` statements. Deliberately NOT one big
/// `+` chain (with a ternary) inside the ViewBuilder that single expression blew Swift's
/// type-checker budget and was what actually broke the iOS archive.
private var pointerFooterText: String {
var text = "Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
text += "click, two-finger tap for a right click, two-finger drag to scroll, "
text += "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
text += "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
text += "multi-touch reaches the host, for apps that understand touch. Applies from "
text += "the next touch."
if UIDevice.current.userInterfaceIdiom == .pad {
text += " Pointer capture locks a hardware mouse/trackpad for relative movement "
text += "(mouse-look); off keeps the pointer free and sends absolute positions. "
text += "The lock needs the stream full-screen and frontmost, and falls back "
text += "automatically (Stage Manager, Slide Over)."
}
return text
} }
#endif #endif
@@ -272,10 +321,11 @@ extension SettingsView {
Text("Video presenter · debug") Text("Video presenter · debug")
} footer: { } footer: {
Text("Stage 2 (default) decodes explicitly and presents through Metal with a display " Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD and " + "link — it gives the HUD the end-to-end (capture→on-glass) headline with the "
+ "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the " + "host+network/decode/display stage equation and self-recovers from decode "
+ "system display layer; it freezes on a lost HEVC reference frame, so it's a debug " + "stalls. Stage 1 feeds compressed video straight to the system display layer; "
+ "fallback only. Applies from the next session.") + "it freezes on a lost HEVC reference frame, so it's a debug fallback only. "
+ "Applies from the next session.")
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -43,6 +43,7 @@ struct SettingsView: View {
#endif #endif
#if os(iOS) #if os(iOS)
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true @AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone. // The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
// Width class decides the initial value: nil on iPhone (show the category list first), // Width class decides the initial value: nil on iPhone (show the category list first),
// General on iPad (a two-column layout should never open with an empty detail). // General on iPad (a two-column layout should never open with an empty detail).
@@ -60,8 +61,12 @@ struct SettingsView: View {
#if os(macOS) #if os(macOS)
@AppStorage(DefaultsKey.speakerUID) var speakerUID = "" @AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
@AppStorage(DefaultsKey.micUID) var micUID = "" @AppStorage(DefaultsKey.micUID) var micUID = ""
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
@State var outputDevices: [AudioDevice] = [] @State var outputDevices: [AudioDevice] = []
@State var inputDevices: [AudioDevice] = [] @State var inputDevices: [AudioDevice] = []
// Input channels of the selected mic drives the "Microphone channel" picker, which only
// appears for a multi-channel interface (>1). 0 until the Audio tab loads it.
@State var micChannelCount = 0
#endif #endif
#if os(iOS) #if os(iOS)
@@ -114,6 +119,12 @@ struct SettingsView: View {
.onAppear { .onAppear {
outputDevices = AudioDevices.outputs() outputDevices = AudioDevices.outputs()
inputDevices = AudioDevices.inputs() inputDevices = AudioDevices.inputs()
micChannelCount = AudioDevices.inputChannelCount(forUID: micUID)
}
.onChange(of: micUID) { _, newUID in
// A different mic different channel count; drop a now-out-of-range pin to Auto.
micChannelCount = AudioDevices.inputChannelCount(forUID: newUID)
if micChannel > micChannelCount { micChannel = 0 }
} }
.tabItem { Label("Audio", systemImage: "speaker.wave.2") } .tabItem { Label("Audio", systemImage: "speaker.wave.2") }
@@ -33,6 +33,49 @@ public enum AudioDevices {
} }
} }
/// Input channel count of the mic the picker would use the device with this UID, or the
/// system default input when `uid` is empty. 0 when it can't be resolved. Drives the
/// "Microphone channel" picker (only shown for multi-channel interfaces).
public static func inputChannelCount(forUID uid: String) -> Int {
let id = uid.isEmpty ? defaultInputDevice() : deviceID(forUID: uid)
guard let id else { return 0 }
return channelCount(id, scope: kAudioObjectPropertyScopeInput)
}
private static func defaultInputDevice() -> AudioDeviceID? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = AudioDeviceID(0)
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
guard AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &dev) == noErr,
dev != 0
else { return nil }
return dev
}
/// Sum of channels across the device's streams in `scope` (its total input/output channels).
private static func channelCount(
_ id: AudioDeviceID, scope: AudioObjectPropertyScope
) -> Int {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: scope,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr, size > 0
else { return 0 }
let raw = UnsafeMutableRawPointer.allocate(
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
defer { raw.deallocate() }
guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, raw) == noErr else { return 0 }
let abl = UnsafeMutableAudioBufferListPointer(
raw.assumingMemoryBound(to: AudioBufferList.self))
return abl.reduce(0) { $0 + Int($1.mNumberChannels) }
}
private static func all() -> [AudioDeviceID] { private static func all() -> [AudioDeviceID] {
var address = AudioObjectPropertyAddress( var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices, mSelector: kAudioHardwarePropertyDevices,
@@ -62,7 +105,8 @@ public enum AudioDevices {
return AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr && size > 0 return AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr && size > 0
} }
private static func describe(_ id: AudioDeviceID) -> AudioDevice? { /// UID + human name for a live AudioDeviceID (nil if either property is unreadable).
static func describe(_ id: AudioDeviceID) -> AudioDevice? {
guard let uid = stringProperty(id, kAudioDevicePropertyDeviceUID), guard let uid = stringProperty(id, kAudioDevicePropertyDeviceUID),
let name = stringProperty(id, kAudioObjectPropertyName) let name = stringProperty(id, kAudioObjectPropertyName)
else { return nil } else { return nil }
@@ -5,9 +5,10 @@
// AVAudioSourceNode pulls from the ring (silence on underrun with re-priming, so a // AVAudioSourceNode pulls from the ring (silence on underrun with re-priming, so a
// network gap costs one dip, not permanent crackle). // network gap costs one dip, not permanent crackle).
// //
// mic host: a second AVAudioEngine taps the input device, resamples to 48 kHz // mic host: a second AVAudioEngine taps the input device, folds it to one mono bus (the
// stereo, slices 20 ms chunks, Opus-encodes, and sendMic()s each packet the host // chosen channel of a multi-channel interface, or a sum of all channels), resamples to 48 kHz
// feeds them into a virtual PipeWire source. // stereo, slices 20 ms chunks, Opus-encodes, and sendMic()s each packet the host feeds them
// into a virtual PipeWire source.
// //
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a // 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 // concrete device and follows default-device changes). Two engines, not one a single
@@ -68,10 +69,11 @@ public final class SessionAudio {
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on /// 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 /// 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. /// on return. The mic may start later still if the permission prompt is pending.
public func start(speakerUID: String, micUID: String, micEnabled: Bool) { public func start(speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool) {
#if os(macOS) #if os(macOS)
// No AVAudioSession on macOS start the engines directly (caller's thread, as before). // No AVAudioSession on macOS start the engines directly (caller's thread, as before).
startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled) startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel, micEnabled: micEnabled)
#else #else
// Configure + activate the session OFF the main thread (it blocks on the audio server), // 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 // then start the engines back on the main thread once it's active engine routing/format
@@ -81,7 +83,9 @@ public final class SessionAudio {
self.activateAudioSession(micEnabled: micEnabled) self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self, !self.flag.isStopped else { return } guard let self, !self.flag.isStopped else { return }
self.startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled) self.startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled)
} }
} }
#endif #endif
@@ -115,7 +119,9 @@ public final class SessionAudio {
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main /// 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. /// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
private func startEngines(speakerUID: String, micUID: String, micEnabled: Bool) { private func startEngines(
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool
) {
startPlayback(speakerUID: speakerUID) startPlayback(speakerUID: speakerUID)
#if os(tvOS) #if os(tvOS)
// No app-accessible microphone input on tvOS playback only. // No app-accessible microphone input on tvOS playback only.
@@ -123,12 +129,12 @@ public final class SessionAudio {
guard micEnabled else { return } guard micEnabled else { return }
switch AVCaptureDevice.authorizationStatus(for: .audio) { switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized: case .authorized:
startCapture(micUID: micUID) startCapture(micUID: micUID, micChannel: micChannel)
case .notDetermined: case .notDetermined:
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
DispatchQueue.main.async { DispatchQueue.main.async {
guard let self, granted, !self.flag.isStopped else { return } guard let self, granted, !self.flag.isStopped else { return }
self.startCapture(micUID: micUID) self.startCapture(micUID: micUID, micChannel: micChannel)
} }
} }
default: default:
@@ -280,7 +286,7 @@ public final class SessionAudio {
// MARK: - Mic (mic host) // MARK: - Mic (mic host)
#if !os(tvOS) #if !os(tvOS)
private func startCapture(micUID: String) { private func startCapture(micUID: String, micChannel: Int) {
let engine = AVAudioEngine() let engine = AVAudioEngine()
let input = engine.inputNode let input = engine.inputNode
#if os(macOS) #if os(macOS)
@@ -300,8 +306,63 @@ public final class SessionAudio {
log.error("no usable input device — mic uplink disabled") log.error("no usable input device — mic uplink disabled")
return return
} }
guard let encoder = try? OpusEncoder(),
let resampler = AVAudioConverter(from: inFormat, to: encoder.pcmFormat), // Multi-channel-interface handling. A pro interface exposes N discrete inputs with the mic
// on ONE of them, but AVAudioConverter's Nstereo downmix takes channels 0/1 dead
// silence when the mic sits higher up (the classic "host receives zeros"). So we fold the
// input to a single mono bus OURSELVES and resample that. micChannel: 0 = Auto (sum every
// channel a lone hot mic passes at full level), n1 pins 1-based input channel n.
let inChannels = Int(inFormat.channelCount)
let pinnedChannel: Int? = {
guard micChannel >= 1 else { return nil }
let idx = micChannel - 1
guard idx < inChannels else {
log.warning(
"mic channel \(micChannel) out of range (device has \(inChannels)) — mixing all")
return nil
}
return idx
}()
let channelPlan = pinnedChannel.map { "channel \($0 + 1)/\(inChannels)" }
?? (inChannels > 1 ? "mix \(inChannels)ch→mono" : "mono")
// Name the device we're ACTUALLY recording from + its format + how we fold it, once per
// session. This single line localizes the whole class of "host receives silence" failures
// that otherwise need a host-side tone injection to pin down: a UID that silently fell back
// to the default, the wrong device being live, or the wrong channel picked.
#if os(macOS)
if let unit = input.audioUnit, let live = Self.currentDevice(of: unit),
let dev = AudioDevices.describe(live) {
if !micUID.isEmpty, dev.uid != micUID {
log.warning("""
mic selection not honored — requested \(micUID) but capturing from \
\(dev.name) [\(dev.uid)]; the device's UID likely changed (replug) — \
reselect it in Settings
""")
}
log.info("""
mic capture: \(dev.name) [\(dev.uid)] — \(Int(inFormat.sampleRate)) Hz, \
\(inChannels) ch, \(channelPlan)
""")
} else {
log.info("""
mic capture: <device unavailable> — \(Int(inFormat.sampleRate)) Hz, \
\(inChannels) ch, \(channelPlan)
""")
}
#else
log.info(
"mic capture: \(Int(inFormat.sampleRate)) Hz, \(inChannels) ch, \(channelPlan)")
#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.
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( let chunk = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat, frameCapacity: OpusEncoder.framesPerPacket) pcmFormat: encoder.pcmFormat, frameCapacity: OpusEncoder.framesPerPacket)
else { else {
@@ -317,11 +378,59 @@ public final class SessionAudio {
let connection = connection let connection = connection
let flag = flag let flag = flag
// Silence tripwire (tap-confined): a "recording" app can be handed pure digital zeros
// a zeroed input-volume slider, a stale TCC grant, a muted device, OR the wrong channel
// picked and everything downstream looks alive while the host gets silence. Track the
// peak of the EXTRACTED mono bus over the first ~10 s (not the raw device a mic present
// on a channel we didn't grab must still read as silence) and emit exactly ONE verdict.
// This is the log line whose absence made the last occurrence take a host-side tone.
let silenceWindow = Int(inFormat.sampleRate * 10)
let deviceLabel = micUID.isEmpty ? "default input" : micUID
var framesInspected = 0
var inputPeak: Float = 0
var levelReported = false
input.installTap(onBus: 0, bufferSize: 2048, format: inFormat) { buffer, _ in input.installTap(onBus: 0, bufferSize: 2048, format: inFormat) { buffer, _ in
if flag.isStopped { return } 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 }
mono.frameLength = buffer.frameLength
// Fold the multi-channel input down to the one mono bus we encode.
Self.foldToMono(
input: src, frames: frames, channels: Int(buffer.format.channelCount),
interleaved: buffer.format.isInterleaved, pinned: pinnedChannel, out: dst)
if !levelReported {
var localPeak: Float = 0
for i in 0..<frames where abs(dst[i]) > localPeak { localPeak = abs(dst[i]) }
if localPeak > inputPeak { inputPeak = localPeak }
framesInspected += frames
if framesInspected >= silenceWindow {
levelReported = true
if inputPeak == 0 {
log.warning("""
mic uplink has been pure digital SILENCE for 10 s (\(deviceLabel), \
\(channelPlan)) — check the input level (System Settings → Sound → \
Input), Privacy & Security → Microphone, and the Microphone channel in \
Settings; the host is receiving zeros
""")
} else {
let dbfs = 20 * log10(inputPeak)
log.info("""
mic uplink OK — peak \(String(format: "%.1f", dbfs)) dBFS over first \
10 s (\(deviceLabel), \(channelPlan))
""")
}
}
}
let ratio = 48_000 / inFormat.sampleRate let ratio = 48_000 / inFormat.sampleRate
let outCapacity = AVAudioFrameCount( let outCapacity = AVAudioFrameCount((Double(frames) * ratio).rounded(.up) + 64)
(Double(buffer.frameLength) * ratio).rounded(.up) + 64)
guard let staging = AVAudioPCMBuffer( guard let staging = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat, frameCapacity: outCapacity) pcmFormat: encoder.pcmFormat, frameCapacity: outCapacity)
else { return } else { return }
@@ -334,7 +443,7 @@ public final class SessionAudio {
} }
fed = true fed = true
outStatus.pointee = .haveData outStatus.pointee = .haveData
return buffer return mono
} }
guard status != .error, let p = staging.floatChannelData?[0] else { return } guard status != .error, let p = staging.floatChannelData?[0] else { return }
fifo.append(contentsOf: UnsafeBufferPointer( fifo.append(contentsOf: UnsafeBufferPointer(
@@ -378,6 +487,42 @@ public final class SessionAudio {
stateLock.unlock() stateLock.unlock()
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))") log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
} }
/// Fold `channels` of input (`floatChannelData` layout: `interleaved` one buffer strided by
/// channel count; else one buffer per channel) down to a single mono bus in `out` (`frames`
/// long). `pinned` (0-based, must be `< channels`) copies exactly that channel the fix for a
/// mic on one input of a multi-channel interface; `nil` sums every channel, clamped to
/// [-1, 1], so a lone hot channel still passes at full level instead of the silent 0/1 the
/// default Nstereo downmix would grab. Pure + `internal` for unit testing the index math.
static func foldToMono(
input: UnsafePointer<UnsafeMutablePointer<Float>>, frames: Int, channels: Int,
interleaved: Bool, pinned: Int?, out: UnsafeMutablePointer<Float>
) {
if let ch = pinned, ch < channels {
if interleaved {
let d = input[0]
for i in 0..<frames { out[i] = d[i * channels + ch] }
} else {
let d = input[ch]
for i in 0..<frames { out[i] = d[i] }
}
} else if interleaved {
let d = input[0]
for i in 0..<frames {
var s: Float = 0
for c in 0..<channels { s += d[i * channels + c] }
out[i] = max(-1, min(1, s))
}
} else {
let d0 = input[0]
for i in 0..<frames { out[i] = d0[i] }
for c in 1..<channels {
let d = input[c]
for i in 0..<frames { out[i] += d[i] }
}
if channels > 1 { for i in 0..<frames { out[i] = max(-1, min(1, out[i])) } }
}
}
#endif #endif
#if os(macOS) #if os(macOS)
@@ -387,5 +532,18 @@ public final class SessionAudio {
unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0,
&dev, UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr &dev, UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr
} }
/// Read back the AUHAL's live device the definitive "what are we actually capturing
/// from", which catches a selection that succeeded on paper but silently fell back to
/// the system default (a stale/changed UID, a device that vanished between resolve and
/// start). 0 / an error means we couldn't tell.
private static func currentDevice(of unit: AudioUnit) -> AudioDeviceID? {
var dev = AudioDeviceID(0)
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
let status = AudioUnitGetProperty(
unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &dev, &size)
guard status == noErr, dev != 0 else { return nil }
return dev
}
#endif #endif
} }
@@ -35,6 +35,10 @@ public struct AccessUnit: Sendable {
public let ptsNs: UInt64 public let ptsNs: UInt64
public let frameIndex: UInt32 public let frameIndex: UInt32
public let flags: UInt32 public let flags: UInt32
/// Client `CLOCK_REALTIME` instant the AU was handed over by the core (post-FEC, decrypted)
/// the **received** measurement point of design/stats-unification.md. The decode stage is
/// `decodedNs - receivedNs`, both client-local (no skew offset applies).
public let receivedNs: Int64
} }
/// One Opus audio packet (48 kHz stereo, 5 ms frames) decode with AVAudioConverter /// One Opus audio packet (48 kHz stereo, 5 ms frames) decode with AVAudioConverter
@@ -79,6 +83,9 @@ public final class PunktfunkConnection {
/// Same role for the feedback drain thread (rumble + HID-output two core planes, /// Same role for the feedback drain thread (rumble + HID-output two core planes,
/// drained sequentially by one thread). /// drained sequentially by one thread).
private let feedbackLock = NSLock() private let feedbackLock = NSLock()
/// Same role for the host-timing (0xCF) puller its own plane in the core, drained
/// non-blockingly by the app's 1 s stats tick (never contends with the blocking pullers).
private let statsLock = NSLock()
/// Negotiated session mode (host-confirmed). /// Negotiated session mode (host-confirmed).
public private(set) var width: UInt32 = 0 public private(set) var width: UInt32 = 0
@@ -419,9 +426,13 @@ public final class PunktfunkConnection {
case statusOK: case statusOK:
guard let base = frame.data, frame.len > 0 else { return nil } guard let base = frame.data, frame.len > 0 else { return nil }
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
var ts = timespec()
clock_gettime(CLOCK_REALTIME, &ts)
let receivedNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
return AccessUnit( return AccessUnit(
data: data, ptsNs: frame.pts_ns, data: data, ptsNs: frame.pts_ns,
frameIndex: frame.frame_index, flags: frame.flags) frameIndex: frame.frame_index, flags: frame.flags,
receivedNs: receivedNs)
case statusNoFrame: case statusNoFrame:
return nil return nil
case statusClosed: case statusClosed:
@@ -657,6 +668,40 @@ public final class PunktfunkConnection {
} }
} }
/// One per-AU host-timing report (0xCF): the host's capturefully-sent duration for the
/// access unit whose `AccessUnit.ptsNs` equals `ptsNs` exactly. The stats consumer derives
/// `network = (receivedNs + clockOffsetNs ptsNs) hostUs` the host/network split of the
/// HUD's `host+network` stage (design/stats-unification.md Phase 2).
public struct HostTiming: Sendable, Equatable {
/// The AU's capture stamp (host capture clock matches the AU's `ptsNs`).
public let ptsNs: UInt64
/// Host capturesent duration, µs.
public let hostUs: UInt32
}
/// Pull the next per-AU host timing; nil on timeout, throws `.closed` once the session
/// ended. Best-effort plane: an older host never emits any keep showing the combined
/// `host+network` stage then. Drain non-blockingly (`timeoutMs: 0`) from ONE stats
/// consumer (its own core plane, safe alongside the other pullers).
public func nextHostTiming(timeoutMs: UInt32 = 0) throws -> HostTiming? {
statsLock.lock()
defer { statsLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var out = PunktfunkHostTiming()
let rc = punktfunk_connection_next_host_timing(h, &out, timeoutMs)
switch rc {
case statusOK:
return HostTiming(ptsNs: out.pts_ns, hostUs: out.host_us)
case statusNoFrame:
return nil
case statusClosed:
throw PunktfunkClientError.closed
default:
throw PunktfunkClientError.status(rc)
}
}
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe; /// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
/// silently dropped after close. /// silently dropped after close.
public func send(_ event: PunktfunkInputEvent) { public func send(_ event: PunktfunkInputEvent) {
@@ -676,10 +721,12 @@ public final class PunktfunkConnection {
pumpLock.lock() // pullers exit at their next poll boundary, releasing these pumpLock.lock() // pullers exit at their next poll boundary, releasing these
audioLock.lock() audioLock.lock()
feedbackLock.lock() feedbackLock.lock()
statsLock.lock()
abiLock.lock() abiLock.lock()
let h = handle let h = handle
handle = nil handle = nil
abiLock.unlock() abiLock.unlock()
statsLock.unlock()
feedbackLock.unlock() feedbackLock.unlock()
audioLock.unlock() audioLock.unlock()
pumpLock.unlock() pumpLock.unlock()
@@ -10,13 +10,20 @@ import GameController
/// a passing test exercises the exact code a session runs. /// a passing test exercises the exact code a session runs.
@MainActor @MainActor
public final class ControllerTester: ObservableObject { public final class ControllerTester: ObservableObject {
private let renderer = RumbleRenderer() // `.manual`: the panel's toggles hold a level until changed no session wire refreshes
// exist here to keep the renderer's staleness watchdog fed.
private let renderer = RumbleRenderer(policy: .manual)
private weak var controller: GCController? private weak var controller: GCController?
/// The rumble backend now in use "DualSense HID · USB/Bluetooth", "CoreHaptics", or "" /// The rumble backend now in use "DualSense HID · USB/Bluetooth", "CoreHaptics", or ""
/// for the test panel to display so it's obvious which path a given pad takes. /// for the test panel to display so it's obvious which path a given pad takes.
@Published public private(set) var rumbleBackend = "" @Published public private(set) var rumbleBackend = ""
/// Why rumble structurally cannot work right now (nil = healthy) e.g. the device's
/// haptics service refusing every connection, or a pad with no rumble engine. Shown by the
/// test panel so silence diagnoses itself instead of reading as an app bug.
@Published public private(set) var rumbleHealth: String?
public init() {} public init() {}
/// Aim the feedback at a controller (nil releases it). Idempotent safe to call on every /// Aim the feedback at a controller (nil releases it). Idempotent safe to call on every
@@ -24,9 +31,14 @@ public final class ControllerTester: ObservableObject {
public func target(_ c: GCController?) { public func target(_ c: GCController?) {
guard c !== controller else { return } guard c !== controller else { return }
controller = c controller = c
renderer.retarget(c) { [weak self] note in renderer.retarget(
c,
onBackend: { [weak self] note in
Task { @MainActor in self?.rumbleBackend = note } Task { @MainActor in self?.rumbleBackend = note }
} },
onHealth: { [weak self] problem in
Task { @MainActor in self?.rumbleHealth = problem }
})
} }
/// Drive both motors at 0...1 amplitudes low = left/heavy, high = right/light mapped to /// Drive both motors at 0...1 amplitudes low = left/heavy, high = right/light mapped to
@@ -102,6 +102,13 @@ public final class GamepadCapture {
tp?.primary.valueChangedHandler = nil tp?.primary.valueChangedHandler = nil
tp?.secondary.valueChangedHandler = nil tp?.secondary.valueChangedHandler = nil
} }
// Hand the system gestures back to the OS before letting the old pad go outside a
// stream the share button's screenshot and the Home overlay are the user's, not ours.
if let old = bound {
for element in old.physicalInputProfile.elements.values {
element.preferredSystemGestureState = .enabled
}
}
if let motion = bound?.motion { if let motion = bound?.motion {
motion.valueChangedHandler = nil motion.valueChangedHandler = nil
// Power the sensors back down left active they keep the pad streaming // Power the sensors back down left active they keep the pad streaming
@@ -114,14 +121,21 @@ public final class GamepadCapture {
ext.valueChangedHandler = { [weak self] g, _ in ext.valueChangedHandler = { [weak self] g, _ in
MainActor.assumeIsolated { self?.sync(g) } MainActor.assumeIsolated { self?.sync(g) }
} }
// The Home/PS button ( guide; the host maps it to the DualSense PS / Xbox guide bit). On // Claim EVERY element's system gesture while this pad drives a stream. The OS attaches
// macOS the SYSTEM grabs it by default (opens Launchpad's Games folder), so it never reached // gestures to several controller buttons share/create local screenshot/recording,
// the app `preferredSystemGestureState = .disabled` on the element is what hands it to us. // Home Game Center overlay (iOS) / Launchpad's Games folder (macOS) and with a
// We drive `guide` DIRECTLY from this handler's pressed value (not via buttonMask), because // gesture attached the press is the system's, not the game's. During capture the remote
// the legacy `extendedGamepad.buttonHome` is unreliable/often nil even when the physical // session IS the game: the share button must reach the host (e.g. Steam screenshots),
// element exists. On tvOS the element is absent (reserved) nil, the whole block no-ops. // the PS button must open the host's Steam overlay. Restored to .enabled on unbind.
for element in c.physicalInputProfile.elements.values {
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
// 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.
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] { if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
home.preferredSystemGestureState = .disabled
home.pressedChangedHandler = { [weak self] _, _, pressed in home.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.sendGuide(down: pressed) } MainActor.assumeIsolated { self?.sendGuide(down: pressed) }
} }
@@ -192,6 +206,11 @@ public final class GamepadCapture {
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight } if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
if g.buttonMenu.isPressed { b |= GamepadWire.start } if g.buttonMenu.isPressed { b |= GamepadWire.start }
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back } if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
// The share/create/capture element (Xbox Series share, a clone pad's screenshot button
// e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose
// the create button BOTH as buttonOptions and as the share element this OR is harmless
// same wire bit.
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back }
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick } if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick } if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder } if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
@@ -25,7 +25,7 @@ public final class GamepadFeedback {
private let flag = StopFlag() private let flag = StopFlag()
private let drainDone = DispatchSemaphore(value: 0) private let drainDone = DispatchSemaphore(value: 0)
private var drainStarted = false private var drainStarted = false
private let rumble = RumbleRenderer() private let rumble = RumbleRenderer(policy: .session)
private var activeSub: AnyCancellable? private var activeSub: AnyCancellable?
// Last applied feedback (main-actor) replayed when the active controller changes. // Last applied feedback (main-actor) replayed when the active controller changes.
@@ -82,8 +82,21 @@ public final class GamepadFeedback {
// poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR // poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps // meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
// rumble/HID latency low while leaving the lock free between polls. // rumble/HID latency low while leaving the lock free between polls.
if let r = try connection.nextRumble(timeoutMs: 0), r.pad == 0 { //
self?.rumble.apply(low: r.low, high: r.high) // Rumble is idempotent state, so drain the plane DRY and apply only the newest
// level. The old one-datagram-per-cycle shape let a burst outpace the ~125 Hz
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
// and its drop-newest overflow could shed a stop while stale nonzero states
// queued ahead of it buzzing until the host's next 500 ms refresh.
var newest: (low: UInt16, high: UInt16)?
var rumbleBurst = 0
while rumbleBurst < 64, !flag.isStopped,
let r = try connection.nextRumble(timeoutMs: 0) {
if r.pad == 0 { newest = (r.low, r.high) }
rumbleBurst += 1
}
if let n = newest {
self?.rumble.apply(low: n.low, high: n.high)
} }
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing // Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle. // per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
@@ -5,28 +5,145 @@ import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad") private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
/// Rumble CoreHaptics, isolated on one serial queue (CHHapticEngine is not main-bound, /// Tuning constants + the pure scheduling decisions of the rumble renderer, split out so the
/// but it isn't a free-for-all either). Engines are created lazily on the first nonzero /// policy is unit-testable without a `CHHapticEngine` or a physical pad.
/// amplitude and torn down on retarget; players run only while their motor is on, so an enum RumbleTuning {
/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets) /// Haptic segment length. **No event is ever infinite**: a player the renderer loses track
/// downgrade to silence rumble is best-effort by design. /// of (a stop dropped inside CoreHaptics, an engine race) self-silences when its segment
/// /// expires, so this is the hard ceiling on how long the actuator can diverge from the
/// `@unchecked Sendable` is sound because every property (`controller`/`low`/`high`/`broken`) is /// target state.
/// read and written only inside `queue` closures the serial queue is the synchronization. static let segmentSeconds: TimeInterval = 4.0
final class RumbleRenderer: @unchecked Sendable { /// Re-arm the successor segment once the current one has less than this left. Generous
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive) /// against the ticker period so a steady rumble can never miss the boundary and gap.
static let rearmHeadroom: TimeInterval = 1.0
/// Renderer ticker period while anything is (or should be) audible. Silence runs no timer.
static let tickSeconds: TimeInterval = 0.05
/// Minimum spacing between player rebuilds for nonzerononzero level changes a game
/// ramping rumble per frame would otherwise stop/start players at 60+ Hz, which is exactly
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
/// zero is never throttled.
static let minRebakeSeconds: TimeInterval = 0.025
/// Session watchdog: silence the motors when no wire command arrived for this long. The
/// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only
/// after 3 consecutive refreshes vanished i.e. the channel or host died while audible.
static let sessionStaleSeconds: TimeInterval = 1.6
/// Levels closer than this (0.4 % of full scale) are the same level an identical host
/// refresh must never rebuild a player.
static let levelEpsilon: Float = 1.0 / 256.0
/// macOS DualSense raw-HID path: re-write an unchanged nonzero level this often so the
/// pad's firmware never times the rumble out mid-effect (Bluetooth pads watchdog output
/// reports), and a dropped report heals.
static let hidKeepaliveSeconds: TimeInterval = 0.9
/// One actuator's started engine plus the player currently driving it (nil = idle). The /// `CHHapticEvent` sharpness = actuator frequency. A DualSense's voice-coil motors need a
/// player is rebuilt per level change `drive` bakes the target intensity into a fresh /// defined frequency to move at all (an intensity-only event left them silent) while a
/// continuous event rather than scaling a long-lived one with a dynamic parameter. /// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at
/// distinct frequencies mirroring the real hardware they emulate low/left the heavy
/// low-frequency rotor, high/right the light buzzer; a single combined actuator keeps the
/// proven mid value.
static let sharpnessLow: Float = 0.3
static let sharpnessHigh: Float = 0.7
static let sharpnessCombined: Float = 0.5
/// Wire amplitude (0...0xFFFF) CoreHaptics intensity (0...1).
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
/// Wire amplitude DualSense HID motor byte.
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
/// Single-actuator pads render whichever motor is stronger.
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
/// Are two baked levels the same (skip the rebuild)?
static func sameLevel(_ a: Float, _ b: Float) -> Bool { abs(a - b) <= levelEpsilon }
/// Time for a segment handoff to act (engine timeline).
static func shouldRearm(endsAt: TimeInterval, now: TimeInterval) -> Bool {
endsAt - now <= rearmHeadroom
}
/// When the successor segment starts: exactly as the current one expires unless that
/// already passed (the gap already happened; start now).
static func handoffStart(endsAt: TimeInterval, now: TimeInterval) -> TimeInterval {
max(endsAt, now)
}
}
/// Rumble the active physical controller (CoreHaptics; a DualSense on macOS goes over raw HID
/// instead, see `DualSenseHID`), built around one principle: **rumble is idempotent state on a
/// lossy channel, and the actuator's divergence from that state must be bounded** not
/// best-effort. The previous renderer drove infinite-duration players torn down and rebuilt per
/// wire update; one asynchronous `stop` dropped inside CoreHaptics left an unstoppable player
/// buzzing with its handle discarded, which no later (0,0) could reach the "walked into the
/// menu and the rumble never stopped" bug.
///
/// The invariants that bound divergence now:
/// 1. **No infinite events.** A motor plays finite `segmentSeconds` segments; while the level
/// holds, the successor is scheduled ON the engine timeline to start exactly when the
/// current segment expires (seamless no stop/start race in steady state). A leaked player
/// therefore self-silences in `segmentSeconds`.
/// 2. **Idempotent targets.** An update equal to the current target (the host re-sends rumble
/// state every 500 ms as its loss heal) is a liveness stamp, never a player rebuild.
/// 3. **Zero is immediate, ramps are throttled.** (0,0) stops players the moment it lands;
/// nonzerononzero changes rebuild at most every `minRebakeSeconds` per motor (the ticker
/// lands the newest value once the window opens).
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown the
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
/// exponential backoff.
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
/// `sessionStaleSeconds` force silence. A lost stop can outlive the host's 500 ms heal
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
/// (the settings test panel) instead holds a level until it is changed.
///
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
/// failures (pads without haptics, engine resets) downgrade to silence rumble is best-effort
/// by design, but *staying silent* when told to stop is not.
///
/// `@unchecked Sendable` is sound because every property is read and written only inside
/// `queue` closures the serial queue is the synchronization.
final class RumbleRenderer: @unchecked Sendable {
/// What an un-refreshed nonzero target means. A live session ties motor life to wire
/// liveness (the host refreshes state every 500 ms); the controller test panel holds a
/// slider level indefinitely.
struct Policy {
let staleAfter: TimeInterval?
static let session = Policy(staleAfter: RumbleTuning.sessionStaleSeconds)
static let manual = Policy(staleAfter: nil)
}
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
private let policy: Policy
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
/// advertises `adv players: 0`, and as of iOS 27 beta 2 an advanced-player sequence load
/// doesn't degrade gracefully there the daemon faults decoding the XPC message and drops
/// it (CoreHaptics -4811/4097, rumble dead). We only need `start(atTime:)`/`stop(atTime:)`,
/// which the plain protocol has.
private struct Segment {
let player: CHHapticPatternPlayer
let endsAt: TimeInterval
}
/// One actuator's started engine and the segment(s) realizing `level` on it. `retiring` is
/// the predecessor across a segment handoff left to expire naturally (its successor
/// starts the instant it ends), but the reference is held so a level change or stop can
/// still force-stop it.
private struct Motor { private struct Motor {
let engine: CHHapticEngine let engine: CHHapticEngine
var player: CHHapticAdvancedPatternPlayer? let sharpness: Float
var level: Float = 0
var current: Segment?
var retiring: Segment?
var lastRebake = DispatchTime(uptimeNanoseconds: 0)
} }
private var controller: GCController? private var controller: GCController?
private var low: Motor? private var low: Motor?
private var high: Motor? private var high: Motor?
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
private var target: (low: UInt16, high: UInt16) = (0, 0)
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
/// so an idle controller costs no timer wakeups and no radio traffic.
private var ticker: DispatchSourceTimer?
// `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad // `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad
// on an OS that doesn't expose rumble through GameController, a Siri Remote) nothing to retry // on an OS that doesn't expose rumble through GameController, a Siri Remote) nothing to retry
// until the controller changes. A transient engine failure does NOT latch it; it tears down for // until the controller changes. A transient engine failure does NOT latch it; it tears down for
@@ -39,86 +156,277 @@ final class RumbleRenderer: @unchecked Sendable {
// break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble // break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble
// update immediately rebuilds into the same dead connection, flooding the log and never // update immediately rebuilds into the same dead connection, flooding the log and never
// recovering. Delay the next setup() growing 0.5124 s on repeated failure and clear it // recovering. Delay the next setup() growing 0.5124 s on repeated failure and clear it
// the moment a player runs cleanly (or the controller changes). // the moment a player is actually running (or the controller changes).
private var retryAfter = Date.distantPast private var retryAfter = DispatchTime(uptimeNanoseconds: 0)
private var consecutiveFailures = 0 private var consecutiveFailures = 0
/// Downgrade after split-handle engines fail: retry with ONE combined `.default` engine
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a /// the configuration virtually every iOS game (and this app's own menu haptics) uses before
/// defined frequency to move at all an intensity-only event (no sharpness) left them /// treating the service as unreachable. A haptics daemon that mishandles per-handle
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid /// localities for a particular pad can still serve the combined engine. One-way per
/// value the known-working macOS DualSense rumble implementations use. (Used only on the /// controller; retarget resets it.
/// CoreHaptics path a DualSense on macOS is driven over raw HID instead, see below.) private var preferCombined = false
private static let sharpness: Float = 0.5 /// Health reporting for the debug test panel: a human-readable problem while rumble cannot
/// work (nil = healthy). Without this, a wedged system haptics service (gamecontrollerd
/// refusing every XPC connection CoreHaptics -4811/4097, which no in-app retry can fix)
/// reads as "the app's rumble is broken" when actually no app on the device can rumble.
private var healthSink: ((String?) -> Void)?
private var lastHealth: String?
#if os(macOS) #if os(macOS)
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics /// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
/// does not reach them on macOS adaptive triggers/lightbar work, rumble is silent). nil for /// does not reach them on macOS adaptive triggers/lightbar work, rumble is silent). nil for
/// every other controller, which keeps the CoreHaptics path. /// every other controller, which keeps the CoreHaptics path.
private var dualSenseHID: DualSenseHID? private var dualSenseHID: DualSenseHID?
private var lastHidWrite: (levels: (UInt8, UInt8), at: DispatchTime) =
((0, 0), DispatchTime(uptimeNanoseconds: 0))
#endif #endif
init(policy: Policy = .session) {
self.policy = policy
}
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the /// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
/// rumble backend now in use for the debug controller-test panel. /// rumble backend now in use; `onHealth` with a problem description whenever rumble transitions
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) { /// between working and structurally failing (nil = healthy) both for the debug test panel.
func retarget(
_ c: GCController?, onBackend: ((String) -> Void)? = nil,
onHealth: ((String?) -> Void)? = nil
) {
queue.async { queue.async {
self.teardown() self.teardown()
self.closeHID() self.closeHID()
self.controller = c self.controller = c
self.broken = false self.broken = false
self.preferCombined = false
self.consecutiveFailures = 0 self.consecutiveFailures = 0
self.retryAfter = .distantPast self.retryAfter = DispatchTime(uptimeNanoseconds: 0)
if let onHealth { self.healthSink = onHealth }
self.lastHealth = nil
self.healthSink?(nil)
_ = self.openHIDIfDualSense(c) _ = self.openHIDIfDualSense(c)
onBackend?(self.backendNote(for: c)) onBackend?(self.backendNote(for: c))
// The target survives the swap: render replays the current level onto the new pad
// right away (a mid-rumble controller change keeps rumbling, like moving a real pad
// between hands mid-effect).
self.render()
} }
} }
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes
/// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise
/// free (invariant 2).
func apply(low lowAmp: UInt16, high highAmp: UInt16) { func apply(low lowAmp: UInt16, high highAmp: UInt16) {
queue.async { queue.async {
self.lastCommand = .now()
let active = lowAmp != 0 || highAmp != 0 let active = lowAmp != 0 || highAmp != 0
if active != self.wasActive { if active != self.wasActive {
self.wasActive = active self.wasActive = active
log.debug( log.debug(
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)") "rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
} }
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every guard (lowAmp, highAmp) != self.target else { return }
// other pad (and for a DualSense whose HID device could not be opened). self.target = (lowAmp, highAmp)
if self.hidRumble(low: lowAmp, high: highAmp) { return } self.render()
guard !self.broken else { return }
if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
self.setup()
}
let ok: Bool
if self.high != nil {
// Per-handle: low = left/heavy motor, high = right/light the XInput convention
// the wire carries.
let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
ok = okLow && okHigh
} else {
// Combined engine: whichever motor is stronger wins.
ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
}
// Rebuild on the next nonzero amplitude if an engine errored and tear down OUTSIDE
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
// still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
// update; once a player is actually running the path has recovered, so clear the backoff.
if !ok {
self.teardown()
self.scheduleRetryBackoff()
} else if self.low?.player != nil || self.high?.player != nil {
self.consecutiveFailures = 0
self.retryAfter = .distantPast
}
} }
} }
/// Silence the motors and drop the engines. Blocks until done call off the main actor.
func stop() { func stop() {
queue.sync { queue.sync {
self.ticker?.cancel()
self.ticker = nil
self.target = (0, 0)
self.wasActive = false
self.teardown() self.teardown()
self.closeHID() self.closeHID()
} }
} }
// MARK: - Reconciliation (all on `queue`)
/// Drive the actuators toward `target`. Idempotent safe to call from every wire update,
/// tick, and retarget; when everything already matches it does nothing.
private func render() {
defer { updateTicker() }
if renderHID() { return }
guard !broken else { return }
let audible = target.low != 0 || target.high != 0
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
setup()
}
// Reconcile BOTH motors (no short-circuit skipping the second on a first-motor error),
// and tear down OUTSIDE the `inout` accesses so teardown() never mutates a motor a
// reconcile call still holds an exclusive reference to.
let ok: Bool
if high != nil {
// Per-handle: low = left/heavy motor, high = right/light the XInput convention
// the wire carries.
let okLow = reconcile(&low, to: RumbleTuning.amplitude(target.low))
let okHigh = reconcile(&high, to: RumbleTuning.amplitude(target.high))
ok = okLow && okHigh
} else {
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
ok = reconcile(&low, to: RumbleTuning.amplitude(mixed))
}
if !ok {
let wasSplit = high != nil
teardown()
scheduleRetryBackoff()
if wasSplit, !preferCombined {
preferCombined = true
log.info("rumble: split-handle engines failing — will retry with one combined engine")
}
} else if low?.current != nil || high?.current != nil {
// A player is actually running the path has recovered; clear the backoff.
consecutiveFailures = 0
retryAfter = DispatchTime(uptimeNanoseconds: 0)
reportHealth(nil)
}
}
/// Publish a health transition to the test panel (deduped transitions only).
private func reportHealth(_ problem: String?) {
guard problem != lastHealth else { return }
lastHealth = problem
healthSink?(problem)
}
/// Watchdog + housekeeping heartbeat while audible.
private func tick() {
if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
// The host refreshes rumble state every 500 ms; this much silence means the channel
// (or host) died while a motor was on. A direct-connected pad would have been
// stopped by its game long ago force the same outcome.
log.warning(
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
target = (0, 0)
}
render()
}
/// Drive one motor toward `desired`, per the invariants above. Returns false when the
/// engine errored the caller then tears everything down (outside this `inout` access) for
/// a lazy, backoff-gated rebuild.
private func reconcile(_ slot: inout Motor?, to desired: Float) -> Bool {
guard var m = slot else { return true }
defer { slot = m }
// Release a handed-off predecessor once it has expired on its own.
if let r = m.retiring, m.engine.currentTime >= r.endsAt + 0.25 {
m.retiring = nil
}
if desired <= RumbleTuning.levelEpsilon {
guard m.level > 0 || m.current != nil || m.retiring != nil else { return true }
m.level = 0
return stopSegments(&m)
}
if RumbleTuning.sameLevel(desired, m.level), m.current != nil {
return rearmIfNeeded(&m)
}
// Nonzero level change. Throttled: the ticker re-runs render() and lands the newest
// value once the window opens (zero above is never throttled).
if m.current != nil, seconds(since: m.lastRebake) < RumbleTuning.minRebakeSeconds {
return true
}
guard stopSegments(&m) else { return false }
do {
m.current = try makeSegment(
m.engine, sharpness: m.sharpness, amplitude: desired, at: CHHapticTimeImmediate)
m.level = desired
m.lastRebake = .now()
return true
} catch {
// A transient failure (the engine stopped/reset between its handler firing and now).
// Signal a rebuild do NOT latch rumble off for the session.
log.warning("rumble: haptic start failed — rebuilding: \(error, privacy: .public)")
return false
}
}
/// Keep a steady level seamless across the finite-segment boundary: when the current
/// segment nears its end, start the successor ON the engine timeline exactly as it expires
/// no stop call, no race, no gap. The old segment is kept as `retiring` until it dies
/// naturally, so a level change can still force-stop it.
private func rearmIfNeeded(_ m: inout Motor) -> Bool {
guard let cur = m.current else { return true }
let now = m.engine.currentTime
guard RumbleTuning.shouldRearm(endsAt: cur.endsAt, now: now) else { return true }
// A predecessor still held this deep into the segment already expired; drop it.
m.retiring = nil
do {
let next = try makeSegment(
m.engine, sharpness: m.sharpness, amplitude: m.level,
at: RumbleTuning.handoffStart(endsAt: cur.endsAt, now: now))
m.retiring = m.current
m.current = next
return true
} catch {
log.warning("rumble: segment re-arm failed — rebuilding: \(error, privacy: .public)")
return false
}
}
/// Stop every segment on the motor NOW. False = a stop threw, so the engine's real state is
/// unknown (a player may still run with its handle gone) the caller must escalate to a
/// full engine teardown, whose `engine.stop()` silences every player the engine hosts.
private func stopSegments(_ m: inout Motor) -> Bool {
var ok = true
for seg in [m.current, m.retiring].compactMap({ $0 }) {
do {
try seg.player.stop(atTime: CHHapticTimeImmediate)
} catch {
log.warning(
"rumble: player stop failed — escalating to engine stop: \(error, privacy: .public)")
ok = false
}
}
m.current = nil
m.retiring = nil
return ok
}
/// Build + start one finite continuous event at `amplitude`. `at` is `CHHapticTimeImmediate`
/// or an absolute engine-timeline instant (a scheduled handoff). The intensity is BAKED into
/// the event: a fixed event scaled by a dynamic `.hapticIntensityControl` parameter drives
/// the iPhone Taptic Engine but is silent on a controller's haptic engine.
private func makeSegment(
_ engine: CHHapticEngine, sharpness: Float, amplitude: Float, at start: TimeInterval
) throws -> Segment {
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness),
],
relativeTime: 0,
duration: RumbleTuning.segmentSeconds)
let player = try engine.makePlayer(
with: CHHapticPattern(events: [event], parameters: []))
try player.start(atTime: start)
let begins = start == CHHapticTimeImmediate ? engine.currentTime : start
return Segment(player: player, endsAt: begins + RumbleTuning.segmentSeconds)
}
/// The ticker runs only while something needs tending any nonzero target (watchdog,
/// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive.
private func updateTicker() {
let needed = target != (0, 0)
|| low?.current != nil || low?.retiring != nil
|| high?.current != nil || high?.retiring != nil
if needed, ticker == nil {
let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(
deadline: .now() + RumbleTuning.tickSeconds, repeating: RumbleTuning.tickSeconds)
t.setEventHandler { [weak self] in self?.tick() }
t.resume()
ticker = t
} else if !needed, let t = ticker {
t.cancel()
ticker = nil
}
}
// MARK: - Engine lifecycle
/// Engines per handle when the pad distinguishes them (low = left/heavy motor, /// Engines per handle when the pad distinguishes them (low = left/heavy motor,
/// high = right/light the Xbox/XInput convention the wire carries); one combined /// high = right/light the Xbox/XInput convention the wire carries); one combined
/// engine otherwise, driven by whichever amplitude is stronger. /// engine otherwise, driven by whichever amplitude is stronger.
@@ -130,20 +438,28 @@ final class RumbleRenderer: @unchecked Sendable {
// the controller changes; latch off (retarget clears it) and say so once. // the controller changes; latch off (retarget clears it) and say so once.
log.info("rumble: active controller exposes no haptics engine — rumble unavailable") log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
broken = true broken = true
reportHealth("This controller exposes no rumble engine to apps on this OS.")
return return
} }
let localities = haptics.supportedLocalities let localities = haptics.supportedLocalities
if localities.contains(.leftHandle), localities.contains(.rightHandle) { let split =
low = makeMotor(haptics, .leftHandle) !preferCombined && localities.contains(.leftHandle)
high = makeMotor(haptics, .rightHandle) && localities.contains(.rightHandle)
if split {
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
} else { } else {
low = makeMotor(haptics, .default) low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
} }
if low == nil, high == nil { if low == nil, high == nil {
// Haptics present but no engine could be built right now (server busy / XPC broken). Do // Haptics present but no engine could be built right now (server busy / XPC broken). Do
// NOT latch broken back off and the next nonzero amplitude past the cooldown retries. // NOT latch broken back off and a later render past the cooldown retries.
log.warning("rumble: haptics present but engine setup failed — backing off, will retry") log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
scheduleRetryBackoff() scheduleRetryBackoff()
if split {
preferCombined = true
log.info("rumble: split-handle engines failing — will retry with one combined engine")
}
} }
} }
@@ -153,10 +469,20 @@ final class RumbleRenderer: @unchecked Sendable {
private func scheduleRetryBackoff() { private func scheduleRetryBackoff() {
consecutiveFailures += 1 consecutiveFailures += 1
let shift = min(consecutiveFailures - 1, 4) let shift = min(consecutiveFailures - 1, 4)
retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4)) retryAfter = .now() + min(0.5 * Double(1 << shift), 4)
if consecutiveFailures >= 2 {
// One failure is a hiccup; repeated ones are the wedged-service signature (every
// XPC connection to gamecontrollerd.haptics breaks no app on the device can
// rumble until it relaunches). Say so instead of failing silently.
reportHealth(
"The system haptics service is refusing connections — no app can rumble a "
+ "controller right now. Rebooting the device usually clears it.")
}
} }
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? { private func makeMotor(
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
) -> Motor? {
guard let engine = haptics.createEngine(withLocality: locality) else { return nil } guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session // A controller's motors carry no audio, so keep this engine OUT of the app's audio session
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time; // (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
@@ -167,7 +493,8 @@ final class RumbleRenderer: @unchecked Sendable {
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left // audio-session interruption (a call, Siri, another audio app), or a server crash. Left
// unhandled the players go dead and every later rumble throws, latching rumble off for the // unhandled the players go dead and every later rumble throws, latching rumble off for the
// rest of the session (the "rumble worked, then went spotty" failure). Tear down on the // rest of the session (the "rumble worked, then went spotty" failure). Tear down on the
// serial queue so the next nonzero amplitude lazily rebuilds the engine, instead. // serial queue; the ticker (or the next wire update) lazily rebuilds the engine and
// re-renders the still-current target.
engine.stoppedHandler = { [weak self] reason in engine.stoppedHandler = { [weak self] reason in
log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild") log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
self?.queue.async { self?.teardown() } self?.queue.async { self?.teardown() }
@@ -177,72 +504,42 @@ final class RumbleRenderer: @unchecked Sendable {
self?.queue.async { self?.teardown() } self?.queue.async { self?.teardown() }
} }
do { do {
// Start the engine now; the player that actually moves the motor is built per level // Start the engine now; the players that actually move the motor are the finite
// change in `drive` (a fresh event baked at the target intensity). // segments `reconcile` bakes per level.
try engine.start() try engine.start()
return Motor(engine: engine, player: nil) return Motor(engine: engine, sharpness: sharpness)
} catch { } catch {
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)") log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
return nil return nil
} }
} }
/// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
/// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
/// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
/// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
/// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
/// duration so a single host update the host sends rumble only when the level changes
/// sustains until the next one. Returns false if the engine errored; the caller tears down for
/// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
guard var m = motor else { return true }
// Replace any running player: stop the old, and for a zero level leave the motor idle.
try? m.player?.stop(atTime: CHHapticTimeImmediate)
m.player = nil
guard amplitude > 0 else { motor = m; return true }
do {
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
],
relativeTime: 0,
duration: TimeInterval(GCHapticDurationInfinite))
let player = try m.engine.makeAdvancedPlayer(
with: CHHapticPattern(events: [event], parameters: []))
try player.start(atTime: CHHapticTimeImmediate)
m.player = player
motor = m
return true
} catch {
// A transient failure (the engine stopped/reset between its handler firing and now).
// Signal a rebuild do NOT latch rumble off for the session (the old "spotty" bug).
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
motor = m
return false
}
}
private func teardown() { private func teardown() {
for m in [low, high].compactMap({ $0 }) { for m in [low, high].compactMap({ $0 }) {
// Disarm the handlers before stopping so stop() can't re-enter teardown via them. // Disarm the handlers before stopping so stop() can't re-enter teardown via them.
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.) // (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
m.engine.stoppedHandler = { _ in } m.engine.stoppedHandler = { _ in }
m.engine.resetHandler = {} m.engine.resetHandler = {}
try? m.player?.stop(atTime: CHHapticTimeImmediate) for seg in [m.current, m.retiring].compactMap({ $0 }) {
try? seg.player.stop(atTime: CHHapticTimeImmediate)
}
// The authoritative silencer: a stopped engine plays nothing, including any player
// whose individual stop was dropped.
m.engine.stop() m.engine.stop()
} }
low = nil low = nil
high = nil high = nil
} }
private func seconds(since t: DispatchTime) -> TimeInterval {
TimeInterval(DispatchTime.now().uptimeNanoseconds - t.uptimeNanoseconds) / 1_000_000_000
}
// MARK: - DualSense raw-HID rumble (macOS) // MARK: - DualSense raw-HID rumble (macOS)
// //
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense // On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
// we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path. // we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
// All three run on the serial `queue`, like the rest of the renderer state. // Runs on the serial `queue`, like the rest of the renderer state.
private func openHIDIfDualSense(_ c: GCController?) -> Bool { private func openHIDIfDualSense(_ c: GCController?) -> Bool {
#if os(macOS) #if os(macOS)
@@ -256,12 +553,19 @@ final class RumbleRenderer: @unchecked Sendable {
#endif #endif
} }
/// Drive the DualSense's motors over HID if that's the active backend; false not a HID pad, /// Write the target to the DualSense over HID if that's the active backend; false not a
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255. /// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
private func hidRumble(low: UInt16, high: UInt16) -> Bool { /// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
private func renderHID() -> Bool {
#if os(macOS) #if os(macOS)
guard let hid = dualSenseHID else { return false } guard let hid = dualSenseHID else { return false }
hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8)) let levels = (RumbleTuning.hidByte(target.low), RumbleTuning.hidByte(target.high))
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())
}
return true return true
#else #else
return false return false
@@ -270,8 +574,9 @@ final class RumbleRenderer: @unchecked Sendable {
private func closeHID() { private func closeHID() {
#if os(macOS) #if os(macOS)
dualSenseHID?.close() dualSenseHID?.close() // writes (0,0) before releasing
dualSenseHID = nil dualSenseHID = nil
lastHidWrite = ((0, 0), DispatchTime(uptimeNanoseconds: 0))
#endif #endif
} }
@@ -0,0 +1,285 @@
// Finger touches host mouse, for the touchscreen devices: a port of the Android client's
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel
// identical. Two mouse modes share one gesture vocabulary tap = left click · two-finger
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
// (text selection / window moves) · three-finger tap = stats-HUD toggle:
//
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
// relative delta with mild acceleration swipe to nudge, lift and re-swipe to walk it
// across, tap to click where it is. This is what makes the cursor reachable on a small
// screen.
// * pointer: the cursor jumps to the finger and follows it (absolute moves through the
// aspect-fit letterbox) direct pointing for desktop-style use.
//
// The third `TouchInputMode` (`touch`) never reaches this type: `StreamLayerUIView` forwards
// those fingers as REAL wire touches (multi-touch passthrough) instead.
#if os(iOS)
import Foundation
import PunktfunkCore
import UIKit
/// How touchscreen fingers drive the host persisted under `DefaultsKey.touchMode`, latched
/// per gesture by `StreamLayerUIView` (a Settings change applies from the NEXT touch, and a
/// gesture never splits across models). `trackpad` is the default: a cursor is the
/// universally workable model; passthrough only helps hosts/apps that actually speak touch.
public enum TouchInputMode: String, CaseIterable, Sendable {
case trackpad
case pointer
case touch
/// The persisted setting, defaulting to trackpad when unset/unknown.
public static var current: TouchInputMode {
TouchInputMode(
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.touchMode) ?? ""
) ?? .trackpad
}
}
/// The gesture state machine behind the two mouse modes. One instance per stream view, fed
/// only the DIRECT touches (fingers/Pencil indirect pointers have their own path). Runs
/// entirely on the main thread (UIKit touch delivery). Touches are tracked by identity key
/// with positions cached per event `UITouch` objects are never retained.
final class TouchMouse {
/// Gesture/ballistics tuning. Distances are in points where they gate gestures; the
/// relative ballistics work in PHYSICAL pixels (point deltas × screen scale) so the
/// acceleration curve matches the Android client's pixel-based constants 1:1.
enum Tuning {
/// Movement under this (pt) still counts as a tap, not a drag.
static let tapSlop: CGFloat = 8
/// A new touch this soon (s) after a tap, near it, starts a held left-button drag.
static let tapDragWindow: TimeInterval = 0.25
/// Two-finger pan distance (pt) per 120-unit wheel notch matches the feel of the
/// indirect-trackpad scroll path in StreamViewIOS (~10 pt per notch).
static let scrollNotchPt: CGFloat = 10
/// Base finger-px host-px gain (~1:1, never twitchy). The acceleration below lets a
/// flick cross the screen while a slow drag stays precise.
static let pointerSens: CGFloat = 1.3
/// Above `accelSpeedFloor` px/ms the gain ramps by `accelGain` per px/ms, capped at
/// `accelMax` (so a fast swipe can't fling the cursor uncontrollably).
static let accelGain: CGFloat = 0.6
static let accelSpeedFloor: CGFloat = 0.3
static let accelMax: CGFloat = 3.0
/// Acceleration multiplier for a finger speed in physical px per ms.
static func accel(forSpeed speed: CGFloat) -> CGFloat {
min(1 + accelGain * max(speed - accelSpeedFloor, 0), accelMax)
}
}
/// Wire events out (the owner gates them on its capture state).
var send: ((PunktfunkInputEvent) -> Void)?
/// View-space point host-mode pixels through the letterbox (pointer mode's moves).
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
/// No gesture in flight (all fingers up) the view uses this to release its mode latch.
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
private var trackpad = true
/// Last known position per active finger (identity key) kept because moved events only
/// carry the CHANGED touches while the scroll centroid needs every finger.
private var lastPos: [ObjectIdentifier: CGPoint] = [:]
private var sessionActive = false
private var startPoint = CGPoint.zero
private var maxFingers = 0
private var moved = false
private var scrolling = false
private var dragHeld = false
// Trackpad relative-motion state: the tracked finger, its last position/time, and the
// sub-pixel remainder so a slow drag isn't lost to integer truncation.
private var trackKey: ObjectIdentifier?
private var prevPoint = CGPoint.zero
private var prevTime: TimeInterval = 0
private var carryX: CGFloat = 0
private var carryY: CGFloat = 0
/// Scroll anchor (centroid) re-anchored every time a notch fires.
private var scrollAnchor = CGPoint.zero
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
private var lastTapUp: TimeInterval = 0
private var lastTapPoint = CGPoint.zero
/// GameStream mouse button ids.
private enum Button { static let left: UInt32 = 1; static let right: UInt32 = 3 }
func began(_ touches: Set<UITouch>, in view: UIView, trackpad: Bool) {
let starting = lastPos.isEmpty
for touch in touches {
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
}
if starting, let first = touches.first {
self.trackpad = trackpad
sessionActive = true
startPoint = first.location(in: view)
maxFingers = 0
moved = false
scrolling = false
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
// button for this whole gesture (laptop-trackpad convention).
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
&& abs(startPoint.x - lastTapPoint.x) < Tuning.tapSlop
&& abs(startPoint.y - lastTapPoint.y) < Tuning.tapSlop
lastTapUp = 0 // consume the arming either way
// Pointer mode jumps the cursor to the finger; trackpad leaves it put (the whole
// point you nudge it with swipes instead).
if !trackpad, let h = hostPoint?(startPoint) {
send?(.mouseMoveAbs(x: h.x, y: h.y, surfaceWidth: h.w, surfaceHeight: h.h))
}
if dragHeld { send?(.mouseButton(Button.left, down: true)) }
trackKey = ObjectIdentifier(first)
prevPoint = startPoint
prevTime = first.timestamp
carryX = 0
carryY = 0
}
maxFingers = max(maxFingers, lastPos.count)
}
func moved(_ touches: Set<UITouch>, in view: UIView) {
guard sessionActive else { return }
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
}
if lastPos.count >= 2 {
scrollByCentroid()
} else if !scrolling, let touch = touches.first(where: {
lastPos[ObjectIdentifier($0)] != nil
}) {
singleFinger(touch, in: view)
}
}
func ended(_ touches: Set<UITouch>, in view: UIView) {
guard sessionActive || !lastPos.isEmpty else { return }
var upTime: TimeInterval = 0
for touch in touches {
lastPos.removeValue(forKey: ObjectIdentifier(touch))
if trackKey == ObjectIdentifier(touch) { trackKey = nil }
upTime = max(upTime, touch.timestamp)
}
guard lastPos.isEmpty, sessionActive else { return }
sessionActive = false
if dragHeld {
dragHeld = false
send?(.mouseButton(Button.left, down: false)) // end the drag
} else if !moved {
switch maxFingers {
case 3...:
Self.toggleHUD() // in-stream stats-overlay toggle, same as Android
case 2: // two-finger tap right click
send?(.mouseButton(Button.right, down: true))
send?(.mouseButton(Button.right, down: false))
default: // tap left click (at the cursor's current spot), arm tap-drag
send?(.mouseButton(Button.left, down: true))
send?(.mouseButton(Button.left, down: false))
lastTapUp = upTime
lastTapPoint = startPoint
}
}
}
/// System-cancelled touches (incoming call, gesture takeover): release anything held but
/// never synthesize a click out of a cancellation.
func cancelled(_ touches: Set<UITouch>) {
for touch in touches {
lastPos.removeValue(forKey: ObjectIdentifier(touch))
if trackKey == ObjectIdentifier(touch) { trackKey = nil }
}
if lastPos.isEmpty { abortSession() }
}
/// Session teardown: release anything held on the wire and forget all gesture state.
func reset() {
lastPos.removeAll()
trackKey = nil
abortSession()
lastTapUp = 0
}
private func abortSession() {
if dragHeld {
dragHeld = false
send?(.mouseButton(Button.left, down: false))
}
sessionActive = false
scrolling = false
moved = false
}
// MARK: - Per-event work
/// Two fingers (or more) scroll by the centroid delta; never move the cursor. Fires a
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger
/// right scrolls right (the host WHEEL(120) convention).
private func scrollByCentroid() {
let n = CGFloat(lastPos.count)
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
let cy = lastPos.values.reduce(0) { $0 + $1.y } / n
if !scrolling {
scrolling = true
scrollAnchor = CGPoint(x: cx, y: cy)
}
let notchesY = Int32((scrollAnchor.y - cy) / Tuning.scrollNotchPt)
let notchesX = Int32((cx - scrollAnchor.x) / Tuning.scrollNotchPt)
if notchesY != 0 {
send?(.scroll(notchesY * 120))
scrollAnchor.y = cy
moved = true
}
if notchesX != 0 {
send?(.scroll(notchesX * 120, horizontal: true))
scrollAnchor.x = cx
moved = true
}
}
/// One finger (and the gesture never became a scroll dropping back from two fingers to
/// one must not jerk the cursor).
private func singleFinger(_ touch: UITouch, in view: UIView) {
let loc = touch.location(in: view)
if abs(loc.x - startPoint.x) > Tuning.tapSlop || abs(loc.y - startPoint.y) > Tuning.tapSlop {
moved = true
}
guard trackpad else {
if let h = hostPoint?(loc) { // pointer mode: the cursor follows the finger
send?(.mouseMoveAbs(x: h.x, y: h.y, surfaceWidth: h.w, surfaceHeight: h.h))
}
return
}
// Relative: move by the finger delta × (sensitivity × acceleration), carrying the
// sub-pixel remainder. Re-anchor (zero delta this frame) if the tracked finger
// changed, so lifting one of several fingers never jumps the cursor.
let key = ObjectIdentifier(touch)
if key != trackKey {
trackKey = key
prevPoint = loc
prevTime = touch.timestamp
return
}
// Ballistics in physical pixels so the curve matches the Android tuning exactly.
let scale = view.window?.screen.scale ?? view.traitCollection.displayScale
let dx = (loc.x - prevPoint.x) * scale
let dy = (loc.y - prevPoint.y) * scale
let dtMs = max((touch.timestamp - prevTime) * 1000, 1)
prevPoint = loc
prevTime = touch.timestamp
let gain = Tuning.pointerSens * Tuning.accel(forSpeed: hypot(dx, dy) / dtMs)
carryX += dx * gain
carryY += dy * gain
let outX = Int32(carryX) // truncates toward zero remainder kept with its sign
let outY = Int32(carryY)
if outX != 0 || outY != 0 {
send?(.mouseMove(dx: outX, dy: outY))
carryX -= CGFloat(outX)
carryY -= CGFloat(outY)
}
}
/// Three-finger tap toggles the stats overlay through the shared `hudEnabled` default,
/// which the app's HUD views observe via @AppStorage (so this needs no wiring to them).
private static func toggleHUD() {
let defaults = UserDefaults.standard
let on = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
defaults.set(!on, forKey: DefaultsKey.hudEnabled)
}
}
#endif
@@ -24,6 +24,12 @@ public enum DefaultsKey {
public static let micEnabled = "punktfunk.micEnabled" public static let micEnabled = "punktfunk.micEnabled"
public static let speakerUID = "punktfunk.speakerUID" public static let speakerUID = "punktfunk.speakerUID"
public static let micUID = "punktfunk.micUID" public static let micUID = "punktfunk.micUID"
/// macOS: which input channel of the chosen mic device feeds the host. 0 = "Auto" (sum every
/// channel to mono a mic on a single input of a multi-channel interface passes at full
/// level); n1 pins 1-based input channel n. Multi-channel interfaces expose the mic on ONE
/// discrete channel, and the default Nstereo downmix grabs channels 0/1 (silence when the mic
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
public static let micChannel = "punktfunk.micChannel"
public static let presenter = "punktfunk.presenter" public static let presenter = "punktfunk.presenter"
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host /// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
/// has HDR content AND this display supports HDR otherwise the stream stays 8-bit SDR. /// has HDR content AND this display supports HDR otherwise the stream stays 8-bit SDR.
@@ -41,6 +47,11 @@ public enum DefaultsKey {
/// scene and silently falls back to the absolute pointer when it can't (Stage Manager / Slide /// scene and silently falls back to the absolute pointer when it can't (Stage Manager / Slide
/// Over). Read by `StreamViewController.prefersPointerLocked`. /// Over). Read by `StreamViewController.prefersPointerLocked`.
public static let pointerCapture = "punktfunk.pointerCapture" public static let pointerCapture = "punktfunk.pointerCapture"
/// iPhone/iPad: how touchscreen fingers drive the host a `TouchInputMode` raw value:
/// "trackpad" (default: relative cursor with tap-click / two-finger-scroll gestures),
/// "pointer" (the cursor jumps to the finger), or "touch" (real multi-touch passthrough).
/// Read live per gesture by `StreamLayerUIView`.
public static let touchMode = "punktfunk.touchMode"
/// Experimental: show the host's game library (browsed over the management API). Off by default. /// Experimental: show the host's game library (browsed over the management API). Off by default.
public static let libraryEnabled = "punktfunk.libraryEnabled" public static let libraryEnabled = "punktfunk.libraryEnabled"
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default. /// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
@@ -0,0 +1,88 @@
// Splits the unified stats model's `host+network` stage (capturereceived) into its `host`
// (capturefully-sent, reported per AU by the host on the 0xCF plane) and `network`
// (the remainder) terms design/stats-unification.md Phase 2.
//
// Receipt samples are recorded per frame from the pump path; host timings are matched to them
// by exact pts (the 0xCF datagram carries the AU's own `pts_ns`). Best-effort by construction:
// a lost 0xCF datagram, an FEC-dropped AU, or an old host that never emits the plane simply
// contributes no split sample the HUD then keeps the combined `host+network` line. NSLock
// rather than an actor the receipt writer is the non-async pump path (same pattern as
// LatencyMeter/FrameMeter).
import Foundation
/// Per-frame `host` / `network` sampler: `recordReceipt` at AU receipt (pts + the combined
/// capturereceived interval), `noteHostTiming` per drained 0xCF report, `drain` the window's
/// p50s once a second. The pending ring is bounded (drop-oldest) so an old host receipts
/// forever, timings never costs a fixed ~4 KB, not growth.
public final class HostNetworkSplitter: @unchecked Sendable {
private let lock = NSLock()
/// Received AUs awaiting their 0xCF host timing: (pts, combined capturereceived µs).
private var pending: [(ptsNs: UInt64, combinedUs: Int64)] = []
private var hostUsSamples: [Int64] = []
private var networkUsSamples: [Int64] = []
/// ~1 s of frames at 240 fps; beyond it the oldest receipt can no longer expect a match.
private static let pendingCap = 256
public init() {}
/// Record one frame at receipt. `ptsNs` is the host capture clock (the AU's pts),
/// `receivedNs` the client `CLOCK_REALTIME` receipt instant (`AccessUnit.receivedNs`),
/// `offsetNs` the connect-time hostclient clock offset (0 = uncorrected). Same
/// absurd-value clamp as LatencyMeter a sample it would drop must not linger here.
public func recordReceipt(ptsNs: UInt64, receivedNs: Int64, offsetNs: Int64) {
let combinedNs = receivedNs &+ offsetNs &- Int64(bitPattern: ptsNs)
guard combinedNs > 0, combinedNs < 10_000_000_000 else { return }
lock.lock()
pending.append((ptsNs: ptsNs, combinedUs: combinedNs / 1000))
if pending.count > Self.pendingCap {
pending.removeFirst(pending.count - Self.pendingCap)
}
lock.unlock()
}
/// Match one host timing (0xCF) to its receipt: `host` = the reported capturesent,
/// `network` = the combined interval minus it, floored at 0 (the terms tile per frame; a
/// slightly-off skew offset must not produce a negative wire time). Unmatched timings
/// the AU was FEC-dropped, or its receipt raced this drain are simply skipped.
public func noteHostTiming(ptsNs: UInt64, hostUs: UInt32) {
lock.lock()
defer { lock.unlock() }
guard let i = pending.firstIndex(where: { $0.ptsNs == ptsNs }) else { return }
let combinedUs = pending.remove(at: i).combinedUs
hostUsSamples.append(Int64(hostUs))
networkUsSamples.append(max(0, combinedUs - Int64(hostUs)))
}
public struct Split: Sendable {
public let hostP50Ms: Double
public let networkP50Ms: Double
public let count: Int
}
/// The window's p50s since the last drain, then reset (matched samples only; the pending
/// ring survives a receipt may still match a timing drained next tick). `nil` when no
/// timing matched in the interval the caller falls back to the combined stage.
public func drain() -> Split? {
lock.lock()
let host = hostUsSamples.sorted()
let network = networkUsSamples.sorted()
hostUsSamples.removeAll(keepingCapacity: true)
networkUsSamples.removeAll(keepingCapacity: true)
lock.unlock()
guard !host.isEmpty else { return nil }
func p50(_ sorted: [Int64]) -> Double {
Double(sorted[min(sorted.count / 2, sorted.count - 1)]) / 1000.0 // µs ms
}
return Split(hostP50Ms: p50(host), networkP50Ms: p50(network), count: host.count)
}
/// Forget everything (pending receipts + window) a fresh connection starts clean.
public func reset() {
lock.lock()
pending.removeAll()
hostUsSamples.removeAll()
networkUsSamples.removeAll()
lock.unlock()
}
}
@@ -1,23 +1,25 @@
// Per-frame latency sampler for the live HUD: records capture->client-receipt latency and drains // Per-frame latency-stage sampler for the live HUD: records one interval per frame (an end
// percentiles on demand. NSLock rather than an actor the writer is the non-async pump/arrival // instant minus a start instant, both CLOCK_REALTIME ns) and drains percentiles on demand.
// path (same pattern as the app's FrameMeter). // NSLock rather than an actor the writers are the non-async pump/decode/present paths (same
// pattern as the app's FrameMeter).
import Foundation import Foundation
/// Samples the **capture->client-receipt** latency of each access unit and reports percentiles. /// Samples one **latency stage** per frame and reports percentiles. One instance per stage of the
/// unified stats model (design/stats-unification.md):
/// ///
/// The latency is `now - pts_ns`, where `pts_ns` is the host's capture wall clock (the AU's pts) and /// - `host+network` = capturereceived: `record(ptsNs:offsetNs:)` at AU receipt.
/// `now` is the client's `CLOCK_REALTIME` instant the AU was received, shifted by the connect-time /// - `decode` = receiveddecoded and `display` = decodeddisplayed: client-local single-clock
/// **clock-skew offset** (`PunktfunkConnection.clockOffsetNs`, host minus client) so the difference /// stages `record(ptsNs:atNs:offsetNs:)` with the start instant as `ptsNs` and `offsetNs: 0`.
/// is valid across machines. `offsetNs == 0` means an old host that didn't answer the skew handshake /// - `end-to-end` = capturedisplayed, measured directly (never summed from the stages):
/// (or genuinely synced clocks) the number is then only meaningful same-host. /// `record(ptsNs:atNs:offsetNs:)` at present.
/// ///
/// SCOPE (stage-1 presenter): this covers host capture -> encode -> FEC -> network -> reassembly -> /// For the host-anchored intervals (capture) the sample is `end + offset - pts_ns`, where
/// decrypt -> handed to the presenter. It does **not** include the on-device VideoToolbox decode or /// `pts_ns` is the host's capture wall clock (the AU's pts) and the connect-time **clock-skew
/// the `AVSampleBufferDisplayLayer` present that layer decodes and presents compressed samples /// offset** (`PunktfunkConnection.clockOffsetNs`, host minus client) makes the difference valid
/// internally with no per-frame callback. True decode->present (the full glass-to-glass) needs the /// across machines. `offsetNs == 0` means an old host that didn't answer the skew handshake (or
/// stage-2 presenter (`VTDecompressionSession` decode-completion + `CAMetalLayer`/display-link /// genuinely synced clocks) the number is then only meaningful same-host, and the HUD tags the
/// present); this meter is the substrate it will extend. /// end-to-end line `(same-host clock)`.
public final class LatencyMeter: @unchecked Sendable { public final class LatencyMeter: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var samplesUs: [Int64] = [] private var samplesUs: [Int64] = []
@@ -34,12 +36,16 @@ public final class LatencyMeter: @unchecked Sendable {
record(ptsNs: ptsNs, atNs: nowNs, offsetNs: offsetNs) record(ptsNs: ptsNs, atNs: nowNs, offsetNs: offsetNs)
} }
/// Record one frame whose latency is `atNs + offsetNs - ptsNs` an EXPLICIT client instant /// Record one frame whose sample is `atNs + offsetNs - ptsNs` an EXPLICIT end instant
/// rather than now. The stage-2 presenter uses this to stamp capturepresent at the display /// rather than now. `ptsNs` is the stage's start point: the AU pts for the host-anchored
/// link's target present time (not the moment the present call ran). All in `CLOCK_REALTIME`. /// intervals, or a client stamp (receivedNs / decodedNs, with `offsetNs: 0`) for the local
/// decode/display stages. The stage-2 presenter stamps its present-side samples at the
/// display link's target present time (not the moment the present call ran). All in
/// `CLOCK_REALTIME`.
public func record(ptsNs: UInt64, atNs: Int64, offsetNs: Int64) { public func record(ptsNs: UInt64, atNs: Int64, offsetNs: Int64) {
let latNs = atNs &+ offsetNs &- Int64(bitPattern: ptsNs) let latNs = atNs &+ offsetNs &- Int64(bitPattern: ptsNs)
// Drop absurd values (a clock step, a wildly wrong offset, or garbage pts). // Drop absurd values (a clock step, a wildly wrong offset, garbage pts, or a stage whose
// start stamp is missing/after its end) samples are clamped to (0, 10 s).
guard latNs > 0, latNs < 10_000_000_000 else { return } guard latNs > 0, latNs < 10_000_000_000 else { return }
lock.lock() lock.lock()
samplesUs.append(latNs / 1000) samplesUs.append(latNs / 1000)
@@ -38,8 +38,9 @@ final class SessionPresenter {
func start( func start(
connection: PunktfunkConnection, connection: PunktfunkConnection,
baseLayer: AVSampleBufferDisplayLayer, baseLayer: AVSampleBufferDisplayLayer,
presentMeter: LatencyMeter?, endToEndMeter: LatencyMeter?,
presentTailMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil,
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink, makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)? onSessionEnd: (@Sendable () -> Void)?
@@ -59,7 +60,8 @@ final class SessionPresenter {
#endif #endif
if !forceStage1, if !forceStage1,
let pipeline = Stage2Pipeline( let pipeline = Stage2Pipeline(
presentMeter: presentMeter, presentTailMeter: presentTailMeter) { endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter) {
let metal = pipeline.layer let metal = pipeline.layer
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which // The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout(). // sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
@@ -1,7 +1,8 @@
// Stage-2 presenter orchestrator: a pump thread pulls AUs VideoDecoder; the decoder's async output // Stage-2 presenter orchestrator: a pump thread pulls AUs VideoDecoder; the decoder's async output
// drops the newest decoded frame into a 1-slot ring; the hosting view's display link calls `renderTick` // drops the newest decoded frame into a 1-slot ring; the hosting view's display link calls `renderTick`
// once per vsync to draw + present the newest ready frame and stamp capturepresent. Mirrors // once per vsync to draw + present the newest ready frame and stamp the unified latency stages
// StreamPump's lifecycle (one per start; cancel is permanent). // (end-to-end captureon-glass, plus the decode and display stage terms
// design/stats-unification.md). Mirrors StreamPump's lifecycle (one per start; cancel is permanent).
// //
// Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick` + // Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick` +
// `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there). Only the ring (lock-guarded) // `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there). Only the ring (lock-guarded)
@@ -40,8 +41,8 @@ public final class Stage2Pipeline {
private let ring = ReadyRing() private let ring = ReadyRing()
private let presenter: MetalVideoPresenter private let presenter: MetalVideoPresenter
private let decoder: VideoDecoder private let decoder: VideoDecoder
private let presentMeter: LatencyMeter? private let endToEndMeter: LatencyMeter?
private let presentTailMeter: LatencyMeter? private let displayMeter: LatencyMeter?
private let recovery = KeyframeRecovery() private let recovery = KeyframeRecovery()
private var token = StopFlag() private var token = StopFlag()
private var offsetNs: Int64 = 0 private var offsetNs: Int64 = 0
@@ -56,28 +57,41 @@ public final class Stage2Pipeline {
/// The Metal layer the hosting view installs + sizes. /// The Metal layer the hosting view installs + sizes.
public var layer: CAMetalLayer { presenter.layer } public var layer: CAMetalLayer { presenter.layer }
/// `presentMeter` records capturepresent (the glass-to-glass term); `presentTailMeter` /// Unified-stats meters (design/stats-unification.md): `endToEndMeter` records the headline
/// records decode-completionpresent (the ring wait + render the tail stage-2 exists to /// end-to-end (captureon-glass, skew-corrected); `decodeMeter` the decode stage
/// shorten). Both optional: metering never gates the presenter choice. Returns nil if Metal /// (receiveddecoded); `displayMeter` the display stage (decodedon-glass, the ring wait +
/// can't be set up (headless / no GPU) caller falls back to the stage-1 presenter. /// render + vsync the tail stage-2 exists to shorten). All optional: metering never gates
public init?(presentMeter: LatencyMeter?, presentTailMeter: LatencyMeter? = nil) { /// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) caller
/// falls back to the stage-1 presenter.
public init?(
endToEndMeter: LatencyMeter?,
decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil
) {
guard let presenter = MetalVideoPresenter.make() else { return nil } guard let presenter = MetalVideoPresenter.make() else { return nil }
self.presenter = presenter self.presenter = presenter
self.presentMeter = presentMeter self.endToEndMeter = endToEndMeter
self.presentTailMeter = presentTailMeter self.displayMeter = displayMeter
let ring = ring let ring = ring
let recovery = recovery let recovery = recovery
self.decoder = VideoDecoder( self.decoder = VideoDecoder(
onDecoded: { ring.submit($0) }, onDecoded: { frame in
// Decode stage = receiveddecoded, both client CLOCK_REALTIME (offset 0 no
// skew applies). Stamped at decode completion, so it covers every decoded frame,
// including ones the newest-wins ring drops before present.
decodeMeter?.record(
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
ring.submit(frame)
},
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to // Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP it wouldn't // re-gate on the next IDR, and we ask the host to send one now (infinite GOP it wouldn't
// otherwise come soon). Throttled in KeyframeRecovery. // otherwise come soon). Throttled in KeyframeRecovery.
onDecodeError: { _ in recovery.request() }) onDecodeError: { _ in recovery.request() })
} }
/// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (captureclient /// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (the
/// meter, exactly as stage-1); `onSessionEnd` on close. `clockOffsetNs` (host minus client) makes the /// host+network / capturereceived meter, exactly as stage-1); `onSessionEnd` on close.
/// present stamp cross-machine valid. /// `clockOffsetNs` (host minus client) makes the end-to-end stamp cross-machine valid.
public func start( public func start(
connection: PunktfunkConnection, connection: PunktfunkConnection,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
@@ -174,14 +188,16 @@ public final class Stage2Pipeline {
public func renderTick(targetPresentNs: Int64) { public func renderTick(targetPresentNs: Int64) {
guard let frame = ring.take() else { return } guard let frame = ring.take() else { return }
let offsetNs = offsetNs let offsetNs = offsetNs
let presentMeter = presentMeter let endToEndMeter = endToEndMeter
let presentTailMeter = presentTailMeter let displayMeter = displayMeter
let rendered = presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) { presentedNs in let rendered = presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) { presentedNs in
let atNs = presentedNs ?? targetPresentNs let atNs = presentedNs ?? targetPresentNs
presentMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs) // End-to-end = captureon-glass, measured directly (skew-corrected via the
// Present tail = decode-completion on-glass. Both instants are client // connect-time clock offset) the HUD headline.
// CLOCK_REALTIME, so no skew offset applies. endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
presentTailMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0) // Display stage = decoded on-glass. Both instants are client CLOCK_REALTIME,
// so no skew offset applies.
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
} }
if !rendered { ring.putBack(frame) } if !rendered { ring.putBack(frame) }
} }
@@ -61,7 +61,7 @@ public enum Stage444Probe {
guard created == noErr, let session else { return false } guard created == noErr, let session else { return false }
defer { VTDecompressionSessionInvalidate(session) } defer { VTDecompressionSessionInvalidate(session) }
let au = AccessUnit(data: data, ptsNs: 0, frameIndex: 0, flags: 0) let au = AccessUnit(data: data, ptsNs: 0, frameIndex: 0, flags: 0, receivedNs: 0)
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false } guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
var produced: OSType = 0 var produced: OSType = 0
@@ -15,6 +15,10 @@ import VideoToolbox
public struct ReadyFrame: @unchecked Sendable { public struct ReadyFrame: @unchecked Sendable {
/// Host capture clock (the AU's pts), in nanoseconds. /// Host capture clock (the AU's pts), in nanoseconds.
public let ptsNs: UInt64 public let ptsNs: UInt64
/// Client `CLOCK_REALTIME` instant the AU was received (`AccessUnit.receivedNs`, threaded
/// through the decode via the frame refcon), in nanoseconds. 0 when unknown (a caller that
/// didn't stamp receipt) the decode-stage meter then drops the sample via its sanity guard.
public let receivedNs: Int64
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds. /// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
public let decodedNs: Int64 public let decodedNs: Int64
/// The decoded image 8-bit NV12 biplanar (SDR) or 10-bit P010 biplanar (HDR), Metal-compatible. /// The decoded image 8-bit NV12 biplanar (SDR) or 10-bit P010 biplanar (HDR), Metal-compatible.
@@ -25,13 +29,16 @@ public struct ReadyFrame: @unchecked Sendable {
} }
/// The C output callback can't capture context, so VideoToolbox hands it the refcon we set at /// The C output callback can't capture context, so VideoToolbox hands it the refcon we set at
/// session creation a pointer back to the owning `VideoDecoder`. /// session creation a pointer back to the owning `VideoDecoder`. The per-frame refcon carries
/// the AU's `receivedNs` as a pointer bit pattern (a scalar smuggled through the C void*, never
/// dereferenced) so the decode stage can be computed against decode-completion.
private let decoderOutputCallback: VTDecompressionOutputCallback = { private let decoderOutputCallback: VTDecompressionOutputCallback = {
refcon, _, status, _, imageBuffer, pts, _ in refcon, frameRefcon, status, _, imageBuffer, pts, _ in
guard let refcon else { return } guard let refcon else { return }
let receivedNs = frameRefcon.map { Int64(Int(bitPattern: $0)) } ?? 0
Unmanaged<VideoDecoder>.fromOpaque(refcon) Unmanaged<VideoDecoder>.fromOpaque(refcon)
.takeUnretainedValue() .takeUnretainedValue()
.handleDecoded(status: status, imageBuffer: imageBuffer, pts: pts) .handleDecoded(status: status, imageBuffer: imageBuffer, pts: pts, receivedNs: receivedNs)
} }
/// Owns a `VTDecompressionSession` rebuilt whenever the format description changes (every IDR / /// Owns a `VTDecompressionSession` rebuilt whenever the format description changes (every IDR /
@@ -112,7 +119,9 @@ public final class VideoDecoder: @unchecked Sendable {
session, session,
sampleBuffer: sample, sampleBuffer: sample,
flags: [._EnableAsynchronousDecompression], flags: [._EnableAsynchronousDecompression],
frameRefcon: nil, // The AU's receipt instant rides through as a bit pattern (nil for 0 the output
// callback maps that back to 0); the callback needs it to stamp the decode stage.
frameRefcon: UnsafeMutableRawPointer(bitPattern: Int(au.receivedNs)),
infoFlagsOut: &infoOut) infoFlagsOut: &infoOut)
lock.unlock() lock.unlock()
if status != noErr { if status != noErr {
@@ -218,8 +227,11 @@ public final class VideoDecoder: @unchecked Sendable {
return true return true
} }
/// VT thread. Stamp decode-completion and enqueue, or report the error. /// VT thread. Stamp decode-completion and enqueue, or report the error. `receivedNs` is the
fileprivate func handleDecoded(status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime) { /// AU's receipt instant threaded through the frame refcon (0 = unknown).
fileprivate func handleDecoded(
status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime, receivedNs: Int64
) {
guard status == noErr, let imageBuffer else { guard status == noErr, let imageBuffer else {
onDecodeError(status) onDecodeError(status)
return return
@@ -242,6 +254,8 @@ public final class VideoDecoder: @unchecked Sendable {
|| fmt == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange || fmt == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|| fmt == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange || fmt == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
onDecoded( onDecoded(
ReadyFrame(ptsNs: ptsNs, decodedNs: decodedNs, pixelBuffer: imageBuffer, isHDR: isHDR)) ReadyFrame(
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
pixelBuffer: imageBuffer, isHDR: isHDR))
} }
} }
@@ -85,39 +85,45 @@ public struct StreamView: NSViewRepresentable {
private let onCaptureChange: ((Bool) -> Void)? private let onCaptureChange: ((Bool) -> Void)?
private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onFrame: (@Sendable (AccessUnit) -> Void)?
private let onSessionEnd: (@Sendable () -> Void)? private let onSessionEnd: (@Sendable () -> Void)?
private let presentMeter: LatencyMeter? private let endToEndMeter: LatencyMeter?
private let presentTailMeter: LatencyMeter? private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter?
/// `onFrame`/`onSessionEnd` fire on the pump thread hop to the main actor for UI. /// `onFrame`/`onSessionEnd` fire on the pump thread hop to the main actor for UI.
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust /// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
/// prompt) is layered over the stream; flipping it to true auto-engages capture /// prompt) is layered over the stream; flipping it to true auto-engages capture
/// once. `onCaptureChange` (main thread) reports engage/release drive the HUD's /// once. `onCaptureChange` (main thread) reports engage/release drive the HUD's
/// "click to capture" / " releases" hint with it. `presentMeter` records capturepresent /// "click to capture" / " releases" hint with it. The meters record the unified latency
/// and `presentTailMeter` decodepresent when the stage-2 presenter is active. /// stages when the stage-2 presenter is active (design/stats-unification.md):
/// `endToEndMeter` captureon-glass, `decodeMeter` receiveddecoded, `displayMeter`
/// decodedon-glass.
public init( public init(
connection: PunktfunkConnection, connection: PunktfunkConnection,
captureEnabled: Bool = true, captureEnabled: Bool = true,
onCaptureChange: ((Bool) -> Void)? = nil, onCaptureChange: ((Bool) -> Void)? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil,
presentMeter: LatencyMeter? = nil, endToEndMeter: LatencyMeter? = nil,
presentTailMeter: LatencyMeter? = nil decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil
) { ) {
self.connection = connection self.connection = connection
self.captureEnabled = captureEnabled self.captureEnabled = captureEnabled
self.onCaptureChange = onCaptureChange self.onCaptureChange = onCaptureChange
self.onFrame = onFrame self.onFrame = onFrame
self.onSessionEnd = onSessionEnd self.onSessionEnd = onSessionEnd
self.presentMeter = presentMeter self.endToEndMeter = endToEndMeter
self.presentTailMeter = presentTailMeter self.decodeMeter = decodeMeter
self.displayMeter = displayMeter
} }
public func makeNSView(context: Context) -> StreamLayerView { public func makeNSView(context: Context) -> StreamLayerView {
let view = StreamLayerView() let view = StreamLayerView()
view.onCaptureChange = onCaptureChange view.onCaptureChange = onCaptureChange
view.captureEnabled = captureEnabled view.captureEnabled = captureEnabled
view.presentMeter = presentMeter view.endToEndMeter = endToEndMeter
view.presentTailMeter = presentTailMeter view.decodeMeter = decodeMeter
view.displayMeter = displayMeter
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
return view return view
} }
@@ -125,8 +131,9 @@ public struct StreamView: NSViewRepresentable {
public func updateNSView(_ view: StreamLayerView, context: Context) { public func updateNSView(_ view: StreamLayerView, context: Context) {
view.onCaptureChange = onCaptureChange view.onCaptureChange = onCaptureChange
view.captureEnabled = captureEnabled view.captureEnabled = captureEnabled
view.presentMeter = presentMeter view.endToEndMeter = endToEndMeter
view.presentTailMeter = presentTailMeter view.decodeMeter = decodeMeter
view.displayMeter = displayMeter
// SwiftUI reuses the NSView across state changes repoint the pump only when the // SwiftUI reuses the NSView across state changes repoint the pump only when the
// connection identity actually changed. // connection identity actually changed.
if view.connection !== connection { if view.connection !== connection {
@@ -141,10 +148,11 @@ public struct StreamView: NSViewRepresentable {
public final class StreamLayerView: NSView { public final class StreamLayerView: NSView {
private let displayLayer = AVSampleBufferDisplayLayer() private let displayLayer = AVSampleBufferDisplayLayer()
/// Record capturepresent / decodepresent when the stage-2 presenter is active. /// Record the unified latency stages (end-to-end / decode / display) when the stage-2
/// Consulted at start(). /// presenter is active. Consulted at start().
var presentMeter: LatencyMeter? var endToEndMeter: LatencyMeter?
var presentTailMeter: LatencyMeter? var decodeMeter: LatencyMeter?
var displayMeter: LatencyMeter?
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the /// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback. /// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter() private let presenter = SessionPresenter()
@@ -571,8 +579,9 @@ public final class StreamLayerView: NSView {
presenter.start( presenter.start(
connection: connection, connection: connection,
baseLayer: displayLayer, baseLayer: displayLayer,
presentMeter: presentMeter, endToEndMeter: endToEndMeter,
presentTailMeter: presentTailMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter,
makeDisplayLink: { displayLink(target: $0, selector: $1) }, makeDisplayLink: { displayLink(target: $0, selector: $1) },
onFrame: onFrame, onFrame: onFrame,
onSessionEnd: onSessionEnd) onSessionEnd: onSessionEnd)
@@ -50,8 +50,9 @@ public struct StreamView: UIViewControllerRepresentable {
private let onCaptureChange: ((Bool) -> Void)? private let onCaptureChange: ((Bool) -> Void)?
private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onFrame: (@Sendable (AccessUnit) -> Void)?
private let onSessionEnd: (@Sendable () -> Void)? private let onSessionEnd: (@Sendable () -> Void)?
private let presentMeter: LatencyMeter? private let endToEndMeter: LatencyMeter?
private let presentTailMeter: LatencyMeter? private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter?
public init( public init(
connection: PunktfunkConnection, connection: PunktfunkConnection,
@@ -59,24 +60,27 @@ public struct StreamView: UIViewControllerRepresentable {
onCaptureChange: ((Bool) -> Void)? = nil, onCaptureChange: ((Bool) -> Void)? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil,
presentMeter: LatencyMeter? = nil, endToEndMeter: LatencyMeter? = nil,
presentTailMeter: LatencyMeter? = nil decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil
) { ) {
self.connection = connection self.connection = connection
self.captureEnabled = captureEnabled self.captureEnabled = captureEnabled
self.onCaptureChange = onCaptureChange self.onCaptureChange = onCaptureChange
self.onFrame = onFrame self.onFrame = onFrame
self.onSessionEnd = onSessionEnd self.onSessionEnd = onSessionEnd
self.presentMeter = presentMeter self.endToEndMeter = endToEndMeter
self.presentTailMeter = presentTailMeter self.decodeMeter = decodeMeter
self.displayMeter = displayMeter
} }
public func makeUIViewController(context: Context) -> StreamViewController { public func makeUIViewController(context: Context) -> StreamViewController {
let controller = StreamViewController() let controller = StreamViewController()
controller.onCaptureChange = onCaptureChange controller.onCaptureChange = onCaptureChange
controller.captureEnabled = captureEnabled controller.captureEnabled = captureEnabled
controller.presentMeter = presentMeter controller.endToEndMeter = endToEndMeter
controller.presentTailMeter = presentTailMeter controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
return controller return controller
} }
@@ -84,8 +88,9 @@ public struct StreamView: UIViewControllerRepresentable {
public func updateUIViewController(_ controller: StreamViewController, context: Context) { public func updateUIViewController(_ controller: StreamViewController, context: Context) {
controller.onCaptureChange = onCaptureChange controller.onCaptureChange = onCaptureChange
controller.captureEnabled = captureEnabled controller.captureEnabled = captureEnabled
controller.presentMeter = presentMeter controller.endToEndMeter = endToEndMeter
controller.presentTailMeter = presentTailMeter controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter
if controller.connection !== connection { if controller.connection !== connection {
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
} }
@@ -101,10 +106,11 @@ public struct StreamView: UIViewControllerRepresentable {
public final class StreamViewController: UIViewController { public final class StreamViewController: UIViewController {
public private(set) var connection: PunktfunkConnection? public private(set) var connection: PunktfunkConnection?
private var observers: [NSObjectProtocol] = [] private var observers: [NSObjectProtocol] = []
/// Record capturepresent / decodepresent when the stage-2 presenter is active. /// Record the unified latency stages (end-to-end / decode / display) when the stage-2
/// Consulted at start(). /// presenter is active. Consulted at start().
var presentMeter: LatencyMeter? var endToEndMeter: LatencyMeter?
var presentTailMeter: LatencyMeter? var decodeMeter: LatencyMeter?
var displayMeter: LatencyMeter?
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the /// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback. /// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter() private let presenter = SessionPresenter()
@@ -285,8 +291,9 @@ public final class StreamViewController: UIViewController {
presenter.start( presenter.start(
connection: connection, connection: connection,
baseLayer: streamView.displayLayer, baseLayer: streamView.displayLayer,
presentMeter: presentMeter, endToEndMeter: endToEndMeter,
presentTailMeter: presentTailMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter,
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) }, makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
onFrame: onFrame, onFrame: onFrame,
onSessionEnd: onSessionEnd) onSessionEnd: onSessionEnd)
@@ -339,6 +346,9 @@ public final class StreamViewController: UIViewController {
setCaptured(false) setCaptured(false)
inputCapture?.stop() inputCapture?.stop()
inputCapture = nil inputCapture = nil
// Release anything the touch-driven mouse still holds (a mid-drag session end) while
// onTouchEvent can still deliver the button-up.
streamView.resetTouchInput()
streamView.onTouchEvent = nil streamView.onTouchEvent = nil
streamView.onPointerMoveAbs = nil streamView.onPointerMoveAbs = nil
streamView.onPointerButton = nil streamView.onPointerButton = nil
@@ -454,7 +464,8 @@ final class StreamLayerUIView: UIView {
/// Reads the LIVE negotiated mode in pixels (the touch/pointer coordinate space). /// Reads the LIVE negotiated mode in pixels (the touch/pointer coordinate space).
var currentHostMode: (() -> CGSize)? var currentHostMode: (() -> CGSize)?
/// Direct fingers / Pencil wire touch events. /// Direct fingers / Pencil wire events: real touches in passthrough mode, or the
/// touch-driven mouse events (`TouchMouse`) in the trackpad/pointer modes.
var onTouchEvent: ((PunktfunkInputEvent) -> Void)? var onTouchEvent: ((PunktfunkInputEvent) -> Void)?
/// Indirect pointer (mouse/trackpad with no lock) absolute cursor moves. /// Indirect pointer (mouse/trackpad with no lock) absolute cursor moves.
var onPointerMoveAbs: ((HostPoint) -> Void)? var onPointerMoveAbs: ((HostPoint) -> Void)?
@@ -468,6 +479,22 @@ final class StreamLayerUIView: UIView {
/// GameStream button held per active indirect-pointer touch (one click/drag session); /// GameStream button held per active indirect-pointer touch (one click/drag session);
/// released when that touch ends. /// released when that touch ends.
private var pointerButtons: [ObjectIdentifier: UInt32] = [:] private var pointerButtons: [ObjectIdentifier: UInt32] = [:]
/// Touch-driven mouse for the trackpad/pointer `TouchInputMode`s (see TouchMouse.swift).
private lazy var touchMouse: TouchMouse = {
let mouse = TouchMouse()
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
return mouse
}()
/// The finger route latched at gesture start a Settings change mid-gesture applies to
/// the NEXT touch, so one gesture never splits across input models.
private var fingerRoute: TouchInputMode?
/// Release anything the touch-driven mouse holds and forget gesture state session stop.
func resetTouchInput() {
touchMouse.reset()
fingerRoute = nil
}
#endif #endif
override init(frame: CGRect) { override init(frame: CGRect) {
@@ -504,10 +531,10 @@ final class StreamLayerUIView: UIView {
route(touches, event: event, kind: .up) route(touches, event: event, kind: .up)
} }
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
route(touches, event: event, kind: .up) route(touches, event: event, kind: .cancel)
} }
private enum TouchKind { case down, move, up } private enum TouchKind { case down, move, up, cancel }
/// Split a touch batch by kind: an INDIRECT POINTER (mouse/trackpad with no lock) drives /// Split a touch batch by kind: an INDIRECT POINTER (mouse/trackpad with no lock) drives
/// the host cursor as an absolute mouse; everything else (direct finger, Pencil) is a host /// the host cursor as an absolute mouse; everything else (direct finger, Pencil) is a host
@@ -521,7 +548,28 @@ final class StreamLayerUIView: UIView {
fingers.insert(touch) fingers.insert(touch)
} }
} }
if !fingers.isEmpty { forwardTouches(fingers, kind: kind) } if !fingers.isEmpty { forwardFingers(fingers, kind: kind) }
}
/// Route direct fingers by the touch-input model, latched for the whole gesture:
/// passthrough real wire touches; trackpad/pointer the TouchMouse gesture engine.
private func forwardFingers(_ touches: Set<UITouch>, kind: TouchKind) {
let mode = fingerRoute ?? TouchInputMode.current
fingerRoute = mode
switch mode {
case .touch:
// A cancellation lifts the wire touch like a normal up the host just sees the
// contact end.
forwardTouches(touches, kind: kind == .cancel ? .up : kind)
case .trackpad, .pointer:
switch kind {
case .down: touchMouse.began(touches, in: self, trackpad: mode == .trackpad)
case .move: touchMouse.moved(touches, in: self)
case .up: touchMouse.ended(touches, in: self)
case .cancel: touchMouse.cancelled(touches)
}
}
if touchIDs.isEmpty, touchMouse.isIdle { fingerRoute = nil }
} }
/// An indirect-pointer touch is a button-held click/drag session: forward its position as /// An indirect-pointer touch is a button-held click/drag session: forward its position as
@@ -537,7 +585,7 @@ final class StreamLayerUIView: UIView {
onPointerButton?(button, true) onPointerButton?(button, true)
case .move: case .move:
if let host { onPointerMoveAbs?(host) } if let host { onPointerMoveAbs?(host) }
case .up: case .up, .cancel:
if let host { onPointerMoveAbs?(host) } if let host { onPointerMoveAbs?(host) }
if let button = pointerButtons.removeValue(forKey: key) { if let button = pointerButtons.removeValue(forKey: key) {
onPointerButton?(button, false) onPointerButton?(button, false)
@@ -554,7 +602,7 @@ final class StreamLayerUIView: UIView {
case .down: case .down:
id = nextFreeID() id = nextFreeID()
touchIDs[key] = id touchIDs[key] = id
case .move, .up: case .move, .up, .cancel:
guard let known = touchIDs[key] else { continue } guard let known = touchIDs[key] else { continue }
id = known id = known
} }
@@ -0,0 +1,93 @@
// Multi-channel input mono fold (SessionAudio.foldToMono): the fix for a mic on one channel of
// a multi-channel interface. AVAudioConverter's default Nstereo downmix grabs channels 0/1 dead
// silence when the mic sits higher up so we fold ourselves. This pins the fiddly bits (the
// interleaved stride, channel pinning, the sum-clamp) against regressions without needing hardware.
#if !os(tvOS)
import XCTest
@testable import PunktfunkKit
final class AudioChannelFoldTests: XCTestCase {
/// Drive `foldToMono` over channel data expressed as `[[Float]]`, mirroring the two
/// `floatChannelData` layouts:
/// - deinterleaved: each inner array is one channel (all `frames` long).
/// - interleaved: a single inner array already interleaved (c0f0, c1f0, ), with the real
/// channel count passed separately.
private func fold(
_ planes: [[Float]], frames: Int, channels: Int, interleaved: Bool, pinned: Int?
) -> [Float] {
// One C buffer per plane + a table of pointers to them the shape of floatChannelData.
let buffers: [UnsafeMutablePointer<Float>] = planes.map { plane in
let p = UnsafeMutablePointer<Float>.allocate(capacity: plane.count)
for i in 0..<plane.count { p[i] = plane[i] }
return p
}
let table = UnsafeMutablePointer<UnsafeMutablePointer<Float>>.allocate(
capacity: buffers.count)
for (i, b) in buffers.enumerated() { table[i] = b }
let out = UnsafeMutablePointer<Float>.allocate(capacity: frames)
defer {
buffers.forEach { $0.deallocate() }
table.deallocate()
out.deallocate()
}
SessionAudio.foldToMono(
input: table, frames: frames, channels: channels,
interleaved: interleaved, pinned: pinned, out: out)
return (0..<frames).map { out[$0] }
}
// A pinned channel is copied verbatim the exact fix: mic on a HIGH channel, not 0/1.
func testPinsHigherChannelDeinterleaved() {
let result = fold(
[[0, 0, 0], [0, 0, 0], [0.1, 0.2, 0.3], [0, 0, 0]],
frames: 3, channels: 4, interleaved: false, pinned: 2)
XCTAssertEqual(result, [0.1, 0.2, 0.3])
}
// Same signal, interleaved layout: [c0f0,c1f0,c2f0,c3f0, c0f1,]. Guards the `i*ch + c` stride.
func testPinsHigherChannelInterleaved() {
let interleaved: [Float] = [
0, 0, 0.1, 0,
0, 0, 0.2, 0,
0, 0, 0.3, 0,
]
let result = fold([interleaved], frames: 3, channels: 4, interleaved: true, pinned: 2)
XCTAssertEqual(result, [0.1, 0.2, 0.3])
}
// Auto (pinned: nil): a lone hot channel amid silence passes at FULL level, never attenuated.
func testAutoSumsAllChannelsSoALoneMicSurvives() {
let result = fold(
[[0, 0], [0.4, -0.4], [0, 0]],
frames: 2, channels: 3, interleaved: false, pinned: nil)
XCTAssertEqual(result, [0.4, -0.4])
}
// Two simultaneously-hot channels sum past the unit range clamped, never wraps/overflows.
func testAutoSumClampsToUnitRange() {
let result = fold(
[[0.8, -0.8], [0.9, -0.9]],
frames: 2, channels: 2, interleaved: false, pinned: nil)
XCTAssertEqual(result, [1.0, -1.0])
}
// A plain mono device is passed through untouched (no clamp, no attenuation).
func testMonoIsIdentity() {
let result = fold(
[[0.25, -0.5, 0.75]], frames: 3, channels: 1, interleaved: false, pinned: nil)
XCTAssertEqual(result, [0.25, -0.5, 0.75])
}
// Belt-and-suspenders: an out-of-range pin (the tap already guards, but the setting is
// persisted) is ignored by foldToMono's own `ch < channels` guard, which sums instead of
// reading past the buffer.
func testOutOfRangePinFallsBackToSum() {
let result = fold(
[[0, 0], [0.3, 0.3]],
frames: 2, channels: 2, interleaved: false, pinned: 2)
XCTAssertEqual(result, [0.3, 0.3])
}
}
#endif
@@ -0,0 +1,107 @@
// Unit tests for HostNetworkSplitter (the host/network split of the unified stats model's
// host+network stage design/stats-unification.md Phase 2): pts matching, the per-frame
// tiling arithmetic (network = combined host, floored at 0), drain/reset semantics, the
// bounded pending ring, and the absurd-receipt clamp. All samples use explicit instants, so
// the expectations are exact.
import Foundation
import XCTest
@testable import PunktfunkKit
final class HostNetworkSplitterTests: XCTestCase {
/// An arbitrary host-capture pts (ns) far from zero, like a real CLOCK_REALTIME stamp.
private let basePts: UInt64 = 1_000_000_000_000
private func receipt(_ s: HostNetworkSplitter, pts: UInt64, combinedMs: Int64,
offsetNs: Int64 = 0) {
s.recordReceipt(
ptsNs: pts, receivedNs: Int64(pts) + combinedMs * 1_000_000 - offsetNs,
offsetNs: offsetNs)
}
func testEmptyDrainIsNil() {
XCTAssertNil(HostNetworkSplitter().drain())
}
func testMatchSplitsCombinedIntoHostAndNetwork() {
let s = HostNetworkSplitter()
receipt(s, pts: basePts, combinedMs: 8) // capturereceived 8 ms
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000) // host says 3 ms of it was its own
guard let split = s.drain() else { return XCTFail("expected a matched sample") }
XCTAssertEqual(split.count, 1)
XCTAssertEqual(split.hostP50Ms, 3.0)
XCTAssertEqual(split.networkP50Ms, 5.0, "the two terms tile the combined interval")
XCTAssertNil(s.drain(), "drain resets the window")
}
func testSkewOffsetAppliesToTheCombinedInterval() {
let s = HostNetworkSplitter()
// Client clock 2 ms behind the host: the raw difference alone would read 6 ms.
receipt(s, pts: basePts, combinedMs: 8, offsetNs: 2_000_000)
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000)
XCTAssertEqual(s.drain()?.networkP50Ms, 5.0)
}
func testUnmatchedTimingIsSkipped() {
let s = HostNetworkSplitter()
receipt(s, pts: basePts, combinedMs: 8)
// A timing for an AU we never received (FEC-dropped) must not fabricate a sample.
s.noteHostTiming(ptsNs: basePts + 1, hostUs: 3_000)
XCTAssertNil(s.drain())
}
func testReceiptSurvivesADrainUntilItsTimingArrives() {
let s = HostNetworkSplitter()
receipt(s, pts: basePts, combinedMs: 8)
XCTAssertNil(s.drain(), "no timing matched yet")
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000) // arrives one tick late still matches
XCTAssertEqual(s.drain()?.hostP50Ms, 3.0)
}
func testEachReceiptMatchesOnce() {
let s = HostNetworkSplitter()
receipt(s, pts: basePts, combinedMs: 8)
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000)
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000) // duplicate 0xCF no second sample
XCTAssertEqual(s.drain()?.count, 1)
}
func testNetworkFlooredAtZero() {
let s = HostNetworkSplitter()
// A slightly-off skew offset can make host_us exceed the combined interval.
receipt(s, pts: basePts, combinedMs: 2)
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000)
guard let split = s.drain() else { return XCTFail("expected a sample") }
XCTAssertEqual(split.hostP50Ms, 3.0)
XCTAssertEqual(split.networkP50Ms, 0.0)
}
func testPendingRingDropsOldest() {
let s = HostNetworkSplitter()
for i in 0..<300 { // cap is 256 the first receipts fall out
receipt(s, pts: basePts + UInt64(i), combinedMs: 8)
}
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000) // evicted no match
XCTAssertNil(s.drain())
s.noteHostTiming(ptsNs: basePts + 299, hostUs: 3_000) // newest still pending
XCTAssertEqual(s.drain()?.count, 1)
}
func testAbsurdReceiptsAreDropped() {
let s = HostNetworkSplitter()
receipt(s, pts: basePts, combinedMs: -1) // received before capture clock step
receipt(s, pts: basePts + 1, combinedMs: 20_000) // > 10 s garbage pts/offset
s.noteHostTiming(ptsNs: basePts, hostUs: 1_000)
s.noteHostTiming(ptsNs: basePts + 1, hostUs: 1_000)
XCTAssertNil(s.drain())
}
func testResetForgetsPendingReceipts() {
let s = HostNetworkSplitter()
receipt(s, pts: basePts, combinedMs: 8)
s.reset()
s.noteHostTiming(ptsNs: basePts, hostUs: 3_000)
XCTAssertNil(s.drain(), "a fresh session must not match a previous session's receipts")
}
}
@@ -1,6 +1,10 @@
// Unit tests for LatencyMeter: percentiles, the skew-corrected flag, reset-on-drain, and the // Unit tests for LatencyMeter (one instance per unified-stats stage see
// absurd-value guard. Latencies are constructed by stamping a pts a known interval in the past, so // design/stats-unification.md): percentiles, the skew-corrected flag, reset-on-drain, the
// the result is that interval plus the (tiny) clock advance between reads asserted with tolerance. // absurd-value guard, and the explicit-instant stage form (record(ptsNs:atNs:offsetNs:), used for
// the client-local decode/display stages and the at-present end-to-end stamp). Receipt-path
// latencies are constructed by stamping a pts a known interval in the past, so the result is that
// interval plus the (tiny) clock advance between reads asserted with tolerance; the explicit
// form is exact.
import Foundation import Foundation
import XCTest import XCTest
@@ -38,6 +42,26 @@ final class LatencyMeterTests: XCTestCase {
XCTAssertEqual(m.drain()?.skewCorrected, true) XCTAssertEqual(m.drain()?.skewCorrected, true)
} }
func testExplicitStageRecordIsExact() {
let m = LatencyMeter()
// A client-local stage (decode: receiveddecoded) start instant as ptsNs, offset 0.
let receivedNs: Int64 = 1_000_000_000_000
m.record(ptsNs: UInt64(receivedNs), atNs: receivedNs + 3_000_000, offsetNs: 0)
guard let s = m.drain() else { return XCTFail("expected a sample") }
XCTAssertEqual(s.count, 1)
XCTAssertEqual(s.p50Ms, 3.0, "explicit instants make the sample exact")
XCTAssertFalse(s.skewCorrected, "local stages record with offset 0")
}
func testExplicitStageDropsNonPositiveInterval() {
let m = LatencyMeter()
// A stage whose start stamp is missing (0) or after its end must not pollute the window.
let decodedNs: Int64 = 1_000_000_000_000
m.record(ptsNs: 0, atNs: decodedNs, offsetNs: 0) // "start unknown" > 10 s dropped
m.record(ptsNs: UInt64(decodedNs + 1), atNs: decodedNs, offsetNs: 0) // negative dropped
XCTAssertNil(m.drain())
}
func testDropsAbsurdValues() { func testDropsAbsurdValues() {
let m = LatencyMeter() let m = LatencyMeter()
let now = nowRealtimeNs() let now = nowRealtimeNs()
@@ -25,12 +25,18 @@ final class LoopbackIntegrationTests: XCTestCase {
XCTAssertEqual(conn.resolvedBitrateKbps, 50_000) XCTAssertEqual(conn.resolvedBitrateKbps, 50_000)
// Pull 25 synthetic frames and byte-verify the documented pattern: // Pull 25 synthetic frames and byte-verify the documented pattern:
// u32 LE frame index, then data[i] = (idx as u8) &+ (i as u8). // u32 LE frame index, then data[i] = (idx as u8) &+ (i as u8). Alongside, drain the
// per-AU host-timing plane (0xCF) the way the app's stats tick does the connector
// ORs VIDEO_CAP_HOST_TIMING in unconditionally and the synthetic host stamps one
// report per AU, so the pts correlation must hold end to end through the xcframework.
var got = 0 var got = 0
var lastIndex: UInt32 = 0 var lastIndex: UInt32 = 0
var receivedPts = Set<UInt64>()
var timings: [PunktfunkConnection.HostTiming] = []
let deadline = Date().addingTimeInterval(30) let deadline = Date().addingTimeInterval(30)
while got < 25 { while got < 25 {
XCTAssertLessThan(Date(), deadline, "timed out after \(got) frames") XCTAssertLessThan(Date(), deadline, "timed out after \(got) frames")
while let t = try conn.nextHostTiming(timeoutMs: 0) { timings.append(t) }
guard let au = try conn.nextAU(timeoutMs: 2000) else { continue } guard let au = try conn.nextAU(timeoutMs: 2000) else { continue }
let idx = au.data.prefix(4).reversed().reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } let idx = au.data.prefix(4).reversed().reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
for (i, byte) in au.data.enumerated().dropFirst(4) { for (i, byte) in au.data.enumerated().dropFirst(4) {
@@ -41,10 +47,22 @@ final class LoopbackIntegrationTests: XCTestCase {
} }
} }
XCTAssertGreaterThan(au.ptsNs, 0) XCTAssertGreaterThan(au.ptsNs, 0)
receivedPts.insert(au.ptsNs)
lastIndex = idx lastIndex = idx
got += 1 got += 1
} }
XCTAssertGreaterThanOrEqual(lastIndex, 24) XCTAssertGreaterThanOrEqual(lastIndex, 24)
// Belt-and-braces: the last frame's timing lands just after its AU give it a bounded
// grace drain (the stream keeps running, so this must not loop on fresh timings).
var grace = 0
while grace < 64, !timings.contains(where: { receivedPts.contains($0.ptsNs) }),
let t = try conn.nextHostTiming(timeoutMs: 100) {
timings.append(t)
grace += 1
}
XCTAssertTrue(
timings.contains { receivedPts.contains($0.ptsNs) },
"no 0xCF host timing matched a received AU's pts (got \(timings.count) timings)")
// Input goes the other way (enqueue-only; the host logs the count on close) // Input goes the other way (enqueue-only; the host logs the count on close)
// including the touch kinds, gamepad events, the rich-input plane (DualSense // including the touch kinds, gamepad events, the rich-input plane (DualSense
@@ -0,0 +1,97 @@
import XCTest
@testable import PunktfunkKit
/// Pins the rumble renderer's pure scheduling/mapping decisions and the relations between its
/// tuning constants that the design depends on (see `RumbleRenderer`'s invariants). No
/// CHHapticEngine or physical pad involved.
final class RumbleTuningTests: XCTestCase {
func testAmplitudeMapsWireRangeToUnitInterval() {
XCTAssertEqual(RumbleTuning.amplitude(0), 0)
XCTAssertEqual(RumbleTuning.amplitude(0xFFFF), 1)
XCTAssertEqual(RumbleTuning.amplitude(0x8000), Float(0x8000) / 65535, accuracy: 1e-6)
// Monotonic a stronger wire value can never render weaker.
XCTAssertLessThan(RumbleTuning.amplitude(0x1000), RumbleTuning.amplitude(0x2000))
}
func testHidByteMapsWireRangeToPadRange() {
XCTAssertEqual(RumbleTuning.hidByte(0), 0)
XCTAssertEqual(RumbleTuning.hidByte(0xFFFF), 255)
XCTAssertEqual(RumbleTuning.hidByte(0x8000), 0x80)
}
func testCombinedActuatorRendersStrongerMotor() {
XCTAssertEqual(RumbleTuning.combined(low: 0x4000, high: 0x8000), 0x8000)
XCTAssertEqual(RumbleTuning.combined(low: 0x8000, high: 0x4000), 0x8000)
XCTAssertEqual(RumbleTuning.combined(low: 0, high: 0), 0)
}
func testLevelDedupeEpsilon() {
// An identical host refresh (and LSB jitter) is the same level no player rebuild.
XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5))
XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon))
// A real level change is not.
XCTAssertFalse(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon * 3))
XCTAssertFalse(RumbleTuning.sameLevel(0, 1))
}
func testRearmDecision() {
let ends: TimeInterval = 100
XCTAssertFalse(
RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom - 0.1))
XCTAssertTrue(
RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom + 0.1))
// Even a segment already past its end re-arms (the gap already happened; recover).
XCTAssertTrue(RumbleTuning.shouldRearm(endsAt: ends, now: ends + 1))
}
func testHandoffStartsAtSegmentEndNeverInThePast() {
// Successor starts exactly at the predecessor's end...
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 99.5), 100)
// ...unless that instant already passed then start immediately, not in the past.
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
}
func testPolicies() {
// The session policy ties motor life to wire liveness; the manual (test-panel) policy
// holds a level indefinitely.
XCTAssertNotNil(RumbleRenderer.Policy.session.staleAfter)
XCTAssertNil(RumbleRenderer.Policy.manual.staleAfter)
}
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
/// storm, an audible target left to the ticker (watchdog path), then `stop()` which runs
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
func testRendererSurvivesCallStormAndTeardownWithoutController() {
let renderer = RumbleRenderer(policy: .session)
renderer.retarget(nil)
for i in 0..<500 {
renderer.apply(
low: i % 2 == 0 ? 0x8000 : 0, high: UInt16(truncatingIfNeeded: i &* 37))
}
// Leave a nonzero target long enough for the ticker to spin a few times.
renderer.apply(low: 0x4000, high: 0x4000)
Thread.sleep(forTimeInterval: 0.2)
renderer.stop()
}
func testTuningRelationsTheDesignDependsOn() {
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
// but trip well before a stuck rumble reads as "still going".
XCTAssertGreaterThan(RumbleTuning.sessionStaleSeconds, 2 * 0.5)
XCTAssertLessThanOrEqual(RumbleTuning.sessionStaleSeconds, 2.5)
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
// segment boundary and gap.
XCTAssertGreaterThanOrEqual(
RumbleTuning.rearmHeadroom, 4 * RumbleTuning.tickSeconds)
// The headroom must fit inside a segment, or re-arm would trigger instantly forever.
XCTAssertLessThan(RumbleTuning.rearmHeadroom, RumbleTuning.segmentSeconds)
// The rebake throttle must be far under the host refresh period, or refreshed level
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
// The ticker (which lands throttled levels) must outpace the HID keepalive and the
// watchdog, or those deadlines could be overshot by a full period.
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.sessionStaleSeconds)
}
}
@@ -31,7 +31,7 @@ final class Stage444Tests: XCTestCase {
let data = Data(Probe444Blobs.au444_8bit) let data = Data(Probe444Blobs.au444_8bit)
let format = try XCTUnwrap( let format = try XCTUnwrap(
AnnexB.formatDescription(fromIDR: data, codec: .hevc), "the 4:4:4 blob must yield a format description") AnnexB.formatDescription(fromIDR: data, codec: .hevc), "the 4:4:4 blob must yield a format description")
let au = AccessUnit(data: data, ptsNs: 7_000_000, frameIndex: 0, flags: 0) let au = AccessUnit(data: data, ptsNs: 7_000_000, frameIndex: 0, flags: 0, receivedNs: 0)
let box = FrameBox() let box = FrameBox()
let done = DispatchSemaphore(value: 0) let done = DispatchSemaphore(value: 0)
@@ -0,0 +1,42 @@
#if os(iOS)
import XCTest
@testable import PunktfunkKit
/// Pins the touch-mouse tuning contract (ported 1:1 from the Android client's TouchInput.kt
/// so the two touch clients feel identical) and the mode parsing. The gesture state machine
/// itself needs UITouch instances and is validated on-glass.
final class TouchMouseTests: XCTestCase {
func testModeParsingDefaultsToTrackpad() {
XCTAssertEqual(TouchInputMode(rawValue: "trackpad"), .trackpad)
XCTAssertEqual(TouchInputMode(rawValue: "pointer"), .pointer)
XCTAssertEqual(TouchInputMode(rawValue: "touch"), .touch)
// Unknown/unset values must fall back to trackpad never crash or go touch-silent.
XCTAssertNil(TouchInputMode(rawValue: "bogus"))
}
func testAccelerationCurve() {
// At or below the speed floor: no acceleration slow drags stay precise.
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: 0), 1)
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: TouchMouse.Tuning.accelSpeedFloor), 1)
// Above the floor the gain ramps...
let mid = TouchMouse.Tuning.accel(forSpeed: 1.0)
XCTAssertGreaterThan(mid, 1)
XCTAssertLessThan(mid, TouchMouse.Tuning.accelMax)
// ...and a flick is capped so it can't fling the cursor uncontrollably.
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: 100), TouchMouse.Tuning.accelMax)
// Monotonic in between.
XCTAssertLessThanOrEqual(
TouchMouse.Tuning.accel(forSpeed: 0.5), TouchMouse.Tuning.accel(forSpeed: 1.5))
}
func testTuningRelations() {
// The tap-drag window must be long enough to hit but short enough not to turn every
// second tap into a drag.
XCTAssertGreaterThan(TouchMouse.Tuning.tapDragWindow, 0.1)
XCTAssertLessThan(TouchMouse.Tuning.tapDragWindow, 0.5)
// A wheel notch per ~10 pt of two-finger pan (the indirect-trackpad path's feel).
XCTAssertGreaterThan(TouchMouse.Tuning.scrollNotchPt, 0)
}
}
#endif
@@ -38,7 +38,8 @@ final class VideoToolboxRoundTripTests: XCTestCase {
XCTAssertEqual(AnnexB.avcc(from: annexB, codec: .hevc), avccSample) XCTAssertEqual(AnnexB.avcc(from: annexB, codec: .hevc), avccSample)
// 3) Sample buffer real decoder pixels. // 3) Sample buffer real decoder pixels.
let au = AccessUnit(data: annexB, ptsNs: 1_000_000, frameIndex: 0, flags: 0) let au = AccessUnit(
data: annexB, ptsNs: 1_000_000, frameIndex: 0, flags: 0, receivedNs: 0)
let sample = try XCTUnwrap(AnnexB.sampleBuffer(au: au, format: rebuilt, codec: .hevc)) let sample = try XCTUnwrap(AnnexB.sampleBuffer(au: au, format: rebuilt, codec: .hevc))
var session: VTDecompressionSession? var session: VTDecompressionSession?
@@ -67,13 +68,14 @@ final class VideoToolboxRoundTripTests: XCTestCase {
} }
/// Stage-2 decode half: the same known IDR through `VideoDecoder` assert its async output /// Stage-2 decode half: the same known IDR through `VideoDecoder` assert its async output
/// callback fires with a CVPixelBuffer of the right dimensions, the pts round-trips, and /// callback fires with a CVPixelBuffer of the right dimensions, the pts and the receipt stamp
/// decode-completion is stamped. /// round-trip (the latter rides the frame refcon), and decode-completion is stamped.
func testVideoDecoderAsyncCallbackDeliversPixels() throws { func testVideoDecoderAsyncCallbackDeliversPixels() throws {
let (formatDesc, avccSample) = try encodeOneHEVCKeyframe() let (formatDesc, avccSample) = try encodeOneHEVCKeyframe()
let annexB = try annexBAU(formatDesc: formatDesc, avccSample: avccSample) let annexB = try annexBAU(formatDesc: formatDesc, avccSample: avccSample)
let format = try XCTUnwrap(AnnexB.formatDescription(fromIDR: annexB, codec: .hevc)) let format = try XCTUnwrap(AnnexB.formatDescription(fromIDR: annexB, codec: .hevc))
let au = AccessUnit(data: annexB, ptsNs: 42_000_000, frameIndex: 0, flags: 0) let au = AccessUnit(
data: annexB, ptsNs: 42_000_000, frameIndex: 0, flags: 0, receivedNs: 41_000_000)
let box = FrameBox() let box = FrameBox()
let done = DispatchSemaphore(value: 0) let done = DispatchSemaphore(value: 0)
@@ -100,6 +102,8 @@ final class VideoToolboxRoundTripTests: XCTestCase {
XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), width) XCTAssertEqual(CVPixelBufferGetWidth(ready.pixelBuffer), width)
XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), height) XCTAssertEqual(CVPixelBufferGetHeight(ready.pixelBuffer), height)
XCTAssertEqual(ready.ptsNs, 42_000_000, "pts round-trips through the decoder") XCTAssertEqual(ready.ptsNs, 42_000_000, "pts round-trips through the decoder")
XCTAssertEqual(
ready.receivedNs, 41_000_000, "receivedNs round-trips through the frame refcon")
XCTAssertGreaterThan(ready.decodedNs, 0, "decode-completion is stamped") XCTAssertGreaterThan(ready.decodedNs, 0, "decode-completion is stamped")
} }
+29 -17
View File
@@ -1,7 +1,7 @@
# punktfunk — Steam Deck plugin (Decky) # Punktfunk — Steam Deck plugin (Decky)
Stream to your **Steam Deck** without ever leaving Gaming Mode. This Stream to your **Steam Deck** without ever leaving Gaming Mode. This
**[Decky Loader](https://decky.xyz/)** plugin adds a **punktfunk** panel to the Quick Access Menu **[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu
(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch (the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch
a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable. a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.
@@ -12,12 +12,22 @@ the panel looks and feels native to Gaming Mode.
## What it does ## What it does
1. **Discover** — browses the LAN over mDNS for punktfunk hosts, in both the QAM panel and a 1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a
fullscreen page. fullscreen page; each host row opens a details view (address, pairing policy, certificate
fingerprint to cross-check against the host's log).
2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing 2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing
ceremony headlessly, then remembers the host so future streams connect silently. ceremony headlessly, then remembers the host so future streams connect silently.
3. **Stream** — launches fullscreen via a hidden Steam shortcut so gamescope focuses it. 3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses it.
4. **Settings**resolution / refresh / bitrate / gamepad / mic, written to the client's config. 4. **Games**each host row has a games button that opens its **library picker**: pin titles as
one-tap "Stream <Game>" rows in the QAM (jump straight into e.g. Playnite on the host), or
**"Open library on screen"** to launch the client's controller-driven, console-style library
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
(matched by certificate fingerprint).
5. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written
to the client's config.
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
a force-stop for a wedged stream client.
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the
"game" from the Steam overlay — either returns you to Gaming Mode. "game" from the Steam overlay — either returns you to Gaming Mode.
@@ -37,8 +47,10 @@ https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.z
``` ```
(or a pinned `.../punktfunk-decky/<version>/punktfunk.zip`). The plugin then **self-updates** without (or a pinned `.../punktfunk-decky/<version>/punktfunk.zip`). The plugin then **self-updates** without
the Decky store — when a newer build exists, an **Update to vX** button appears and drives Decky the Decky store — when a newer build exists, an **Update** button appears and drives Decky
Loader's own (SHA-256-verified) install. Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
before the actual download proceeds.
## Build & sideload (development) ## Build & sideload (development)
@@ -58,20 +70,20 @@ restart is required for an out-of-band install to appear.
| File | Role | | File | Role |
| --- | --- | | --- | --- |
| `src/index.tsx` | Frontend: QAM panel + the `/punktfunk` fullscreen page (host list, PIN keypad, settings). | | `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. | | `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=<id>` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=<port>` (on-screen library); ids are validated space/quote-free at pin AND launch time. |
| `src/backend.ts` | Typed `callable` bridges to `main.py`. | | `src/backend.ts` | Typed `callable` bridges to `main.py`. |
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut targets (so the window is focusable). | | `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable); maps `PF_LAUNCH`/`PF_BROWSE`/`PF_MGMT` to `--launch`/`--browse`/`--mgmt`. An older flatpak ignores the flags harmlessly (plain stream / hosts page). |
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update`. | | `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / `library` (headless flatpak `--library`, TSV) / pins store (`decky-pinned.json`) / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
| `scripts/test-backend.py` | Stdlib-only checks for the backend's pure parsers (TSV, error classes, avahi TXT) + the pins round trip. |
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. | | `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
The client binary is resolved `PATH``/usr/bin``/usr/local/bin``~/.local/bin` → a
`flatpak run io.unom.Punktfunk` fallback, so the flatpak install always works.
## Limitations / next steps ## Limitations / next steps
- **Needs on-Deck validation in Gaming Mode** — the Steam-shortcut launch and headless pairing follow
MoonDeck's proven pattern but are verified only at build time here.
- No manual "add host by IP" entry yet (discovery is mDNS-only). - No manual "add host by IP" entry yet (discovery is mDNS-only).
- No in-stream overlay inside the plugin — the client owns the session once launched. - No in-stream overlay inside the plugin — the client owns the session once launched.
- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm - Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+29 -2
View File
@@ -11,13 +11,26 @@
# #
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch # Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves # options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
# every host: # every host (and every pinned game):
# PF_HOST host[:port] to connect to (required) # PF_HOST host[:port] to connect to (required)
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
# PF_APPID flatpak app id (default io.unom.Punktfunk) # PF_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) # PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
# #
# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before
# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores
# the unknown flags harmlessly (hand-scanned argv): PF_LAUNCH degrades to the plain desktop
# session, PF_BROWSE to the client's hosts page.
#
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and # Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope. # WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
#
# NO EXEC BIT REQUIRED: the Steam shortcut's exe is `/bin/sh` and this script rides behind
# `%command%` as an argument (see src/steam.ts). Decky extracts plugin zips without preserving
# permission bits and ~/homebrew/plugins is root-owned (the unprivileged plugin backend can't
# chmod), so the launch path must never depend on +x. Keep this script POSIX-sh clean.
set -u set -u
APPID="${PF_APPID:-io.unom.Punktfunk}" APPID="${PF_APPID:-io.unom.Punktfunk}"
@@ -28,9 +41,23 @@ if [ -z "${PF_HOST:-}" ]; then
exit 2 exit 2
fi fi
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and # exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and
# Gaming Mode reclaims focus automatically (no manual refocus needed). # Gaming Mode reclaims focus automatically (no manual refocus needed).
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the # --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it). # Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
if [ -n "${PF_BROWSE:-}" ]; then
# The gamepad library launcher: browse the host's games on-screen, A streams one,
# session end returns to the launcher, B quits back to Gaming Mode.
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
if [ -n "${PF_MGMT:-}" ]; then
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
fi
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
fi
if [ -n "${PF_LAUNCH:-}" ]; then
# A pinned game: the id rides the session Hello and the host launches that title.
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" --fullscreen
fi
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --fullscreen exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --fullscreen
+366 -23
View File
@@ -12,6 +12,11 @@ The backend's jobs are the things Steam can't do:
* **pair(host, port, pin, name)** — run the SPAKE2 PIN ceremony headlessly via the flatpak * **pair(host, port, pin, name)** — run the SPAKE2 PIN ceremony headlessly via the flatpak
client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same
identity store the stream uses), so once paired the stream connects silently. identity store the stream uses), so once paired the stream connects silently.
* **library(host, mgmt_port, fp)** — fetch a paired host's game library headlessly via the
flatpak client's ``--library`` mode (mTLS with the client's own identity; TSV on stdout),
so the picker UI can offer games to pin.
* **get_pins() / set_pins()** — the pinned-games store (``decky-pinned.json`` next to the
client's config, so pins survive plugin reinstalls), annotated with live pairing state.
* **runner_info()** — the absolute path to the launch wrapper + the flatpak app id, handed to * **runner_info()** — the absolute path to the launch wrapper + the flatpak app id, handed to
the frontend so it can create/point the Steam shortcut. the frontend so it can create/point the Steam shortcut.
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON * **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
@@ -20,16 +25,16 @@ The backend's jobs are the things Steam can't do:
* **check_update()** — poll the registry's per-channel ``manifest.json`` and report whether a * **check_update()** — poll the registry's per-channel ``manifest.json`` and report whether a
newer build is available (the frontend then drives Decky's own install RPC to apply it). newer build is available (the frontend then drives Decky's own install RPC to apply it).
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the host The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by
advert in ``crates/punktfunk-host/src/discovery.rs``. the host advert in ``crates/punktfunk-host/src/discovery.rs``.
""" """
import asyncio import asyncio
import base64
import json import json
import os import os
import shutil import shutil
import ssl import ssl
import stat
import time import time
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -77,6 +82,46 @@ def _runner_path() -> str:
return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh") return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh")
def _pins_path() -> Path:
"""The pinned-games store — plugin-owned, but deliberately in the CLIENT's config dir
(like everything else we persist): the plugins dir is root-owned and wiped on
reinstall, while ``~/.config/punktfunk`` survives both."""
return _client_config_dir() / "decky-pinned.json"
def _parse_library_tsv(stdout: str) -> list[dict]:
"""Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per
game plus a trailing ``N game(s)`` count line (no tabs — it self-skips here). A title
may itself contain tabs, so split at most twice."""
games: list[dict] = []
for line in stdout.splitlines():
parts = line.split("\t", 2)
if len(parts) == 3:
games.append({"id": parts[0], "store": parts[1], "title": parts[2]})
return games
def _classify_library_error(stderr: str) -> str:
"""Map the client's ``library: <LibraryError Display>`` stderr line to a stable error
code for the UI. Substring-matched against the Display strings in
``clients/linux/src/library.rs`` — a wording change degrades to ``client-error``
(generic copy), never a crash."""
s = stderr.lower()
if "didn't recognize this device" in s:
return "not-paired"
if "pinned fingerprint" in s:
return "pin-mismatch"
if "couldn't reach the host" in s:
return "unreachable"
if "management api returned http" in s:
return "http"
if "display" in s or "gtk" in s:
# A flatpak so old it predates --library falls through to GTK init, which fails
# headless from this backend.
return "client-outdated"
return "client-error"
# ---------------------------------------------------------------------------------------- # ----------------------------------------------------------------------------------------
# Self-update check (no Decky store). The plugin is distributed via "Install Plugin from # Self-update check (no Decky store). The plugin is distributed via "Install Plugin from
# URL" pointing at our Gitea generic registry, so the official store never sees it and # URL" pointing at our Gitea generic registry, so the official store never sees it and
@@ -125,13 +170,68 @@ def _semver_tuple(v: str) -> tuple[int, int, int]:
return (parts[0], parts[1], parts[2]) return (parts[0], parts[1], parts[2])
# Decky Loader ships its own embedded (PyInstaller) Python whose compiled-in OpenSSL default
# verify paths don't exist on SteamOS — ``ssl.create_default_context()`` then trusts NOTHING
# and every HTTPS fetch dies with CERTIFICATE_VERIFY_FAILED (seen live on the Deck). Fix: find
# a real CA bundle on disk and load it explicitly. Verification is NEVER disabled — if no
# bundle exists the fetch just fails, and check_update() is non-fatal by design.
_CA_BUNDLES = (
"/etc/ssl/certs/ca-certificates.crt", # SteamOS / Arch / Debian / Ubuntu
"/etc/ssl/cert.pem", # Arch/openssl compat symlink
"/etc/pki/tls/certs/ca-bundle.crt", # Fedora / Bazzite
"/etc/ssl/ca-bundle.pem", # openSUSE
)
_ssl_context_cache: ssl.SSLContext | None = None
def _build_ssl_context() -> ssl.SSLContext:
"""A verifying SSLContext that actually has CA roots under Decky's embedded Python."""
ctx = ssl.create_default_context() # honors SSL_CERT_FILE / SSL_CERT_DIR when set
if ctx.cert_store_stats().get("x509_ca", 0):
return ctx # the interpreter found its own roots (e.g. a system python)
dvp = ssl.get_default_verify_paths()
candidates: list[str | None] = [dvp.cafile, dvp.openssl_cafile, *_CA_BUNDLES]
try: # not shipped by Decky's runtime, but honor it when importable
import certifi
candidates.append(certifi.where())
except ImportError:
pass
tried: set[str] = set()
for cafile in candidates:
if not cafile or cafile in tried or not Path(cafile).is_file():
continue
tried.add(cafile)
try:
ctx.load_verify_locations(cafile=cafile)
except (ssl.SSLError, OSError):
continue
if ctx.cert_store_stats().get("x509_ca", 0):
decky.logger.info("TLS roots loaded from %s", cafile)
return ctx
decky.logger.warning(
"no CA bundle found — HTTPS update checks will fail certificate verification"
)
return ctx
def _ssl_context() -> ssl.SSLContext:
"""The (cached) context for registry fetches; building it scans disk, so do it once."""
global _ssl_context_cache
if _ssl_context_cache is None:
_ssl_context_cache = _build_ssl_context()
return _ssl_context_cache
def _fetch_json(url: str, timeout: float = 8.0) -> dict: def _fetch_json(url: str, timeout: float = 8.0) -> dict:
"""Blocking HTTPS GET of a small JSON document (run in an executor).""" """Blocking HTTPS GET of a small JSON document (run in an executor)."""
req = urllib.request.Request( req = urllib.request.Request(
url, headers={"Accept": "application/json", "User-Agent": "punktfunk-decky"} url, headers={"Accept": "application/json", "User-Agent": "punktfunk-decky"}
) )
ctx = ssl.create_default_context() with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
return json.loads(resp.read().decode("utf-8", errors="replace")) return json.loads(resp.read().decode("utf-8", errors="replace"))
@@ -170,6 +270,71 @@ def _flatpak_env() -> dict:
return env return env
async def _flatpak_capture(args: list[str], timeout: float = 20.0) -> tuple[int, str]:
"""Run ``flatpak <args>`` with the user-session env, merging stderr into stdout. Returns
``(returncode, output)``; ``(-1, "")`` if the binary is missing or the call errors/times out.
Best-effort by design — every caller here treats a failure as "no update / can't tell"."""
flatpak = _flatpak()
if not flatpak:
return -1, ""
proc = None
try:
proc = await asyncio.create_subprocess_exec(
flatpak, *args,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
env=_flatpak_env(),
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
rc = proc.returncode if proc.returncode is not None else -1
return rc, (out or b"").decode("utf-8", "replace")
except asyncio.TimeoutError:
decky.logger.warning("flatpak %s timed out", " ".join(args))
if proc:
try:
proc.kill()
except ProcessLookupError:
pass
return -1, ""
except Exception: # noqa: BLE001
decky.logger.exception("flatpak %s failed", " ".join(args))
return -1, ""
def _field_from(text: str, name: str) -> str:
"""Pull ``<name>: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``,
``Origin``)."""
prefix = f"{name}:"
for line in text.splitlines():
s = line.strip()
if s.startswith(prefix):
return s.split(":", 1)[1].strip()
return ""
async def _client_update_state() -> dict:
"""Is a newer commit of the flatpak client available in the remote it tracks? The client is a
**per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and
it versions independently of this plugin — so we compare the installed commit against the
remote's here and let the QAM offer a user-scope update. Best-effort; all-``False`` on any error
(not installed, no flatpak, offline)."""
state = {"available": False, "installed": "", "remote": ""}
rc, info = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
if rc != 0:
return state # client not installed as a user app / no flatpak
state["installed"] = _field_from(info, "Commit")
origin = _field_from(info, "Origin")
if not origin:
return state
rc, rinfo = await _flatpak_capture(["remote-info", "--user", origin, APP_ID], timeout=25.0)
if rc != 0:
return state # remote unreachable — treat as "up to date", retry next check
state["remote"] = _field_from(rinfo, "Commit")
state["available"] = bool(
state["installed"] and state["remote"] and state["installed"] != state["remote"]
)
return state
def _split_txt(txt: str) -> list[str]: def _split_txt(txt: str) -> list[str]:
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting.""" """Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
tokens: list[str] = [] tokens: list[str] = []
@@ -219,6 +384,11 @@ def _parse_avahi_browse(stdout: str) -> list[dict]:
if props.get("proto") and not props["proto"].startswith("punktfunk/"): if props.get("proto") and not props["proto"].startswith("punktfunk/"):
continue continue
try:
mgmt = int(props.get("mgmt", ""))
except ValueError:
mgmt = 0 # not advertised (standalone punktfunk1-host) — callers default 47990
entry = { entry = {
"name": name, "name": name,
"host": address, "host": address,
@@ -226,6 +396,8 @@ def _parse_avahi_browse(stdout: str) -> list[dict]:
"pair": props.get("pair", "optional"), "pair": props.get("pair", "optional"),
"fp": props.get("fp", ""), "fp": props.get("fp", ""),
"proto": props.get("proto", ""), "proto": props.get("proto", ""),
"id": props.get("id", ""),
"mgmt": mgmt,
} }
key = props.get("id") or f"{address}:{port}" key = props.get("id") or f"{address}:{port}"
existing = out.get(key) existing = out.get(key)
@@ -317,15 +489,142 @@ class Plugin:
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1] reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
return {"ok": False, "error": reason} return {"ok": False, "error": reason}
async def library(self, host: str, mgmt_port: int = 0, fp: str = "") -> dict:
"""Fetch a paired host's game library via the flatpak client's headless
``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport —
no trust logic reimplemented here). ``fp`` is passed through whenever the caller
knows the host's cert fingerprint so an IP change can never degrade the pin to a
TOFU accept. Returns ``{ok, games: [{id, store, title}]}`` or
``{ok: False, error: <code>, detail}`` (codes: ``flatpak-not-found`` / ``timeout`` /
``not-paired`` / ``pin-mismatch`` / ``unreachable`` / ``http`` /
``client-outdated`` / ``client-error``)."""
flatpak = _flatpak()
if not flatpak:
return {"ok": False, "error": "flatpak-not-found", "detail": ""}
target = f"{host}:{int(mgmt_port) or 47990}"
argv = [flatpak, "run", "--arch=x86_64", APP_ID, "--library", target]
if fp:
argv += ["--fp", fp]
decky.logger.info("library: fetching %s", target)
proc = None
try:
# Separate pipes (unlike _flatpak_capture): the TSV comes on stdout, the
# client's one-line error reason on stderr. Cold flatpak start on a Deck can
# take seconds — generous timeout, spinner in the UI.
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_flatpak_env(),
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=45.0)
except asyncio.TimeoutError:
if proc:
try:
proc.kill()
except ProcessLookupError:
pass
return {"ok": False, "error": "timeout", "detail": ""}
except Exception as exc: # noqa: BLE001
decky.logger.exception("library fetch failed to launch")
return {"ok": False, "error": "client-error", "detail": str(exc)}
err = stderr.decode(errors="replace")
if proc.returncode != 0:
detail = (err.strip().splitlines() or ["library fetch failed"])[-1]
code = _classify_library_error(err)
decky.logger.warning("library fetch failed (%s): %s", code, detail)
return {"ok": False, "error": code, "detail": detail}
games = _parse_library_tsv(stdout.decode(errors="replace"))
decky.logger.info("library: %d game(s) from %s", len(games), target)
return {"ok": True, "games": games}
async def get_pins(self) -> dict:
"""The pinned games, each annotated with the LIVE ``paired`` state of its host (by
cert fingerprint — an unpaired-since host renders "pairing required" in the QAM)."""
try:
data = json.loads(_pins_path().read_text())
except (OSError, json.JSONDecodeError):
return {"pins": []}
pins = data.get("pins", []) if isinstance(data, dict) else []
paired = _paired_fingerprints()
out = []
for p in pins:
if not isinstance(p, dict) or not p.get("game_id"):
continue
p = dict(p)
p["paired"] = str(p.get("host_fp", "")).lower() in paired
out.append(p)
return {"pins": out}
async def set_pins(self, pins: list) -> dict:
"""Persist the pinned-games list (the frontend sends the whole list — add, remove,
and address-refresh all funnel through here). Validated + deduped on
``(host_fp, game_id)``; written atomically (tmp + rename) — pins are long-lived
user data."""
clean: list[dict] = []
seen: set[tuple[str, str]] = set()
for p in pins if isinstance(pins, list) else []:
if not isinstance(p, dict):
continue
game_id = str(p.get("game_id", ""))
host_fp = str(p.get("host_fp", ""))
if not game_id or not (host_fp or p.get("host")):
continue
key = (host_fp, game_id)
if key in seen:
continue
seen.add(key)
clean.append({
"game_id": game_id,
"title": str(p.get("title", game_id)),
"store": str(p.get("store", "")),
"host_fp": host_fp,
"host_id": str(p.get("host_id", "")),
"host_name": str(p.get("host_name", p.get("host", ""))),
"host": str(p.get("host", "")),
"port": int(p.get("port", 9777) or 9777),
"mgmt": int(p.get("mgmt", 0) or 0),
"added_at": int(p.get("added_at", 0) or 0),
})
try:
d = _client_config_dir()
d.mkdir(parents=True, exist_ok=True)
tmp = _pins_path().with_suffix(".json.tmp")
tmp.write_text(json.dumps({"version": 1, "pins": clean}, indent=2))
os.replace(tmp, _pins_path())
return {"ok": True}
except OSError as exc:
decky.logger.exception("could not write pins")
return {"ok": False, "error": str(exc)}
async def shortcut_art(self) -> dict:
"""The Steam-shortcut artwork shipped with the plugin (``assets/``, generated by
``scripts/gen-steam-art.py``): base64 PNGs for SetCustomArtworkForApp plus the
icon's absolute path for SetShortcutIcon (which wants a file, not bytes). Missing
files are simply omitted — artwork is cosmetic and must never block a launch."""
art: dict = {}
base = Path(decky.DECKY_PLUGIN_DIR) / "assets"
for key, fname in (
("grid", "grid.png"),
("gridwide", "gridwide.png"),
("hero", "hero.png"),
("logo", "logo.png"),
):
try:
art[key] = base64.b64encode((base / fname).read_bytes()).decode()
except OSError:
pass
icon = base / "icon.png"
art["icon_path"] = str(icon) if icon.exists() else ""
return art
async def runner_info(self) -> dict: async def runner_info(self) -> dict:
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam """The wrapper-script path + flatpak app id the frontend needs to create the Steam
shortcut. Also (re)asserts the script's exec bit — packaging can drop it.""" shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
exec bit is needed — Decky's zip extraction drops it, and the root-owned plugins dir
means this unprivileged backend couldn't chmod it back on anyway."""
path = _runner_path() path = _runner_path()
try:
st = os.stat(path)
os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
except OSError:
decky.logger.warning("could not chmod runner %s", path)
return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()} return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()}
async def get_settings(self) -> dict: async def get_settings(self) -> dict:
@@ -368,11 +667,37 @@ class Plugin:
return {"ok": False} return {"ok": False}
return {"ok": True} return {"ok": True}
async def update_client(self) -> dict:
"""Update the flatpak **client** (io.unom.Punktfunk) in the USER installation — the scope a
Steam Deck install lives in, which ``sudo flatpak update`` (system-scope) never reaches.
Returns whether a new commit was actually pulled. Best-effort; non-fatal."""
flatpak = _flatpak()
if not flatpak:
return {"ok": False, "updated": False, "error": "flatpak-not-found"}
_, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
before_commit = _field_from(before, "Commit")
rc, out = await _flatpak_capture(["update", "--user", "-y", APP_ID], timeout=300.0)
if rc != 0:
decky.logger.warning("flatpak client update failed (rc=%s): %s", rc, out[-400:])
return {"ok": False, "updated": False, "error": "update-failed"}
_, after = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
after_commit = _field_from(after, "Commit")
updated = bool(before_commit and after_commit and before_commit != after_commit)
decky.logger.info(
"flatpak client update: %s -> %s (updated=%s)",
before_commit[:10], after_commit[:10], updated,
)
_update_cache["data"] = None # invalidate the cached "update available" snapshot
return {"ok": True, "updated": updated}
async def check_update(self, force: bool = False) -> dict: async def check_update(self, force: bool = False) -> dict:
"""Is a newer build available in our registry? Compares the installed version """Report pending updates for BOTH the plugin and the flatpak client.
(``package.json``) against the per-channel ``manifest.json`` the CI publishes, and
returns everything the frontend needs to drive Decky's install RPC. Non-fatal: any The plugin updates via Decky's install RPC (the per-channel ``manifest.json`` the CI
failure (no channel baked in, network down) returns ``update_available: False``. publishes); the **client** updates via ``flatpak update --user`` (a per-user install, so
``sudo flatpak update`` — system-scope — never touches it) and versions independently, so
it's checked here too and applied through :meth:`update_client`. Non-fatal: any failure
leaves the respective ``*_update_available`` ``False``.
""" """
current = _installed_version() current = _installed_version()
cfg = _update_config() cfg = _update_config()
@@ -383,23 +708,37 @@ class Plugin:
"hash": "", "hash": "",
"channel": str(cfg.get("channel", "")), "channel": str(cfg.get("channel", "")),
"update_available": False, "update_available": False,
"client_update_available": False,
"client_current": "",
"client_latest": "",
} }
manifest_url = cfg.get("manifest")
if not manifest_url:
result["error"] = "update-channel-unknown" # dev / sideloaded build
return result
now = time.monotonic() now = time.monotonic()
cached = _update_cache["data"] cached = _update_cache["data"]
if not force and cached and (now - _update_cache["at"]) < _UPDATE_TTL_S: if not force and cached and (now - _update_cache["at"]) < _UPDATE_TTL_S:
return cached return cached
# Client (flatpak) update — checked ALWAYS, even on a dev/sideloaded plugin build.
try:
cu = await _client_update_state()
result["client_update_available"] = bool(cu["available"])
result["client_current"] = (cu["installed"] or "")[:10]
result["client_latest"] = (cu["remote"] or "")[:10]
except Exception: # noqa: BLE001
decky.logger.warning("client update check failed", exc_info=True)
manifest_url = cfg.get("manifest")
if not manifest_url:
result["error"] = "update-channel-unknown" # dev / sideloaded plugin build
_update_cache["at"] = now
_update_cache["data"] = result # the client info is still valid to cache
return result
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
manifest = await loop.run_in_executor(None, _fetch_json, manifest_url) manifest = await loop.run_in_executor(None, _fetch_json, manifest_url)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
decky.logger.warning("update check failed: %s", exc) decky.logger.warning("plugin update check failed: %s", exc)
result["error"] = "fetch-failed" result["error"] = "fetch-failed"
return result # transient — don't cache, retry next open return result # transient — don't cache, retry next open
@@ -410,8 +749,12 @@ class Plugin:
result["update_available"] = bool(result["artifact"]) and ( result["update_available"] = bool(result["artifact"]) and (
_semver_tuple(latest) > _semver_tuple(current) _semver_tuple(latest) > _semver_tuple(current)
) )
if result["update_available"]: if result["update_available"] or result["client_update_available"]:
decky.logger.info("update available: %s -> %s (%s)", current, latest, result["channel"]) decky.logger.info(
"updates: plugin %s->%s (avail=%s), client->%s (avail=%s)",
current, latest, result["update_available"],
result["client_latest"], result["client_update_available"],
)
_update_cache["at"] = now _update_cache["at"] = now
_update_cache["data"] = result _update_cache["data"] = result
return result return result
+3 -2
View File
@@ -1,14 +1,15 @@
{ {
"name": "punktfunk-decky", "name": "punktfunk-decky",
"version": "0.0.1", "version": "0.0.1",
"description": "SteamOS / Steam Deck Gaming-Mode launcher for the punktfunk streaming client.", "description": "SteamOS / Steam Deck Gaming-Mode launcher for the Punktfunk streaming client.",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "rollup -c", "build": "rollup -c",
"watch": "rollup -c -w", "watch": "rollup -c -w",
"typecheck": "tsc --noEmit --skipLibCheck",
"package": "pnpm build && bash scripts/package.sh", "package": "pnpm build && bash scripts/package.sh",
"deploy": "bash scripts/deploy.sh", "deploy": "bash scripts/deploy.sh",
"test": "echo \"Error: no test specified\" && exit 1" "test": "pnpm typecheck"
}, },
"keywords": [ "keywords": [
"decky", "decky",
+1 -1
View File
@@ -5,7 +5,7 @@
"api_version": 1, "api_version": 1,
"publish": { "publish": {
"tags": ["streaming", "game-streaming", "remote-play"], "tags": ["streaming", "game-streaming", "remote-play"],
"description": "Launch the punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS and connect to one.", "description": "Launch the Punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS, pair with a PIN, and stream.",
"image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader" "image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader"
} }
} }
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""Generate the Steam-shortcut artwork for the Decky plugin (committed, like the tray icons).
The plugin registers a non-Steam shortcut ("Punktfunk") whose grid/hero/logo/icon Steam
would otherwise render as a gray placeholder tile. These assets brand it: the lens mark
(same geometry as scripts/gen-tray-icons.py / web's brand-mark.tsx) over the brand-navy
gradient, plus a monoline "punktfunk" wordmark built from stroke segments ("punktfunk"
needs only p·u·n·k·t·f). The frontend applies them via
SteamClient.Apps.SetCustomArtworkForApp / SetShortcutIcon (src/steam.ts).
Outputs (checked in; re-run only when the brand changes):
clients/decky/assets/grid.png 600 x 900 library capsule (portrait)
clients/decky/assets/gridwide.png 920 x 430 wide capsule (recent games / search)
clients/decky/assets/hero.png 1920 x 620 game-page banner
clients/decky/assets/logo.png transparent overlaid on the hero by Steam
clients/decky/assets/icon.png 256 x 256 list icon (SetShortcutIcon)
Pure stdlib. Unlike the tiny tray icons this rasterizes big surfaces, so edges are
antialiased analytically from signed distances (one sample per pixel) instead of 4x4
supersampling.
"""
import math
import struct
import zlib
from pathlib import Path
HERE = Path(__file__).resolve().parent.parent # clients/decky
OUT = HERE / "assets"
# Brand-mark geometry in its 1000-unit viewbox (identical to gen-tray-icons.py).
R = 194.41
C1 = (403.037, 597.262) # light circle, behind
C2 = (597.8075, 402.8525) # deep circle, in front
BB_MIN = (C1[0] - R, C2[1] - R)
BB_MAX = (C2[0] + R, C1[1] + R)
MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2)
MARK_SPAN = BB_MAX[0] - BB_MIN[0]
COL_LIGHT = (0xA7, 0x9F, 0xF8)
COL_DEEP = (0x6C, 0x5B, 0xF3)
COL_HI = (0xD2, 0xC9, 0xFB)
WORD = (0xEF, 0xEC, 0xFD) # wordmark: near-white lavender
BG_TOP = (0x28, 0x1E, 0x46)
BG_BOT = (0x12, 0x0D, 0x22)
# ------------------------------------------------------------------------------------------
# Wordmark: monoline glyphs as polylines in a unit box (y down; x-height top y=0, baseline
# y=1, ascender to -0.5, descender to +1.5). Arcs are sampled into the polylines, so the
# rasterizer only ever measures distance-to-segment; round caps/joins fall out of that.
# ------------------------------------------------------------------------------------------
def _arc(cx, cy, r, a0, a1, n=24):
"""Polyline along a circle arc; degrees, 0 = +x, angles grow clockwise on screen."""
pts = []
for i in range(n + 1):
a = math.radians(a0 + (a1 - a0) * i / n)
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
return pts
GLYPHS = {
# letter: (advance, [polyline, ...])
"p": (1.05, [[(0, 0), (0, 1.5)], _arc(0.5, 0.5, 0.5, 0, 360)]),
"u": (1.05, [[(0, 0), (0, 0.5)], _arc(0.5, 0.5, 0.5, 0, 180), [(1, 0), (1, 0.5)]]),
"n": (1.05, [[(0, 0), (0, 1)], _arc(0.5, 0.5, 0.5, 180, 360), [(1, 0.5), (1, 1)]]),
"k": (1.0, [[(0, -0.5), (0, 1)], [(0, 0.62), (0.78, 0)], [(0.30, 0.38), (0.85, 1)]]),
"t": (0.85, [[(0.42, -0.42), (0.42, 1)], [(0, 0), (0.84, 0)]]),
"f": (
0.85,
[[(0.42, 1), (0.42, -0.15)] + _arc(0.75, -0.15, 0.33, 180, 270, 12), [(0, 0), (0.78, 0)]],
),
}
GAP = 0.34 # inter-letter gap, in glyph units
STROKE = 0.26 # stroke thickness, in glyph units
ASCENT, DESCENT = -0.5, 1.5 # glyph-space vertical extent
def word_segments(text):
"""The word's stroke segments [(x1,y1,x2,y2)] in glyph units, plus its unit width."""
segs = []
x = 0.0
for ch in text:
adv, lines = GLYPHS[ch]
for line in lines:
for (x1, y1), (x2, y2) in zip(line, line[1:]):
segs.append((x + x1, y1, x + x2, y2))
x += adv + GAP
return segs, x - GAP
def render_word_alpha(text, unit_px):
"""Coverage (0..255) buffer of the word at `unit_px` pixels per glyph unit."""
segs, width_u = word_segments(text)
half = STROKE / 2 * unit_px
pad = half + 1.5
w = math.ceil(width_u * unit_px + 2 * pad)
h = math.ceil((DESCENT - ASCENT) * unit_px + 2 * pad)
ox, oy = pad, pad - ASCENT * unit_px
px_segs = [(ox + a * unit_px, oy + b * unit_px, ox + c * unit_px, oy + d * unit_px) for a, b, c, d in segs]
# Bucket segments per pixel column range so each pixel tests only nearby strokes.
buf = bytearray(w * h)
for x1, y1, x2, y2 in px_segs:
lo_x = max(0, math.floor(min(x1, x2) - pad))
hi_x = min(w, math.ceil(max(x1, x2) + pad))
lo_y = max(0, math.floor(min(y1, y2) - pad))
hi_y = min(h, math.ceil(max(y1, y2) + pad))
dx, dy = x2 - x1, y2 - y1
len2 = dx * dx + dy * dy
for py in range(lo_y, hi_y):
row = py * w
fy = py + 0.5
for px in range(lo_x, hi_x):
fx = px + 0.5
if len2 > 0:
t = max(0.0, min(1.0, ((fx - x1) * dx + (fy - y1) * dy) / len2))
else:
t = 0.0
d = math.hypot(fx - (x1 + t * dx), fy - (y1 + t * dy))
cov = 0.5 + (half - d)
if cov > 0:
v = min(255, round(min(1.0, cov) * 255))
if v > buf[row + px]:
buf[row + px] = v
return buf, w, h
# ------------------------------------------------------------------------------------------
# Canvas: RGBA bytearray, straight alpha, painted back to front.
# ------------------------------------------------------------------------------------------
class Canvas:
def __init__(self, w, h):
self.w, self.h = w, h
self.buf = bytearray(w * h * 4)
def fill_gradient(self, top, bottom):
for y in range(self.h):
t = y / max(1, self.h - 1)
c = bytes(
(
round(top[0] + (bottom[0] - top[0]) * t),
round(top[1] + (bottom[1] - top[1]) * t),
round(top[2] + (bottom[2] - top[2]) * t),
255,
)
)
self.buf[y * self.w * 4 : (y + 1) * self.w * 4] = c * self.w
def _blend(self, i, rgb, a):
"""`rgb` over the pixel at byte offset i with coverage a (0..1)."""
if a <= 0:
return
b = self.buf
ia = 1.0 - a
da = b[i + 3] / 255.0
oa = a + da * ia
if oa <= 0:
return
for k in range(3):
b[i + k] = round((rgb[k] * a + b[i + k] * da * ia) / oa)
b[i + 3] = round(oa * 255)
def glow(self, cx, cy, radius, rgb, strength):
"""Soft gaussian-ish radial glow (for the mark's halo on the big surfaces)."""
lo_x = max(0, math.floor(cx - 2.2 * radius))
hi_x = min(self.w, math.ceil(cx + 2.2 * radius))
lo_y = max(0, math.floor(cy - 2.2 * radius))
hi_y = min(self.h, math.ceil(cy + 2.2 * radius))
for y in range(lo_y, hi_y):
for x in range(lo_x, hi_x):
d2 = ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2) / (radius * radius)
a = strength * math.exp(-2.5 * d2)
if a > 1 / 255:
self._blend((y * self.w + x) * 4, rgb, a)
def mark(self, cx, cy, span):
"""The lens mark centered at (cx, cy) with the given pixel span."""
scale = span / MARK_SPAN
c1 = (cx + (C1[0] - MARK_CENTER[0]) * scale, cy + (C1[1] - MARK_CENTER[1]) * scale)
c2 = (cx + (C2[0] - MARK_CENTER[0]) * scale, cy + (C2[1] - MARK_CENTER[1]) * scale)
r = R * scale
lo_x = max(0, math.floor(min(c1[0], c2[0]) - r - 2))
hi_x = min(self.w, math.ceil(max(c1[0], c2[0]) + r + 2))
lo_y = max(0, math.floor(min(c1[1], c2[1]) - r - 2))
hi_y = min(self.h, math.ceil(max(c1[1], c2[1]) + r + 2))
for y in range(lo_y, hi_y):
for x in range(lo_x, hi_x):
fx, fy = x + 0.5, y + 0.5
cov1 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c1[0], fy - c1[1])))
cov2 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c2[0], fy - c2[1])))
if cov1 <= 0 and cov2 <= 0:
continue
i = (y * self.w + x) * 4
self._blend(i, COL_LIGHT, cov1)
self._blend(i, COL_DEEP, cov2)
self._blend(i, COL_HI, min(cov1, cov2))
def word(self, text, unit_px, cx, cy):
"""The wordmark centered at (cx, cy); `unit_px` = pixels per glyph unit."""
alpha, w, h = render_word_alpha(text, unit_px)
ox = round(cx - w / 2)
# Optical vertical centering on the x-height band (0..1 in glyph units), not the
# ascender/descender box — the word reads centered that way.
pad = STROKE / 2 * unit_px + 1.5
band_mid = pad - ASCENT * unit_px + 0.5 * unit_px
oy = round(cy - band_mid)
for y in range(h):
ty = y + oy
if not 0 <= ty < self.h:
continue
for x in range(w):
a = alpha[y * w + x]
if a:
tx = x + ox
if 0 <= tx < self.w:
self._blend((ty * self.w + tx) * 4, WORD, a / 255.0)
def round_corners(self, radius):
"""Multiply alpha with a rounded-rect mask (icon)."""
for y in range(self.h):
for x in range(self.w):
dx = max(0.0, max(radius - (x + 0.5), (x + 0.5) - (self.w - radius)))
dy = max(0.0, max(radius - (y + 0.5), (y + 0.5) - (self.h - radius)))
if dx > 0 and dy > 0:
cov = min(1.0, max(0.0, 0.5 + radius - math.hypot(dx, dy)))
i = (y * self.w + x) * 4
self.buf[i + 3] = round(self.buf[i + 3] * cov)
def png(self):
def chunk(tag, data):
return (
struct.pack(">I", len(data))
+ tag
+ data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
)
ihdr = struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0)
raw = b"".join(
b"\x00" + bytes(self.buf[y * self.w * 4 : (y + 1) * self.w * 4]) for y in range(self.h)
)
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b"")
)
def save(name, canvas):
OUT.mkdir(parents=True, exist_ok=True)
out = OUT / name
out.write_bytes(canvas.png())
print(f"wrote {out.relative_to(HERE.parent.parent)} ({canvas.w}x{canvas.h})")
def main():
# Portrait capsule: mark in the upper half, wordmark beneath.
c = Canvas(600, 900)
c.fill_gradient(BG_TOP, BG_BOT)
c.glow(300, 340, 260, COL_DEEP, 0.35)
c.mark(300, 340, 320)
c.word("punktfunk", 44, 300, 640)
save("grid.png", c)
# Wide capsule: mark left, wordmark right of it.
c = Canvas(920, 430)
c.fill_gradient(BG_TOP, BG_BOT)
c.glow(230, 215, 200, COL_DEEP, 0.35)
c.mark(230, 215, 240)
c.word("punktfunk", 40, 620, 220)
save("gridwide.png", c)
# Hero: ambient banner — the mark rides the right third; Steam overlays logo.png itself.
c = Canvas(1920, 620)
c.fill_gradient(BG_TOP, BG_BOT)
c.glow(1500, 310, 330, COL_DEEP, 0.4)
c.mark(1500, 310, 400)
save("hero.png", c)
# Logo (transparent): mark + wordmark side by side, overlaid on the hero by Steam.
c = Canvas(1120, 300)
c.mark(150, 150, 240)
c.word("punktfunk", 62, 660, 155)
save("logo.png", c)
# Icon: brand tile, rounded corners, mark only.
c = Canvas(256, 256)
c.fill_gradient(BG_TOP, BG_BOT)
c.glow(128, 128, 110, COL_DEEP, 0.3)
c.mark(128, 128, 190)
c.round_corners(36)
save("icon.png", c)
if __name__ == "__main__":
main()
+3 -1
View File
@@ -20,12 +20,14 @@ VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])
STAGE="$(mktemp -d)" STAGE="$(mktemp -d)"
DEST="$STAGE/$NAME" DEST="$STAGE/$NAME"
mkdir -p "$DEST/dist" "$DEST/bin" mkdir -p "$DEST/dist" "$DEST/bin" "$DEST/assets"
cp dist/index.js "$DEST/dist/index.js" # ship the bundle only, not the sourcemap cp dist/index.js "$DEST/dist/index.js" # ship the bundle only, not the sourcemap
cp main.py plugin.json package.json LICENSE "$DEST/" cp main.py plugin.json package.json LICENSE "$DEST/"
# The stream-launch wrapper (target of the Steam shortcut) — must stay executable. # The stream-launch wrapper (target of the Steam shortcut) — must stay executable.
cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh" cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh"
chmod 0755 "$DEST/bin/punktfunkrun.sh" chmod 0755 "$DEST/bin/punktfunkrun.sh"
# Steam-shortcut artwork (grid/hero/logo/icon — scripts/gen-steam-art.py, committed).
cp assets/*.png "$DEST/assets/"
[ -f decky.pyi ] && cp decky.pyi "$DEST/" [ -f decky.pyi ] && cp decky.pyi "$DEST/"
[ -f README.md ] && cp README.md "$DEST/" [ -f README.md ] && cp README.md "$DEST/"
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Unit checks for main.py's pure helpers — stdlib only, no Decky runtime needed.
Stubs the ``decky`` module (main.py imports it at module level), then asserts the
avahi/TSV/error parsers against fixture strings. The LibraryError fixtures are pinned to
the REAL Display strings in clients/linux/src/library.rs — if those are reworded, the
classifier degrades to ``client-error`` and the matching assertion here fails on purpose.
python3 clients/decky/scripts/test-backend.py
"""
import sys
import types
from pathlib import Path
# ---- stub the decky module before importing main.py ------------------------------------
decky = types.ModuleType("decky")
decky.DECKY_USER_HOME = "/tmp/pf-test-home"
decky.DECKY_PLUGIN_DIR = "/tmp/pf-test-plugin"
class _Log:
def __getattr__(self, _name):
return lambda *a, **k: None
decky.logger = _Log()
sys.modules["decky"] = decky
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import main # noqa: E402 (the plugin backend)
failures = 0
def check(name: str, cond: bool):
global failures
print(("ok " if cond else "FAIL") + " " + name)
if not cond:
failures += 1
# ---- _parse_library_tsv -----------------------------------------------------------------
tsv = (
"steam:570\tsteam\tDota 2\n"
"custom:abc\tcustom\tTabs\tin\ttitle\n" # tabs inside the title survive (split max 2)
"2 game(s)\n" # the count trailer has no tabs — self-skips
)
games = main._parse_library_tsv(tsv)
check("tsv: two games parsed", len(games) == 2)
check("tsv: fields", games[0] == {"id": "steam:570", "store": "steam", "title": "Dota 2"})
check("tsv: tabs in title preserved", games[1]["title"] == "Tabs\tin\ttitle")
check("tsv: empty input", main._parse_library_tsv("0 game(s)\n") == [])
# ---- _classify_library_error (fixtures = library.rs Display strings) --------------------
check(
"err: not-paired",
main._classify_library_error(
"library: The host didn't recognize this device. Pair with the host first — the "
"library is authorized by this device's certificate (no token needed)."
)
== "not-paired",
)
check(
"err: pin-mismatch",
main._classify_library_error(
"library: The host's certificate doesn't match the pinned fingerprint. "
"Re-pair with a PIN to re-establish trust."
)
== "pin-mismatch",
)
check(
"err: unreachable",
main._classify_library_error(
"library: Couldn't reach the host's management API: connection refused. Check the "
"host is updated and reachable."
)
== "unreachable",
)
check(
"err: http",
main._classify_library_error("library: The management API returned HTTP 500.") == "http",
)
check(
"err: outdated client (GTK init noise)",
main._classify_library_error("cannot open display: \nGtk-WARNING: init failed")
== "client-outdated",
)
check("err: generic fallback", main._classify_library_error("boom") == "client-error")
# ---- _parse_avahi_browse (incl. the new id/mgmt TXT keys) --------------------------------
avahi = (
"+;eth0;IPv4;living-room;_punktfunk._udp;local\n"
"=;eth0;IPv4;living-room;_punktfunk._udp;local;lr.local;192.168.1.42;9777;"
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
"=;eth0;IPv6;living-room;_punktfunk._udp;local;lr.local;fe80::1;9777;"
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
"=;eth0;IPv4;bare-host;_punktfunk._udp;local;bh.local;192.168.1.77;9777;"
'"proto=punktfunk/1" "fp=ddeeff" "pair=optional"\n'
)
hosts = main._parse_avahi_browse(avahi)
check("avahi: two hosts (id-dedup, IPv4 preferred)", len(hosts) == 2)
lr = next(h for h in hosts if h["name"] == "living-room")
check("avahi: ipv4 wins", lr["host"] == "192.168.1.42")
check("avahi: mgmt parsed", lr["mgmt"] == 47990)
check("avahi: id parsed", lr["id"] == "abc123")
bare = next(h for h in hosts if h["name"] == "bare-host")
check("avahi: mgmt absent -> 0", bare["mgmt"] == 0)
check("avahi: id absent -> empty", bare["id"] == "")
# ---- pins store (round-trip through the real methods, isolated HOME) --------------------
import asyncio # noqa: E402
import shutil # noqa: E402
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
plugin = main.Plugin()
pin = {
"game_id": "steam:570",
"title": "Dota 2",
"store": "steam",
"host_fp": "AABBCC",
"host_id": "abc123",
"host_name": "living-room",
"host": "192.168.1.42",
"port": 9777,
"mgmt": 47990,
"added_at": 1780000000,
}
dupe = dict(pin, title="Dota 2 again")
junk = {"title": "no game id"}
res = asyncio.run(plugin.set_pins([pin, dupe, junk]))
check("pins: write ok", res.get("ok") is True)
got = asyncio.run(plugin.get_pins())["pins"]
check("pins: dedup + junk dropped", len(got) == 1)
check("pins: unpaired without known-hosts", got[0]["paired"] is False)
# Mark the host paired in the client's known-hosts store — get_pins must pick it up.
cfg = main._client_config_dir()
cfg.mkdir(parents=True, exist_ok=True)
(cfg / "client-known-hosts.json").write_text(
'{"hosts": [{"name": "living-room", "addr": "192.168.1.42", "port": 9777, '
'"fp_hex": "aabbcc", "paired": true}]}'
)
got = asyncio.run(plugin.get_pins())["pins"]
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
print()
if failures:
print(f"{failures} check(s) FAILED")
sys.exit(1)
print("all checks passed")
+78 -5
View File
@@ -6,8 +6,46 @@ export interface Host {
host: string; host: string;
port: number; port: number;
pair: string; // "required" | "optional" — the HOST's policy pair: string; // "required" | "optional" — the HOST's policy
fp: string; fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
proto: string; // advertised protocol, e.g. "punktfunk/1"
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint) paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
id: string; // the host's stable instance id (mDNS TXT `id`; "" when not advertised)
mgmt: number; // management-API port (mDNS TXT `mgmt`; 0 = not advertised → default 47990)
}
// One title from a host's game library (the flatpak client's --library TSV, parsed by the
// backend). `id` is store-qualified (steam:<appid> / custom:<id>) and doubles as the
// launch handle (PF_LAUNCH → the session Hello).
export interface GameEntry {
id: string;
store: string; // "steam" | "custom" | "heroic" | "lutris" | …
title: string;
}
export interface LibraryResult {
ok: boolean;
games?: GameEntry[];
// "flatpak-not-found" | "timeout" | "not-paired" | "pin-mismatch" | "unreachable" |
// "http" | "client-outdated" | "client-error"
error?: string;
detail?: string; // the client's own one-line reason, for the generic error copy
}
// A pinned game — a one-tap stream row in the QAM. The host is identified primarily by
// cert fingerprint (survives IP changes; pairing is fp-keyed too), with the stored
// address as the launch fallback when the host isn't currently advertising.
export interface PinnedGame {
game_id: string;
title: string;
store: string;
host_fp: string;
host_id: string;
host_name: string;
host: string;
port: number;
mgmt: number;
added_at: number; // unix seconds
paired?: boolean; // annotated by get_pins from the client's known-hosts store
} }
export interface PairResult { export interface PairResult {
@@ -22,36 +60,71 @@ export interface RunnerInfo {
exists: boolean; exists: boolean;
} }
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
// keys (codec, decoder, … set from the desktop client's own UI) — they round-trip untouched
// because get_settings returns the whole parsed file and patches are object spreads.
export interface StreamSettings { export interface StreamSettings {
width: number; // 0 = native width: number; // 0 = native
height: number; // 0 = native height: number; // 0 = native
refresh_hz: number; // 0 = native refresh_hz: number; // 0 = native
bitrate_kbps: number; // 0 = host default bitrate_kbps: number; // 0 = host default
gamepad: string; // "auto" | "xbox360" | "dualsense" gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
inhibit_shortcuts: boolean; inhibit_shortcuts: boolean;
mic_enabled: boolean; mic_enabled: boolean;
} }
export interface UpdateInfo { export interface UpdateInfo {
current: string; // installed version (package.json) current: string; // installed PLUGIN version (package.json)
latest: string; // newest version in our registry for this channel latest: string; // newest plugin version in our registry for this channel
artifact: string; // immutable zip URL Decky should install artifact: string; // immutable zip URL Decky should install
hash: string; // sha256 of that zip (Decky verifies it) hash: string; // sha256 of that zip (Decky verifies it)
channel: string; // "latest" (stable) | "canary" channel: string; // "latest" (stable) | "canary"
update_available: boolean; update_available: boolean; // a newer PLUGIN build is available
// The flatpak CLIENT (io.unom.Punktfunk) versions independently and is a per-user install, so
// `sudo flatpak update` never touches it — the plugin offers a user-scope update instead.
client_update_available: boolean;
client_current: string; // installed client commit (short) — informational
client_latest: string; // remote client commit (short) — informational
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed" error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
} }
// Steam-shortcut artwork (assets/ in the plugin dir): base64 PNGs keyed grid / gridwide /
// hero / logo, plus the icon's absolute path (SetShortcutIcon wants a file). Keys for
// missing files are absent.
export interface ShortcutArt {
grid?: string;
gridwide?: string;
hero?: string;
logo?: string;
icon_path: string;
}
export const discover = callable<[], Host[]>("discover"); export const discover = callable<[], Host[]>("discover");
export const pair = callable< export const pair = callable<
[host: string, port: number, pin: string, name: string], [host: string, port: number, pin: string, name: string],
PairResult PairResult
>("pair"); >("pair");
// Fetch a paired host's game library (headless flatpak --library; can take seconds on a
// cold client start — show a spinner). Pass fp whenever known so the pin can't degrade.
export const library = callable<
[host: string, mgmt_port: number, fp: string],
LibraryResult
>("library");
export const getPins = callable<[], { pins: PinnedGame[] }>("get_pins");
export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: string }>(
"set_pins",
);
export const runnerInfo = callable<[], RunnerInfo>("runner_info"); export const runnerInfo = callable<[], RunnerInfo>("runner_info");
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
export const getSettings = callable<[], StreamSettings>("get_settings"); export const getSettings = callable<[], StreamSettings>("get_settings");
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>( export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
"set_settings", "set_settings",
); );
export const killStream = callable<[], { ok: boolean }>("kill_stream"); export const killStream = callable<[], { ok: boolean }>("kill_stream");
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update"); export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
export const updateClient = callable<
[],
{ ok: boolean; updated: boolean; error?: string }
>("update_client");
+51
View File
@@ -0,0 +1,51 @@
// Error boundary — contains ANY render failure in our UI so a single bad render can never take
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic
// "Something went wrong while displaying this content" for the entire tab when one plugin
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
// (possibly broken) Steam-internal component — it is guaranteed to render.
import { Component, ErrorInfo, ReactNode } from "react";
export class PluginErrorBoundary extends Component<
{ children: ReactNode },
{ error: Error | null }
> {
state: { error: Error | null } = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Surface it for diagnosis, but never rethrow — containment is the whole point.
// eslint-disable-next-line no-console
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
}
render() {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div style={{ padding: "1em", lineHeight: 1.45 }}>
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
Punktfunk couldnt draw this view
</div>
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
The plugin hit a display error your Steam Deck is fine. Reload Punktfunk from
Decky&apos;s plugin list, or update the plugin.
</div>
<div
style={{
opacity: 0.55,
fontFamily: "monospace",
fontSize: "0.8em",
wordBreak: "break-word",
}}
>
{String(error?.message ?? error)}
</div>
</div>
);
}
}
+342
View File
@@ -0,0 +1,342 @@
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
import { toaster } from "@decky/api";
import { Navigation } from "@decky/ui";
import { useCallback, useEffect, useRef, useState } from "react";
import {
checkUpdate,
discover,
GameEntry,
getPins,
Host,
PinnedGame,
setPins as setPinsBackend,
updateClient,
UpdateInfo,
} from "./backend";
import { LaunchOpts, launchStream } from "./steam";
export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck";
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
// is root-owned, so our unprivileged backend can't swap its own files.
declare global {
interface Window {
DeckyBackend?: {
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
};
}
}
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
const INSTALL_TYPE_UPDATE = 2;
// ----------------------------------------------------------------------------------------
// Discovery — mDNS scan state shared by the QAM panel and the full page.
// ----------------------------------------------------------------------------------------
export function useHosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [scanning, setScanning] = useState(false);
const refresh = useCallback(async () => {
setScanning(true);
try {
setHosts(await discover());
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
} finally {
setScanning(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { hosts, scanning, refresh };
}
// ----------------------------------------------------------------------------------------
// Self-update — checks our registry on mount (the backend caches for 30 min + is non-fatal
// offline); `check(true)` bypasses the cache for the explicit "Check for updates" button.
// ----------------------------------------------------------------------------------------
export function useUpdate() {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [checking, setChecking] = useState(false);
const check = useCallback(async (force: boolean): Promise<UpdateInfo | null> => {
setChecking(true);
try {
const res = await checkUpdate(force);
setInfo(res);
return res;
} catch {
return null;
} finally {
setChecking(false);
}
}, []);
useEffect(() => {
void check(false);
}, [check]);
return { info, checking, check };
}
/** True when EITHER the plugin or the flatpak client has a pending update. */
export function hasUpdate(info: UpdateInfo | null | undefined): boolean {
return !!info && (info.update_available || info.client_update_available);
}
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
export async function checkForUpdatesNow(
check: (force: boolean) => Promise<UpdateInfo | null>,
): Promise<void> {
const res = await check(true);
let body: string;
if (!res || res.error === "fetch-failed") {
body = "Couldnt reach the update server — are you online?";
} else if (hasUpdate(res)) {
const parts: string[] = [];
if (res.update_available) parts.push(`plugin v${res.current} → v${res.latest}`);
if (res.client_update_available) parts.push("client");
body = `Update available: ${parts.join(" + ")}.`;
} else if (res.error === "update-channel-unknown") {
body = "Development build — plugin updates are disabled; the client is up to date.";
} else {
body = `Youre up to date (plugin v${res.current}).`;
}
toaster.toast({ title: "Punktfunk", body });
}
/**
* Apply whichever updates are pending. The flatpak CLIENT is updated first (a user-scope
* `flatpak update`, awaited); then, if the PLUGIN itself has an update, Decky's install RPC
* reinstalls it — which reloads the plugin and tears this panel down, so it goes last and is
* fire-and-forget. `check` (when passed) refreshes the panel state after a client-only update so
* the "Update available" button clears.
*/
export async function applyUpdate(
info: UpdateInfo,
check?: (force: boolean) => Promise<UpdateInfo | null>,
): Promise<void> {
if (info.client_update_available) {
toaster.toast({ title: "Punktfunk", body: "Updating the client…" });
try {
const r = await updateClient();
toaster.toast({
title: "Punktfunk",
body: !r.ok
? `Client update failed${r.error ? ` (${r.error})` : ""}.`
: r.updated
? "Client updated to the latest version."
: "Client is already up to date.",
});
} catch {
toaster.toast({ title: "Punktfunk", body: "Client update failed." });
}
}
if (info.update_available) {
try {
const backend = window.DeckyBackend;
if (backend?.callable) {
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
void backend.callable("utilities/install_plugin")(
info.artifact,
"punktfunk",
info.latest,
info.hash,
INSTALL_TYPE_UPDATE,
);
toaster.toast({
title: "Punktfunk",
// Decky's installer also phones the plugin store first, which can hang on some
// networks before the actual install proceeds — set expectations.
body: `Updating the plugin to v${info.latest} — confirm Deckys prompt. This can take a couple of minutes.`,
});
return;
}
} catch {
// fall through to the manual path
}
toaster.toast({
title: "Punktfunk",
body: "Update the plugin from Decky → Developer → Install Plugin from URL.",
});
return;
}
// Client-only update (no plugin reinstall): refresh so the button clears.
if (check) void check(true);
}
// ----------------------------------------------------------------------------------------
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
// ----------------------------------------------------------------------------------------
export async function startStream(
h: Host,
opts: LaunchOpts = {},
label?: string,
): Promise<void> {
try {
await launchStream(h.host, h.port, opts);
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"}${h.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
}
}
/** Open the GTK client's gamepad library launcher for a host (`--browse` via PF_BROWSE). */
export async function startBrowse(h: Host): Promise<void> {
try {
await launchStream(h.host, h.port, { browse: true, mgmt: h.mgmt });
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Opening library — ${h.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
}
}
// ----------------------------------------------------------------------------------------
// Pinned games — the QAM's one-tap game rows, persisted by the backend next to the
// client's config (survives plugin reinstalls).
// ----------------------------------------------------------------------------------------
export interface PinsApi {
pins: PinnedGame[];
addPin: (h: Host, g: GameEntry) => void;
removePin: (hostFp: string, gameId: string) => void;
isPinned: (hostFp: string, gameId: string) => boolean;
/** Refresh a pin's stored address from a live advert (hosts change IPs). */
updatePinHost: (pin: PinnedGame, h: Host) => void;
refresh: () => Promise<void>;
}
export function usePins(): PinsApi {
const [pins, setPins] = useState<PinnedGame[]>([]);
// A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a
// detached portal that captures this hook's callbacks ONCE and never re-renders with fresh
// props, so a mutator closing over the `pins` array reads a frozen base — pinning a second
// game in the same session would compute from the stale `[]` and clobber the first (silent
// data loss). Reading the ref keeps every mutation based on the current set, and lets the
// callbacks keep a stable identity (deps free of `pins`).
const pinsRef = useRef<PinnedGame[]>([]);
pinsRef.current = pins;
const refresh = useCallback(async () => {
try {
setPins((await getPins()).pins);
} catch {
/* backend unavailable — keep the current view */
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// Optimistic local state; the backend validates/dedups and is re-read on failure.
const save = useCallback(
(next: PinnedGame[]) => {
pinsRef.current = next;
setPins(next);
setPinsBackend(next).catch(() => void refresh());
},
[refresh],
);
const addPin = useCallback(
(h: Host, g: GameEntry) => {
const pin: PinnedGame = {
game_id: g.id,
title: g.title,
store: g.store,
host_fp: h.fp,
host_id: h.id,
host_name: h.name,
host: h.host,
port: h.port,
mgmt: h.mgmt,
added_at: Math.floor(Date.now() / 1000),
paired: h.paired,
};
save([
...pinsRef.current.filter(
(p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id),
),
pin,
]);
},
[save],
);
const removePin = useCallback(
(hostFp: string, gameId: string) => {
save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
},
[save],
);
const isPinned = useCallback(
(hostFp: string, gameId: string) =>
pins.some((p) => p.host_fp === hostFp && p.game_id === gameId),
[pins],
);
const updatePinHost = useCallback(
(pin: PinnedGame, h: Host) => {
if (pin.host === h.host && pin.port === h.port && pin.mgmt === h.mgmt) {
return;
}
save(
pinsRef.current.map((p) =>
p.host_fp === pin.host_fp && p.game_id === pin.game_id
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
: p,
),
);
},
[save],
);
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
}
/**
* The host a pin should launch against right now: match the live mDNS scan by cert
* fingerprint first (pairing is fp-keyed, survives IP changes), then by the host's stable
* id, else fall back to the stored address (host offline or scan flaky — still launch).
*/
export function resolvePinHost(
pin: PinnedGame,
live: Host[],
): { host: Host; online: boolean } {
const fp = pin.host_fp.toLowerCase();
const match =
(fp && live.find((h) => h.fp && h.fp.toLowerCase() === fp)) ||
(pin.host_id && live.find((h) => h.id && h.id === pin.host_id)) ||
undefined;
if (match) {
return { host: match, online: true };
}
return {
host: {
name: pin.host_name || pin.host,
host: pin.host,
port: pin.port,
pair: pin.paired ? "optional" : "required",
fp: pin.host_fp,
proto: "",
paired: !!pin.paired,
id: pin.host_id,
mgmt: pin.mgmt,
},
online: false,
};
}
+102 -561
View File
@@ -1,591 +1,108 @@
// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
import { import {
ButtonItem, ButtonItem,
Dropdown,
Field, Field,
Focusable,
DialogButton,
ModalRoot,
Navigation, Navigation,
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
SliderField,
Spinner, Spinner,
Tabs,
ToggleField,
showModal, showModal,
staticClasses, staticClasses,
} from "@decky/ui"; } from "@decky/ui";
import { definePlugin, routerHook, toaster } from "@decky/api"; import { definePlugin, routerHook } from "@decky/api";
import { FC } from "react";
import { FaDownload, FaLock, FaLockOpen, FaPlay, FaSyncAlt, FaTv } from "react-icons/fa";
import { PluginErrorBoundary } from "./boundary";
import { import {
Component, applyUpdate,
CSSProperties, checkForUpdatesNow,
ErrorInfo, hasUpdate,
FC, resolvePinHost,
ReactNode, startStream,
useCallback, useHosts,
useEffect, usePins,
useState, useUpdate,
} from "react"; } from "./hooks";
import { import { streamPin } from "./library";
FaTv, import { PunktfunkRoute, ROUTE } from "./page";
FaSyncAlt, import { PairModal } from "./pair";
FaLock,
FaLockOpen,
FaPlay,
FaArrowLeft,
FaDownload,
} from "react-icons/fa";
import {
discover,
getSettings,
pair,
setSettings,
checkUpdate,
Host,
StreamSettings,
UpdateInfo,
} from "./backend";
import { launchStream } from "./steam";
const ROUTE = "/punktfunk";
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
// is root-owned, so our unprivileged backend can't swap its own files.
declare global {
interface Window {
DeckyBackend?: {
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
};
}
}
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
const INSTALL_TYPE_UPDATE = 2;
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
// Error boundary — contains ANY render failure in our UI so a single bad render can never take // QAM panel — quick status + entry into the full page + one-tap stream for known hosts
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic // and pinned games.
// "Something went wrong while displaying this content" for the entire tab when one plugin
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
// (possibly broken) Steam-internal component — it is guaranteed to render.
// ----------------------------------------------------------------------------------------
class PluginErrorBoundary extends Component<
{ children: ReactNode },
{ error: Error | null }
> {
state: { error: Error | null } = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Surface it for diagnosis, but never rethrow — containment is the whole point.
// eslint-disable-next-line no-console
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
}
render() {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div style={{ padding: "1em", lineHeight: 1.45 }}>
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
punktfunk couldnt draw this view
</div>
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
The plugin hit a display error your Steam Deck is fine. Reload punktfunk from
Decky&apos;s plugin list, or update the plugin.
</div>
<div
style={{
opacity: 0.55,
fontFamily: "monospace",
fontSize: "0.8em",
wordBreak: "break-word",
}}
>
{String(error?.message ?? error)}
</div>
</div>
);
}
}
// Checks our registry for a newer build on mount (the backend caches + is non-fatal offline).
function useUpdate() {
const [info, setInfo] = useState<UpdateInfo | null>(null);
useEffect(() => {
void checkUpdate(false)
.then(setInfo)
.catch(() => {});
}, []);
return info;
}
async function applyUpdate(info: UpdateInfo) {
try {
const backend = window.DeckyBackend;
if (backend?.callable) {
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
void backend.callable("utilities/install_plugin")(
info.artifact,
"punktfunk",
info.latest,
info.hash,
INSTALL_TYPE_UPDATE,
);
toaster.toast({
title: "punktfunk",
body: `Updating to v${info.latest}… confirm the Decky prompt.`,
});
return;
}
} catch {
// fall through to the manual path
}
toaster.toast({
title: "punktfunk",
body: "Update from Decky → Developer → Install Plugin from URL.",
});
}
// ----------------------------------------------------------------------------------------
// Discovery hook — shared by the QAM panel and the full page.
// ----------------------------------------------------------------------------------------
function useHosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [scanning, setScanning] = useState(false);
const refresh = useCallback(async () => {
setScanning(true);
try {
setHosts(await discover());
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` });
} finally {
setScanning(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { hosts, scanning, refresh };
}
async function startStream(h: Host) {
try {
await launchStream(h.host, h.port);
Navigation.CloseSideMenus();
toaster.toast({ title: "punktfunk", body: `Starting stream — ${h.name}` });
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Launch failed: ${e}` });
}
}
// ----------------------------------------------------------------------------------------
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
// The host displays the PIN after the operator arms pairing; the user enters it here.
// ----------------------------------------------------------------------------------------
const PairModal: FC<{
host: Host;
closeModal?: () => void;
onPaired: () => void;
}> = ({ host, closeModal, onPaired }) => {
const [pin, setPin] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
const back = () => setPin((p) => p.slice(0, -1));
const submit = async () => {
setBusy(true);
setError(null);
try {
const res = await pair(host.host, host.port, pin, "Steam Deck");
if (res.ok) {
toaster.toast({ title: "punktfunk", body: `Paired with ${host.name}` });
onPaired();
closeModal?.();
} else {
setError(res.error ?? "pairing failed");
setPin("");
}
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
Pair with {host.name}
</div>
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
</div>
<div
style={{
fontSize: "2.2em",
letterSpacing: "0.4em",
textAlign: "center",
fontFamily: "monospace",
minHeight: "1.4em",
marginBottom: "0.6em",
}}
>
{pin.padEnd(4, "•")}
</div>
{error && (
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
{error}
</div>
)}
<Focusable
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "0.5em",
}}
>
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
{d}
</DialogButton>
))}
<DialogButton disabled={busy} onClick={back}>
</DialogButton>
<DialogButton disabled={busy} onClick={() => press("0")}>
0
</DialogButton>
<DialogButton
disabled={busy || pin.length !== 4}
onClick={submit}
>
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
</DialogButton>
</Focusable>
</ModalRoot>
);
};
// ----------------------------------------------------------------------------------------
// Settings section — resolution / refresh / bitrate / gamepad, written to the client's JSON.
// ----------------------------------------------------------------------------------------
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
[1280, 720, "1280 × 720"],
[1920, 1080, "1920 × 1080"],
[2560, 1440, "2560 × 1440"],
];
const REFRESH = [0, 30, 60, 90, 120];
const GAMEPADS = ["auto", "xbox360", "dualsense", "steamdeck"];
const GAMEPAD_LABELS: Record<string, string> = {
auto: "Automatic",
xbox360: "Xbox 360",
dualsense: "DualSense",
steamdeck: "Steam Deck",
};
const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
useEffect(() => {
void getSettings().then(setS);
}, []);
const patch = (p: Partial<StreamSettings>) => {
setS((cur) => {
if (!cur) return cur;
const next = { ...cur, ...p };
void setSettings(next);
return next;
});
};
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const resIdx = Math.max(
0,
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
);
return (
<>
<Field
label="Resolution"
description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
</Field>
<Field label="Refresh rate" childrenContainerWidth="max">
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</Field>
<SliderField
label="Bitrate"
description="Mbit/s · 0 = host default"
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<Field label="Gamepad type" childrenContainerWidth="max">
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</Field>
{s.gamepad === "steamdeck" && (
<Field
label="⚠ Disable Steam Input"
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
/>
)}
<ToggleField
label="Stream microphone"
checked={s.mic_enabled}
onChange={(v) => patch({ mic_enabled: v })}
/>
</>
);
};
// ----------------------------------------------------------------------------------------
// One host row on the full page.
// ----------------------------------------------------------------------------------------
const HostRow: FC<{ host: Host }> = ({ host }) => {
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
// pair again — show it as trusted and go straight to Stream.
const needsPair = host.pair === "required" && !host.paired;
return (
<Field
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{needsPair ? <FaLock /> : <FaLockOpen />}
{host.name}
</span>
}
description={`${host.host}:${host.port}${
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
}`}
childrenContainerWidth="max"
>
<Focusable style={{ display: "flex", gap: "0.5em" }}>
{needsPair && (
<DialogButton
style={{ minWidth: "5em" }}
onClick={() =>
showModal(<PairModal host={host} onPaired={() => {}} />)
}
>
Pair
</DialogButton>
)}
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
</Focusable>
</Field>
);
};
// ----------------------------------------------------------------------------------------
// The fullscreen page (registered as the /punktfunk route) — a tabbed Hosts / Settings view.
// ----------------------------------------------------------------------------------------
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
const SAFE_BOTTOM = "80px";
// Each tab is its own scroll area so long content is always reachable above the footer.
const tabScroll: CSSProperties = {
height: "100%",
overflowY: "auto",
padding: "0.5em 2.5em",
paddingBottom: SAFE_BOTTOM,
boxSizing: "border-box",
};
const HostsTab: FC<{
hosts: Host[];
scanning: boolean;
refresh: () => void;
}> = ({ hosts, scanning, refresh }) => (
<div style={tabScroll}>
<Field
label="Discover"
description={
scanning
? "Scanning the LAN…"
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
}
childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"}
>
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</Field>
{hosts.length === 0 && !scanning && (
<Field
focusable={false}
description="No punktfunk hosts found. Make sure a host is running on the same network."
>
No hosts found
</Field>
)}
{hosts.map((h) => (
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
))}
</div>
);
const SettingsTab: FC = () => (
<div style={tabScroll}>
<SettingsSection />
</div>
);
const PunktfunkPage: FC = () => {
const { hosts, scanning, refresh } = useHosts();
const update = useUpdate();
const [tab, setTab] = useState("hosts");
return (
<div
style={{
marginTop: "40px",
height: "calc(100% - 40px)",
display: "flex",
flexDirection: "column",
}}
>
<Focusable
style={{
display: "flex",
alignItems: "center",
gap: "1em",
padding: "0 2.5em",
marginBottom: "0.4em",
flexShrink: 0,
}}
>
<DialogButton
style={{ width: "3em", minWidth: "3em", padding: 0 }}
onClick={() => Navigation.NavigateBack()}
>
<FaArrowLeft />
</DialogButton>
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
punktfunk
</div>
{update?.update_available && (
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update v{update.latest}
</DialogButton>
)}
</Focusable>
<div style={{ flex: 1, minHeight: 0 }}>
<Tabs
activeTab={tab}
onShowTab={(id: string) => setTab(id)}
autoFocusContents
tabs={[
{
id: "hosts",
title: "Hosts",
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
},
{
id: "settings",
title: "Settings",
content: <SettingsTab />,
},
]}
/>
</div>
</div>
);
};
// ----------------------------------------------------------------------------------------
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts.
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
const QamPanel: FC = () => { const QamPanel: FC = () => {
const { hosts, scanning, refresh } = useHosts(); const { hosts, scanning, refresh } = useHosts();
const update = useUpdate(); const { info: update, checking, check } = useUpdate();
const pins = usePins();
return ( return (
<> <>
{update?.update_available && ( {hasUpdate(update) && (
<PanelSection title="Update"> <PanelSection title="Update available">
<PanelSectionRow> <PanelSectionRow>
<ButtonItem <ButtonItem
layout="below" layout="below"
onClick={() => applyUpdate(update)} onClick={() => applyUpdate(update!, check)}
label={`v${update.current} → v${update.latest}`} label={
update!.update_available
? `Plugin v${update!.current} → v${update!.latest}${
update!.client_update_available ? " + client" : ""
}`
: "New client version"
}
description="Installing can take a couple of minutes"
> >
<FaDownload style={{ marginRight: "0.5em" }} /> <FaDownload style={{ marginRight: "0.5em" }} />
Update punktfunk Update Punktfunk
</ButtonItem> </ButtonItem>
</PanelSectionRow> </PanelSectionRow>
</PanelSection> </PanelSection>
)} )}
<PanelSection title="punktfunk"> <PanelSection title="Punktfunk">
<PanelSectionRow> <PanelSectionRow>
<ButtonItem <ButtonItem
layout="below" layout="below"
description="Host details, stream settings, and help"
onClick={() => { onClick={() => {
Navigation.Navigate(ROUTE); Navigation.Navigate(ROUTE);
Navigation.CloseSideMenus(); Navigation.CloseSideMenus();
}} }}
> >
<FaTv style={{ marginRight: "0.5em" }} /> <FaTv style={{ marginRight: "0.5em" }} />
Open punktfunk Open Punktfunk
</ButtonItem> </ButtonItem>
</PanelSectionRow> </PanelSectionRow>
</PanelSection>
{/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's
picker (fullscreen page → host row → games button). */}
{pins.pins.length > 0 && (
<PanelSection title="Games">
{pins.pins.map((pin) => {
const { online } = resolvePinHost(pin, hosts);
return (
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
<ButtonItem
layout="below"
onClick={() => streamPin(pin, hosts, pins)}
label={pin.title}
description={`${pin.host_name}${online ? "" : " · offline?"}${
pin.paired ? "" : " · pairing required"
}`}
>
<FaPlay style={{ marginRight: "0.5em" }} />
Stream
</ButtonItem>
</PanelSectionRow>
);
})}
</PanelSection>
)}
<PanelSection title="Hosts">
<PanelSectionRow> <PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={scanning}> <ButtonItem layout="below" onClick={refresh} disabled={scanning}>
{scanning ? ( {scanning ? (
@@ -593,15 +110,21 @@ const QamPanel: FC = () => {
) : ( ) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} /> <FaSyncAlt style={{ marginRight: "0.5em" }} />
)} )}
{scanning ? "Scanning…" : "Refresh hosts"} {scanning ? "Scanning…" : "Refresh"}
</ButtonItem> </ButtonItem>
</PanelSectionRow> </PanelSectionRow>
</PanelSection> {hosts.length === 0 && scanning && (
<PanelSectionRow>
<PanelSection title="Hosts"> <Field focusable={false} description="Scanning your network…" />
</PanelSectionRow>
)}
{hosts.length === 0 && !scanning && ( {hosts.length === 0 && !scanning && (
<PanelSectionRow> <PanelSectionRow>
<Field focusable={false}>No hosts found.</Field> <Field
focusable={false}
label="No hosts found"
description="Start a Punktfunk host on this network, then refresh."
/>
</PanelSectionRow> </PanelSectionRow>
)} )}
{hosts.map((h) => { {hosts.map((h) => {
@@ -629,24 +152,42 @@ const QamPanel: FC = () => {
); );
})} })}
</PanelSection> </PanelSection>
<PanelSection title="About">
<PanelSectionRow>
<Field
focusable={false}
label="Version"
description={
update
? `v${update.current}${update.channel ? ` · ${update.channel}` : " · dev build"}`
: "…"
}
/>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? "Checking…" : "Check for updates"}
</ButtonItem>
</PanelSectionRow>
</PanelSection>
</> </>
); );
}; };
// Full page behind the boundary — registered as the /punktfunk route.
const PunktfunkRoute: FC = () => (
<PluginErrorBoundary>
<PunktfunkPage />
</PluginErrorBoundary>
);
export default definePlugin(() => { export default definePlugin(() => {
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true }); routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
return { return {
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
name: "punktfunk", name: "punktfunk",
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw // `staticClasses?.Title` is guarded so a future client that drops the export can't throw
// at plugin-load time (an error boundary only catches render-time, not load-time, errors). // at plugin-load time (an error boundary only catches render-time, not load-time, errors).
titleView: <div className={staticClasses?.Title}>punktfunk</div>, titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
content: ( content: (
<PluginErrorBoundary> <PluginErrorBoundary>
<QamPanel /> <QamPanel />
+231
View File
@@ -0,0 +1,231 @@
// The per-host game picker + pinned-game launch helper. The picker fetches a paired
// host's library through the backend (headless flatpak --library — a cold client start
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad
// library (`--browse`).
import { DialogButton, Field, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui";
import { CSSProperties, FC, useEffect, useState } from "react";
import { FaThLarge, FaTv } from "react-icons/fa";
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
import { isSafeLaunchId } from "./steam";
import { PairModal } from "./pair";
/** Human store tag (mirrors the GTK client's `store_label`). */
export function storeLabel(store: string): string {
switch (store) {
case "steam":
return "Steam";
case "custom":
return "Custom";
case "heroic":
return "Heroic";
case "lutris":
return "Lutris";
case "epic":
return "Epic";
case "gog":
return "GOG";
case "xbox":
return "Xbox";
default:
return "Game";
}
}
/**
* Stream a pinned game: resolve the host from the live scan (fp → id → stored address),
* opportunistically refresh a drifted stored address, and route through pairing first if
* this device is no longer paired with the host.
*/
export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
const { host, online } = resolvePinHost(pin, live);
if (online) {
pins.updatePinHost(pin, host); // no-op unless the address actually drifted
}
if (!pin.paired) {
showModal(
<PairModal
host={host}
onPaired={() => {
void pins.refresh(); // pick up the now-paired annotation
void startStream(host, { launchId: pin.game_id }, pin.title);
}}
/>,
);
return;
}
void startStream(host, { launchId: pin.game_id }, pin.title);
}
const pickButton: CSSProperties = {
width: "fit-content",
minWidth: "5em",
flexShrink: 0,
};
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
function errorCopy(res: LibraryResult): string {
switch (res.error) {
case "not-paired":
return "This Deck isn't paired with the host — pair first, then browse its library.";
case "pin-mismatch":
return "The host's identity changed — re-pair to re-establish trust.";
case "unreachable":
return "Couldn't reach the host's management API. Is the host online and up to date?";
case "timeout":
return "Timed out talking to the host — try again.";
case "flatpak-not-found":
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
case "client-outdated":
return "The installed client is too old for library browsing — update it from the About tab.";
default:
return res.detail || "Couldn't fetch the library.";
}
}
// ----------------------------------------------------------------------------------------
// The picker modal: "open on screen" + a pin-toggle list of the host's games.
// ----------------------------------------------------------------------------------------
export const GamePickerModal: FC<{
host: Host;
pins: PinsApi;
clientUpdatePending?: boolean;
closeModal?: () => void;
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
const [result, setResult] = useState<LibraryResult | null>(null);
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
// The modal is a detached `showModal` portal that never re-renders from the page's pin
// state, so `pins.isPinned` would read a frozen snapshot and the Pin/Unpin label would
// never flip within a session. Track this host's pinned ids locally, seeded once from the
// snapshot at open; persistence still goes through the (stale-closure-safe) pins API.
const [pinnedIds, setPinnedIds] = useState<Set<string>>(
() => new Set(pins.pins.filter((p) => p.host_fp === host.fp).map((p) => p.game_id)),
);
const togglePin = (g: GameEntry) => {
const wasPinned = pinnedIds.has(g.id);
setPinnedIds((prev) => {
const next = new Set(prev);
if (wasPinned) next.delete(g.id);
else next.add(g.id);
return next;
});
if (wasPinned) pins.removePin(host.fp, g.id);
else pins.addPin(host, g);
};
useEffect(() => {
let stale = false;
setResult(null);
library(host.host, host.mgmt, host.fp)
.then((res) => {
if (!stale) setResult(res);
})
.catch((e) => {
if (!stale) setResult({ ok: false, error: "client-error", detail: String(e) });
});
return () => {
stale = true;
};
}, [host.host, host.mgmt, host.fp, attempt]);
const games = (result?.ok && result.games) || [];
const sorted = [...games].sort((a, b) => a.title.localeCompare(b.title));
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
{host.name} Games
</div>
<Field
label="Open library on screen"
description="Browse this host's games with the controller, full screen"
childrenContainerWidth="max"
>
<DialogButton
style={pickButton}
onClick={() => {
closeModal?.();
void startBrowse(host);
}}
>
<FaTv style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</Field>
{clientUpdatePending && (
<Field
focusable={false}
description="A client update is available — direct game launch and on-screen browsing need the latest client."
/>
)}
{result === null && (
<Field
focusable={false}
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.6em" }}>
<Spinner style={{ height: "1em" }} />
Fetching the library
</span>
}
description="This starts the client headlessly — a cold start can take a few seconds."
/>
)}
{result !== null && !result.ok && (
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
{result.error === "not-paired" && (
<DialogButton
style={pickButton}
onClick={() =>
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
}
>
Pair
</DialogButton>
)}
<DialogButton style={pickButton} onClick={() => setAttempt((n) => n + 1)}>
Retry
</DialogButton>
</Focusable>
</Field>
)}
{result?.ok && sorted.length === 0 && (
<Field
focusable={false}
label="No games found"
description="Install Steam titles or add custom entries in the host's web console."
/>
)}
{sorted.length > 0 && (
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
{sorted.map((g: GameEntry) => {
const pinned = pinnedIds.has(g.id);
const safe = isSafeLaunchId(g.id);
return (
<Field
key={g.id}
label={g.title}
description={
storeLabel(g.store) + (safe ? "" : " · unsupported id — can't be pinned")
}
childrenContainerWidth="max"
>
<DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}>
<FaThLarge style={{ marginRight: "0.4em" }} />
{pinned ? "Unpin" : "Pin"}
</DialogButton>
</Field>
);
})}
</div>
)}
</ModalRoot>
);
};
+448
View File
@@ -0,0 +1,448 @@
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
import {
DialogButton,
Field,
Focusable,
ModalRoot,
Navigation,
Spinner,
Tabs,
showModal,
staticClasses,
} from "@decky/ui";
import { toaster } from "@decky/api";
import { CSSProperties, FC, useState } from "react";
import {
FaArrowLeft,
FaDownload,
FaExternalLinkAlt,
FaInfoCircle,
FaLock,
FaLockOpen,
FaPlay,
FaSyncAlt,
FaThLarge,
} from "react-icons/fa";
import { Host, UpdateInfo, killStream } from "./backend";
import { PluginErrorBoundary } from "./boundary";
import {
DOCS_URL,
PinsApi,
applyUpdate,
checkForUpdatesNow,
hasUpdate,
resolvePinHost,
startStream,
useHosts,
usePins,
useUpdate,
} from "./hooks";
import { GamePickerModal, storeLabel, streamPin } from "./library";
import { PairModal } from "./pair";
import { SettingsSection } from "./settings";
import { stopStream } from "./steam";
export const ROUTE = "/punktfunk";
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
const SAFE_BOTTOM = "80px";
// Each tab is its own scroll area so long content is always reachable above the footer.
const tabScroll: CSSProperties = {
height: "100%",
overflowY: "auto",
padding: "0.5em 2.5em",
paddingBottom: SAFE_BOTTOM,
boxSizing: "border-box",
};
// DialogButton stretches to 100% width in the gamepad UI — on a fullscreen row that means a
// screen-wide button. Size action buttons to their content instead (right-aligned by the
// Field's children container).
const actionButton: CSSProperties = {
width: "fit-content",
minWidth: "6em",
flexShrink: 0,
};
// Square icon-only button (details ⓘ, header back arrow) — needs an explicit height too, or
// the zero padding collapses it to the icon's line height.
const iconButton: CSSProperties = {
width: "40px",
minWidth: "40px",
height: "40px",
padding: 0,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
// ----------------------------------------------------------------------------------------
// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check
// against the host's own log / web console before trusting it.
// ----------------------------------------------------------------------------------------
const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({
host,
closeModal,
}) => {
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not advertised";
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
{host.name}
</div>
<Field focusable={false} label="Address">
{host.host}:{host.port}
</Field>
<Field focusable={false} label="Protocol">
{host.proto || "unknown"}
</Field>
<Field focusable={false} label="Pairing policy">
{host.pair === "required" ? "PIN pairing required" : "Open (trust on first connect)"}
</Field>
<Field focusable={false} label="This Deck">
{host.paired ? "Paired" : "Not paired yet"}
</Field>
<Field
focusable={false}
label="Certificate fingerprint (SHA-256)"
description={
<span
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
>
{fp}
</span>
}
/>
</ModalRoot>
);
};
// ----------------------------------------------------------------------------------------
// One host row: status icon + address, details / pair / stream actions.
// ----------------------------------------------------------------------------------------
const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ({
host,
onPaired,
onGames,
}) => {
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
// pair again — show it as trusted and go straight to Stream.
const needsPair = host.pair === "required" && !host.paired;
return (
<Field
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{needsPair ? <FaLock /> : <FaLockOpen />}
{host.name}
</span>
}
description={`${host.host}:${host.port}${
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
}`}
childrenContainerWidth="max"
>
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
<DialogButton
style={iconButton}
onClick={() => showModal(<HostDetailsModal host={host} />)}
>
<FaInfoCircle />
</DialogButton>
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
<DialogButton style={{ ...actionButton, minWidth: "6em" }} onClick={onGames}>
<FaThLarge style={{ marginRight: "0.4em" }} />
Games
</DialogButton>
{needsPair && (
<DialogButton
style={{ ...actionButton, minWidth: "5em" }}
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
>
Pair
</DialogButton>
)}
<DialogButton
style={actionButton}
onClick={() =>
needsPair
? showModal(
<PairModal host={host} onPaired={() => startStream(host)} />,
)
: startStream(host)
}
>
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
</Focusable>
</Field>
);
};
const HostsTab: FC<{
hosts: Host[];
scanning: boolean;
refresh: () => void;
pins: PinsApi;
clientUpdatePending: boolean;
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
<div style={tabScroll}>
<Field
label="Discover"
description={
scanning
? "Scanning the LAN…"
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
}
childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"}
>
<DialogButton style={{ ...actionButton, minWidth: "8em" }} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</Field>
{hosts.length === 0 && !scanning && (
<Field
focusable={false}
label="No hosts found"
description="Start a Punktfunk host on the same network, then refresh. The setup guide (About tab) covers installing a host."
/>
)}
{hosts.map((h) => (
<HostRow
key={h.fp || `${h.host}:${h.port}`}
host={h}
onPaired={refresh}
onGames={() =>
showModal(
<GamePickerModal host={h} pins={pins} clientUpdatePending={clientUpdatePending} />,
)
}
/>
))}
{/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */}
{pins.pins.length > 0 && (
<>
<Field
focusable={false}
label="Pinned games"
description="One-tap streams — they also live in the quick-access menu"
bottomSeparator="standard"
/>
{pins.pins.map((pin) => {
const { online } = resolvePinHost(pin, hosts);
return (
<Field
key={`${pin.host_fp}:${pin.game_id}`}
label={pin.title}
description={`${storeLabel(pin.store)} · ${pin.host_name}${
online ? "" : " · offline?"
}${pin.paired ? "" : " · pairing required"}`}
childrenContainerWidth="max"
>
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
<DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}>
<FaPlay style={{ marginRight: "0.4em" }} />
Play
</DialogButton>
<DialogButton
style={{ ...actionButton, minWidth: "5em" }}
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
>
Remove
</DialogButton>
</Focusable>
</Field>
);
})}
</>
)}
</div>
);
const SettingsTab: FC = () => (
<div style={tabScroll}>
<SettingsSection />
</div>
);
// ----------------------------------------------------------------------------------------
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop.
// ----------------------------------------------------------------------------------------
async function forceStopStream(): Promise<void> {
stopStream(); // ask Steam to end the "game" first (clean path)
const res = await killStream(); // then the flatpak-level hammer for a wedged client
toaster.toast({
title: "Punktfunk",
body: res.ok ? "Stream client stopped." : "Couldnt stop the stream client.",
});
}
const AboutTab: FC<{
update: UpdateInfo | null;
checking: boolean;
check: (force: boolean) => Promise<UpdateInfo | null>;
}> = ({ update, checking, check }) => (
<div style={tabScroll}>
<Field
label="Version"
description={
update
? `v${update.current}${
update.channel ? ` · ${update.channel} channel` : " · development build"
}`
: "…"
}
childrenContainerWidth="max"
>
<DialogButton
style={{ ...actionButton, minWidth: "11em" }}
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
</DialogButton>
</Field>
{hasUpdate(update) && (
<Field
label={
update!.update_available
? `Plugin update — v${update!.latest}${
update!.client_update_available ? " + client" : ""
}`
: "Client update available"
}
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
childrenContainerWidth="max"
>
<DialogButton
style={{ ...actionButton, minWidth: "9em" }}
onClick={() => applyUpdate(update!, check)}
>
<FaDownload style={{ marginRight: "0.4em" }} />
Update
</DialogButton>
</Field>
)}
<Field
label="Setup guide"
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
childrenContainerWidth="max"
>
<DialogButton
style={{ ...actionButton, minWidth: "8em" }}
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
>
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</Field>
<Field
focusable={false}
label="Leaving a stream"
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
/>
<Field
label="Stream stuck?"
description="Force-stop the stream client if a session wedges"
childrenContainerWidth="max"
>
<DialogButton style={{ ...actionButton, minWidth: "8em" }} onClick={() => void forceStopStream()}>
Force-stop
</DialogButton>
</Field>
</div>
);
const PunktfunkPage: FC = () => {
const { hosts, scanning, refresh } = useHosts();
const { info: update, checking, check } = useUpdate();
const pins = usePins();
const [tab, setTab] = useState("hosts");
return (
<div
style={{
marginTop: "40px",
height: "calc(100% - 40px)",
display: "flex",
flexDirection: "column",
}}
>
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */}
<Focusable
style={{
display: "flex",
alignItems: "center",
gap: "1em",
padding: "0 2.5em",
marginBottom: "0.4em",
flexShrink: 0,
}}
>
<DialogButton style={iconButton} onClick={() => Navigation.NavigateBack()}>
<FaArrowLeft />
</DialogButton>
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
Punktfunk
</div>
</Focusable>
{/* overflow:hidden is load-bearing: Valve's Tabs slides the incoming panel in from the
right on L1/R1, and with autoFocusContents it scrollIntoViews a control inside that
still-offscreen panel. Without a clip here the scroll pans #GamepadUI itself — the whole
Steam UI (top bar included) slides left until you click a tab. Valve's own Tabs always
live in a clipped flex box; match that. */}
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<Tabs
activeTab={tab}
onShowTab={(id: string) => setTab(id)}
autoFocusContents
tabs={[
{
id: "hosts",
title: "Hosts",
content: (
<HostsTab
hosts={hosts}
scanning={scanning}
refresh={refresh}
pins={pins}
clientUpdatePending={!!update?.client_update_available}
/>
),
},
{
id: "settings",
title: "Settings",
content: <SettingsTab />,
},
{
id: "about",
title: "About",
content: <AboutTab update={update} checking={checking} check={check} />,
},
]}
/>
</div>
</div>
);
};
// Full page behind the boundary — registered as the /punktfunk route.
export const PunktfunkRoute: FC = () => (
<PluginErrorBoundary>
<PunktfunkPage />
</PluginErrorBoundary>
);
+91
View File
@@ -0,0 +1,91 @@
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
// The host displays the PIN after the operator arms pairing; the user enters it here.
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
import { toaster } from "@decky/api";
import { FC, useState } from "react";
import { Host, pair } from "./backend";
export const PairModal: FC<{
host: Host;
closeModal?: () => void;
onPaired: () => void;
}> = ({ host, closeModal, onPaired }) => {
const [pin, setPin] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
const back = () => setPin((p) => p.slice(0, -1));
const submit = async () => {
setBusy(true);
setError(null);
try {
const res = await pair(host.host, host.port, pin, "Steam Deck");
if (res.ok) {
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
onPaired();
closeModal?.();
} else {
setError(res.error ?? "pairing failed");
setPin("");
}
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
Pair with {host.name}
</div>
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
</div>
<div
style={{
fontSize: "2.2em",
letterSpacing: "0.4em",
textAlign: "center",
fontFamily: "monospace",
minHeight: "1.4em",
marginBottom: "0.6em",
}}
>
{pin.padEnd(4, "•")}
</div>
{error && (
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
{error}
</div>
)}
<Focusable
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "0.5em",
}}
>
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
{d}
</DialogButton>
))}
<DialogButton disabled={busy} onClick={back}>
</DialogButton>
<DialogButton disabled={busy} onClick={() => press("0")}>
0
</DialogButton>
<DialogButton disabled={busy || pin.length !== 4} onClick={submit}>
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
</DialogButton>
</Focusable>
</ModalRoot>
);
};
+127
View File
@@ -0,0 +1,127 @@
// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
import { FC, useEffect, useState } from "react";
import { getSettings, setSettings, StreamSettings } from "./backend";
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
[1280, 720, "1280 × 720"],
[1280, 800, "1280 × 800 (Deck)"],
[1920, 1080, "1920 × 1080"],
[2560, 1440, "2560 × 1440"],
];
const REFRESH = [0, 30, 60, 90, 120];
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
const GAMEPAD_LABELS: Record<string, string> = {
auto: "Automatic",
xbox360: "Xbox 360",
xboxone: "Xbox One",
dualsense: "DualSense",
dualshock4: "DualShock 4",
steamdeck: "Steam Deck",
};
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
const COMPOSITOR_LABELS: Record<string, string> = {
auto: "Automatic",
kwin: "KDE Plasma (KWin)",
wlroots: "Sway (wlroots)",
mutter: "GNOME (Mutter)",
gamescope: "gamescope",
};
export const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
useEffect(() => {
void getSettings().then(setS);
}, []);
const patch = (p: Partial<StreamSettings>) => {
setS((cur) => {
if (!cur) return cur;
const next = { ...cur, ...p };
void setSettings(next);
return next;
});
};
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const resIdx = Math.max(
0,
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
);
return (
<>
<Field
label="Resolution"
description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
</Field>
<Field label="Refresh rate" childrenContainerWidth="max">
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</Field>
<SliderField
label="Bitrate"
description="Mbit/s · 0 = host default"
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<Field
label="Gamepad type"
description="Which virtual controller the host creates for your inputs"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</Field>
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
<Field
label="⚠ Disable Steam Input"
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
/>
)}
<Field
label="Host compositor"
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
selectedOption={s.compositor}
onChange={(o) => patch({ compositor: o.data as string })}
/>
</Field>
<ToggleField
label="Stream microphone"
description="Send the Deck's microphone to the host's virtual mic"
checked={s.mic_enabled}
onChange={(v) => patch({ mic_enabled: v })}
/>
</>
);
};
+133 -38
View File
@@ -3,11 +3,12 @@
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to // THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
// the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see // the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE // gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE
// hidden non-Steam shortcut that points at our wrapper script (bin/punktfunkrun.sh), pass the // hidden non-Steam shortcut whose exe is `/bin/sh` running our wrapper script
// per-session host as the shortcut's Steam launch options, and start it with RunGame. The // (bin/punktfunkrun.sh), pass the per-session host as the shortcut's Steam launch options,
// wrapper then execs `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant. // and start it with RunGame. The wrapper then execs
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
import { runnerInfo } from "./backend"; import { runnerInfo, shortcutArt } from "./backend";
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed // SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the // by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
@@ -23,24 +24,35 @@ declare const SteamClient: {
SetShortcutName(appId: number, name: string): void; SetShortcutName(appId: number, name: string): void;
SetShortcutExe(appId: number, exe: string): void; SetShortcutExe(appId: number, exe: string): void;
SetShortcutStartDir(appId: number, dir: string): void; SetShortcutStartDir(appId: number, dir: string): void;
SetShortcutIcon(appId: number, iconPath: string): void;
SetAppLaunchOptions(appId: number, options: string): void; SetAppLaunchOptions(appId: number, options: string): void;
// assetType: 0 = grid (portrait capsule), 1 = hero, 2 = logo, 3 = wide grid.
SetCustomArtworkForApp(
appId: number,
base64Image: string,
imageType: string,
assetType: number,
): Promise<unknown>;
RunGame(gameId: string, _unused: string, _i: number, _j: number): void; RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
TerminateApp(gameId: string, _b: boolean): void; TerminateApp(gameId: string, _b: boolean): void;
}; };
}; };
// Steam removed `SteamClient.Apps.SetAppHidden`. Hiding a non-Steam shortcut now goes through // Steam removed `SteamClient.Apps.SetAppHidden`; visibility goes through
// `collectionStore.SetAppsAsHidden([appId], true)` — but that looks the app up in appStore, which // `collectionStore.SetAppsAsHidden` — but that looks the app up in appStore, which only
// only registers a freshly-created shortcut a moment later (calling it immediately throws on a // registers a freshly-created shortcut a moment later (calling it immediately throws on a
// null overview). So hiding is BEST-EFFORT + DEFERRED and must NEVER block the launch. // null overview). So visibility changes are BEST-EFFORT + DEFERRED, never launch-blocking.
declare const collectionStore: declare const collectionStore:
| { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void } | { SetAppsAsHidden?: (appIds: number[], hidden: boolean) => void }
| undefined; | undefined;
function hideShortcut(appId: number): void { // The shortcut used to be hidden ("implementation detail"); it is user-visible now — it
// carries proper artwork and living in the library is how users relaunch their last host.
// Existing installs still have theirs hidden, so unhide is applied every ensure (idempotent).
function unhideShortcut(appId: number): void {
const attempt = () => { const attempt = () => {
try { try {
collectionStore?.SetAppsAsHidden?.([appId], true); collectionStore?.SetAppsAsHidden?.([appId], false);
} catch { } catch {
/* overview not registered yet, or the API changed — cosmetic, ignore */ /* overview not registered yet, or the API changed — cosmetic, ignore */
} }
@@ -49,7 +61,49 @@ function hideShortcut(appId: number): void {
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
} }
const SHORTCUT_NAME = "punktfunk"; // Bump when the shipped artwork changes so existing shortcuts re-apply it once.
const ART_VERSION = 1;
const ART_KEY = "punktfunk:shortcutArt";
/**
* Apply the plugin's grid/hero/logo/icon to the shortcut (idempotent, once per ART_VERSION).
* Cosmetic and fully best-effort: any failure is swallowed and retried on the next launch.
*/
async function applyArtwork(appId: number): Promise<void> {
try {
if (localStorage.getItem(ART_KEY) === `${appId}:${ART_VERSION}`) {
return;
}
const art = await shortcutArt();
const assets: [string | undefined, number][] = [
[art.grid, 0],
[art.hero, 1],
[art.logo, 2],
[art.gridwide, 3],
];
for (const [data, assetType] of assets) {
if (data) {
await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType);
}
}
if (art.icon_path) {
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
}
localStorage.setItem(ART_KEY, `${appId}:${ART_VERSION}`);
} catch (e) {
console.warn("punktfunk: shortcut artwork not applied", e);
}
}
// The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it.
const SHORTCUT_NAME = "Punktfunk";
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
// backend can't chmod it back on. Passing the script as an argument to the always-executable
// shell removes the +x dependency entirely. SteamOS /bin/sh is bash; the wrapper is plain
// POSIX sh regardless.
const SHELL = "/bin/sh";
// The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the // The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the
// standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this. // standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this.
@@ -78,39 +132,36 @@ function recallAppId(): number | null {
} }
/** /**
* Ensure exactly one hidden "punktfunk" shortcut exists pointing at the wrapper script, and * Ensure exactly one "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is
* return its appId. Reuses the remembered one when its exe still matches the current runner * appended per-launch via the launch options), branded and visible in the library, and
* path (the plugin dir can change across reinstalls). * return its appId + the current runner path. Reuses the remembered shortcut, re-pointing
* it each time — the plugin dir can change across reinstalls, pre-0.4 shortcuts pointed at
* the script directly, and pre-0.7 shortcuts were hidden and artless.
*/ */
async function ensureShortcut(): Promise<number> { async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
const info = await runnerInfo(); const info = await runnerInfo();
if (!info.exists) { if (!info.exists) {
throw new Error(`launch wrapper missing at ${info.runner}`); throw new Error(`launch wrapper missing at ${info.runner}`);
} }
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
const remembered = recallAppId(); const remembered = recallAppId();
if (remembered != null) { if (remembered != null) {
// Re-point the existing shortcut at the current runner path (cheap + idempotent). // Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs).
SteamClient.Apps.SetShortcutExe(remembered, info.runner); SteamClient.Apps.SetShortcutExe(remembered, SHELL);
SteamClient.Apps.SetShortcutStartDir( SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
remembered, SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
info.runner.replace(/\/[^/]*$/, ""), unhideShortcut(remembered); // pre-0.7 installs hid it
); void applyArtwork(remembered); // fire-and-forget — cosmetic, never blocks the launch
return remembered; return { appId: remembered, runner: info.runner };
} }
const appId = await SteamClient.Apps.AddShortcut( const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
SHORTCUT_NAME,
info.runner,
info.runner.replace(/\/[^/]*$/, ""), // start dir = the bin/ dir
"",
);
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME); SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
// Hide it from the library — it's an implementation detail, launched programmatically. unhideShortcut(appId);
// Best-effort + deferred (see hideShortcut); never let it block the launch. void applyArtwork(appId); // fire-and-forget — cosmetic, never blocks the launch
hideShortcut(appId);
rememberAppId(appId); rememberAppId(appId);
return appId; return { appId, runner: info.runner };
} }
/** /**
@@ -133,18 +184,62 @@ function disableSteamInputForShortcut(appId: number): void {
} }
} }
/** Per-launch extras beyond the host target (all optional — {} is the plain stream). */
export interface LaunchOpts {
/** Library id to launch on connect (a pinned game) — rides PF_LAUNCH → `--launch`. */
launchId?: string;
/** Open the gamepad library launcher instead of streaming (PF_BROWSE → `--browse`). */
browse?: boolean;
/** Management-API port for the launcher's library fetch (PF_MGMT; 0/absent = default). */
mgmt?: number;
}
// Launch ids ride Steam launch options as an env-prefix token (`PF_LAUNCH=<id>`), so they
// must be space/quote-free — Steam's tokenizer and the wrapper's env both break otherwise.
// Real ids are `steam:<digits>` / `custom:<slug>`, so this rejects nothing in practice;
// it's VALIDATION, never encoding (the host must match the opaque token verbatim).
const UNSAFE_LAUNCH_ID = /["'\\$`\s]/;
export function isSafeLaunchId(id: string): boolean {
return (
id.length > 0 &&
id.length <= 128 &&
UNSAFE_LAUNCH_ID.exec(id) === null &&
/^[\x21-\x7e]+$/.test(id)
);
}
/** /**
* Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the * Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
* shortcut's launch options (so one generic shortcut serves every host), then RunGame. * library title, or into the gamepad library launcher). Encodes the target into the
* shortcut's launch options (so one generic shortcut serves every host and every pinned
* game), then RunGame.
*/ */
export async function launchStream(host: string, port: number): Promise<void> { export async function launchStream(
const appId = await ensureShortcut(); host: string,
port: number,
opts: LaunchOpts = {},
): Promise<void> {
const { appId, runner } = await ensureShortcut();
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user // Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
// disables Steam Input manually — see the Settings instruction). // disables Steam Input manually — see the Settings instruction).
disableSteamInputForShortcut(appId); disableSteamInputForShortcut(appId);
const target = port && port !== 9777 ? `${host}:${port}` : host; const target = port && port !== 9777 ? `${host}:${port}` : host;
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment. const env = [`PF_HOST=${target}`];
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`); if (opts.browse) {
env.push("PF_BROWSE=1");
if (opts.mgmt) {
env.push(`PF_MGMT=${Math.floor(opts.mgmt)}`);
}
} else if (opts.launchId) {
if (!isSafeLaunchId(opts.launchId)) {
// Enforced at pin time too (the picker disables Pin) — this is the backstop.
throw new Error(`unsupported launch id: ${opts.launchId}`);
}
env.push(`PF_LAUNCH=${opts.launchId}`);
}
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
// script rides behind it as an argument and reads PF_* from the environment.
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100); SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
} }
+12 -4
View File
@@ -26,6 +26,10 @@ Built in Rust, it links the shared **`punktfunk-core`** directly (no C ABI) and
shows its games (Steam + custom) as a poster grid; click one to launch it in the session. shows its games (Steam + custom) as a poster grid; click one to launch it in the session.
Fetched from the host's management API over mTLS — paired devices are authorized by their Fetched from the host's management API over mTLS — paired devices are authorized by their
certificate, no extra host setup. certificate, no extra host setup.
- **Gamepad library launcher** (`--browse host`) — a console-style, controller-driven coverflow of
a paired host's library (drifting aurora backdrop, center-focus posters, button hints): A plays
the focused title, B quits, L1/R1 jump. Built for the Steam Deck plugin's "Open library" launch;
session end returns to the launcher. Arrow keys/Enter/Esc drive it too (no pad needed).
## Get it ## Get it
@@ -49,24 +53,28 @@ and SDL3 (with hidapi) development packages.
```sh ```sh
# from the repo root # from the repo root
cargo run -p punktfunk-client-linux # launch the app cargo run -p punktfunk-client-linux # launch the app
cargo run -p punktfunk-client-linux -- --discover # list hosts on the LAN, then exit
cargo run -p punktfunk-client-linux -- --connect HOST[:PORT] # skip the host list and connect cargo run -p punktfunk-client-linux -- --connect HOST[:PORT] # skip the host list and connect
cargo run -p punktfunk-client-linux -- --browse HOST # the gamepad library launcher
``` ```
The binary is named **`punktfunk-client`**. Handy flags: `--connect host[:port]` (start a session The binary is named **`punktfunk-client`**. Handy flags: `--connect host[:port]` (start a session
immediately — for scripting and the Steam Deck launcher), `--discover [secs]`, and immediately — for scripting and the Steam Deck launcher) with optional `--launch <id>` (ask the
host to launch that library title, id from `--library`), `--browse host[:port]` (the gamepad
library launcher; `--mgmt <port>` overrides the management port it fetches from),
`--pair <PIN> --connect host[:port]` (run the pairing ceremony headlessly), and `--pair <PIN> --connect host[:port]` (run the pairing ceremony headlessly), and
`--library host[:mgmt_port]` (print a host's game library headlessly). Force a decoder with `--library host[:mgmt_port]` (print a host's game library headlessly). Force a decoder with
`PUNKTFUNK_DECODER=software|vaapi`. `PUNKTFUNK_DECODER=software|vaapi`; `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds the launcher
canned entries for UI work with no host.
## Layout ## Layout
``` ```
src/ src/
main.rs · app.rs entry point, GTK application, primary menu, CSS main.rs · app.rs entry point, GTK application, primary menu, CSS
cli.rs CLI paths (--connect, headless --pair, screenshot scenes) cli.rs CLI paths (--connect/--launch, --browse, headless --pair, screenshot scenes)
ui_hosts.rs host card grids (saved + discovered) · add-host dialog · banner ui_hosts.rs host card grids (saved + discovered) · add-host dialog · banner
ui_library.rs game-library poster grid (per-host, launches titles) ui_library.rs game-library poster grid (per-host, launches titles)
ui_gamepad_library.rs the --browse gamepad launcher (aurora · coverflow · hint bar)
ui_trust.rs TOFU / PIN-pairing / request-access dialogs ui_trust.rs TOFU / PIN-pairing / request-access dialogs
ui_settings.rs resolution · refresh · decoder · bitrate · compositor · mic ui_settings.rs resolution · refresh · decoder · bitrate · compositor · mic
ui_stream.rs the stream window (GtkGraphicsOffload present) + input capture ui_stream.rs the stream window (GtkGraphicsOffload present) + input capture
+91 -6
View File
@@ -22,14 +22,44 @@ const CSS: &str = "
color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); } color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); }
.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); } .pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); }
.pf-pill.pf-accent { color: @accent_color; background: alpha(@accent_color, 0.15); } .pf-pill.pf-accent { color: @accent_color; background: alpha(@accent_color, 0.15); }
.pf-pill.pf-neutral { color: alpha(currentColor, 0.75); background: alpha(currentColor, 0.12); }
.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px; .pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
background: alpha(currentColor, 0.35); } background: alpha(currentColor, 0.35); }
.pf-pip.pf-online { background: @success_color; } .pf-pip.pf-online { background: @success_color; }
.pf-recent { box-shadow: inset 3px 0 0 0 @accent_bg_color; } /* Most-recent host: a full accent ring drawn as an inset outline so it follows the card's
rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves
the card's own elevation shadow intact. */
.pf-recent { outline: 2px solid @accent_color; outline-offset: -2px; }
.pf-discovered { border: 1px dashed alpha(currentColor, 0.35); } .pf-discovered { border: 1px dashed alpha(currentColor, 0.35); }
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); } .pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); } .pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); } .pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); }
/* Gaming-Mode launches: gamescope displays the window fullscreen but never ACKs the
xdg_toplevel fullscreen state, so GTK keeps the floating-CSD styling — libadwaita's
rounded corners + shadow margin stay visible over the stream. Flatten them outright. */
window.pf-chromeless { border-radius: 0; box-shadow: none; }
/* The gamepad library launcher (`--browse`, ui_gamepad_library) — always-dark console
chrome over the aurora, independent of the desktop theme. */
.pf-gl-page { background: black; color: white; }
.pf-gl-host { font-size: 1.15em; font-weight: bold; color: rgba(255, 255, 255, 0.9); }
.pf-gl-chip { font-size: 0.8em; color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px; padding: 4px 12px; }
/* Solid face, not glass: coverflow side cards OVERLAP — a translucent card would bleed
the stack through the one on top. */
.pf-gl-poster { border-radius: 16px; background: rgb(30, 30, 37);
border: 1px solid rgba(255, 255, 255, 0.07); }
.pf-gl-dim { background: black; border-radius: 16px; }
.pf-gl-detail-title { font-size: 1.7em; font-weight: bold; color: white; }
.pf-gl-detail-store { font-size: 0.75em; font-weight: 600; letter-spacing: 2px;
color: rgba(255, 255, 255, 0.5); }
.pf-gl-glyph { font-size: 0.85em; font-weight: bold; color: white;
background: rgba(255, 255, 255, 0.14);
border-radius: 999px; min-width: 26px; min-height: 26px; padding: 2px 8px; }
.pf-gl-hint { color: rgba(255, 255, 255, 0.85); }
.pf-gl-status { font-size: 0.85em; color: #ff938a; }
.pf-gl-error-title { font-size: 1.4em; font-weight: bold; color: white; }
"; ";
pub struct App { pub struct App {
@@ -44,9 +74,16 @@ pub struct App {
pub busy: std::cell::Cell<bool>, pub busy: std::cell::Cell<bool>,
/// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts. /// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts.
pub fullscreen: bool, pub fullscreen: bool,
/// Quit when the session ends (Gaming-Mode `--connect` launch): the app IS the stream —
/// exiting ends the Steam "game" so the Deck returns to Gaming Mode instead of stranding
/// the user on the client's own hosts page.
pub quit_on_session_end: bool,
/// The hosts page handle (banner + per-card connecting spinner), set right after the /// The hosts page handle (banner + per-card connecting spinner), set right after the
/// page is built — `None` only during construction. /// page is built — `None` only during construction.
pub hosts: RefCell<Option<Rc<HostsUi>>>, pub hosts: RefCell<Option<Rc<HostsUi>>>,
/// The gamepad library launcher — `Some` only under `--browse`, where it replaces the
/// hosts page as the root (and session end returns here instead of quitting).
pub browse: RefCell<Option<Rc<crate::ui_gamepad_library::LauncherUi>>>,
} }
impl App { impl App {
@@ -58,11 +95,17 @@ impl App {
self.hosts.borrow().clone() self.hosts.borrow().clone()
} }
/// Surface a connect failure on the hosts page banner (toast fallback pre-build). pub fn browse_ui(&self) -> Option<Rc<crate::ui_gamepad_library::LauncherUi>> {
self.browse.borrow().clone()
}
/// Surface a connect failure: the launcher in browse mode, else the hosts page banner
/// (toast fallback pre-build).
pub fn connect_error(&self, msg: &str) { pub fn connect_error(&self, msg: &str) {
match self.hosts_ui() { match (self.browse_ui(), self.hosts_ui()) {
Some(h) => h.show_error(msg), (Some(l), _) => l.show_error(msg),
None => self.toast(msg), (_, Some(h)) => h.show_error(msg),
_ => self.toast(msg),
} }
} }
} }
@@ -104,6 +147,14 @@ fn build_ui(gtk_app: &adw::Application) {
} }
}; };
load_css(); load_css();
// Screenshot scenes must capture settled frames: kill every GTK/libadwaita animation
// (nav-push slides especially — a headless session may starve the frame clock and
// leave a transition frozen mid-flight in the capture).
if crate::cli::shot_scene().is_some() {
if let Some(s) = gtk::Settings::default() {
s.set_gtk_enable_animations(false);
}
}
let nav = adw::NavigationView::new(); let nav = adw::NavigationView::new();
let toasts = adw::ToastOverlay::new(); let toasts = adw::ToastOverlay::new();
@@ -116,6 +167,14 @@ fn build_ui(gtk_app: &adw::Application) {
.content(&toasts) .content(&toasts)
.build(); .build();
let fullscreen = crate::cli::fullscreen_mode();
if fullscreen {
// Chrome-less shell: no CSD rounding/shadow (see CSS — gamescope never ACKs the
// fullscreen state, so GTK would keep them), and ask for fullscreen up front.
window.add_css_class("pf-chromeless");
window.fullscreen();
}
let app = Rc::new(App { let app = Rc::new(App {
window: window.clone(), window: window.clone(),
nav: nav.clone(), nav: nav.clone(),
@@ -124,10 +183,36 @@ fn build_ui(gtk_app: &adw::Application) {
identity, identity,
gamepad: crate::gamepad::GamepadService::start(), gamepad: crate::gamepad::GamepadService::start(),
busy: std::cell::Cell::new(false), busy: std::cell::Cell::new(false),
fullscreen: crate::cli::fullscreen_mode(), fullscreen,
// (`--browse` makes cli_connect_request None — browse mode returns to the
// launcher on session end instead of quitting.)
quit_on_session_end: fullscreen && crate::cli::cli_connect_request().is_some(),
hosts: RefCell::new(None), hosts: RefCell::new(None),
browse: RefCell::new(None),
}); });
// Re-apply the persisted forwarded-controller pin (stable key; the service matches it
// whenever such a pad connects) — without this the pin silently resets to Automatic on
// every launch, and Automatic may resolve to a gyro-less pad (Steam's virtual gamepad).
{
let forward = app.settings.borrow().forward_pad.clone();
if !forward.is_empty() {
app.gamepad.set_pinned(Some(forward));
}
}
// Browse mode (`--browse host`): the app IS the gamepad library launcher — it becomes
// the ONE root page. No hosts page (whose construction starts the mDNS browse), no
// header-menu actions; `Settings::library_enabled` is deliberately ignored (the flag
// gates the desktop menu item — asking to browse IS the opt-in here).
if let Some((req, paired, mgmt_port)) = crate::cli::cli_browse_request() {
let launcher = crate::ui_gamepad_library::open(app.clone(), req, paired, mgmt_port);
nav.add(&launcher.page);
*app.browse.borrow_mut() = Some(launcher);
window.present();
return;
}
let hosts_ui = Rc::new(crate::ui_hosts::new( let hosts_ui = Rc::new(crate::ui_hosts::new(
app.settings.clone(), app.settings.clone(),
HostsCallbacks { HostsCallbacks {
+85 -20
View File
@@ -84,19 +84,66 @@ pub fn headless_pair(pin: &str) -> glib::ExitCode {
/// already pinned at this address connects silently on its stored pin; an unknown host is /// already pinned at this address connects silently on its stored pin; an unknown host is
/// routed to the PIN ceremony (never a silent TOFU connect — `fp_hex`/`pair_optional` are /// routed to the PIN ceremony (never a silent TOFU connect — `fp_hex`/`pair_optional` are
/// unset, so `initiate_connect`'s manual arm mandates pairing). /// unset, so `initiate_connect`'s manual arm mandates pairing).
///
/// `--launch <id>` asks the host to launch that library title (store-qualified id from
/// `--library`, e.g. `steam:570` — the Decky wrapper's `PF_LAUNCH`); the raw id doubles
/// as the stream title (best-effort — no extra fetch just for a prettier label).
pub fn cli_connect_request() -> Option<ConnectRequest> { pub fn cli_connect_request() -> Option<ConnectRequest> {
if arg_value("--browse").is_some() {
return None; // browse mode owns the session lifecycle (precedence over --connect)
}
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?; let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
let (addr, port) = parse_host_port(&target); let (addr, port) = parse_host_port(&target);
// An unparsable port (`host:notaport`) used to make the whole request `None` → the app
// silently landed on the hosts page with no session and no message. Fall back to the
// native default like the add-host dialog, and say so, instead of doing nothing.
let port = port.unwrap_or_else(|| {
eprintln!("--connect: unparsable port in '{target}', using default 9777");
9777
});
Some(ConnectRequest { Some(ConnectRequest {
name: addr.clone(), name: addr.clone(),
addr, addr,
port: port?, port,
fp_hex: None, fp_hex: None,
pair_optional: false, pair_optional: false,
launch: None, launch: arg_value("--launch").map(|id| (id.clone(), id)),
}) })
} }
/// `--browse host[:port]` — open the gamepad library launcher for that host instead of
/// connecting (the Decky wrapper's `PF_BROWSE`; native port, default 9777). The host must
/// already be paired: the stored pin is what lets the launcher fetch the library and
/// connect silently — no dialog can run under gamescope, so an unpaired target renders
/// the launcher's pair-first scene. Returns the request (name + stored fingerprint from
/// the known-hosts store), whether it's paired, and the mgmt port (`--mgmt <port>`, the
/// wrapper's `PF_MGMT`; default 47990 — browse mode runs no mDNS to learn it).
pub fn cli_browse_request() -> Option<(ConnectRequest, bool, u16)> {
let target = arg_value("--browse")?;
let (addr, port) = parse_host_port(&target);
let port = port.unwrap_or(9777);
let known = crate::trust::KnownHosts::load();
let k = known
.hosts
.iter()
.find(|h| h.addr == addr && h.port == port);
let mgmt = arg_value("--mgmt")
.and_then(|p| p.parse().ok())
.unwrap_or(crate::library::DEFAULT_MGMT_PORT);
Some((
ConnectRequest {
name: k.map_or_else(|| addr.clone(), |k| k.name.clone()),
addr,
port,
fp_hex: k.map(|k| k.fp_hex.clone()),
pair_optional: false,
launch: None,
},
k.is_some_and(|k| k.paired),
mgmt,
))
}
/// `--library host[:mgmt_port]` — fetch and print the host's game library over the real /// `--library host[:mgmt_port]` — fetch and print the host's game library over the real
/// mTLS + pinned-fingerprint client, no GTK window (scripting, and the live-API proof /// mTLS + pinned-fingerprint client, no GTK window (scripting, and the live-API proof
/// that the library HTTP path works against a real host). The pin comes from `--fp HEX` /// that the library HTTP path works against a real host). The pin comes from `--fp HEX`
@@ -219,26 +266,17 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
// no-art placeholders (monogram tiles), and one solid-color texture standing in // no-art placeholders (monogram tiles), and one solid-color texture standing in
// for a loaded poster (the real poster path, minus the network). // for a loaded poster (the real poster path, minus the network).
"library" | "08-library" => { "library" | "08-library" => {
let game = |id: &str, store: &str, title: &str| crate::library::GameEntry { let (games, art) = mock_library();
id: id.to_string(),
store: store.to_string(),
title: title.to_string(),
art: crate::library::Artwork::default(),
};
let games = vec![
game("steam:570", "steam", "Dota 2"),
game("steam:1091500", "steam", "Cyberpunk 2077"),
game("custom:emu-1", "custom", "RetroArch"),
game("heroic:fortnite", "heroic", "Fortnite"),
game("gog:witcher3", "gog", "The Witcher 3"),
game("lutris:osu", "lutris", "osu!"),
];
let art = vec![(
"steam:570".to_string(),
solid_texture(300, 450, 0x35, 0x84, 0xe4),
)];
crate::ui_library::open_mock(app.clone(), mock_req(), games, art); crate::ui_library::open_mock(app.clone(), mock_req(), games, art);
} }
// The gamepad launcher (`--browse`) with the same injected entries — cursor sits
// at 1 so both recede directions show; aurora + easing render frozen (shot mode).
"gamepad-library" | "09-gamepad-library" => {
let (games, art) = mock_library();
let ui = crate::ui_gamepad_library::open_mock(app.clone(), mock_req(), games, art);
app.nav.push(&ui.page);
*app.browse.borrow_mut() = Some(ui);
}
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"), other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
} }
@@ -268,6 +306,33 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
}); });
} }
/// The mock game set shared by the `library` and `gamepad-library` scenes: mixed stores
/// exercising the badge set, plus one solid-colour poster texture.
fn mock_library() -> (
Vec<crate::library::GameEntry>,
Vec<(String, gtk::gdk::Texture)>,
) {
let game = |id: &str, store: &str, title: &str| crate::library::GameEntry {
id: id.to_string(),
store: store.to_string(),
title: title.to_string(),
art: crate::library::Artwork::default(),
};
let games = vec![
game("steam:570", "steam", "Dota 2"),
game("steam:1091500", "steam", "Cyberpunk 2077"),
game("custom:emu-1", "custom", "RetroArch"),
game("heroic:fortnite", "heroic", "Fortnite"),
game("gog:witcher3", "gog", "The Witcher 3"),
game("lutris:osu", "lutris", "osu!"),
];
let art = vec![(
"steam:570".to_string(),
solid_texture(300, 450, 0x35, 0x84, 0xe4),
)];
(games, art)
}
/// A WxH single-colour RGBA texture — the `library` scene's stand-in for a fetched poster. /// A WxH single-colour RGBA texture — the `library` scene's stand-in for a fetched poster.
fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture { fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture {
let px = [r, g, b, 0xff].repeat((w * h) as usize); let px = [r, g, b, 0xff].repeat((w * h) as usize);
+648 -84
View File
@@ -2,12 +2,32 @@
//! `GamepadCapture`/`GamepadFeedback`). //! `GamepadCapture`/`GamepadFeedback`).
//! //!
//! One worker thread owns SDL for the process lifetime: it tracks connected pads for the //! One worker thread owns SDL for the process lifetime: it tracks connected pads for the
//! Settings UI, selects the ONE controller forwarded as pad 0 (user pin, else the most //! Settings UI (metadata only — see below), selects the ONE controller forwarded as pad 0
//! recently connected), and — while a session is attached — forwards buttons/axes, //! (the user pin — persisted in Settings by stable `vid:pid:name` key — else the most
//! DualSense touchpad contacts and motion samples (0xCC), and renders feedback: rumble on //! recently connected real pad; Steam Input's virtual pad is skipped), and — while a
//! every pad, lightbar via SDL, and on a real DualSense the raw effects packet //! session is attached — forwards buttons/axes, DualSense touchpad contacts and motion
//! (adaptive-trigger blocks replayed verbatim, player LEDs). Held state is zeroed on the //! samples (0xCC), and renders feedback: rumble, lightbar via SDL, and on a real DualSense
//! wire when the active pad switches or the session detaches, so nothing sticks down. //! the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs). Held
//! state is zeroed on the wire when the active pad switches or the session detaches, so
//! nothing sticks down.
//!
//! **Idle means hands off the hardware.** Outside an attached session the worker never
//! opens a device and keeps SDL's Valve HIDAPI drivers disabled ([`set_valve_hidapi`]):
//! the Steam Deck driver clears the built-in controller's "lizard mode" (trackpad-mouse,
//! clicky pads) the moment the device *enumerates* and keeps feeding that watchdog — so an
//! idle host-list window would kill the Deck's system input. The pad list for Settings is
//! built from SDL's ID-based metadata getters, which need no open.
//!
//! **Menu mode is the one idle exception.** The gamepad library launcher (`--browse`)
//! flips [`GamepadService::set_menu_mode`] on for its lifetime: the worker then holds the
//! active pad open and translates its buttons/stick into [`MenuEvent`]s (polled off the
//! open handle each loop — Apple `GamepadMenuInput` parity: edge-triggered buttons,
//! snapshot-on-entry so a button still held from a previous screen or stream can't ghost-
//! fire, stick/dpad direction with initial-delay auto-repeat). The Valve HIDAPI drivers
//! stay OFF — a plain SDL open of the virtual X360 / evdev pad doesn't touch lizard mode —
//! and an attached session always supersedes menu translation (the stream path is
//! untouched); detach re-snapshots so the escape chord that ended the session fires
//! nothing in the menu.
//! //!
//! This thread is also the single consumer of the rumble and HID-output pull planes. //! This thread is also the single consumer of the rumble and HID-output pull planes.
@@ -15,7 +35,6 @@ use punktfunk_core::client::NativeClient;
use punktfunk_core::config::GamepadPref; use punktfunk_core::config::GamepadPref;
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind}; use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender}; use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -42,14 +61,183 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press). /// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500); const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
const MENU_DEADZONE: u16 = 16384;
/// A held direction starts auto-repeating after this initial delay…
const MENU_REPEAT_DELAY: Duration = Duration::from_millis(380);
/// …and then repeats at this cadence until released or changed.
const MENU_REPEAT_INTERVAL: Duration = Duration::from_millis(160);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuDir {
Up,
Down,
Left,
Right,
}
/// One controller action for the launcher UI, translated from the open pad while menu
/// mode is on and no session is attached. Buttons are edge-triggered; `Move` debounces
/// the stick/dpad and auto-repeats ([`MENU_REPEAT_DELAY`]/[`MENU_REPEAT_INTERVAL`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuEvent {
Move(MenuDir),
/// A — activate the focused item.
Confirm,
/// B — back / quit.
Back,
/// Y (Apple "secondary"; unused by the launcher today, kept for parity).
Secondary,
/// X (Apple "tertiary"; unused).
Tertiary,
/// L1 — jump back 5.
JumpBack,
/// R1 — jump forward 5.
JumpForward,
}
/// Menu haptic pulses — short rumble ticks on the menu pad (never during a stream).
#[derive(Clone, Copy, Debug)]
pub enum MenuPulse {
Move,
Confirm,
Boundary,
}
/// Raw pad state sampled once per worker iteration for menu translation.
#[derive(Clone, Copy, Default)]
struct MenuSample {
/// a, b, x, y, l1, r1 — the order [`MenuNav::poll`] maps to events.
buttons: [bool; 6],
/// Left stick, SDL convention (+y = down).
lx: i16,
ly: i16,
/// up, down, left, right.
dpad: [bool; 4],
}
/// The pure menu-input state machine (no SDL types — unit-tested below). Port of the
/// Swift client's `GamepadMenuInput`: the poll after a [`reset`](Self::reset) adopts the
/// currently-held buttons and direction WITHOUT firing, so a press that crossed a screen
/// handoff (the B that closed a stream, a held A on mode entry) must be released before
/// it can act; buttons fire on the rising edge only.
struct MenuNav {
/// Adopt the next sample silently (set on mode entry / stream detach / pad change).
snapshot_pending: bool,
/// Previous button states, [`MenuSample::buttons`] order.
was: [bool; 6],
dir: Option<MenuDir>,
/// When `dir` engaged — start of the initial-repeat delay.
dir_since: Instant,
last_repeat: Instant,
}
impl MenuNav {
fn new() -> MenuNav {
MenuNav {
snapshot_pending: true,
was: [false; 6],
dir: None,
dir_since: Instant::now(),
last_repeat: Instant::now(),
}
}
/// Arm the snapshot: the next poll adopts held state without firing.
fn reset(&mut self) {
self.snapshot_pending = true;
self.dir = None;
}
/// Direction from the left stick (dominant axis wins past the deadzone), falling back
/// to the discrete dpad. SDL sticks are +y = down.
fn resolve_dir(s: &MenuSample) -> Option<MenuDir> {
let (ax, ay) = (s.lx.unsigned_abs(), s.ly.unsigned_abs());
if ax > MENU_DEADZONE || ay > MENU_DEADZONE {
return Some(if ax >= ay {
if s.lx > 0 {
MenuDir::Right
} else {
MenuDir::Left
}
} else if s.ly > 0 {
MenuDir::Down
} else {
MenuDir::Up
});
}
let [up, down, left, right] = s.dpad;
if left {
Some(MenuDir::Left)
} else if right {
Some(MenuDir::Right)
} else if up {
Some(MenuDir::Up)
} else if down {
Some(MenuDir::Down)
} else {
None
}
}
fn poll(&mut self, s: &MenuSample, now: Instant, out: &mut Vec<MenuEvent>) {
let dir = Self::resolve_dir(s);
if self.snapshot_pending {
self.snapshot_pending = false;
self.was = s.buttons;
self.dir = dir;
self.dir_since = now;
self.last_repeat = now;
return;
}
// buttons order a, b, x, y, l1, r1 → the matching event per index.
const EVENTS: [MenuEvent; 6] = [
MenuEvent::Confirm,
MenuEvent::Back,
MenuEvent::Tertiary,
MenuEvent::Secondary,
MenuEvent::JumpBack,
MenuEvent::JumpForward,
];
for (i, ev) in EVENTS.iter().enumerate() {
if s.buttons[i] && !self.was[i] {
out.push(*ev);
}
self.was[i] = s.buttons[i];
}
if dir != self.dir {
self.dir = dir;
self.dir_since = now;
self.last_repeat = now;
if let Some(d) = dir {
out.push(MenuEvent::Move(d));
}
} else if let Some(d) = dir {
if now.duration_since(self.dir_since) >= MENU_REPEAT_DELAY
&& now.duration_since(self.last_repeat) >= MENU_REPEAT_INTERVAL
{
self.last_repeat = now;
out.push(MenuEvent::Move(d));
}
}
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PadInfo { pub struct PadInfo {
pub id: u32,
pub name: String, pub name: String,
/// Stable identity (`vid:pid:name`) for pinning across restarts — SDL instance ids are
/// per-run, so [`Settings::forward_pad`](crate::trust::Settings) persists this instead.
pub key: String,
/// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a /// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a
/// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything /// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything
/// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path. /// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path.
pub pref: GamepadPref, pub pref: GamepadPref,
/// Steam Input's emulated pad ("Steam Virtual Gamepad", Valve 28de:11ff). It shadows the
/// physical controller and has no sensors/touchpad, so auto-selection skips it while a real
/// pad is connected — otherwise gyro silently dies on Bazzite/Deck game mode.
pub steam_virtual: bool,
} }
impl PadInfo { impl PadInfo {
@@ -71,6 +259,24 @@ impl PadInfo {
} }
} }
/// Enable/disable SDL's Valve HIDAPI drivers at runtime. The Steam Deck driver sends
/// `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` in `InitDevice` — at *enumeration*, before
/// any open — and its `UpdateDevice` keeps feeding the firmware's lizard-mode watchdog
/// (`SDL_hidapi_steamdeck.c`), so a Deck's built-in trackpad-mouse dies for the whole
/// system while the driver merely runs. These drivers therefore run ONLY while a session
/// is attached (input is captured then anyway, and streaming wants the paddles, both
/// trackpads, and gyro first-class). SDL3 applies the hint changes live: disabling detaches
/// the driver and the firmware watchdog restores lizard mode within seconds.
///
/// On a Deck in Game Mode, Steam Input still holds the device — the user must disable
/// Steam Input for this app (see the Decky UX); on a desktop client (or a Deck with Steam
/// Input off) the in-session enable just works.
fn set_valve_hidapi(enabled: bool) {
let v = if enabled { "1" } else { "0" };
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", v);
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v);
}
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create. /// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref { fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
use sdl3::gamepad::GamepadType as T; use sdl3::gamepad::GamepadType as T;
@@ -82,17 +288,33 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
} }
} }
/// Best-effort "this machine is a Steam Deck". The Gaming-Mode env short-circuits; desktop
/// mode falls back to DMI (Valve board, Jupiter = LCD / Galileo = OLED — readable inside the
/// flatpak sandbox). Cached: the answer can't change while we run.
pub fn is_steam_deck() -> bool {
static DECK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*DECK.get_or_init(|| {
if std::env::var_os("SteamDeck").is_some() {
return true;
}
let dmi = |f: &str| std::fs::read_to_string(format!("/sys/class/dmi/id/{f}"));
dmi("board_vendor").is_ok_and(|v| v.trim() == "Valve")
&& dmi("product_name").is_ok_and(|p| matches!(p.trim(), "Jupiter" | "Galileo"))
})
}
enum Ctl { enum Ctl {
Attach(Arc<NativeClient>), Attach(Arc<NativeClient>),
Detach, Detach,
Pin(Option<u32>), Pin(Option<String>),
MenuMode(bool),
MenuRumble(MenuPulse),
} }
#[derive(Clone)] #[derive(Clone)]
pub struct GamepadService { pub struct GamepadService {
pads: Arc<Mutex<Vec<PadInfo>>>, pads: Arc<Mutex<Vec<PadInfo>>>,
active: Arc<Mutex<Option<PadInfo>>>, active: Arc<Mutex<Option<PadInfo>>>,
pinned: Arc<Mutex<Option<u32>>>,
ctl: Sender<Ctl>, ctl: Sender<Ctl>,
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave /// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
/// fullscreen + release capture. /// fullscreen + release capture.
@@ -100,21 +322,24 @@ pub struct GamepadService {
/// Fires once when the [`ESCAPE_CHORD`] is held past [`DISCONNECT_HOLD`]; the stream page /// Fires once when the [`ESCAPE_CHORD`] is held past [`DISCONNECT_HOLD`]; the stream page
/// consumes it to end the session (the controller equivalent of Ctrl+Alt+Shift+D). /// consumes it to end the session (the controller equivalent of Ctrl+Alt+Shift+D).
disconnect_rx: async_channel::Receiver<()>, disconnect_rx: async_channel::Receiver<()>,
/// Menu-navigation events while menu mode is on and no session is attached; the
/// launcher page consumes them.
menu_rx: async_channel::Receiver<MenuEvent>,
} }
impl GamepadService { impl GamepadService {
pub fn start() -> GamepadService { pub fn start() -> GamepadService {
let pads = Arc::new(Mutex::new(Vec::new())); let pads = Arc::new(Mutex::new(Vec::new()));
let active = Arc::new(Mutex::new(None)); let active = Arc::new(Mutex::new(None));
let pinned = Arc::new(Mutex::new(None));
let (ctl, ctl_rx) = std::sync::mpsc::channel(); let (ctl, ctl_rx) = std::sync::mpsc::channel();
let (escape_tx, escape_rx) = async_channel::unbounded(); let (escape_tx, escape_rx) = async_channel::unbounded();
let (disconnect_tx, disconnect_rx) = async_channel::unbounded(); let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone()); let (menu_tx, menu_rx) = async_channel::unbounded();
let (p, a) = (pads.clone(), active.clone());
if let Err(e) = std::thread::Builder::new() if let Err(e) = std::thread::Builder::new()
.name("punktfunk-gamepad".into()) .name("punktfunk-gamepad".into())
.spawn(move || { .spawn(move || {
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) { if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx, &menu_tx) {
tracing::warn!(error = %e, "gamepad service ended — pads disabled"); tracing::warn!(error = %e, "gamepad service ended — pads disabled");
} }
}) })
@@ -124,10 +349,10 @@ impl GamepadService {
GamepadService { GamepadService {
pads, pads,
active, active,
pinned,
ctl, ctl,
escape_rx, escape_rx,
disconnect_rx, disconnect_rx,
menu_rx,
} }
} }
@@ -143,6 +368,25 @@ impl GamepadService {
self.disconnect_rx.clone() self.disconnect_rx.clone()
} }
/// Menu-navigation events ([`MenuEvent`]) — flowing only while menu mode is on and no
/// session is attached. A fresh clone per call; the launcher spawns a future on it.
pub fn menu_events(&self) -> async_channel::Receiver<MenuEvent> {
self.menu_rx.clone()
}
/// Turn menu mode on/off: while on (and no session attached) the worker holds the
/// active pad open and translates it into [`MenuEvent`]s. The launcher flips this on
/// once for its lifetime — an attached session supersedes translation automatically.
pub fn set_menu_mode(&self, on: bool) {
let _ = self.ctl.send(Ctl::MenuMode(on));
}
/// Play a short menu haptic tick on the menu pad (no-op while a session is attached
/// or no pad is open; best-effort on pads without rumble).
pub fn menu_rumble(&self, pulse: MenuPulse) {
let _ = self.ctl.send(Ctl::MenuRumble(pulse));
}
pub fn pads(&self) -> Vec<PadInfo> { pub fn pads(&self) -> Vec<PadInfo> {
self.pads.lock().unwrap().clone() self.pads.lock().unwrap().clone()
} }
@@ -151,12 +395,11 @@ impl GamepadService {
self.active.lock().unwrap().clone() self.active.lock().unwrap().clone()
} }
pub fn pinned(&self) -> Option<u32> { /// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic.
*self.pinned.lock().unwrap() /// The pin persists as `Settings::forward_pad` (the UI's source of truth) and survives
} /// the pad disconnecting: it re-applies the moment a matching controller shows up again.
pub fn set_pinned(&self, key: Option<String>) {
pub fn set_pinned(&self, id: Option<u32>) { let _ = self.ctl.send(Ctl::Pin(key));
let _ = self.ctl.send(Ctl::Pin(id));
} }
pub fn attach(&self, connector: Arc<NativeClient>) { pub fn attach(&self, connector: Arc<NativeClient>) {
@@ -169,8 +412,19 @@ impl GamepadService {
/// What "Automatic" resolves to right now — the virtual pad matching the physical one /// What "Automatic" resolves to right now — the virtual pad matching the physical one
/// (Swift parity); no pad connected leaves the host's own default. /// (Swift parity); no pad connected leaves the host's own default.
///
/// **Steam Deck special case:** this is read at session start, *before* attach — but the
/// Deck's built-in controller is only enumerable with its real 28DE:1205 identity while
/// the Valve HIDAPI drivers run, and those are enabled on attach only (see
/// [`set_valve_hidapi`]); with Steam Input on, SDL sees nothing but Steam's virtual
/// X360 pad anyway. Both cases used to fall through to Xbox 360. On a Deck, a virtual
/// pad (or no pad at all) means the physical controller behind it IS the built-in one —
/// resolve to the Steam Deck virtual pad so the paddles/trackpads/gyro have somewhere
/// to land. A real external controller still wins (it's the one that gets forwarded).
pub fn auto_pref(&self) -> GamepadPref { pub fn auto_pref(&self) -> GamepadPref {
match self.active() { match self.active() {
Some(p) if !p.steam_virtual => p.pref,
_ if is_steam_deck() => GamepadPref::SteamDeck,
Some(p) => p.pref, Some(p) => p.pref,
None => GamepadPref::Auto, None => GamepadPref::Auto,
} }
@@ -279,11 +533,16 @@ struct Worker<'a> {
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin. /// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
pads_out: &'a Mutex<Vec<PadInfo>>, pads_out: &'a Mutex<Vec<PadInfo>>,
active_out: &'a Mutex<Option<PadInfo>>, active_out: &'a Mutex<Option<PadInfo>>,
pinned_out: &'a Mutex<Option<u32>>, /// The ONE device held open — the active pad while a session is attached, `None`
opened: HashMap<u32, sdl3::gamepad::Gamepad>, /// otherwise. Opening is what grabs the hardware (SDL's HIDAPI drivers take the
/// Connection order; the most recently connected is the auto selection. /// hidraw device away from the system), so idle keeps this empty; see the module doc.
open: Option<(u32, sdl3::gamepad::Gamepad)>,
/// Connected pad ids in connection order (metadata only, no device open); the most
/// recently connected is the auto selection.
order: Vec<u32>, order: Vec<u32>,
pinned: Option<u32>, /// Stable key of the user-pinned controller (persisted in Settings) — matched against
/// connected pads, so it survives restarts and disconnects.
pinned: Option<String>,
attached: Option<Arc<NativeClient>>, attached: Option<Arc<NativeClient>>,
/// Wire state of the active pad — zeroed on the wire at switch/detach. /// Wire state of the active pad — zeroed on the wire at switch/detach.
last_axis: [i32; 6], last_axis: [i32; 6],
@@ -304,36 +563,112 @@ struct Worker<'a> {
chord_since: Option<Instant>, chord_since: Option<Instant>,
/// The disconnect signal already fired for the current hold — latched so it fires once. /// The disconnect signal already fired for the current hold — latched so it fires once.
disconnect_fired: bool, disconnect_fired: bool,
/// Menu mode ([`GamepadService::set_menu_mode`]): hold the active pad open while idle
/// and translate it into [`MenuEvent`]s. An attached session pauses translation.
menu_mode: bool,
menu_nav: MenuNav,
menu_tx: async_channel::Sender<MenuEvent>,
} }
impl Worker<'_> { impl Worker<'_> {
fn active_id(&self) -> Option<u32> { fn active_id(&self) -> Option<u32> {
self.pinned // The pin matches by stable key (most recently connected wins if two identical pads
.filter(|id| self.opened.contains_key(id)) // share one); an unmatched pin falls through to automatic without being cleared.
if let Some(key) = &self.pinned {
if let Some(id) = self
.order
.iter()
.rev()
.copied()
.find(|&id| self.pad_info(id).is_some_and(|p| &p.key == key))
{
return Some(id);
}
}
// Automatic: the most recently connected pad — but never Steam Input's virtual pad
// while a real controller is present (see `PadInfo::steam_virtual`).
self.order
.iter()
.rev()
.copied()
.find(|&id| self.pad_info(id).is_some_and(|p| !p.steam_virtual))
.or_else(|| self.order.last().copied()) .or_else(|| self.order.last().copied())
} }
/// Pad metadata from SDL's ID-based getters — deliberately NO device open (see the
/// module doc; an open would grab the hardware).
fn pad_info(&self, id: u32) -> Option<PadInfo> { fn pad_info(&self, id: u32) -> Option<PadInfo> {
let pad = self.opened.get(&id)?; if !self.order.contains(&id) {
let mut pref = pref_for_type( return None;
self.subsystem }
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)), let jid = sdl3::sys::joystick::SDL_JoystickID(id);
let mut pref = pref_for_type(self.subsystem.type_for_id(jid));
let (vid, pid) = (
self.subsystem.vendor_for_id(jid).unwrap_or(0),
self.subsystem.product_for_id(jid).unwrap_or(0),
); );
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by // There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
// VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual // VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual
// hid-steam pad with the back grips + dual trackpads and the right glyph identity. // hid-steam pad with the back grips + dual trackpads and the right glyph identity.
if pad.vendor_id() == Some(0x28DE) if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
{
pref = GamepadPref::SteamDeck; pref = GamepadPref::SteamDeck;
} }
let name = self
.subsystem
.name_for_id(jid)
.unwrap_or_else(|_| "Controller".into());
Some(PadInfo { Some(PadInfo {
id, key: format!("{vid:04x}:{pid:04x}:{name}"),
name: pad.name().unwrap_or_else(|| "Controller".into()), steam_virtual: (vid == 0x28DE && pid == 0x11FF)
|| name.starts_with("Steam Virtual Gamepad"),
name,
pref, pref,
}) })
} }
/// Hold exactly the right device: the active pad while a session is attached or menu
/// mode owns navigation, nothing otherwise. The single place that decides to open
/// (= grab) hardware; dropping the old handle closes it (`SDL_CloseGamepad`) — on a
/// Deck the firmware watchdog then restores lizard mode.
fn sync_open(&mut self) {
let want = if self.attached.is_some() || self.menu_mode {
self.active_id()
} else {
None
};
if self.open.as_ref().map(|(id, _)| *id) == want {
return;
}
self.open = None;
let Some(id) = want else { return };
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
Ok(pad) => {
self.open = Some((id, pad));
// Sensors stream only for an attached session (USB/BT bandwidth); the
// menu needs buttons + stick only.
if self.attached.is_some() {
self.set_sensors(true);
} else {
// The menu pad changed under us (hot-plug while the launcher is
// open): adopt the new pad's held state instead of firing it.
self.menu_nav.reset();
}
}
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
}
}
/// React to anything that may have moved the active-pad selection (hotplug, pin
/// change): flush held wire state if it did, then re-sync the opened device and the
/// UI-facing snapshot.
fn refresh_active(&mut self, before: Option<u32>) {
if self.active_id() != before {
self.flush_held();
}
self.sync_open();
self.publish();
}
/// Zero everything the host believes is held — on pad switch and detach. /// Zero everything the host believes is held — on pad switch and detach.
fn flush_held(&mut self) { fn flush_held(&mut self) {
if let Some(c) = &self.attached { if let Some(c) = &self.attached {
@@ -432,8 +767,7 @@ impl Worker<'_> {
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth). /// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
fn set_sensors(&mut self, enabled: bool) { fn set_sensors(&mut self, enabled: bool) {
let Some(id) = self.active_id() else { return }; if let Some((_, pad)) = self.open.as_mut() {
if let Some(pad) = self.opened.get_mut(&id) {
use sdl3::sensor::SensorType; use sdl3::sensor::SensorType;
for s in [SensorType::Gyroscope, SensorType::Accelerometer] { for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
if unsafe { pad.has_sensor(s) } { if unsafe { pad.has_sensor(s) } {
@@ -459,9 +793,10 @@ impl Worker<'_> {
return; return;
}; };
let multi = self let multi = self
.opened .open
.get(&which) .as_ref()
.map(|p| p.touchpads_count() >= 2) .filter(|(id, _)| *id == which)
.map(|(_, p)| p.touchpads_count() >= 2)
.unwrap_or(false); .unwrap_or(false);
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)); let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
let surface = if multi { (touchpad as u8) + 1 } else { 0 }; let surface = if multi { (touchpad as u8) + 1 } else { 0 };
@@ -503,7 +838,6 @@ impl Worker<'_> {
list.reverse(); // most recent first — the Settings list order list.reverse(); // most recent first — the Settings list order
*self.pads_out.lock().unwrap() = list; *self.pads_out.lock().unwrap() = list;
*self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id)); *self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id));
*self.pinned_out.lock().unwrap() = self.pinned;
} }
/// Apply queued control-plane messages from the UI thread. Returns false when the /// Apply queued control-plane messages from the UI thread. Returns false when the
@@ -515,23 +849,50 @@ impl Worker<'_> {
self.attached = Some(c); self.attached = Some(c);
self.last_axis = [i32::MIN; 6]; self.last_axis = [i32::MIN; 6];
self.reset_chord(); // every session starts un-latched (Attach doesn't flush) self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
self.set_sensors(true); // The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
// enabling them re-enumerates a Deck's built-in pad with paddles/
// trackpads/gyro first-class — sync_open follows the churn events.
set_valve_hidapi(true);
self.sync_open();
} }
Ok(Ctl::Detach) => { Ok(Ctl::Detach) => {
self.flush_held(); self.flush_held();
self.set_sensors(false);
self.attached = None; self.attached = None;
self.sync_open(); // closes the held device (menu mode keeps it)
set_valve_hidapi(false);
if self.menu_mode {
// Back to the launcher: adopt whatever is still physically held
// (the escape chord that ended the session, a lingering B) so it
// can't ghost-fire menu actions.
self.menu_nav.reset();
} }
Ok(Ctl::Pin(id)) => { }
Ok(Ctl::Pin(key)) => {
let before = self.active_id(); let before = self.active_id();
self.pinned = id; self.pinned = key;
if self.active_id() != before { self.refresh_active(before);
self.flush_held(); }
if self.attached.is_some() { Ok(Ctl::MenuMode(on)) => {
self.set_sensors(true); self.menu_mode = on;
if on {
self.menu_nav.reset();
}
self.sync_open();
}
Ok(Ctl::MenuRumble(pulse)) => {
if self.attached.is_none() {
if let Some((_, pad)) = self.open.as_mut() {
let (low, high, ms) = match pulse {
// Light high-freq detent — won't jackhammer at repeat rate.
MenuPulse::Move => (0, 0x3000, 25),
// Fuller both-motor thunk.
MenuPulse::Confirm => (0x5000, 0x5000, 60),
// Dull low-freq wall.
MenuPulse::Boundary => (0x6000, 0, 60),
};
let _ = pad.set_rumble(low, high, ms);
} }
} }
self.publish();
} }
Err(std::sync::mpsc::TryRecvError::Empty) => return true, Err(std::sync::mpsc::TryRecvError::Empty) => return true,
Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone
@@ -546,35 +907,31 @@ impl Worker<'_> {
let active = self.active_id(); let active = self.active_id();
match event { match event {
Event::ControllerDeviceAdded { which, .. } => { Event::ControllerDeviceAdded { which, .. } => {
if !self.opened.contains_key(&which) { if !self.order.contains(&which) {
match self self.order.push(which);
.subsystem if let Some(p) = self.pad_info(which) {
.open(sdl3::sys::joystick::SDL_JoystickID(which)) // Full identity: on a Steam Deck this is the one lever for diagnosing an
{ // empty controller list — it tells you whether SDL sees the physical pad
Ok(pad) => { // (28DE:1205), Steam Input's virtual pad (28DE:11FF), both, or nothing.
tracing::info!( tracing::info!(
name = pad.name().unwrap_or_default(), name = p.name,
key = p.key,
pref = ?p.pref,
steam_virtual = p.steam_virtual,
"gamepad attached" "gamepad attached"
); );
self.opened.insert(which, pad);
self.order.push(which);
if self.attached.is_some() && self.active_id() == Some(which) {
self.set_sensors(true);
}
self.publish();
}
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
} }
self.refresh_active(active);
} }
} }
Event::ControllerDeviceRemoved { which, .. } => { Event::ControllerDeviceRemoved { which, .. } => {
if self.opened.remove(&which).is_some() { if self.order.contains(&which) {
self.order.retain(|&id| id != which); self.order.retain(|&id| id != which);
if active == Some(which) { if self.open.as_ref().map(|(id, _)| *id) == Some(which) {
self.flush_held(); self.open = None; // the device is gone; drop our handle
} }
tracing::info!("gamepad detached"); tracing::info!("gamepad detached");
self.publish(); self.refresh_active(active);
} }
} }
Event::ControllerButtonDown { which, button, .. } if active == Some(which) => { Event::ControllerButtonDown { which, button, .. } if active == Some(which) => {
@@ -677,6 +1034,42 @@ impl Worker<'_> {
} }
} }
/// Sample the open pad and translate it into [`MenuEvent`]s — only while menu mode is
/// on and no session is attached (attach supersedes; SDL events merely wake the loop,
/// so a press is translated the iteration it arrives).
fn menu_poll(&mut self) {
if !self.menu_mode || self.attached.is_some() {
return;
}
let Some((_, pad)) = self.open.as_ref() else {
return;
};
use sdl3::gamepad::{Axis, Button};
let s = MenuSample {
buttons: [
pad.button(Button::South),
pad.button(Button::East),
pad.button(Button::West),
pad.button(Button::North),
pad.button(Button::LeftShoulder),
pad.button(Button::RightShoulder),
],
lx: pad.axis(Axis::LeftX),
ly: pad.axis(Axis::LeftY),
dpad: [
pad.button(Button::DPadUp),
pad.button(Button::DPadDown),
pad.button(Button::DPadLeft),
pad.button(Button::DPadRight),
],
};
let mut out = Vec::new();
self.menu_nav.poll(&s, Instant::now(), &mut out);
for e in out {
let _ = self.menu_tx.try_send(e);
}
}
/// Drain and render the feedback planes — rumble plus HID output (lightbar / /// Drain and render the feedback planes — rumble plus HID output (lightbar /
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single /// player LEDs / adaptive triggers) — on the active pad; this thread is their single
/// consumer. The host re-sends rumble state periodically, so a generous duration with /// consumer. The host re-sends rumble state periodically, so a generous duration with
@@ -687,7 +1080,7 @@ impl Worker<'_> {
}; };
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
if pad == 0 { if pad == 0 {
if let Some(p) = self.active_id().and_then(|id| self.opened.get_mut(&id)) { if let Some((_, p)) = self.open.as_mut() {
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in // Surface a failed SDL rumble write: a swallowed error here (DualSense not in
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The // the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
// host logs the send side on 0xCA, so the two together pinpoint host-game vs // host logs the send side on 0xCA, so the two together pinpoint host-game vs
@@ -703,9 +1096,12 @@ impl Worker<'_> {
} }
} }
while let Ok(hid) = connector.next_hidout(Duration::ZERO) { while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let Some(id) = self.active_id() else { continue }; let is_ds = self
let is_ds = self.pad_info(id).is_some_and(|p| p.is_dualsense()); .open
let Some(pad) = self.opened.get_mut(&id) else { .as_ref()
.and_then(|(id, _)| self.pad_info(*id))
.is_some_and(|p| p.is_dualsense());
let Some((_, pad)) = self.open.as_mut() else {
continue; continue;
}; };
match hid { match hid {
@@ -734,21 +1130,19 @@ impl Worker<'_> {
fn run( fn run(
pads_out: &Mutex<Vec<PadInfo>>, pads_out: &Mutex<Vec<PadInfo>>,
active_out: &Mutex<Option<PadInfo>>, active_out: &Mutex<Option<PadInfo>>,
pinned_out: &Mutex<Option<u32>>,
ctl: &Receiver<Ctl>, ctl: &Receiver<Ctl>,
escape_tx: &async_channel::Sender<()>, escape_tx: &async_channel::Sender<()>,
disconnect_tx: &async_channel::Sender<()>, disconnect_tx: &async_channel::Sender<()>,
menu_tx: &async_channel::Sender<MenuEvent>,
) -> Result<(), String> { ) -> Result<(), String> {
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its // Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
// own thread. // own thread.
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1"); sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the // The Valve HIDAPI drivers start DISABLED (SDL defaults the Deck one ON, and its mere
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs. On a Deck in Game // enumeration kills the Deck's trackpad-mouse system-wide — see set_valve_hidapi);
// Mode, Steam Input still holds the device — the user must disable Steam Input for this app (see // they are enabled for the duration of an attached session only.
// the Decky UX); on a desktop client (or a Deck with Steam Input off) the hints just work. set_valve_hidapi(false);
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
let sdl = sdl3::init().map_err(|e| e.to_string())?; let sdl = sdl3::init().map_err(|e| e.to_string())?;
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?; let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?; let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
@@ -757,8 +1151,7 @@ fn run(
subsystem, subsystem,
pads_out, pads_out,
active_out, active_out,
pinned_out, open: None,
opened: HashMap::new(),
order: Vec::new(), order: Vec::new(),
pinned: None, pinned: None,
attached: None, attached: None,
@@ -771,6 +1164,9 @@ fn run(
chord_armed: false, chord_armed: false,
chord_since: None, chord_since: None,
disconnect_fired: false, disconnect_fired: false,
menu_mode: false,
menu_nav: MenuNav::new(),
menu_tx: menu_tx.clone(),
}; };
loop { loop {
@@ -785,8 +1181,13 @@ fn run(
// rumble/HID feedback, and the escape-chord hold check all run once per wakeup, // rumble/HID feedback, and the escape-chord hold check all run once per wakeup,
// so their worst case is one timeout (~10 ms attached, imperceptible for // so their worst case is one timeout (~10 ms attached, imperceptible for
// haptics; DISCONNECT_HOLD is 1500 ms, so 10 ms hold-check granularity is far // haptics; DISCONNECT_HOLD is 1500 ms, so 10 ms hold-check granularity is far
// inside tolerance). Idle (no session) wakes lazily at 30 ms for hotplug + ctl. // inside tolerance; menu mode needs the same cadence for its repeat timing).
let timeout = Duration::from_millis(if w.attached.is_some() { 10 } else { 30 }); // Idle (no session, no menu) wakes lazily at 30 ms for hotplug + ctl.
let timeout = Duration::from_millis(if w.attached.is_some() || w.menu_mode {
10
} else {
30
});
if let Some(event) = pump.wait_event_timeout(timeout) { if let Some(event) = pump.wait_event_timeout(timeout) {
w.handle_event(event); w.handle_event(event);
// Drain whatever else queued while we were waiting or handling. // Drain whatever else queued while we were waiting or handling.
@@ -799,6 +1200,169 @@ fn run(
// new button events; the chord itself is only detected while a session is attached). // new button events; the chord itself is only detected while a session is attached).
w.maybe_fire_disconnect(); w.maybe_fire_disconnect();
w.menu_poll();
w.render_feedback(); w.render_feedback();
} }
} }
#[cfg(test)]
mod menu_nav_tests {
use super::*;
fn sample() -> MenuSample {
MenuSample::default()
}
fn events(nav: &mut MenuNav, s: &MenuSample, at: Instant) -> Vec<MenuEvent> {
let mut out = Vec::new();
nav.poll(s, at, &mut out);
out
}
#[test]
fn snapshot_adopts_held_state_without_firing() {
let mut nav = MenuNav::new();
let t = Instant::now();
let mut held = sample();
held.buttons[0] = true; // A held on entry
held.lx = 30000; // stick already deflected right
assert!(events(&mut nav, &held, t).is_empty(), "snapshot poll fired");
// Still held: nothing (no rising edge, direction unchanged since snapshot).
assert!(events(&mut nav, &held, t + Duration::from_millis(10)).is_empty());
// Release, then press again → now it fires.
assert!(events(&mut nav, &sample(), t + Duration::from_millis(20)).is_empty());
assert_eq!(
events(&mut nav, &held, t + Duration::from_millis(30)),
vec![MenuEvent::Confirm, MenuEvent::Move(MenuDir::Right)]
);
}
#[test]
fn buttons_fire_on_rising_edge_only() {
let mut nav = MenuNav::new();
let t = Instant::now();
events(&mut nav, &sample(), t); // consume the snapshot
let mut s = sample();
s.buttons[1] = true; // B down
assert_eq!(
events(&mut nav, &s, t + Duration::from_millis(10)),
vec![MenuEvent::Back]
);
for i in 2..20 {
assert!(
events(&mut nav, &s, t + Duration::from_millis(10 * i)).is_empty(),
"held button re-fired"
);
}
}
#[test]
fn reset_rearms_the_snapshot() {
let mut nav = MenuNav::new();
let t = Instant::now();
events(&mut nav, &sample(), t);
nav.reset();
let mut s = sample();
s.buttons[1] = true;
assert!(
events(&mut nav, &s, t + Duration::from_millis(10)).is_empty(),
"post-reset poll fired a held button"
);
}
#[test]
fn direction_repeats_after_delay_at_interval() {
let mut nav = MenuNav::new();
let t = Instant::now();
events(&mut nav, &sample(), t);
let mut s = sample();
s.dpad[3] = true; // dpad right
// Engage: fires immediately.
assert_eq!(
events(&mut nav, &s, t + Duration::from_millis(10)),
vec![MenuEvent::Move(MenuDir::Right)]
);
// Inside the initial delay: silent.
assert!(events(&mut nav, &s, t + Duration::from_millis(300)).is_empty());
// Past the delay: repeats…
assert_eq!(
events(&mut nav, &s, t + Duration::from_millis(400)),
vec![MenuEvent::Move(MenuDir::Right)]
);
// …but not faster than the interval…
assert!(events(&mut nav, &s, t + Duration::from_millis(500)).is_empty());
// …and again once it elapses.
assert_eq!(
events(&mut nav, &s, t + Duration::from_millis(570)),
vec![MenuEvent::Move(MenuDir::Right)]
);
// Release cancels; re-engage fires immediately again.
assert!(events(&mut nav, &sample(), t + Duration::from_millis(580)).is_empty());
assert_eq!(
events(&mut nav, &s, t + Duration::from_millis(590)),
vec![MenuEvent::Move(MenuDir::Right)]
);
}
#[test]
fn direction_change_fires_immediately() {
let mut nav = MenuNav::new();
let t = Instant::now();
events(&mut nav, &sample(), t);
let mut right = sample();
right.lx = 30000;
let mut left = sample();
left.lx = -30000;
assert_eq!(
events(&mut nav, &right, t + Duration::from_millis(10)),
vec![MenuEvent::Move(MenuDir::Right)]
);
assert_eq!(
events(&mut nav, &left, t + Duration::from_millis(20)),
vec![MenuEvent::Move(MenuDir::Left)]
);
}
#[test]
fn direction_resolution() {
// Below the deadzone: nothing.
let mut s = sample();
s.lx = MENU_DEADZONE as i16;
assert_eq!(MenuNav::resolve_dir(&s), None);
// Dominant axis wins; SDL +y = down.
s.lx = 20000;
s.ly = 25000;
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Down));
s.ly = -25000;
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Up));
s.lx = 26000;
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Right));
s.lx = -26000;
assert_eq!(MenuNav::resolve_dir(&s), Some(MenuDir::Left));
// Dpad fallback…
let mut d = sample();
d.dpad[1] = true;
assert_eq!(MenuNav::resolve_dir(&d), Some(MenuDir::Down));
// …but the stick overrides it.
d.lx = 30000;
assert_eq!(MenuNav::resolve_dir(&d), Some(MenuDir::Right));
}
#[test]
fn shoulder_and_face_button_mapping() {
let mut nav = MenuNav::new();
let t = Instant::now();
events(&mut nav, &sample(), t);
let mut s = sample();
s.buttons = [false, false, true, true, true, true]; // x, y, l1, r1
assert_eq!(
events(&mut nav, &s, t + Duration::from_millis(10)),
vec![
MenuEvent::Tertiary,
MenuEvent::Secondary,
MenuEvent::JumpBack,
MenuEvent::JumpForward,
]
);
}
}
+42 -8
View File
@@ -265,13 +265,19 @@ impl SessionUi {
stop: self.stop.clone(), stop: self.stop.clone(),
inhibit_shortcuts: self.inhibit, inhibit_shortcuts: self.inhibit,
show_stats: self.show_stats, show_stats: self.show_stats,
chromeless: self.app.fullscreen,
// The attach just went out, so a Deck's built-in pad may not have enumerated
// yet — chromeless (controller-first) shows the chord hint regardless.
pad_connected: self.app.gamepad.active().is_some(),
title, title,
}); });
self.app.nav.push(&p.page); self.app.nav.push(&p.page);
// Steam Deck / Gaming Mode: gamescope fullscreens the window but GTK doesn't // Streams start fullscreen by default (Settings toggle) — a streaming window with
// know it, so its header bar stays drawn. Enter GTK fullscreen explicitly — // chrome is never what anyone wants mid-game; F11 / the controller chord / the
// the stream page's `connect_fullscreened_notify` then hides all chrome. // top-edge header reveal lead back out. Gaming-Mode launches (`--fullscreen`)
if self.app.fullscreen { // fullscreen regardless: gamescope fullscreens the window at its level but GTK
// doesn't know it, so the header bar would stay drawn.
if self.app.fullscreen || self.app.settings.borrow().fullscreen_on_stream {
self.app.window.fullscreen(); self.app.window.fullscreen();
} }
self.page = Some(p); self.page = Some(p);
@@ -293,21 +299,49 @@ impl SessionUi {
} }
// A pinned connect rejected on trust grounds means the host's cert no // A pinned connect rejected on trust grounds means the host's cert no
// longer matches the stored pin (rotated cert or impostor) — route to // longer matches the stored pin (rotated cert or impostor) — route to
// the PIN ceremony to re-establish trust rather than dead-ending. // the PIN ceremony to re-establish trust rather than dead-ending. Browse
if trust_rejected && !self.tofu { // mode can't: gamescope never maps dialogs, so it renders the advice instead
// (re-pairing is the plugin's job there).
if trust_rejected && !self.tofu && self.app.browse_ui().is_none() {
self.app self.app
.toast("Host fingerprint changed — re-pair with a PIN to continue"); .toast("Host fingerprint changed — re-pair with a PIN to continue");
crate::ui_trust::pin_dialog(self.app.clone(), self.req.clone()); crate::ui_trust::pin_dialog(self.app.clone(), self.req.clone());
} else if trust_rejected && !self.tofu {
self.app
.connect_error("Host identity changed — re-pair from the Punktfunk plugin.");
} else { } else {
// Errors land on the hosts page banner, not a transient toast. // Errors land on the hosts page banner / launcher strip, not a transient toast.
self.app.connect_error(&format!("Couldn't connect — {msg}")); self.app.connect_error(&format!("Couldn't connect — {msg}"));
} }
} }
/// `Ended`: detach gamepads, pop back to the hosts page, and surface the reason. /// `Ended`: detach gamepads, pop back to the launcher (browse mode) or the hosts
/// page, and surface the reason.
fn on_ended(&mut self, err: Option<String>) { fn on_ended(&mut self, err: Option<String>) {
self.close_waiting(); self.close_waiting();
self.app.gamepad.detach(); self.app.gamepad.detach();
// Gaming-Mode `--connect` launch: the app IS the stream. Quit so Steam ends the
// "game" and the Deck returns to Gaming Mode — popping to our own hosts page would
// strand the user in a fullscreen shell with no way back.
if self.app.quit_on_session_end {
if let Some(e) = err {
tracing::warn!(error = %e, "session ended");
}
self.app.window.close();
return;
}
// Browse mode: back to the launcher to pick the next game — B there quits to
// Gaming Mode. (The gamepad worker re-opened the pad and armed the held-state
// snapshot on the detach above, so the chord that ended the session fires nothing.)
if let Some(l) = self.app.browse_ui() {
self.app.nav.pop_to_tag("launcher");
l.on_session_ended();
if let Some(e) = err {
self.app.connect_error(&e);
}
self.app.busy.set(false);
return;
}
self.app.nav.pop_to_tag("hosts"); self.app.nav.pop_to_tag("hosts");
if let Some(h) = self.app.hosts_ui() { if let Some(h) = self.app.hosts_ui() {
h.set_connecting(None); h.set_connecting(None);
+53 -1
View File
@@ -6,8 +6,9 @@
//! verified by its pinned SHA-256 fingerprint (`KnownHost::fp_hex`), not a CA chain. //! verified by its pinned SHA-256 fingerprint (`KnownHost::fp_hex`), not a CA chain.
use serde::Deserialize; use serde::Deserialize;
use std::collections::VecDeque;
use std::io::Read; use std::io::Read;
use std::sync::Arc; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
/// The management API's default port — matches `mgmt::DEFAULT_PORT` on the host. A /// The management API's default port — matches `mgmt::DEFAULT_PORT` on the host. A
@@ -181,6 +182,57 @@ pub fn fetch_art(pinned: &ureq::Agent, base: &str, url: &str) -> Result<Vec<u8>,
Ok(bytes) Ok(bytes)
} }
/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a
/// big library into a connection burst.
const ART_WORKERS: usize = 3;
/// Fetch poster bytes for `jobs` (entry id → candidate URLs, walked in order until one
/// loads) on a small worker pool; results stream on the returned channel as they land.
/// Dropping the receiver (the consuming page popped) winds the workers down. Shared by
/// the touch grid and the gamepad launcher — the consumer does its own texture decode on
/// the main loop.
pub fn spawn_art_fetch(
base: String,
identity: (String, String),
pin: Option<[u8; 32]>,
jobs: VecDeque<(String, Vec<String>)>,
) -> async_channel::Receiver<(String, Vec<u8>)> {
let queue = Arc::new(Mutex::new(jobs));
let (tx, rx) = async_channel::unbounded::<(String, Vec<u8>)>();
for _ in 0..ART_WORKERS {
let queue = queue.clone();
let tx = tx.clone();
let base = base.clone();
let identity = identity.clone();
std::thread::Builder::new()
.name("punktfunk-lib-art".into())
.spawn(move || {
let Ok(agent) = agent(&identity, pin) else {
return;
};
loop {
let job = queue.lock().unwrap().pop_front();
let Some((id, candidates)) = job else { break };
for url in &candidates {
match fetch_art(&agent, &base, url) {
Ok(bytes) => {
// Receiver gone (page popped) — stop fetching.
if tx.send_blocking((id, bytes)).is_err() {
return;
}
break;
}
// 404 on a guessed CDN path is routine — try the next kind.
Err(e) => tracing::debug!(%id, url, error = %e, "poster miss"),
}
}
}
})
.expect("spawn art thread");
}
rx
}
fn classify(e: ureq::Error) -> LibraryError { fn classify(e: ureq::Error) -> LibraryError {
match e { match e {
ureq::Error::Status(401 | 403, _) => LibraryError::NotPaired, ureq::Error::Status(401 | 403, _) => LibraryError::NotPaired,
+2
View File
@@ -26,6 +26,8 @@ mod session;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod trust; mod trust;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod ui_gamepad_library;
#[cfg(target_os = "linux")]
mod ui_hosts; mod ui_hosts;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod ui_library; mod ui_library;
+125 -21
View File
@@ -45,18 +45,55 @@ pub struct SessionParams {
pub connect_timeout: Duration, pub connect_timeout: Duration,
} }
/// The session pump's share of the unified stats window (design/stats-unification.md):
/// stream facts plus the two stages measured before the presenter. The frame consumer in
/// `ui_stream` contributes the `display` stage and the end-to-end percentiles.
#[derive(Clone, Copy, Default)] #[derive(Clone, Copy, Default)]
pub struct Stats { pub struct Stats {
/// AUs received (reassembled) per second, actual-elapsed-time denominator.
pub fps: f32, pub fps: f32,
/// Received payload bytes × 8 / elapsed (goodput, excludes FEC overhead).
pub mbps: f32, pub mbps: f32,
/// p50 `host+network` stage: capture → received, host-clock corrected (ms).
pub host_net_ms: f32,
/// p50 `host` stage: the host's own capture→fully-sent, from the per-AU 0xCF host
/// timings (design/stats-unification.md Phase 2). Valid only when `split`.
pub host_ms: f32,
/// p50 `network` stage: capture→received minus the host-reported share
/// (`hostnet host`, per-frame, saturating). Valid only when `split`.
pub net_ms: f32,
/// The window had matched host timings — the OSD splits `host+network` into
/// `host + network`. An old host never emits 0xCF, so this stays false and the
/// combined stage renders unchanged.
pub split: bool,
/// p50 `decode` stage: received → decoded, single-clock client-local (ms).
pub decode_ms: f32, pub decode_ms: f32,
/// Median capture→decoded latency over the last window (host-clock corrected). /// Unrecoverable network frame drops this window, and their share of
pub latency_ms: f32, /// received+lost (%). The OSD renders the counter line only when nonzero.
pub lost: u32,
pub lost_pct: f32,
/// The decode path frames actually took this window (`"vaapi"`/`"software"`, empty /// The decode path frames actually took this window (`"vaapi"`/`"software"`, empty
/// until the first frame) — the OSD's trailing tag; tracks a mid-session fallback. /// until the first frame) — the OSD's trailing tag; tracks a mid-session fallback.
pub decoder: &'static str, pub decoder: &'static str,
} }
/// Frames the pump keeps waiting for their 0xCF host timing (pts → capture→received µs).
/// ~2 s at 120 Hz — a timing arrives within a frame or two of its AU, and against an old
/// host (no 0xCF at all) this just caps the dead-weight ring.
const PENDING_SPLIT_CAP: usize = 256;
/// Sort a window of µs samples in place and return `(p50, p95)` per the spec's index
/// rules (`sorted[len/2]`, `sorted[min(len*95/100, len-1)]`); an empty window reads 0.
pub fn window_percentiles(samples: &mut [u64]) -> (u64, u64) {
if samples.is_empty() {
return (0, 0);
}
samples.sort_unstable();
let p50 = samples[samples.len() / 2];
let p95 = samples[(samples.len() * 95 / 100).min(samples.len() - 1)];
(p50, p95)
}
pub enum SessionEvent { pub enum SessionEvent {
Connected { Connected {
connector: Arc<NativeClient>, connector: Arc<NativeClient>,
@@ -219,13 +256,23 @@ fn pump(
let mut window_start = Instant::now(); let mut window_start = Instant::now();
let mut frames_n = 0u32; let mut frames_n = 0u32;
let mut bytes_n = 0u64; let mut bytes_n = 0u64;
let mut decode_us_sum = 0u64; // Stage windows (µs samples): `host+network` = capture→received (host-clock
let mut lat_us: Vec<u64> = Vec::with_capacity(256); // corrected), `decode` = received→decoded (client-local). p50 per 1 s window.
let mut hostnet_us: Vec<u64> = Vec::with_capacity(256);
let mut decode_us: Vec<u64> = Vec::with_capacity(256);
// Host/network split (Phase 2): frames awaiting their per-AU 0xCF host timing,
// correlated by pts_ns. Bounded — an old host never sends any, so entries just age out.
let mut pending_split: std::collections::VecDeque<(u64, u64)> =
std::collections::VecDeque::with_capacity(PENDING_SPLIT_CAP);
let mut host_us_win: Vec<u64> = Vec::with_capacity(256);
let mut net_us_win: Vec<u64> = Vec::with_capacity(256);
// What actually decoded the last frame — a VAAPI failure demotes mid-session, so // What actually decoded the last frame — a VAAPI failure demotes mid-session, so
// this is read off each frame's image variant rather than fixed at startup. // this is read off each frame's image variant rather than fixed at startup.
let mut dec_path: &'static str = ""; let mut dec_path: &'static str = "";
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs. // Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
let mut last_dropped = connector.frames_dropped(); let mut last_dropped = connector.frames_dropped();
// The stats window keeps its own drop cursor — the OSD shows the per-window delta.
let mut window_dropped = last_dropped;
let mut last_kf_req: Option<Instant> = None; let mut last_kf_req: Option<Instant> = None;
let end: Option<String> = loop { let end: Option<String> = loop {
@@ -237,7 +284,11 @@ fn pump(
// every ~816 ms at 60120 Hz anyway, so this rarely times out mid-stream). // every ~816 ms at 60120 Hz anyway, so this rarely times out mid-stream).
match connector.next_frame(Duration::from_millis(20)) { match connector.next_frame(Duration::from_millis(20)) {
Ok(frame) => { Ok(frame) => {
let t0 = Instant::now(); // The `received` point: AU fully reassembled, in hand, before decode.
let received_ns = now_ns();
// fps / goodput count every received AU (spec), decoded or not.
frames_n += 1;
bytes_n += frame.data.len() as u64;
match decoder.decode(&frame.data) { match decoder.decode(&frame.data) {
Ok(Some(image)) => { Ok(Some(image)) => {
total_frames += 1; total_frames += 1;
@@ -252,18 +303,27 @@ fn pump(
}; };
tracing::info!(width = w, height = h, path, "first frame decoded"); tracing::info!(width = w, height = h, path, "first frame decoded");
} }
// Latency: our wall clock expressed in the host's capture clock, // The `decoded` point — travels with the frame so the presenter
// minus the host-stamped capture pts (same math as client-rs). // can measure its `display` stage against it.
let lat = (now_ns() as i128 + clock_offset as i128 - frame.pts_ns as i128) let decoded_ns = now_ns();
// `host+network` stage: received expressed in the host's capture
// clock, minus the host-stamped capture pts (clamped (0, 10 s)).
let hn = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
.max(0) as u64; .max(0) as u64;
if lat > 0 && lat < 10_000_000_000 { if hn > 0 && hn < 10_000_000_000 {
lat_us.push(lat / 1000); hostnet_us.push(hn / 1000);
// Remember the sample for the host/network split — matched
// against the AU's 0xCF host timing when it arrives.
if pending_split.len() >= PENDING_SPLIT_CAP {
pending_split.pop_front();
} }
decode_us_sum += t0.elapsed().as_micros() as u64; pending_split.push_back((frame.pts_ns, hn / 1000));
frames_n += 1; }
bytes_n += frame.data.len() as u64; // `decode` stage: received→decoded, single clock, no skew.
decode_us.push(decoded_ns.saturating_sub(received_ns) / 1000);
let _ = frame_tx.force_send(DecodedFrame { let _ = frame_tx.force_send(DecodedFrame {
pts_ns: frame.pts_ns, pts_ns: frame.pts_ns,
decoded_ns,
image, image,
}); });
} }
@@ -271,12 +331,39 @@ fn pump(
// Survivable (loss until the next IDR/RFI recovery) — keep feeding. // Survivable (loss until the next IDR/RFI recovery) — keep feeding.
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"), Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
} }
// A decode error / VAAPI→software demotion asks for a fresh IDR: the infinite
// GOP has no periodic keyframe, so a rebuilt/erroring decoder would stay
// gray/frozen until an unrelated packet drop happened to request one. Route it
// through the same throttle as loss recovery below.
if decoder.take_keyframe_request() {
let now = Instant::now();
if last_kf_req
.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
{
last_kf_req = Some(now);
let _ = connector.request_keyframe();
tracing::debug!("requested keyframe (decoder recovery)");
}
}
} }
Err(PunktfunkError::NoFrame) => {} Err(PunktfunkError::NoFrame) => {}
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()), Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
Err(e) => break Some(format!("session: {e:?}")), Err(e) => break Some(format!("session: {e:?}")),
} }
// Drain the per-AU host timings (0xCF) non-blockingly and match them to received
// frames by pts: host = the host's own capture→sent, network = our
// capture→received minus it (the two tile per frame by construction). An old
// host never emits any — the deque fills to its cap and the OSD keeps the
// combined `host+network` stage.
while let Ok(t) = connector.next_host_timing(Duration::ZERO) {
if let Some(i) = pending_split.iter().position(|(p, _)| *p == t.pts_ns) {
let (_, hn_us) = pending_split.remove(i).unwrap();
host_us_win.push(t.host_us as u64);
net_us_win.push(hn_us.saturating_sub(t.host_us as u64));
}
}
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The // Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the // reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
// reference-missing delta frames that follow and returns Ok, so keying off a decode error // reference-missing delta frames that follow and returns Ok, so keying off a decode error
@@ -295,30 +382,47 @@ fn pump(
if window_start.elapsed() >= Duration::from_secs(1) { if window_start.elapsed() >= Duration::from_secs(1) {
let secs = window_start.elapsed().as_secs_f32(); let secs = window_start.elapsed().as_secs_f32();
lat_us.sort_unstable(); let (hn_p50, _) = window_percentiles(&mut hostnet_us);
let p50 = lat_us.get(lat_us.len() / 2).copied().unwrap_or(0); let (dec_p50, _) = window_percentiles(&mut decode_us);
// Host/network split — present only when this window matched 0xCF timings.
let split = !host_us_win.is_empty();
let (host_p50, _) = window_percentiles(&mut host_us_win);
let (net_p50, _) = window_percentiles(&mut net_us_win);
let lost = dropped.saturating_sub(window_dropped) as u32;
window_dropped = dropped;
tracing::debug!( tracing::debug!(
fps = frames_n, fps = frames_n,
lat_p50_us = p50, hostnet_p50_us = hn_p50,
host_p50_us = host_p50,
net_p50_us = net_p50,
decode_p50_us = dec_p50,
lost,
total_frames, total_frames,
"stream window" "stream window"
); );
let _ = ev_tx.try_send(SessionEvent::Stats(Stats { let _ = ev_tx.try_send(SessionEvent::Stats(Stats {
fps: frames_n as f32 / secs, fps: frames_n as f32 / secs,
mbps: bytes_n as f32 * 8.0 / 1e6 / secs, mbps: bytes_n as f32 * 8.0 / 1e6 / secs,
decode_ms: if frames_n > 0 { host_net_ms: hn_p50 as f32 / 1000.0,
decode_us_sum as f32 / frames_n as f32 / 1000.0 host_ms: host_p50 as f32 / 1000.0,
net_ms: net_p50 as f32 / 1000.0,
split,
decode_ms: dec_p50 as f32 / 1000.0,
lost,
lost_pct: if lost > 0 {
lost as f32 * 100.0 / (frames_n + lost) as f32
} else { } else {
0.0 0.0
}, },
latency_ms: p50 as f32 / 1000.0,
decoder: dec_path, decoder: dec_path,
})); }));
window_start = Instant::now(); window_start = Instant::now();
frames_n = 0; frames_n = 0;
bytes_n = 0; bytes_n = 0;
decode_us_sum = 0; hostnet_us.clear();
lat_us.clear(); decode_us.clear();
host_us_win.clear();
net_us_win.clear();
} }
}; };
+25
View File
@@ -182,6 +182,10 @@ pub struct Settings {
/// Requested encoder bitrate (kbps); 0 = host default. /// Requested encoder bitrate (kbps); 0 = host default.
pub bitrate_kbps: u32, pub bitrate_kbps: u32,
pub gamepad: String, pub gamepad: String,
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
/// gamepad service at startup so the choice survives restarts.
pub forward_pad: String,
/// Which host compositor backend to request (advisory; the host falls back to /// Which host compositor backend to request (advisory; the host falls back to
/// auto-detect when unavailable). /// auto-detect when unavailable).
pub compositor: String, pub compositor: String,
@@ -201,6 +205,9 @@ pub struct Settings {
pub decoder: String, pub decoder: String,
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S). /// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
pub show_stats: bool, pub show_stats: bool,
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
pub fullscreen_on_stream: bool,
/// Experimental: the game-library browser ("Browse library…" on saved cards) — /// Experimental: the game-library browser ("Browse library…" on saved cards) —
/// mirrors the Apple client's "Show game library" toggle, default off. /// mirrors the Apple client's "Show game library" toggle, default off.
pub library_enabled: bool, pub library_enabled: bool,
@@ -230,6 +237,7 @@ impl Default for Settings {
refresh_hz: 0, refresh_hz: 0,
bitrate_kbps: 0, bitrate_kbps: 0,
gamepad: "auto".into(), gamepad: "auto".into(),
forward_pad: String::new(),
compositor: "auto".into(), compositor: "auto".into(),
inhibit_shortcuts: true, inhibit_shortcuts: true,
mic_enabled: false, mic_enabled: false,
@@ -237,6 +245,7 @@ impl Default for Settings {
codec: "auto".into(), codec: "auto".into(),
decoder: "auto".into(), decoder: "auto".into(),
show_stats: true, show_stats: true,
fullscreen_on_stream: true,
library_enabled: false, library_enabled: false,
} }
} }
@@ -263,3 +272,19 @@ impl Settings {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
#[test]
fn settings_forward_pad_defaults_empty() {
let old = r#"{"width":1280,"height":720,"refresh_hz":60,"bitrate_kbps":0,
"gamepad":"auto","compositor":"auto","inhibit_shortcuts":true,"mic_enabled":true}"#;
let s: Settings = serde_json::from_str(old).unwrap();
assert_eq!(s.forward_pad, "");
let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(round.forward_pad, "");
}
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -153,6 +153,15 @@ pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
let disc_heading = heading("On this network"); let disc_heading = heading("On this network");
let disc_flow = make_flow(); let disc_flow = make_flow();
// A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never
// the child's own `activate` signal — so bridge it back to the child, where each card wires
// its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead.
for flow in [&saved_flow, &disc_flow] {
flow.connect_child_activated(|_, child| {
child.activate();
});
}
// Shown under the discovered heading while no (unsaved) advert is live yet. // Shown under the discovered heading while no (unsaved) advert is live yet.
let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8); let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let spinner = gtk::Spinner::new(); let spinner = gtk::Spinner::new();
+10 -40
View File
@@ -14,11 +14,6 @@ use gtk::{gdk, glib};
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::rc::Rc; use std::rc::Rc;
use std::sync::{Arc, Mutex};
/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a
/// big library into a connection burst.
const ART_WORKERS: usize = 3;
/// Everything the page re-renders from. Kept alive by the widget closures (reload/retry/ /// Everything the page re-renders from. Kept alive by the widget closures (reload/retry/
/// card activation); dropped when the page is popped, which also winds down any in-flight /// card activation); dropped when the page is popped, which also winds down any in-flight
@@ -76,6 +71,11 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
.row_spacing(18) .row_spacing(18)
.valign(gtk::Align::Start) .valign(gtk::Align::Start)
.build(); .build();
// Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own
// `activate` — bridge it so each poster's connect handler (below) runs on click.
flow.connect_child_activated(|_, child| {
child.activate();
});
let content = gtk::Box::new(gtk::Orientation::Vertical, 0); let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
content.set_margin_top(24); content.set_margin_top(24);
content.set_margin_bottom(24); content.set_margin_bottom(24);
@@ -295,39 +295,7 @@ fn load_art(state: &Rc<State>, games: &[GameEntry]) {
} }
let identity = state.app.identity.clone(); let identity = state.app.identity.clone();
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32); let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
let queue = Arc::new(Mutex::new(jobs)); let rx = library::spawn_art_fetch(base, identity, pin, jobs);
let (tx, rx) = async_channel::unbounded::<(String, Vec<u8>)>();
for _ in 0..ART_WORKERS {
let queue = queue.clone();
let tx = tx.clone();
let base = base.clone();
let identity = identity.clone();
std::thread::Builder::new()
.name("punktfunk-lib-art".into())
.spawn(move || {
let Ok(agent) = library::agent(&identity, pin) else {
return;
};
loop {
let job = queue.lock().unwrap().pop_front();
let Some((id, candidates)) = job else { break };
for url in &candidates {
match library::fetch_art(&agent, &base, url) {
Ok(bytes) => {
// Receiver gone (page popped) — stop fetching.
if tx.send_blocking((id, bytes)).is_err() {
return;
}
break;
}
// 404 on a guessed CDN path is routine — try the next kind.
Err(e) => tracing::debug!(%id, url, error = %e, "poster miss"),
}
}
}
})
.expect("spawn art thread");
}
let weak = Rc::downgrade(state); let weak = Rc::downgrade(state);
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
while let Ok((id, bytes)) = rx.recv().await { while let Ok((id, bytes)) = rx.recv().await {
@@ -349,7 +317,8 @@ fn load_art(state: &Rc<State>, games: &[GameEntry]) {
/// The store badge text — `store` comes from the entry (today `steam`/`custom`; future /// The store badge text — `store` comes from the entry (today `steam`/`custom`; future
/// stores per the host's provider list), with the id prefix as a fallback spelling. /// stores per the host's provider list), with the id prefix as a fallback spelling.
fn store_label(store: &str) -> &'static str { /// Shared with the gamepad launcher's posters.
pub fn store_label(store: &str) -> &'static str {
match store { match store {
"steam" => "Steam", "steam" => "Steam",
"custom" => "Custom", "custom" => "Custom",
@@ -363,7 +332,8 @@ fn store_label(store: &str) -> &'static str {
} }
/// Monogram for the placeholder tile: the first letters of the first two words. /// Monogram for the placeholder tile: the first letters of the first two words.
fn initials(title: &str) -> String { /// Shared with the gamepad launcher's posters.
pub fn initials(title: &str) -> String {
title title
.split_whitespace() .split_whitespace()
.take(2) .take(2)
+389 -78
View File
@@ -3,7 +3,7 @@
use crate::trust::Settings; use crate::trust::Settings;
use adw::prelude::*; use adw::prelude::*;
use std::cell::RefCell; use std::cell::{Cell, RefCell};
use std::rc::Rc; use std::rc::Rc;
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect. /// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
@@ -16,7 +16,14 @@ const RESOLUTIONS: &[(u32, u32)] = &[
]; ];
/// `0` = the monitor's native refresh, resolved at connect. /// `0` = the monitor's native refresh, resolved at connect.
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"]; const GAMEPADS: &[&str] = &[
"auto",
"xbox360",
"dualsense",
"xboxone",
"dualshock4",
"steamdeck",
];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]; const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
/// Codec setting values (persisted) paired with their display labels below. /// Codec setting values (persisted) paired with their display labels below.
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"]; const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
@@ -25,7 +32,7 @@ const DECODERS: &[&str] = &["auto", "vaapi", "software"];
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page. /// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
const APP_LICENSE: &str = concat!( const APP_LICENSE: &str = concat!(
"punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n", "Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
"================================ MIT ================================\n\n", "================================ MIT ================================\n\n",
include_str!("../../../LICENSE-MIT"), include_str!("../../../LICENSE-MIT"),
"\n\n=============================== Apache-2.0 ===============================\n\n", "\n\n=============================== Apache-2.0 ===============================\n\n",
@@ -39,7 +46,7 @@ const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt
/// from the primary menu (app.rs `win.about`). /// from the primary menu (app.rs `win.about`).
pub fn show_about(parent: &impl IsA<gtk::Widget>) { pub fn show_about(parent: &impl IsA<gtk::Widget>) {
let about = adw::AboutDialog::builder() let about = adw::AboutDialog::builder()
.application_name("punktfunk") .application_name("Punktfunk")
.developer_name("unom") .developer_name("unom")
.version(env!("CARGO_PKG_VERSION")) .version(env!("CARGO_PKG_VERSION"))
.website("https://git.unom.io/unom/punktfunk") .website("https://git.unom.io/unom/punktfunk")
@@ -67,6 +74,179 @@ pub fn show_about(parent: &impl IsA<gtk::Widget>) {
about.present(Some(parent)); about.present(Some(parent));
} }
/// True inside a gamescope session (Steam game mode on the Deck / Bazzite): GTK popovers
/// are xdg_popups, which gamescope never maps for nested apps — a ComboRow's dropdown
/// flashes the row but no list ever appears. Selection UI must stay inside the toplevel.
fn gamescope_session() -> bool {
std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope"))
|| std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok()
}
type ChangedFn = Rc<RefCell<Option<Box<dyn Fn(u32)>>>>;
/// A titled single-choice preference row. On a desktop this is a stock popover
/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable
/// row that pushes an in-window selection subpage onto the preferences dialog instead.
struct ChoiceRow {
row: adw::PreferencesRow,
selected: Rc<Cell<u32>>,
/// Fires on user changes only — [`connect_changed`](Self::connect_changed) is installed
/// after seeding, so programmatic `set_selected` during setup never fires it.
changed: ChangedFn,
/// Subpage mode only: the current value rendered as the row's suffix.
value_label: Option<gtk::Label>,
options: Rc<Vec<String>>,
}
impl ChoiceRow {
/// `inline` = subpage mode (gamescope): computed once per dialog via
/// [`gamescope_session`] and passed in so tests can drive both modes directly.
fn new(
dialog: &adw::PreferencesDialog,
inline: bool,
title: &str,
subtitle: &str,
options: &[&str],
) -> ChoiceRow {
let options: Rc<Vec<String>> = Rc::new(options.iter().map(|s| s.to_string()).collect());
let selected = Rc::new(Cell::new(0u32));
let changed: ChangedFn = Rc::new(RefCell::new(None));
if !inline {
let row = adw::ComboRow::builder()
.title(title)
.subtitle(subtitle)
.model(&gtk::StringList::new(
&options.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
let (sel, chg) = (selected.clone(), changed.clone());
row.connect_selected_notify(move |r| {
if sel.replace(r.selected()) != r.selected() {
if let Some(f) = chg.borrow().as_ref() {
f(r.selected());
}
}
});
return ChoiceRow {
row: row.upcast(),
selected,
changed,
value_label: None,
options,
};
}
let value = gtk::Label::builder().css_classes(["dim-label"]).build();
let row = adw::ActionRow::builder()
.title(title)
.subtitle(subtitle)
.activatable(true)
.build();
row.add_suffix(&value);
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
{
let dialog = dialog.downgrade();
let (options, sel, chg, value) = (
options.clone(),
selected.clone(),
changed.clone(),
value.clone(),
);
let title = title.to_string();
row.connect_activated(move |_| {
let Some(dialog) = dialog.upgrade() else {
return;
};
let list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
for (i, opt) in options.iter().enumerate() {
let check = gtk::Image::from_icon_name("object-select-symbolic");
check.set_visible(i as u32 == sel.get());
let opt_row = adw::ActionRow::builder()
.title(opt)
.use_markup(false)
.activatable(true)
.build();
opt_row.add_suffix(&check);
let idx = i as u32;
let dlg = dialog.downgrade();
let (sel, chg, value, label) =
(sel.clone(), chg.clone(), value.clone(), opt.clone());
opt_row.connect_activated(move |_| {
let user_change = sel.replace(idx) != idx;
value.set_text(&label);
if user_change {
if let Some(f) = chg.borrow().as_ref() {
f(idx);
}
}
if let Some(d) = dlg.upgrade() {
d.pop_subpage();
}
});
list.append(&opt_row);
}
let clamp = adw::Clamp::builder()
.child(&list)
.margin_top(24)
.margin_bottom(24)
.margin_start(12)
.margin_end(12)
.build();
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&clamp)
.build();
let view = adw::ToolbarView::new();
view.add_top_bar(&adw::HeaderBar::new());
view.set_content(Some(&scroll));
dialog.push_subpage(&adw::NavigationPage::new(&view, &title));
});
}
let cr = ChoiceRow {
row: row.upcast(),
selected,
changed,
value_label: Some(value),
options,
};
cr.sync_value();
cr
}
/// Subpage mode: reflect the current selection in the row's suffix label.
fn sync_value(&self) {
if let Some(l) = &self.value_label {
let i = self.selected.get() as usize;
l.set_text(self.options.get(i).map(String::as_str).unwrap_or(""));
}
}
fn widget(&self) -> &adw::PreferencesRow {
&self.row
}
fn selected(&self) -> u32 {
self.selected.get()
}
fn set_selected(&self, i: u32) {
if let Some(combo) = self.row.downcast_ref::<adw::ComboRow>() {
combo.set_selected(i); // the notify handler syncs the cell
} else {
self.selected.set(i);
self.sync_value();
}
}
fn connect_changed(&self, f: impl Fn(u32) + 'static) {
*self.changed.borrow_mut() = Some(Box::new(f));
}
}
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid /// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
/// there so the experimental library toggle takes effect without a nav round-trip). /// there so the experimental library toggle takes effect without a nav round-trip).
pub fn show( pub fn show(
@@ -75,6 +255,11 @@ pub fn show(
gamepads: &crate::gamepad::GamepadService, gamepads: &crate::gamepad::GamepadService,
on_closed: impl Fn() + 'static, on_closed: impl Fn() + 'static,
) { ) {
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
// subpage onto it.
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
let inline = gamescope_session();
let page = adw::PreferencesPage::new(); let page = adw::PreferencesPage::new();
let stream = adw::PreferencesGroup::builder().title("Stream").build(); let stream = adw::PreferencesGroup::builder().title("Stream").build();
@@ -88,13 +273,13 @@ pub fn show(
} }
}) })
.collect(); .collect();
let res_row = adw::ComboRow::builder() let res_row = ChoiceRow::new(
.title("Resolution") &dialog,
.subtitle("The host creates a virtual output at exactly this size") inline,
.model(&gtk::StringList::new( "Resolution",
"The host creates a virtual output at exactly this size",
&res_names.iter().map(String::as_str).collect::<Vec<_>>(), &res_names.iter().map(String::as_str).collect::<Vec<_>>(),
)) );
.build();
let hz_names: Vec<String> = REFRESH let hz_names: Vec<String> = REFRESH
.iter() .iter()
.map(|&r| { .map(|&r| {
@@ -105,123 +290,154 @@ pub fn show(
} }
}) })
.collect(); .collect();
let hz_row = adw::ComboRow::builder() let hz_row = ChoiceRow::new(
.title("Refresh rate") &dialog,
.model(&gtk::StringList::new( inline,
"Refresh rate",
"",
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(), &hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
)) );
.build();
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0); let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
bitrate_row.set_title("Bitrate"); bitrate_row.set_title("Bitrate");
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high"); bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
let compositor_row = adw::ComboRow::builder() let compositor_row = ChoiceRow::new(
.title("Host compositor") &dialog,
.subtitle("Advisory — the host falls back to auto-detect when unavailable") inline,
.model(&gtk::StringList::new(&[ "Host compositor",
"Advisory — the host falls back to auto-detect when unavailable",
&[
"Automatic", "Automatic",
"KWin", "KWin",
"wlroots (Sway/Hyprland)", "wlroots (Sway/Hyprland)",
"Mutter (GNOME)", "Mutter (GNOME)",
"gamescope", "gamescope",
])) ],
.build(); );
let decoder_row = adw::ComboRow::builder() let decoder_row = ChoiceRow::new(
.title("Video decoder") &dialog,
.subtitle("Automatic tries VAAPI hardware decode, then software") inline,
.model(&gtk::StringList::new(&[ "Video decoder",
"Automatic tries VAAPI hardware decode, then software",
&[
"Automatic (VAAPI → software)", "Automatic (VAAPI → software)",
"Hardware (VAAPI)", "Hardware (VAAPI)",
"Software", "Software",
])) ],
.build(); );
let stats_row = adw::SwitchRow::builder() let stats_row = adw::SwitchRow::builder()
.title("Show statistics overlay") .title("Show statistics overlay")
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live") .subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live")
.build(); .build();
stream.add(&res_row); let fullscreen_row = adw::SwitchRow::builder()
stream.add(&hz_row); .title("Start streams in fullscreen")
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
.build();
stream.add(res_row.widget());
stream.add(hz_row.widget());
stream.add(&bitrate_row); stream.add(&bitrate_row);
stream.add(&compositor_row); stream.add(compositor_row.widget());
stream.add(&decoder_row); stream.add(decoder_row.widget());
stream.add(&fullscreen_row);
stream.add(&stats_row); stream.add(&stats_row);
let input = adw::PreferencesGroup::builder().title("Input").build(); let input = adw::PreferencesGroup::builder().title("Input").build();
// Which physical controller forwards as pad 0: automatic = the most recently // Which physical controller forwards as pad 0: automatic = the most recently connected
// connected; pinning survives until the app exits (Swift parity). // real pad (Steam's virtual pad skipped). A pin is persisted by stable key
// (`Settings::forward_pad`), so it survives restarts — and disconnects: an offline
// pinned pad keeps its entry here instead of silently snapping back to Automatic.
let pads = gamepads.pads(); let pads = gamepads.pads();
let saved_pin = settings.borrow().forward_pad.clone();
let mut pad_names = vec!["Automatic (most recent)".to_string()]; let mut pad_names = vec!["Automatic (most recent)".to_string()];
pad_names.extend(pads.iter().map(|p| { let mut pad_keys: Vec<String> = Vec::new();
for p in &pads {
let kind = p.kind_label(); let kind = p.kind_label();
if kind.is_empty() { pad_names.push(if kind.is_empty() {
p.name.clone() p.name.clone()
} else { } else {
format!("{} · {kind}", p.name) format!("{} · {kind}", p.name)
});
pad_keys.push(p.key.clone());
} }
})); if !saved_pin.is_empty() && !pad_keys.contains(&saved_pin) {
let forward_row = adw::ComboRow::builder() let name = saved_pin
.title("Forwarded controller") .splitn(3, ':')
.subtitle(if pads.is_empty() { .nth(2)
.unwrap_or("Saved controller");
pad_names.push(format!("{name} (not connected)"));
pad_keys.push(saved_pin.clone());
}
let forward_row = ChoiceRow::new(
&dialog,
inline,
"Forwarded controller",
if pads.is_empty() {
"No controllers detected" "No controllers detected"
} else { } else {
"Exactly one controller is forwarded to the host" "Exactly one controller is forwarded to the host"
}) },
.model(&gtk::StringList::new(
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(), &pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
)) );
.build(); let pinned_i = pad_keys
let pinned_i = gamepads .iter()
.pinned() .position(|k| k == &saved_pin)
.and_then(|id| pads.iter().position(|p| p.id == id))
.map_or(0, |i| i + 1); .map_or(0, |i| i + 1);
forward_row.set_selected(pinned_i as u32); forward_row.set_selected(pinned_i as u32);
// The dialog-local choice, written into Settings on close (reading the service back
// would race its worker thread applying the Pin message).
let chosen_pin: Rc<RefCell<String>> = Rc::new(RefCell::new(saved_pin));
{ {
let svc = gamepads.clone(); let svc = gamepads.clone();
let ids: Vec<u32> = pads.iter().map(|p| p.id).collect(); let keys = pad_keys.clone();
forward_row.connect_selected_notify(move |row| { let chosen = chosen_pin.clone();
let sel = row.selected() as usize; forward_row.connect_changed(move |sel| {
svc.set_pinned(if sel == 0 { let key = if sel == 0 {
None None
} else { } else {
ids.get(sel - 1).copied() keys.get(sel as usize - 1).cloned()
}); };
*chosen.borrow_mut() = key.clone().unwrap_or_default();
svc.set_pinned(key);
}); });
} }
let pad_row = adw::ComboRow::builder() let pad_row = ChoiceRow::new(
.title("Gamepad type") &dialog,
.subtitle("The virtual pad the host creates — Automatic matches the physical pad") inline,
.model(&gtk::StringList::new(&[ "Gamepad type",
"The virtual pad the host creates — Automatic matches the physical pad",
&[
"Automatic", "Automatic",
"Xbox 360", "Xbox 360",
"DualSense", "DualSense",
"Xbox One", "Xbox One",
"DualShock 4", "DualShock 4",
])) "Steam Deck",
.build(); ],
);
let inhibit_row = adw::SwitchRow::builder() let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts") .title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured") .subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build(); .build();
input.add(&forward_row); input.add(forward_row.widget());
input.add(&pad_row); input.add(pad_row.widget());
input.add(&inhibit_row); input.add(&inhibit_row);
let audio = adw::PreferencesGroup::builder().title("Audio").build(); let audio = adw::PreferencesGroup::builder().title("Audio").build();
let surround_row = adw::ComboRow::builder() let surround_row = ChoiceRow::new(
.title("Audio channels") &dialog,
.subtitle("Request stereo or surround (the host downmixes if its output has fewer)") inline,
.model(&gtk::StringList::new(&[ "Audio channels",
"Stereo", "Request stereo or surround (the host downmixes if its output has fewer)",
"5.1 Surround", &["Stereo", "5.1 Surround", "7.1 Surround"],
"7.1 Surround", );
])) audio.add(surround_row.widget());
.build(); let codec_row = ChoiceRow::new(
audio.add(&surround_row); &dialog,
let codec_row = adw::ComboRow::builder() inline,
.title("Video codec") "Video codec",
.subtitle("Preferred codec — the host falls back if it can't encode this one") "Preferred codec — the host falls back if it can't encode this one",
.model(&gtk::StringList::new(CODEC_LABELS)) CODEC_LABELS,
.build(); );
stream.add(&codec_row); stream.add(codec_row.widget());
let mic_row = adw::SwitchRow::builder() let mic_row = adw::SwitchRow::builder()
.title("Stream microphone") .title("Stream microphone")
.subtitle("Send the default input device to the host's virtual microphone") .subtitle("Send the default input device to the host's virtual microphone")
@@ -268,6 +484,7 @@ pub fn show(
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0); let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
decoder_row.set_selected(dec_i as u32); decoder_row.set_selected(dec_i as u32);
stats_row.set_active(s.show_stats); stats_row.set_active(s.show_stats);
fullscreen_row.set_active(s.fullscreen_on_stream);
inhibit_row.set_active(s.inhibit_shortcuts); inhibit_row.set_active(s.inhibit_shortcuts);
mic_row.set_active(s.mic_enabled); mic_row.set_active(s.mic_enabled);
library_row.set_active(s.library_enabled); library_row.set_active(s.library_enabled);
@@ -280,8 +497,6 @@ pub fn show(
codec_row.set_selected(codec_i as u32); codec_row.set_selected(codec_i as u32);
} }
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
dialog.add(&page); dialog.add(&page);
dialog.connect_closed(move |_| { dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut(); let mut s = settings.borrow_mut();
@@ -290,10 +505,12 @@ pub fn show(
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)]; s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string(); s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
s.forward_pad = chosen_pin.borrow().clone();
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string(); .to_string();
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string(); s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
s.show_stats = stats_row.is_active(); s.show_stats = stats_row.is_active();
s.fullscreen_on_stream = fullscreen_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active(); s.inhibit_shortcuts = inhibit_row.is_active();
s.mic_enabled = mic_row.is_active(); s.mic_enabled = mic_row.is_active();
s.audio_channels = match surround_row.selected() { s.audio_channels = match surround_row.selected() {
@@ -309,3 +526,97 @@ pub fn show(
}); });
dialog.present(Some(parent)); dialog.present(Some(parent));
} }
#[cfg(test)]
mod tests {
use super::*;
/// Depth-first search for an [`adw::ActionRow`] with the given title.
fn find_action_row(root: &gtk::Widget, title: &str) -> Option<adw::ActionRow> {
if let Some(row) = root.downcast_ref::<adw::ActionRow>() {
if row.title() == title {
return Some(row.clone());
}
}
let mut child = root.first_child();
while let Some(c) = child {
if let Some(hit) = find_action_row(&c, title) {
return Some(hit);
}
child = c.next_sibling();
}
None
}
fn pump() {
let ctx = gtk::glib::MainContext::default();
while ctx.iteration(false) {}
}
/// Both ChoiceRow modes in ONE test (GTK is thread-affine and libtest gives every test
/// its own thread, so the display tests can't be split). Gamescope mode: activating the
/// row pushes the in-window selection subpage; activating an option updates the
/// selection + suffix label, fires the change callback, and pops the subpage. Combo
/// mode: cell sync + change callback. Needs a display — run manually with
/// `cargo test -p punktfunk-client-linux -- --ignored` on a session box.
#[test]
#[ignore = "needs a Wayland/X display"]
fn choice_row_modes() {
assert!(gtk::init().is_ok() && adw::init().is_ok(), "no display");
let win = adw::Window::new();
let dialog = adw::PreferencesDialog::new();
let page = adw::PreferencesPage::new();
let group = adw::PreferencesGroup::new();
let row = ChoiceRow::new(&dialog, true, "Resolution", "sub", &["A", "B", "C"]);
group.add(row.widget());
page.add(&group);
dialog.add(&page);
let fired = Rc::new(Cell::new(u32::MAX));
{
let f = fired.clone();
row.connect_changed(move |i| f.set(i));
}
win.present();
dialog.present(Some(&win));
pump();
// Suffix label reflects the seed.
assert_eq!(row.value_label.as_ref().unwrap().text(), "A");
// Row activation → subpage with the options list.
row.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.emit_by_name::<()>("activated", &[]);
pump();
let opt_b = find_action_row(dialog.upcast_ref(), "B").expect("subpage option missing");
// Option activation → state + label + callback, subpage popped.
opt_b.emit_by_name::<()>("activated", &[]);
pump();
assert_eq!(row.selected(), 1);
assert_eq!(fired.get(), 1);
assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
// Re-activating shows the check on the new selection (fresh subpage each time).
row.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.emit_by_name::<()>("activated", &[]);
pump();
assert!(find_action_row(dialog.upcast_ref(), "B").is_some());
// Desktop (ComboRow) mode: cell sync + change callback on selection change.
let combo = ChoiceRow::new(&dialog, false, "Codec", "", &["X", "Y"]);
combo.set_selected(1);
assert_eq!(combo.selected(), 1);
let combo_fired = Rc::new(Cell::new(u32::MAX));
{
let f = combo_fired.clone();
combo.connect_changed(move |i| f.set(i));
}
combo.set_selected(0);
assert_eq!(combo.selected(), 0);
assert_eq!(combo_fired.get(), 0);
}
}

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