13 Commits

Author SHA1 Message Date
enricobuehler aaed4380e5 chore(release): bump workspace version to 0.9.1
audit / bun-audit (push) Successful in 13s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 55s
apple / swift (push) Successful in 1m10s
audit / cargo-audit (push) Successful in 2m41s
ci / bench (push) Successful in 5m39s
android-screenshots / screenshots (push) Successful in 3m3s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 58s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m15s
decky / build-publish (push) Successful in 17s
flatpak / build-publish (push) Failing after 2m17s
android / android (push) Successful in 12m47s
arch / build-publish (push) Successful in 11m58s
windows-host / package (push) Successful in 8m53s
deb / build-publish (push) Successful in 12m37s
ci / rust (push) Successful in 24m59s
release / apple (push) Successful in 12m36s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m5s
web-screenshots / screenshots (push) Successful in 2m44s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m18s
linux-client-screenshots / screenshots (push) Successful in 8m34s
apple / screenshots (push) Successful in 5m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m19s
docker / deploy-docs (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
Release 0.9.1 — a patch release led by three field-reported crash/brick
fixes, a security-review hardening pass, and the tiered stats overlay
landing on every platform.

The regression that forces the release: 0.9.0's stats-HUD display stage
hard-linked an API-33 NDK symbol believed API-26, so the native library
failed to load on every Android < 13 device — "Identity unavailable:
…NativeBridge", dead pair button, no discovery. The callback is now
dlsym-resolved (pre-13 devices simply lose that one HUD row), and a new CI
guard fails the APK build if the native lib ever imports a symbol beyond
the API-28 floor.

Two more crash-class host fixes ride along. GNOME: one console Approve
admitted every parked knock a retrying client had stacked up — three
instant Mutter virtual monitors segfaulted gnome-shell down to the GDM
greeter; knock generations now admit exactly one, Mutter topology
mutations are serialized process-wide, stale session-env is cleared, and
an opt-in PUNKTFUNK_RECOVER_SESSION_CMD hook can revive a dead session on
the next connect. Windows: twin identical GPUs died in a TEX_FAIL retry
loop when the IDD-push ring opened on the wrong twin — the ring now
rebinds once onto the render LUID the driver reports, and GPU inventory
ordering is LUID-sorted and stable for the boot.

Input: held gamepad state is rock-solid — Android pins one qualified pad
device (motion-sensor siblings zeroed axes on every event interleave), and
pad state now travels as sequence-gated idempotent snapshots
(HOST_CAP_GAMEPAD_STATE, negotiated; either end older falls back to the
per-transition path).

Security review findings addressed: sliding-window anti-replay on the
AEAD-authenticated data-plane sequence, 0600 client key + 0700 config dir
(existing stores re-locked), the web console's post-login open redirect
closed, the Windows installer's FFmpeg DLLs SHA-256-pinned, and a fork
guard on PR jobs sharing signing-runner labels.

Features: the 3-tier stats overlay (Compact/Normal/Detailed) ships on
every platform with cycle shortcuts and settings pickers; Apple gains the
experimental stage-3 glass-gated presenter (fixes the hidden 29-30 ms
display-stage queue on 120 Hz ProMotion; stage 2 stays default) and a
morphing, device-corner-concentric overlay card. Plus repo-wide docs sync
and the flatpak CI network hardening.

The [workspace.package] version (inherited by every crate via
version.workspace) is the release being cut; the 14 workspace entries in
Cargo.lock and the api/openapi.json info.version are refreshed to match
(CI builds --locked). Canary derives from the tag as minor+1 of the latest
stable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:49:10 +02:00
enricobuehler 3d6c06bb06 fix(ci): flatpak job rides out the runner's network flake — retry every single-shot fetch
ci / web (push) Successful in 52s
apple / swift (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m8s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
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 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 6m13s
windows-host / package (push) Successful in 7m50s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m0s
docker / deploy-docs (push) Successful in 1m16s
arch / build-publish (push) Successful in 11m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m19s
release / apple (push) Successful in 11m7s
android / android (push) Successful in 13m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m2s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m18s
deb / build-publish (push) Successful in 14m52s
apple / screenshots (push) Successful in 5m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m13s
ci / rust (push) Successful in 22m12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m18s
flatpak / build-publish (push) Successful in 5m18s
The flatpak run dies most pushes at `flatpak remote-add` with an instant
"[6] Could not resolve hostname" (runs 8755/8702/8689), 25ms after dnf pulled
329 packages fine — and deploy-docs separately hits "dial tcp: i/o timeout"
to unom-1 (8716/8679). The busy runner drops UDP DNS + TCP dials under
parallel-job load; tools with built-in retries (dnf) ride it out, single-shot
fetches killed the whole 2h build slot.

* scripts/ci/retry.sh: linear-backoff wrapper (5 tries, attempt*10s); stdout
  passes through untouched so $(…) capture works.
* Tooling: retry the flathub remote-add.
* Build split: a retried prefetch phase (--install-deps-only, then
  --download-only for every crate in cargo-sources.json) warms the state dir;
  the long compile then runs with no network left to flake on.
* Seed step: probe (retried) whether the server repo exists — ONLY first
  publish may continue fresh. The old blanket `rsync || warn` swallowed
  transient failures too, producing the single-branch summary that clobbers
  the other channel (the exact bug the step's comment documents).
* Deploy: retry the idempotent ssh/rsync calls to unom-1.
* raw.githubusercontent curl: --retry 5 --retry-all-errors.

docker.yml's deploy-docs has the same disease but sits on drone-ssh; left
for a follow-up. Real root cause is runner-host networking (conntrack /
upstream DNS under burst load) — retries make the jobs indifferent to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:20:40 +02:00
enricobuehler f5e5297a2a style: cargo fmt — settle comment/assert layout the last two fixes left unformatted
`cargo fmt --all --check` on main flags decode.rs (android dlsym fix),
probe/main.rs (0600 key fix), and session.rs (anti-replay tests). The probe
one is restructured rather than machine-formatted: rustfmt wanted the key-
permissions comment gutter-aligned to the trailing `// the certificate is
public` comment, so fold both into one block comment above the write instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:20:40 +02:00
enricobuehler 8fa12167af fix(android): client loads again on Android < 13 — dlsym the API-33 render callback
ci / rust (push) Failing after 41s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 53s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
apple / swift (push) Successful in 1m11s
ci / bench (push) Successful in 6m46s
apple / screenshots (push) Successful in 5m45s
android / android (push) Successful in 12m38s
deb / build-publish (push) Successful in 11m58s
arch / build-publish (push) Successful in 14m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m33s
docker / deploy-docs (push) Successful in 7s
0.9.0's HUD display stage hard-linked AMediaCodec_setOnFrameRenderedCallback via
ndk-sys believing it API 26; the symbol is API 33 ("Available since Android T").
A cdylib links fine with the dangling import, so it only exploded at
System.loadLibrary on every pre-13 device: UnsatisfiedLinkError, then every
NativeBridge touch throws NoClassDefFoundError — surfacing as "Identity
unavailable: io.unom.punktfunk.kit.NativeBridge", a dead pair button, and no
discovery (reported on a Y700 / Android 12; 13+ devices unaffected).

- decode::install_render_callback now dlsym-resolves the entry point from
  libmediandk.so, mirroring try_set_frame_rate; on API < 33 the HUD simply has
  no display stage (the pre-0.9.0 behaviour) and the .so loads.
- New scripts/ci/check-android-jni-imports.sh, wired as checkJniImports* gradle
  tasks the APK build depends on: fails the build if libpunktfunk_android.so
  imports any symbol absent from the NDK's API-28 stubs — `--platform 28` never
  enforced this (cdylib links permit undefined symbols), despite the old
  comment's claim. Verified: 3 ABIs clean at the 28 floor, and the check flags
  the known API-28 symbols when pointed at a 27 floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:06:31 +02:00
enricobuehler 21be4fc620 feat(apple): morph + scale-up the stats overlay, inset it to the device corner
windows-drivers / probe-and-proto (push) Successful in 23s
ci / rust (push) Failing after 49s
ci / docs-site (push) Successful in 50s
ci / web (push) Successful in 53s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
apple / swift (push) Successful in 1m13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 45s
windows-drivers / driver-build (push) Successful in 1m28s
flatpak / build-publish (push) Failing after 56s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, 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
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Remove the "releases mouse/keyboard" shortcut hint from the stats overlay
  (kept the capture hint). The release shortcut still lives on the Stream menu
  and, on macOS, the start-of-stream banner.
- Animate the overlay: one shared glass card now wraps the tier content, so a
  verbosity change MORPHS the card's frame/shape in place instead of
  cross-fading a fresh card; the enter/exit transition is a scale-up from the
  HUD's own corner. Driven by .animation(.smooth, value: statsVerbosity).
- iOS: give the card a corner radius concentric with the physical display
  (displayCornerRadius - inset, continuous curve) so it no longer cuts into the
  very rounded device corner. Reads _displayCornerRadius via KVC with a safe
  fallback (note: private API — App Store consideration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:04:31 +02:00
enricobuehler e707a962b6 fix(security): anti-replay, 0600 client key, open redirect, supply-chain
Address findings from a repo security review:

- core: add a sliding-window anti-replay filter over the AEAD-authenticated
  sequence in Session (poll_input/poll_frame), closing the input-replay gap the
  data plane previously left to the LAN/VPN trust assumption. 4096-deep window,
  unit-tested; the encrypted loopback suite confirms no false drops.
- clients: write the mTLS client private key 0600 and lock the config dir 0700
  on Unix (it was world-readable at the umask default), re-locking existing
  stores on load. pf-client-core::trust plus the probe's own identity writer.
  Windows keeps the %APPDATA% ACL; Android/Apple already wrap the key.
- web: fix a post-login open redirect — resolve `next` via URL and require it to
  stay same-origin, rejecting `/\evil.com` and tab/encoding variants the old
  `!startsWith("//")` guard missed. Also fixes the dead safeNextPath helper.
- ci: SHA-256-pin the BtbN FFmpeg DLLs bundled into the signed Windows installer
  (were fetched from the rolling `latest` tag unverified); fails closed on a
  re-roll, matching the VB-CABLE gate.
- ci: fail-open fork-guard on the Windows/Apple host-mode PR build jobs that
  share runner labels with the signing jobs. Definitive fix stays server-side
  (Gitea outside-collaborator approval / isolated PR runners) — see the notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:04:23 +02:00
enricobuehler ef39050dbc docs: repo-wide housekeeping — sync README & docs with the code as shipped
windows-drivers / probe-and-proto (push) Successful in 47s
ci / web (push) Successful in 58s
apple / swift (push) Successful in 1m13s
decky / build-publish (push) Successful in 16s
ci / docs-site (push) Successful in 1m19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
windows-drivers / driver-build (push) Successful in 1m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 55s
ci / bench (push) Successful in 7m18s
flatpak / build-publish (push) Successful in 6m18s
docker / deploy-docs (push) Successful in 25s
windows-host / package (push) Successful in 8m31s
release / apple (push) Successful in 11m41s
android / android (push) Successful in 13m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m57s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m16s
arch / build-publish (push) Successful in 16m24s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m6s
deb / build-publish (push) Successful in 17m54s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m27s
ci / rust (push) Successful in 18m23s
apple / screenshots (push) Successful in 5m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m43s
Six parallel audits swept the root docs, docs-site, every per-directory
README, and the packaging docs; every claim below was verified against
the source before editing.

- README: Layout gains the six missing crates (pf-client-core,
  pf-presenter, pf-console-ui, pf-ffvk, pf-driver-proto, punktfunk-tray),
  clients/session, api/ and ci/; Linux/Windows client rows reflect the
  shell + Vulkan-session split and the Vulkan Video -> VAAPI/D3D11VA ->
  software decode chains; the "every client over a C ABI" claim is
  corrected (Rust clients link the core directly); tiered stats overlay
  + console shell noted; Apple row mentions AV1.
- CONTRIBUTING: drop the dead CLAUDE.md link (deliberately untracked);
  point at the README's build/invariants sections. SECURITY: 0.9.0.
- host-cli/pairing: --allow-pairing/--require-pairing are no-op legacy
  names — pairing is required by default, --allow-tofu is the real flag;
  document --data-port and --idle-timeout-ms.
- configuration: document PUNKTFUNK_RECOVER_SESSION_CMD (session-crash
  recovery hook), PUNKTFUNK_MDNS, PUNKTFUNK_DATA_PORT.
- virtual-displays/gnome: GNOME per-client scaling shipped (host-
  persisted) — flip the  to  and describe how it works.
- stats: new "Detail levels" section (Off/Compact/Normal/Detailed +
  per-platform cycle gestures); retire the GTK hand-off note.
- clients/install-client/status/roadmap: decode chains, Windows client
  validation narrowed to HDR-only pending, adaptive bitrate, console
  shell, Apple AV1, Windows host vendor list.
- Sub-READMEs: clients/linux rewritten for the re-architecture; session
  Windows decode rung + d3d11va knob; Windows tiered overlay; Android
  minSdk 28; decky file table; host zerocopy/ path; scripts port
  47992 and steamos-host.md; pf-dualsense source path.
- packaging: canary version bases are tag-derived (<next-minor> via
  pf-version.sh/.ps1), codecs-extra not ffmpeg-full, document the
  pinned offline-Skia tarball + SKIA_BINARIES_URL and vulkan-headers.
- Convert 15 dangling design/*.md links to the punktfunk-planning
  prose convention (those docs live in the private planning repo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:13:42 +02:00
enricobuehler b7cb75a48a fix(host): dual identical GPUs stream again — self-heal the IDD-push ring onto the driver's real render adapter
ci / web (push) Successful in 50s
apple / swift (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m5s
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 6s
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
ci / bench (push) Successful in 5m30s
docker / deploy-docs (push) Successful in 20s
apple / screenshots (push) Successful in 5m35s
windows-host / package (push) Successful in 7m57s
deb / build-publish (push) Successful in 12m0s
arch / build-publish (push) Successful in 14m51s
android / android (push) Successful in 16m21s
ci / rust (push) Successful in 17m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m0s
A box with two identical GPUs (twin RTX 4090s in the field report) died out
of pipeline retries with driver_status=2 (DRV_STATUS_TEX_FAIL, detail
0x80070057): SET_RENDER_ADAPTER pinned the driver to one twin at monitor ADD,
but the ring open re-ran the GPU picker, whose max-VRAM tie is settled by
DXGI enumeration order — which follows the primary display that the vdisplay
flow itself moves mid-session. Ring on one twin, swap-chain on the other →
the driver can't open the shared textures, and every retry repeats the same
wrong pick.

- wait_for_attach: TEX_FAIL becomes a typed AttachTexFail carrying the render
  LUID the driver reports in the shared frame header (written before the
  texture opens, so valid on failure) — the error now names both adapters
  instead of guessing "mismatch?".
- open_inner: on AttachTexFail with a different, non-zero driver LUID, rebind
  the ring there and reopen ONCE. Both candidates are real GPU adapters, so
  NVENC keeps getting a device it accepts.
- gpu::enumerate (Windows): sort the inventory by LUID so the max-VRAM tie,
  the env-substring first match, and twin occurrence numbering are stable for
  the boot instead of tracking the primary display.
- manager: record the ADD-time render pin on Monitor and warn on reuse when
  the current pick has moved away from it (the pick only takes effect on the
  next monitor create; /display/release forces one).
- pf_vdisplay: drop the "ADD render adapter DIFFERS from pinned" warn — the
  ADD reply carries IDARG_OUT_MONITORARRIVAL.OsAdapterLuid (the IddCx DISPLAY
  adapter, verified on-glass), not the render GPU, so the check compared
  unrelated LUIDs and fired on every ADD.

Verified on the RTX 4090 box: single-GPU streaming is unchanged (IddPush +
NVENC AV1 session, no new warnings). The rebind path needs a twin-GPU box —
until validated there, /display/release remains the manual recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 02:19:07 +02:00
enricobuehler 08694b4026 feat(apple): stage-3 presenter — glass-gated present pacing as a live A/B
ci / web (push) Successful in 52s
apple / swift (push) Successful in 1m11s
ci / docs-site (push) Successful in 1m2s
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 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
ci / bench (push) Successful in 5m47s
docker / deploy-docs (push) Failing after 37s
release / apple (push) Successful in 8m15s
android / android (push) Successful in 12m57s
arch / build-publish (push) Successful in 13m28s
deb / build-publish (push) Successful in 12m15s
apple / screenshots (push) Successful in 5m55s
ci / rust (push) Successful in 17m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m24s
Stage-2's present-on-arrival saturates CAMetalLayer's FIFO image queue
whenever the stream rate runs at the panel's refresh (iOS always
vsync-latches; the macOS 26 compositor latch-paces our out-of-band
presents the same way): one early burst fills the queue to
maximumDrawableCount and — arrivals then matching latches one-for-one —
it never drains. That sticky depth is the measured 29-30 ms display
stage on the 120 Hz ProMotion devices, and the full-queue regime is
where host<->panel clock drift turns into the reported fixed-interval
repeats/drops. The 240 Hz Studio never saturates, which is why it never
showed either symptom.

Stage-3 is the same pipeline with a PresentGate: at most ONE
presented-but-undisplayed drawable in flight; the drawable's presented
handler reopens the gate and re-signals the render thread, so the next
present always takes the freshest newest-wins ring frame — the hidden
queue latency becomes explicit, correct frame drops. A 100 ms stale
fallback force-opens a gate whose handler never fires (the "presents
aren't damage" hazard class) so a pathological system degrades visibly
instead of freezing; the PUNKTFUNK_PRESENT_DEBUG `forced` counter
exposes it (0 on healthy systems).

Selection: the Settings > Display presenter picker now ships in release
builds (stage 2 default / stage 3 experimental; the freeze-prone
stage-1 diagnostic stays DEBUG-only), resolved per session with a
PUNKTFUNK_PRESENTER=stage1|stage2|stage3 env override for CLI A/B. The
pf-present debug line gains gated/forced/inflightMax — inflightMax is
the direct image-queue-depth measurement for the A/B.

Live-verified both ways against a real host at 1080p120: stage-3 holds
120/120 fps with inflightMax=1, forced=0, glass deltas p50 8.33 ms;
stage-2 is behaviorally unchanged (120/120 fps, inflightMax=2 even on
the wired 240 Hz setup — the saturation signal in miniature). Unit
tests cover the gate (one-in-flight, stale force-open, idempotent
release) and the presenter resolution (env override, release stage-1
gating); macOS tests green, iOS/tvOS xcodebuild clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 02:04:42 +02:00
enricobuehler 38b9f310e2 feat(clients): tiered stats overlay everywhere — Compact/Normal/Detailed on every platform
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m10s
ci / web (push) Successful in 1m12s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m46s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m57s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m10s
ci / bench (push) Successful in 5m43s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
release / apple (push) Successful in 8m10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 11m59s
android / android (push) Successful in 13m3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m39s
docker / deploy-docs (push) Successful in 18s
apple / screenshots (push) Successful in 5m32s
deb / build-publish (push) Successful in 14m54s
ci / rust (push) Successful in 23m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m44s
flatpak / build-publish (push) Failing after 35s
Ship the Android client's 3-tier stats-overlay semantics in every other client
(design/stats-unification.md vocabulary): Off → Compact (one line: fps · e2e ms ·
Mb/s + loss flag) → Normal (mode + e2e p50/p95 + loss counters) → Detailed
(decoder path, HDR tag, per-stage latency equation).

Apple: new StatsVerbosity in PunktfunkKit persisted under punktfunk.statsVerbosity
(migrates the legacy hudEnabled bool: explicit off → Off, else Normal). The
existing three-finger tap (TouchMouse, trackpad/pointer modes only — touch
passthrough untouched) now cycles the tiers instead of toggling, matching
Android; ⌃⌥⇧S (menu + captured-state monitor) cycles the same ladder. Tiered
StreamHUDView (compact glass pill / headline HUD / full equation HUD); the iOS
corner disconnect also shows in Compact (the pill carries no button). Tier
pickers on iOS, macOS, tvOS and the gamepad settings UI.

Session stack (Linux + Windows + Deck share punktfunk-session): shared
pf_client_core::trust::StatsVerbosity; Settings grows stats_verbosity with a
show_stats fallback, and writes keep the legacy bool in sync so pre-tier
binaries reading the same JSON agree on off vs on. Ctrl+Alt+Shift+S cycles the
tier and re-renders the OSD immediately from the last stats window; the stdout
stats: line always carries the full Detailed text so the shell status card and
scripts keep a stable shape; --stats bumps Off → Normal without demoting a
richer tier. Tier pickers in the GTK dialog, the WinUI settings page and the
console-UI settings row; shortcut copy updated (GTK shortcuts window, Windows
help, session README). The Windows legacy builtin path keeps its bool HUD.

Tests: tier migration/round-trip in trust.rs, tiered stats_text output in
pf-presenter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 01:42:46 +02:00
enricobuehler 7b25868a19 fix(input): rock-solid held gamepad state — Android device pinning + seq'd snapshots
apple / swift (push) Successful in 1m12s
release / apple (push) Successful in 8m54s
windows-host / package (push) Successful in 7m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m49s
ci / web (push) Successful in 1m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m8s
ci / docs-site (push) Successful in 1m21s
android / android (push) Successful in 12m25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 56s
decky / build-publish (push) Successful in 18s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m23s
apple / screenshots (push) Successful in 5m45s
arch / build-publish (push) Successful in 11m9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 5m26s
flatpak / build-publish (push) Failing after 33s
ci / rust (push) Successful in 17m39s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m38s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8m39s
docker / deploy-docs (push) Successful in 22s
deb / build-publish (push) Successful in 11m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m8s
Two causes behind one field report (a held trigger jittering mid-game,
Android client → Windows host):

Android folded joystick ACTION_MOVEs from EVERY device into one axis
state. A controller's joystick-classified sibling node (DualSense/DS4
motion sensors) or a second/drifting pad reports every pad axis as 0,
so a held trigger flapped value→0→value on each event interleave. The
mapper now qualifies the source DEVICE (its source classes must include
GAMEPAD — a joystick event's own source is always plain JOYSTICK), pins
to one deviceId until that device disconnects, and merges LTRIGGER/BRAKE
(and RTRIGGER/GAS) with max, the same fold as the Controllers probe.

Underneath, gamepad input rode per-transition events over unreliable,
unordered QUIC datagrams — no sequence numbers, sharing the 4 KiB
oldest-first-shed send buffer — so one dropped or reordered event
corrupted held pad state until the NEXT change. Gamepad state now
travels the way rumble already does: idempotent state, refreshed.
InputKind::GamepadState packs the whole pad + a wrapping u8 seq into
the existing 18-byte layout; the host advertises HOST_CAP_GAMEPAD_STATE
(Welcome trailing byte, offset 67) and applies snapshots through a
per-pad stale-seq gate, skipping frame emits for unchanged refreshes;
the client folds embedder events into snapshots inside NativeClient's
input task (send on change + 100 ms refresh of touched pads), so the
SDL clients (Linux/Windows/session), Android, and Apple (C ABI) are all
covered with zero capture-code changes. Either end older ⇒ the legacy
per-transition path runs unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 01:11:38 +02:00
enricobuehler 47d22b6082 fix(host): admit exactly ONE parked knock per Approve — stop crashing gnome-shell, and self-heal dead sessions
ci / docs-site (push) Successful in 55s
ci / web (push) Successful in 1m11s
apple / swift (push) Successful in 1m12s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
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 6s
ci / bench (push) Successful in 7m3s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m57s
windows-host / package (push) Successful in 8m20s
arch / build-publish (push) Successful in 11m48s
deb / build-publish (push) Successful in 12m57s
apple / screenshots (push) Successful in 5m42s
android / android (push) Successful in 17m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m28s
ci / rust (push) Successful in 25m57s
docker / deploy-docs (push) Failing after 5m44s
A retrying unpaired client parks one QUIC connection per knock, but the
delegated-approval waiters were keyed on fingerprint alone — one console
Approve resolved ALL of them. Observed live (2026-07-10): an iPad knocked
3x, one Approve admitted three full sessions, three Mutter virtual
monitors were created within ~200us plus an ApplyMonitorsConfig, and
gnome-shell SIGSEGV'd inside meta_monitor_manager_rebuild — dropping the
box to the GDM greeter, unreachable until reboot (GDM auto-login runs
once per boot) while the lingering host spammed "RemoteDesktop ... not
activatable" libei errors.

Four fixes, outermost symptom inward:

- Knock generations (native_pairing): note_pending returns a per-knock
  generation; a re-knock bumps it and wakes the previous waiter, which
  resolves the new PairingDecision::Superseded (connection closes; the
  console list is unchanged). An approval records WHICH generation it
  admitted, so a stale waiter polling only after the pending entry is
  cleared still loses the tie — exactly one admission, no matter the
  interleaving.

- TOPOLOGY_LOCK (vdisplay/mutter): every Mutter monitor mutation
  (pre-snapshot -> RecordVirtual -> ApplyMonitorsConfig, and the
  teardown Stop) is serialized process-wide. Concurrent rebuilds are
  what segfault the shell (second on-glass crash of this class — the
  teardown race is already documented in-file), and serialization also
  keeps wait_virtual_connector's snapshot diff from attributing a
  sibling's connector. Create timeout 20s -> 45s for lock queueing.

- Session-env hygiene (vdisplay): when detection finds NOTHING live,
  clear the previous connect's XDG_CURRENT_DESKTOP/WAYLAND_DISPLAY
  retarget. A stale GNOME value kept mutter::is_available() true after
  the crash, routing explicit-backend connects into the dead session
  (create timeouts + libei error loops) instead of the crisp "no live
  graphical session" handshake error.

- Opt-in recovery hook (config + punktfunk1): PUNKTFUNK_RECOVER_SESSION_CMD
  fires (detached via sh -c, debounced to one launch/min) when a client
  connects while no graphical session is live — e.g. `sudo -n systemctl
  restart gdm` re-runs auto-login and the client's retry lands in the
  recovered desktop. The handshake error tells the client to retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 01:10:26 +02:00
enricobuehler c86da1a1ff chore(android): silence the two vendored-ndk warnings on every native build
apple / swift (push) Successful in 1m14s
apple / screenshots (push) Successful in 5m38s
ci / web (push) Successful in 45s
ci / docs-site (push) Successful in 50s
windows-host / package (push) Successful in 8m30s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m17s
decky / build-publish (push) Successful in 16s
ci / bench (push) Successful in 6m46s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m21s
android / android (push) Successful in 12m38s
arch / build-publish (push) Successful in 14m7s
deb / build-publish (push) Successful in 15m11s
docker / deploy-docs (push) Successful in 9s
ci / rust (push) Successful in 22m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m7s
The vendored ndk 0.9.0 warned twice per Android build: an unnecessary
`std::ptr::` qualification in input_queue.rs (the crate opts into
unused_qualifications) and an unused `Result` import in media_format.rs —
unused only because its sole consumers are #[cfg(feature = "api-level-29")]
methods our default-feature build compiles out, so the import is now gated
the same way (both feature configurations stay warning-free). Vendor-patch
inventory in the workspace Cargo.toml updated to stay honest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:13:36 +02:00
101 changed files with 2646 additions and 441 deletions
+9
View File
@@ -16,8 +16,17 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
# code could persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's
# "require approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral
# runners. The `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and
# still runs same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
swift: swift:
runs-on: macos-arm64 runs-on: macos-arm64
if: >-
gitea.event_name != 'pull_request' ||
gitea.event.pull_request.head.repo.fork != true
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+46 -16
View File
@@ -73,7 +73,10 @@ jobs:
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \ dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \
gnupg2 rsync openssh-clients gnupg2 rsync openssh-clients
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions. # Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
flatpak remote-add --user --if-not-exists flathub \ # retry.sh: the busy runner intermittently drops DNS lookups ("[6] Could not resolve
# hostname" seconds after dnf pulled 300+ packages fine) — never fail the job on a
# single-shot fetch. Same treatment for every network command below.
bash scripts/ci/retry.sh 5 flatpak remote-add --user --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo https://dl.flathub.org/repo/flathub.flatpakrepo
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
@@ -108,7 +111,7 @@ jobs:
# device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is # device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is
# untouched; cargo --offline only needs sources for the crates it compiles. # untouched; cargo --offline only needs sources for the crates it compiles.
run: | run: |
curl -fsSL -o /tmp/flatpak-cargo-generator.py \ curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o /tmp/flatpak-cargo-generator.py \
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \ python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
@@ -142,18 +145,43 @@ jobs:
DEST="${DEPLOY_USER}@${DEPLOY_HOST}" DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
mkdir -p "$PWD/repo" mkdir -p "$PWD/repo"
# Pull the currently-published repo (all channels' objects + refs) into the repo the build # 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 # will extend. No --delete: the local repo starts empty, so this only ADDS.
# server repo (very first publish) is fine — we continue with a fresh repo. # Probe first (retried) whether a published repo exists at all: ONLY that case may
rsync -az --info=stats1 -e "$SSH" "$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/" \ # continue with a fresh repo. A transient network failure must FAIL the job instead —
|| echo "::warning::no published repo to seed (first publish?) — continuing fresh" # a blanket `rsync || continue` here is exactly how a flaky link produces the
# single-branch summary that clobbers the other channel (the bug described above).
PRESENT=$(bash scripts/ci/retry.sh 5 $SSH "$DEST" \
"[ -d $DEPLOY_DIR/site/repo/refs ] && echo present || echo absent")
if [ "$PRESENT" = present ]; then
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" \
"$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/"
else
echo "::warning::no published repo on the server (first publish) — continuing fresh"
fi
echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)" 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: Prefetch deps + sources (retried — the network phase, split off the build)
run: | run: |
# --install-deps-from=flathub pulls everything the manifest declares: the GNOME 50 # All of the job's heavy network I/O happens HERE, retried, so a dropped DNS lookup
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus # or TCP dial costs a backoff-retry instead of the whole (long) compile:
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the # 1) --install-deps-only pulls everything the manifest declares from Flathub: the
# container-safe path (no FUSE). # GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
# extensions, plus the runtime's auto codecs-extra (HEVC libavcodec).
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
# after a partial failure is safe and cheap.
# --disable-rofiles-fuse is the container-safe path (no FUSE).
bash scripts/ci/retry.sh 5 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--install-deps-from=flathub --install-deps-only \
"$PWD/build-dir" "$MANIFEST"
bash scripts/ci/retry.sh 5 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--download-only \
"$PWD/build-dir" "$MANIFEST"
- name: Build the flatpak (offline — deps + sources prefetched above)
run: |
# Everything is already local (state dir warmed by the prefetch step), so this long
# step needs no network; --install-deps-from stays as a no-op safety net.
# --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch> # --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
# (canary or stable) so the matching hosted .flatpakref resolves deterministically # (canary or stable) so the matching hosted .flatpakref resolves deterministically
# (manifest sets no branch). # (manifest sets no branch).
@@ -272,11 +300,13 @@ jobs:
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy 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" SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}" DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
$SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo" # All idempotent — retried because the runner's link to unom-1 drops TCP dials under
rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/" # load (the same flake that hits docker.yml's deploy-docs with "dial tcp: i/o timeout").
rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/" bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/" bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
$SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d" bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
echo "deployed → $REPO_URL/${APP_ID}.flatpakref" echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
- name: Attach bundle to the Gitea release (stable tags only) - name: Attach bundle to the Gitea release (stable tags only)
+10
View File
@@ -30,8 +30,15 @@ on:
# scripts/ci/ensure-windows-toolchain.ps1, a fast no-op once already present. # scripts/ci/ensure-windows-toolchain.ps1, a fast no-op once already present.
jobs: jobs:
# SECURITY: builds PULL-REQUEST code on the host-mode, persistent `windows-amd64` runner shared with
# the release-signing jobs (windows-host.yml / windows-msix.yml). See windows.yml for the full
# rationale. Definitive fix is server-side (Gitea outside-collaborator approval + isolated PR
# runners); the `if:` is a fail-open backstop that never blocks internal PR CI.
probe-and-proto: probe-and-proto:
runs-on: windows-amd64 runs-on: windows-amd64
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork != true
timeout-minutes: 30 timeout-minutes: 30
defaults: defaults:
run: run:
@@ -108,6 +115,9 @@ jobs:
# DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question. # DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question.
driver-build: driver-build:
runs-on: windows-amd64 runs-on: windows-amd64
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork != true
timeout-minutes: 45 timeout-minutes: 45
defaults: defaults:
run: run:
+12
View File
@@ -68,8 +68,20 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
# host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml /
# windows-msix.yml, which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
# never blocks internal PR CI.
build: build:
runs-on: windows-amd64 runs-on: windows-amd64
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork != true
timeout-minutes: 90 timeout-minutes: 90
strategy: strategy:
fail-fast: false fail-fast: false
+3 -1
View File
@@ -40,4 +40,6 @@ Generated artifacts are checked in and CI fails on drift: `include/punktfunk_cor
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment `api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`). density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
See [`CLAUDE.md`](CLAUDE.md) for the full build/test/run guide and design invariants. See the [README's Build & test section](README.md#build--test-from-source) and
[Design invariants](README.md#design-invariants) for the full build/test/run guide, and the
[docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
Generated
+14 -14
View File
@@ -2154,7 +2154,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.9.0" version = "0.9.1"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2286,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2765,7 +2765,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pf-client-core" name = "pf-client-core"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2787,7 +2787,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-console-ui" name = "pf-console-ui"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2808,7 +2808,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-ffvk" name = "pf-ffvk"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"ash", "ash",
"bindgen", "bindgen",
@@ -2817,7 +2817,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-presenter" name = "pf-presenter"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -3001,7 +3001,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -3017,7 +3017,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3033,7 +3033,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-session" name = "punktfunk-client-session"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-client-core", "pf-client-core",
@@ -3048,7 +3048,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3072,7 +3072,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -3103,7 +3103,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3175,7 +3175,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3189,7 +3189,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
+3 -2
View File
@@ -26,7 +26,8 @@ exclude = [
"clients/android/native/vendor/ndk", "clients/android/native/vendor/ndk",
] ]
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change: `MediaCodec::as_ptr` made public # ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
# unnecessary `std::` qualification and a feature-gated `Result` import): `MediaCodec::as_ptr` made public
# (upstream keeps it private and exposes no frame-rendered binding), so the Android client can # (upstream keeps it private and exposes no frame-rendered binding), so the Android client can
# call `AMediaCodec_setOnFrameRenderedCallback` via ndk-sys for the HUD's `display` stage # call `AMediaCodec_setOnFrameRenderedCallback` via ndk-sys for the HUD's `display` stage
# (design/stats-unification.md). Drop the patch when upstream exposes the pointer or the callback. # (design/stats-unification.md). Drop the patch when upstream exposes the pointer or the callback.
@@ -34,7 +35,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" } ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package] [workspace.package]
version = "0.9.0" version = "0.9.1"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+20 -6
View File
@@ -22,7 +22,8 @@ punktfunk pairs a **virtual-display streaming host** with native clients on ever
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
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the **GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
protocol, FEC, and crypto, linked into the host and every client over a stable C ABI. protocol, FEC, and crypto, linked into the host and every native client — directly as a Rust crate
on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
## What makes it different ## What makes it different
@@ -58,12 +59,16 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
| **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** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green | | **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **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 (HEVC, and AV1 on hardware that decodes it), 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` + `clients/session`) | ✅ Streaming live: relm4/GTK4 launcher shell that spawns a Vulkan session binary — Vulkan Video / VAAPI / software decode, PipeWire audio, SDL3 controllers, Skia console UI; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing | | **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
| **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 | | **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs | | **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 |
Every native client also ships a tiered **stats overlay** (Compact / Normal / Detailed) with a
shared vocabulary across platforms, and the session client carries a full gamepad-driven **console
shell** (`pf-console-ui`): host list, PIN pairing, settings, and an on-screen keyboard.
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,
@@ -117,7 +122,7 @@ Each client discovers hosts on the network automatically and does a one-time
For development, or as an install fallback where no package is available: For development, or as an install fallback where no package is available:
```sh ```sh
cargo build --workspace # the Rust core, host, Linux client, and probe (Linux & macOS) cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, probe (Linux & macOS)
cargo test --workspace # unit + loopback + proptest + C ABI harness cargo test --workspace # unit + loopback + proptest + C ABI harness
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check cargo fmt --all --check
@@ -137,18 +142,27 @@ and the [docs site](https://docs.punktfunk.unom.io).
crates/ crates/
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib) punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
pf-client-core/ shared client plumbing (Linux + Windows): session pump · FFmpeg decode · audio · SDL3 gamepads · trust · discovery
pf-presenter/ Vulkan session presenter: SDL3 window · ash swapchain · frame present · input capture
pf-console-ui/ Skia console UI for the session client: gamepad shell · stats OSD · pairing · on-screen keyboard
pf-ffvk/ FFmpeg Vulkan hwcontext bindings (AVVkFrame) for Vulkan Video decode on the presenter's device
pf-driver-proto/ host ↔ pf-vdisplay driver contract: control IOCTLs + IDD-push frame transport (no_std)
punktfunk-tray/ host tray icon (Windows notification area / Linux StatusNotifierItem)
clients/ clients/
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController) apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3) linux/ Linux launcher shell (Rust · relm4 / GTK4 / libadwaita) — spawns the session client to stream
session/ punktfunk-session, the Vulkan streaming session (Rust · SDL3 · ash · Skia console UI) — also runs standalone (gamescope, Decky)
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3) windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
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 · GPUs · performance · logs web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
api/openapi.json management-API OpenAPI spec (regenerated via `punktfunk-host openapi`, checked in)
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
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)
ci/ CI container images (rust-ci · fedora-rpm)
``` ```
## Design invariants ## Design invariants
+1 -1
View File
@@ -14,7 +14,7 @@ exposes other users before a fix exists.
The more of this you can give us, the faster we can act: 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 component and version (e.g. `punktfunk-host 0.9.0`, Windows or Linux, which client).
- The impact — what an attacker can do, and from what position (same LAN, a local service account, - The impact — what an attacker can do, and from what position (same LAN, a local service account,
admin, a paired client, …). admin, a paired client, …).
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one. - Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
+1 -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.9.0" "version": "0.9.1"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
+1 -1
View File
@@ -58,7 +58,7 @@ kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · K
`build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 1721, not `build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 1721, not
a newer default); Rust with `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` and a newer default); Rust with `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` and
`cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM `cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
2026.05.01 · compileSdk 37 · minSdk 31). 2026.05.01 · compileSdk 37 · minSdk 28).
**Android Studio:** open `clients/android` — it uses its bundled JBR 21, and the `cargoNdk*` task **Android Studio:** open `clients/android` — it uses its bundled JBR 21, and the `cargoNdk*` task
builds the `.so` as part of the normal build. builds the `.so` as part of the normal build.
+29 -5
View File
@@ -91,9 +91,12 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
val cmd = mutableListOf( val cmd = mutableListOf(
"$cargoBin/cargo", "ndk", "$cargoBin/cargo", "ndk",
"-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64", "-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
// Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the // Link against the minSdk-28 sysroot (libaaudio, API 26, is present). NOTE: this does
// floor makes the linker reject any accidental >28 hard import (the one API-30 call we // NOT reject an accidental >28 hard import — a cdylib link permits undefined symbols,
// make, ANativeWindow_setFrameRate, is dlsym-resolved — see decode::try_set_frame_rate). // which then fail at System.loadLibrary on every device below the symbol's API level
// (the 0.9.0 Android-≤12 regression). The checkJniImports* task after this build is
// what actually enforces the floor; >28 entry points must be dlsym-resolved (see
// decode::try_set_frame_rate, decode::install_render_callback, adpf).
"--platform", "28", "--platform", "28",
"-o", file("src/main/jniLibs").absolutePath, "-o", file("src/main/jniLibs").absolutePath,
"build", "-p", "punktfunk-client-android", "build", "-p", "punktfunk-client-android",
@@ -102,8 +105,28 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
commandLine(cmd) commandLine(cmd)
} }
// Post-link floor check: every undefined symbol in the built .so must exist in the API-28 stubs,
// else System.loadLibrary fails on devices at the minSdk floor (see the script header for the
// 0.9.0 incident this guards against). Runs right after its cargo-ndk task; the APK build depends
// on this task (not the cargo one directly), so a violation fails the build, local and CI alike.
fun registerCheckJniImports(taskName: String, cargoTask: TaskProvider<Exec>) =
tasks.register<Exec>(taskName) {
group = "rust"
description = "verify libpunktfunk_android.so imports stay within the API-28 floor"
dependsOn(cargoTask)
workingDir = repoRoot
commandLine(
"sh", File(repoRoot, "scripts/ci/check-android-jni-imports.sh").absolutePath,
"${androidSdkDir()}/ndk/$ndkVer",
file("src/main/jniLibs").absolutePath,
"28",
)
}
val cargoNdkDebug = registerCargoNdk("cargoNdkDebug", release = false) val cargoNdkDebug = registerCargoNdk("cargoNdkDebug", release = false)
val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true) val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true)
val checkJniImportsDebug = registerCheckJniImports("checkJniImportsDebug", cargoNdkDebug)
val checkJniImportsRelease = registerCheckJniImports("checkJniImportsRelease", cargoNdkRelease)
afterEvaluate { afterEvaluate {
// `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi // `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi
@@ -120,8 +143,9 @@ afterEvaluate {
// debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native // debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native
// build for the rare session that actually steps through Rust. // build for the rare session that actually steps through Rust.
if (!project.hasProperty("skipRustBuild")) { if (!project.hasProperty("skipRustBuild")) {
val debugRust = if (project.hasProperty("rustDebug")) cargoNdkDebug else cargoNdkRelease val debugRust =
if (project.hasProperty("rustDebug")) checkJniImportsDebug else checkJniImportsRelease
tasks.named("preDebugBuild").configure { dependsOn(debugRust) } tasks.named("preDebugBuild").configure { dependsOn(debugRust) }
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) } tasks.named("preReleaseBuild").configure { dependsOn(checkJniImportsRelease) }
} }
} }
@@ -175,6 +175,12 @@ object Gamepad {
* Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per * Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per
* session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing * session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing
* sticks on the host (which has no client-side held-state knowledge). * sticks on the host (which has no client-side held-state knowledge).
*
* Single-source: only ONE qualifying controller feeds pad 0. Events must come from a device
* whose source classes include GAMEPAD (see [onMotion]) and the mapper pins itself to the
* first such device — a controller's joystick-classified sibling nodes (DualSense/DS4 motion
* sensors) and any second pad report every axis as 0, and folding them into the same state
* flapped a held trigger/stick between its value and 0 on every event interleave.
*/ */
class AxisMapper(private val handle: Long) { class AxisMapper(private val handle: Long) {
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity). // Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
@@ -182,10 +188,29 @@ object Gamepad {
private var hatX = 0 // -1 / 0 / +1 private var hatX = 0 // -1 / 0 / +1
private var hatY = 0 private var hatY = 0
/** deviceId of the controller pad 0 is pinned to; 1 until the first qualifying event. */
private var deviceId = -1
/** Returns true if this was a joystick ACTION_MOVE we consumed. */ /** Returns true if this was a joystick ACTION_MOVE we consumed. */
fun onMotion(event: MotionEvent): Boolean { fun onMotion(event: MotionEvent): Boolean {
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
// Only a true gamepad drives pad 0. A joystick ACTION_MOVE's own source is plain
// JOYSTICK for every sender, so qualify by the DEVICE's source classes: a real pad
// carries the GAMEPAD (button) class too, its sensor/touchpad sibling nodes and
// joystick-class remotes don't — and those report every pad axis as 0 (see the
// class doc for the held-trigger flap this caused).
val dev = event.device ?: return false
if (dev.sources and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD) return false
// Single-pad model: pin to the first qualifying controller so a second pad (or its
// stick drift) can't fight pad 0; re-adopt only once the pinned device is gone.
if (deviceId != event.deviceId) {
if (deviceId != -1) {
if (InputDevice.getDevice(deviceId) != null) return false
reset() // the pinned pad is gone — lift its held state before adopting
}
deviceId = event.deviceId
}
// Sticks: Android floats 1..1, +y = down → ±32767, negate Y for the wire's +y = up. // Sticks: Android floats 1..1, +y = down → ±32767, negate Y for the wire's +y = up.
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X))) sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
@@ -193,9 +218,27 @@ object Gamepad {
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z))) sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ))) sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
// Triggers: LTRIGGER/RTRIGGER if present, else BRAKE/GAS; 0..1 float → 0..255. // Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
sendAxis(AXIS_LT, trigger(firstNonZero(event, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE))) // with max, the same fold as the Controllers screen probe, so a pad that reports
sendAxis(AXIS_RT, trigger(firstNonZero(event, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS))) // only one pair and a pad that reports both behave identically; 0..1 → 0..255.
sendAxis(
AXIS_LT,
trigger(
maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
),
),
)
sendAxis(
AXIS_RT,
trigger(
maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
),
),
)
// HAT → dpad button transitions (track previous, emit only the deltas). // HAT → dpad button transitions (track previous, emit only the deltas).
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)) val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X))
@@ -237,10 +280,5 @@ object Gamepad {
private fun trigger(v: Float): Int = (v.coerceIn(0f, 1f) * 255f).toInt() private fun trigger(v: Float): Int = (v.coerceIn(0f, 1f) * 255f).toInt()
private fun sign(v: Float): Int = if (v < -0.5f) -1 else if (v > 0.5f) 1 else 0 private fun sign(v: Float): Int = if (v < -0.5f) -1 else if (v > 0.5f) 1 else 0
private fun firstNonZero(e: MotionEvent, a: Int, b: Int): Float {
val va = e.getAxisValue(a)
return if (va != 0f) va else e.getAxisValue(b)
}
} }
} }
+13 -8
View File
@@ -43,15 +43,20 @@ tracing = { version = "0.1", default-features = false, features = ["std", "log"]
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode + # Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant). # audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
# api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and # api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; the one API-30 call we make # ANativeWindow_setBuffersDataSpace (28) are all ≤28; every call above the floor —
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not # ANativeWindow_setFrameRate (30), …WithChangeStrategy (31), AMediaCodec_setOnFrameRenderedCallback
# linked, so the .so still loads on API 28/29. # (33), the ADPF hints — is dlsym-resolved at runtime (decode::try_set_frame_rate,
# decode::install_render_callback, adpf), never linked, so the .so still loads on API 28+.
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] } ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
# Raw FFI for the one AMediaCodec entry point the `ndk` wrapper lacks: # Raw FFI *types* for the one AMediaCodec entry point the `ndk` wrapper lacks:
# `AMediaCodec_setOnFrameRenderedCallback` (API 26, under the minSdk-28 floor ⇒ hard-linked) — the # `AMediaCodec_setOnFrameRenderedCallback` — the per-frame render-timestamp callback behind the
# per-frame render-timestamp callback behind the HUD's `display` stage (see decode::DisplayTracker). # HUD's `display` stage (see decode::DisplayTracker). The symbol is API 33 ("Available since
# Reaching it needs the codec's raw pointer, which is why the workspace pins `ndk` to the vendored # Android T"), ABOVE the minSdk-28 floor, so it is dlsym-resolved at runtime
# copy whose only patch makes `MediaCodec::as_ptr` public (vendor/ndk, wired in the root Cargo.toml). # (decode::install_render_callback), NEVER hard-linked: 0.9.0 hard-linked it and
# `System.loadLibrary` failed on every pre-Android-13 device. Only ndk-sys's pointer/typedef
# types are referenced, which creates no import. Reaching it needs the codec's raw pointer, which
# is why the workspace pins `ndk` to the vendored copy whose only patch makes `MediaCodec::as_ptr`
# public (vendor/ndk, wired in the root Cargo.toml).
ndk-sys = { version = "0.6", features = ["media"] } ndk-sys = { version = "0.6", features = ["media"] }
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority). # setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
libc = "0.2" libc = "0.2"
+35 -12
View File
@@ -449,26 +449,49 @@ impl DisplayTracker {
} }
} }
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`, API 26 — /// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`,
/// under the minSdk-28 floor, so hard-linked via `ndk-sys`; the `ndk` wrapper has no binding, which /// **API 33** — "Available since Android T" per the NDK header; only the *Java* listener dates
/// is what the vendored crate's public `as_ptr` patch is for). Returns the userdata pointer holding /// back further). That sits above the API-28 floor, so the entry point is dlsym-resolved at
/// a leaked `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with /// runtime like [`try_set_frame_rate`] — hard-linking it (as 0.9.0 shipped) made
/// [`release_render_callback`] AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no /// `System.loadLibrary` fail on every pre-Android-13 device, taking down all of `NativeBridge`.
/// further callback can fire). `None` (nothing to reclaim) if the platform refused — the HUD then /// The `ndk` wrapper has no binding and the call needs the raw codec pointer, which is what the
/// simply has no `display` stage, exactly the pre-callback behaviour. /// vendored crate's public `as_ptr` patch is for. Returns the userdata pointer holding a leaked
/// `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with [`release_render_callback`]
/// AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no further callback can
/// fire). `None` (nothing to reclaim) if the symbol is absent (API < 33) or the platform refused —
/// the HUD then simply has no `display` stage, exactly the pre-callback behaviour.
fn install_render_callback( fn install_render_callback(
codec: &MediaCodec, codec: &MediaCodec,
tracker: &Arc<DisplayTracker>, tracker: &Arc<DisplayTracker>,
) -> Option<*const DisplayTracker> { ) -> Option<*const DisplayTracker> {
// media_status_t AMediaCodec_setOnFrameRenderedCallback(
// AMediaCodec*, AMediaCodecOnFrameRendered, void*) (API 33)
type SetOnFrameRenderedFn = unsafe extern "C" fn(
*mut ndk_sys::AMediaCodec,
ndk_sys::AMediaCodecOnFrameRendered,
*mut c_void,
) -> ndk_sys::media_status_t;
// SAFETY: `dlopen` of `libmediandk.so`, which the `ndk` media wrapper already links — always
// mapped, so this only bumps its refcount (never closed — process-lifetime handle). `dlsym`
// returns null when the symbol is absent (device below API 33), checked before transmuting the
// non-null pointer to its fn-pointer type.
let set_on_frame_rendered = unsafe {
let lib = libc::dlopen(c"libmediandk.so".as_ptr(), libc::RTLD_NOW);
if lib.is_null() {
return None;
}
let sym = libc::dlsym(lib, c"AMediaCodec_setOnFrameRenderedCallback".as_ptr());
if sym.is_null() {
log::info!("decode: no render callback on this API level (<33) — no display stage");
return None;
}
std::mem::transmute::<*mut c_void, SetOnFrameRenderedFn>(sym)
};
let ud = Arc::into_raw(tracker.clone()); let ud = Arc::into_raw(tracker.clone());
// SAFETY: `codec.as_ptr()` is the live codec this thread owns; `ud` outlives the registration // SAFETY: `codec.as_ptr()` is the live codec this thread owns; `ud` outlives the registration
// (reclaimed only after the codec is deleted, per this function's contract). // (reclaimed only after the codec is deleted, per this function's contract).
let status = unsafe { let status = unsafe {
ndk_sys::AMediaCodec_setOnFrameRenderedCallback( set_on_frame_rendered(codec.as_ptr(), Some(on_frame_rendered), ud as *mut c_void)
codec.as_ptr(),
Some(on_frame_rendered),
ud as *mut c_void,
)
}; };
if status == ndk_sys::media_status_t::AMEDIA_OK { if status == ndk_sys::media_status_t::AMEDIA_OK {
Some(ud) Some(ud)
+1 -1
View File
@@ -7,7 +7,7 @@ in the workspace root.
**The only change** is in `src/media/media_codec.rs`: `MediaCodec::as_ptr` is made **The only change** is in `src/media/media_codec.rs`: `MediaCodec::as_ptr` is made
`pub` (upstream keeps it private) so the Android client can register `pub` (upstream keeps it private) so the Android client can register
`AMediaCodec_setOnFrameRenderedCallback` through `ndk-sys` — the render-timestamp `AMediaCodec_setOnFrameRenderedCallback` through `ndk-sys` — the render-timestamp
callback behind the HUD's `display` stage (`design/stats-unification.md`), which the callback behind the HUD's `display` stage (punktfunk-planning: `stats-unification.md`), which the
wrapper doesn't expose. Grep for `punktfunk vendored patch` to find it. wrapper doesn't expose. Grep for `punktfunk vendored patch` to find it.
Drop this vendor copy when upstream exposes the raw pointer or a frame-rendered Drop this vendor copy when upstream exposes the raw pointer or a frame-rendered
+1 -1
View File
@@ -130,7 +130,7 @@ impl InputQueue {
looper.ptr().as_ptr(), looper.ptr().as_ptr(),
id, id,
None, None,
std::ptr::null_mut(), ptr::null_mut(),
) )
} }
} }
@@ -9,7 +9,11 @@ use std::{
slice, slice,
}; };
use crate::media_error::{MediaError, Result}; use crate::media_error::MediaError;
// `Result` is only referenced by the api-level-29 methods below; an ungated import warns
// (unused_imports) on every default-feature build.
#[cfg(feature = "api-level-29")]
use crate::media_error::Result;
/// A native [`AMediaFormat *`] /// A native [`AMediaFormat *`]
/// ///
+2 -1
View File
@@ -122,7 +122,8 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the - **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
every main push. See the script header for details. every main push. See the script header for details.
- Deeper design notes live in [`design/apple-stage2-presenter.md`](../../design/apple-stage2-presenter.md). - Deeper design notes live in the internal planning repo (punktfunk-planning:
`apple-stage2-presenter.md`).
## Related ## Related
@@ -31,8 +31,15 @@ struct ContentView: View {
@AppStorage(DefaultsKey.codec) private var codec = "auto" @AppStorage(DefaultsKey.codec) private var codec = "auto"
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true @AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true @AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true // The raw string is what @AppStorage observes (so cycles from any surface re-render this
// view); the absent-key default runs the legacy-hudEnabled migration once per init.
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
/// The persisted overlay tier (unknown raw falls back to .normal, like the migration).
private var statsVerbosity: StatsVerbosity {
StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
}
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto). /// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto).
private var preferredCodecByte: UInt8 { private var preferredCodecByte: UInt8 {
switch codec { switch codec {
@@ -393,10 +400,24 @@ struct ContentView: View {
displayMeter: model.displayStage displayMeter: model.displayStage
) )
.overlay(alignment: placement.alignment) { .overlay(alignment: placement.alignment) {
if captureEnabled && hudEnabled { // The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
StreamHUDView(model: model, connection: conn, placement: placement) // verbosity change keeps the same StreamHUDView identity, so its one shared glass
// card animates its frame/shape to the new tier (a morph) instead of cross-fading a
// fresh card in. The `.transition` therefore fires only on the offon boundary a
// scale-up (0.81) from the HUD's own corner. The ZStack is the stable host the
// `.animation` watches as the child enters/leaves and morphs.
ZStack {
if captureEnabled && statsVerbosity != .off {
StreamHUDView(
model: model, connection: conn, placement: placement,
verbosity: statsVerbosity)
.transition(
.scale(scale: 0.8, anchor: placement.unitPoint)
.combined(with: .opacity))
} }
} }
.animation(.smooth(duration: 0.28), value: statsVerbosity)
}
#if os(macOS) #if os(macOS)
// The start-of-stream shortcut banner (Windows-client parity): the full // The start-of-stream shortcut banner (Windows-client parity): the full
// reserved key set on a glass pill, bottom-centre, for the first 6 seconds of // reserved key set on a glass pill, bottom-centre, for the first 6 seconds of
@@ -422,17 +443,18 @@ struct ContentView: View {
} }
#endif #endif
#if os(iOS) #if os(iOS)
// Touch users have no menu / D, so when the HUD (and its Disconnect button) // Touch users have no menu / D, so when the HUD's Disconnect button isn't on
// is hidden, keep a minimal always-reachable exit in a corner. It rides a // screen the overlay off, or the compact pill (which carries no button)
// material disc (like the HUD) so the glyph stays legible over a bright frame // keep a minimal always-reachable exit in a corner. It rides a material disc
// this is the sole touch disconnect path when stats are off. // (like the HUD) so the glyph stays legible over a bright frame this is the
// sole touch disconnect path in those tiers.
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
if captureEnabled && !hudEnabled { if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
Button { model.disconnect() } label: { Button { model.disconnect() } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.headline.weight(.semibold)) .font(.headline.weight(.semibold))
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
// Sole touch exit when the HUD is off a floating glass disc // Sole touch exit in the off/compact tiers a floating glass disc
// over the frame (26+, material fallback). interactive: the disc // over the frame (26+, material fallback). interactive: the disc
// IS the tap target, so the glass reacts to press. // IS the tap target, so the glass reacts to press.
.glassBackground(Circle(), interactive: true) .glassBackground(Circle(), interactive: true)
@@ -23,6 +23,17 @@ enum HUDPlacement: String, CaseIterable, Identifiable {
/// The HUD's own stack hugs the screen edge it sits against, so its text aligns outward. /// The HUD's own stack hugs the screen edge it sits against, so its text aligns outward.
var isTrailing: Bool { self == .topTrailing || self == .bottomTrailing } var isTrailing: Bool { self == .topTrailing || self == .bottomTrailing }
/// The corner as a `UnitPoint`, so a scale transition grows/shrinks the HUD out of its own
/// corner rather than its centre.
var unitPoint: UnitPoint {
switch self {
case .topLeading: return .topLeading
case .topTrailing: return .topTrailing
case .bottomLeading: return .bottomLeading
case .bottomTrailing: return .bottomTrailing
}
}
/// User-facing corner label. /// User-facing corner label.
var label: String { var label: String {
switch self { switch self {
@@ -5,8 +5,9 @@
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While // (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows // input is CAPTURED these key equivalents never reach the menu (the stream view swallows
// keys); InputCapture's monitor detects the same combos there and performs the same actions // keys); InputCapture's monitor detects the same combos there and performs the same actions
// the menu covers the released state and discoverability. The stats toggle just flips the // the menu covers the released state and discoverability. The stats item cycles the shared
// shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts. // `statsVerbosity` tier (off compact normal detailed off); ContentView reads the same
// @AppStorage and reacts.
// //
// tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri // tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri
// Remote's Menu button, handled by ContentView's `.onExitCommand`), so this whole file is // Remote's Menu button, handled by ContentView's `.onExitCommand`), so this whole file is
@@ -36,12 +37,16 @@ extension FocusedValues {
struct StreamCommands: Commands { struct StreamCommands: Commands {
@FocusedValue(\.sessionFocus) private var session @FocusedValue(\.sessionFocus) private var session
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true // The raw string so @AppStorage observes the shared key; the absent-key default runs the
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= StatsVerbosity.current.rawValue
var body: some Commands { var body: some Commands {
CommandMenu("Stream") { CommandMenu("Stream") {
Button(hudEnabled ? "Hide Statistics" : "Show Statistics") { Button("Cycle Statistics") {
hudEnabled.toggle() let current = StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
statsVerbosityRaw = current.next().rawValue
} }
.keyboardShortcut("s", modifiers: [.control, .option, .shift]) .keyboardShortcut("s", modifiers: [.control, .option, .shift])
// Reaches the key window's stream view via NotificationCenter capture is view // Reaches the key window's stream view via NotificationCenter capture is view
@@ -1,17 +1,81 @@
// The streaming overlay HUD: mode + fps/throughput, the unified latency lines // The streaming overlay HUD, tiered by StatsVerbosity (the Android client's 3-tier semantics):
// (design/stats-unification.md end-to-end headline + the stage equation under stage-2, the // * compact one glass-pill line: fps · end-to-end p50 · throughput (+ loss when lossy);
// capturereceived headline under the stage-1 fallback), the loss counter, the platform input // * normal mode + fps/throughput, the unified latency HEADLINE (design/stats-unification.md
// hint, and disconnect. // end-to-end under stage-2, capturereceived under the stage-1 fallback), the loss
// counter, a capture hint (shown until input is captured), and disconnect;
// * detailed everything normal has plus the stage equation line(s) under the headline.
// `.off` never reaches this view (ContentView gates the overlay on the tier).
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct StreamHUDView: View { struct StreamHUDView: View {
@ObservedObject var model: SessionModel @ObservedObject var model: SessionModel
let connection: PunktfunkConnection let connection: PunktfunkConnection
var placement: HUDPlacement = .topTrailing var placement: HUDPlacement = .topTrailing
let verbosity: StatsVerbosity
var body: some View { var body: some View {
// .off is gated upstream (ContentView only mounts the HUD when the tier is on)
// render nothing if it ever slips through.
if verbosity != .off {
// ONE shared glass card wraps the tier-dependent content, so a verbosity change MORPHS
// this card its frame (and, on iOS, its clamped corner) animate to the new size rather
// than cross-fading a whole new card in. Only the inner content switches per tier.
tierContent
.padding(10)
.glassBackground(cardShape)
.padding(edgeInset)
}
}
/// The tier-dependent content, unwrapped (the shared card in `body` supplies the padding +
/// glass background). Compact is a one-line pill; normal/detailed the full stack.
@ViewBuilder private var tierContent: some View {
if verbosity == .compact {
compactContent
} else {
fullContent
}
}
// MARK: - Compact tier
/// One line: `{fps} fps · {e2e p50} ms · {mbps} Mb/s`. The ms segment is the best available
/// latency headline (stage-2 end-to-end, else the stage-1 capturereceived) and is omitted until
/// either is valid. Loss appends in the same quiet styling the full HUD's lost line uses.
private var compactContent: some View {
HStack(spacing: 6) {
Circle()
.fill(Color.accentColor)
.frame(width: 7, height: 7)
Text(compactLine)
.font(.system(.caption, design: .monospaced))
if model.lostFrames > 0 {
Text("· lost \(model.lostFrames)")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
private var compactLine: String {
var parts = ["\(model.fps) fps"]
if model.endToEndValid {
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
} else if model.hostNetworkValid {
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
}
parts.append(String(format: "%.1f Mb/s", model.mbps))
return parts.joined(separator: " · ")
}
// MARK: - Normal / detailed tiers
private var fullContent: some View {
VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) { VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) {
HStack(spacing: 6) { HStack(spacing: 6) {
Circle() Circle()
@@ -26,11 +90,11 @@ struct StreamHUDView: View {
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")") 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)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
// The equation: the stages tiling the headline interval (per-window p50s // The equation (detailed tier only): the stages tiling the headline interval
// they only approximately sum to the directly-measured total). With a host // (per-window p50s they only approximately sum to the directly-measured
// that reports per-AU timings (0xCF) the first term splits into host + network // total). With a host that reports per-AU timings (0xCF) the first term splits
// (phase 2); an old host keeps the combined term. // into host + network (phase 2); an old host keeps the combined term.
if model.hostNetworkValid && model.decodeValid && model.displayValid { if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
if model.splitValid { if model.splitValid {
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")") 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)) .font(.system(.caption2, design: .monospaced))
@@ -45,11 +109,12 @@ struct StreamHUDView: View {
// Stage-1 fallback presenter: the layer decodes + presents internally with no // Stage-1 fallback presenter: the layer decodes + presents internally with no
// per-frame stamp, so the honest headline ends at receipt. The host/network // per-frame stamp, so the honest headline ends at receipt. The host/network
// split still applies there (receipt is presenter-independent) it becomes the // split still applies there (receipt is presenter-independent) it becomes the
// only equation line; without it, host+network IS the whole measured interval. // only equation line (detailed tier); 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)")") Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")")
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if model.splitValid { if verbosity == .detailed && model.splitValid {
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")") 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)
@@ -63,22 +128,22 @@ struct StreamHUDView: View {
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
// While captured the cursor is hidden+frozen, so the button is keyboard-only // Capture hint, shown only until input is captured how to grab it. The RELEASE
// (Q the cross-client Ctrl+Alt+Shift+Q or /Cmd+Tab release the cursor; // shortcut is intentionally not surfaced in the overlay (it lives on the Stream menu
// released, it's clickable again). // and, on macOS, the start-of-stream banner), keeping the HUD uncluttered while playing.
#if os(macOS) #if os(macOS)
Text(model.mouseCaptured if !model.mouseCaptured {
? "⌃⌥⇧Q releases the mouse" Text("Click the stream to capture input")
: "Click the stream to capture input")
.font(.geist(11, relativeTo: .caption2)) .font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
}
#elseif os(iOS) #elseif os(iOS)
// Touch always plays directly; (hardware keyboard) toggles kb/mouse. // Touch always plays directly; (hardware keyboard) captures kb/mouse.
Text(model.mouseCaptured if !model.mouseCaptured {
? "⌘⎋ releases keyboard & mouse" Text("⌘⎋ captures keyboard & mouse")
: "⌘⎋ captures keyboard & mouse")
.font(.geist(11, relativeTo: .caption2)) .font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
}
#endif #endif
#if os(tvOS) #if os(tvOS)
// No focusable control during play: a focusable button steals the controller's // No focusable control during play: a focusable button steals the controller's
@@ -100,10 +165,58 @@ struct StreamHUDView: View {
#endif #endif
#endif #endif
} }
.padding(10) }
// Floating HUD over live video the canonical Liquid-Glass overlay surface (26+);
// falls back to .regularMaterial below 26 (see GlassStyle). // MARK: - Card metrics
.glassBackground(RoundedRectangle(cornerRadius: 10))
.padding(10) /// The OUTER gap between the card and the screen edge. (Inner content padding stays a fixed 10.)
/// On iOS the card hugs a physically rounded display corner, so it sits a little further in and
/// pairs with a concentric corner radius (below); on macOS/tvOS windows the classic 10 reads fine.
private var edgeInset: CGFloat {
#if os(iOS)
return 14
#else
return 10
#endif
}
/// The card's corner radius. On iOS it's concentric with the physical display corner
/// `displayCornerRadius edgeInset`, so the gap to the screen edge stays uniform right around the
/// corner instead of a small-radius card cutting into the very rounded glass. Clamped so a
/// flat-cornered device (or a hidden radius) still gets a sensibly rounded card.
private var cardCornerRadius: CGFloat {
#if os(iOS)
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
#else
return 10
#endif
}
/// The card background shape a continuous (squircle) rounded rectangle, matching the curve
/// Apple's hardware display corners use so the concentric inset actually reads as parallel.
private var cardShape: RoundedRectangle {
RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous)
} }
} }
#if os(iOS)
/// Device display geometry the overlay needs but UIKit doesn't expose publicly.
enum DeviceMetrics {
/// The physical display's corner radius. There's no public API for it, so read the private
/// `_displayCornerRadius` via KVC on the active window scene's screen, guarded by a fallback that
/// approximates a modern rounded device a future OS that hides the key just yields a slightly
/// less-perfect inset, never a crash. The key is assembled from parts so it isn't a plain literal
/// in the binary; note the App Store private-API consideration regardless.
static var displayCornerRadius: CGFloat {
let key = ["_display", "Corner", "Radius"].joined()
guard
let screen = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first?.screen,
let radius = screen.value(forKey: key) as? NSNumber,
radius.doubleValue > 0
else { return 44 }
return CGFloat(radius.doubleValue)
}
}
#endif
@@ -29,7 +29,10 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.enable444) private var enable444 = true @AppStorage(DefaultsKey.enable444) private var enable444 = true
@AppStorage(DefaultsKey.codec) private var codec = "auto" @AppStorage(DefaultsKey.codec) private var codec = "auto"
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true @AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true // The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@@ -271,10 +274,12 @@ struct GamepadSettingsView: View {
options: SettingsOptions.padTypes, current: gamepadType options: SettingsOptions.padTypes, current: gamepadType
) { gamepadType = $0 }, ) { gamepadType = $0 },
toggleRow( choiceRow(
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay", id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
detail: "Resolution, frame rate, throughput and latency while streaming.", detail: "How much to show while streaming — Compact is a one-line pill, "
value: $hudEnabled), + "Detailed adds the latency stage breakdown.",
options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw
) { statsVerbosityRaw = $0 },
choiceRow( choiceRow(
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position", id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
detail: "Which corner the statistics overlay sits in.", detail: "Which corner the statistics overlay sits in.",
@@ -37,6 +37,10 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] = static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) } HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) the `tag` is the raw value.
static let statsVerbosities: [(label: String, tag: String)] =
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
/// Video-codec preference (`DefaultsKey.codec`) a soft preference the host falls back from. /// Video-codec preference (`DefaultsKey.codec`) a soft preference the host falls back from.
/// AV1 appears only on devices with an AV1 hardware decoder (the same /// AV1 appears only on devices with an AV1 hardware decoder (the same
/// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) elsewhere it would be a /// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) elsewhere it would be a
@@ -337,28 +337,32 @@ extension SettingsView {
#endif #endif
} }
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter it // Stage-2 (Metal/VTDecompressionSession, present on frame arrival) is the proven default;
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a // stage-3 is the same pipeline with glass-gated present pacing a user-visible A/B while the
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like // pacing work settles (see Stage2Pipeline's PresentPacing for the queue-saturation rationale).
// the controller test. Empty in release builds (no presenter UI; stage-2 always). // Stage-1 (compressed video straight to the system layer) stays a DEBUG-only diagnostic it
// freezes hard on a lost HEVC reference.
@ViewBuilder var presenterSection: some View { @ViewBuilder var presenterSection: some View {
#if DEBUG
Section { Section {
Picker("Presenter", selection: $presenter) { Picker("Presenter", selection: $presenter) {
Text("Stage 2 (default)").tag("stage2") Text("Stage 2 (default)").tag("stage2")
Text("Stage 3 (experimental)").tag("stage3")
#if DEBUG
Text("Stage 1 (debug)").tag("stage1") Text("Stage 1 (debug)").tag("stage1")
#endif
} }
} header: { } header: {
Text("Video presenter · debug") Text("Video presenter")
} footer: { } footer: {
Text("Stage 2 (default): explicit decode + Metal present — full HUD latency " Text("Stage 2: each frame is shown the moment it's decoded — proven, but on displays "
+ "breakdown and self-recovery from decode stalls. Stage 1: compressed video " + "running near the stream's frame rate, queued frames can add two to three "
+ "straight to the system layer; freezes on a lost HEVC reference, so it's a " + "refreshes of display latency that never drains. Stage 3: presents are paced "
+ "debug fallback only. Applies from the next session.") + "to the display — at most one undisplayed frame in flight, always the freshest, "
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
+ "display time to compare. Applies from the next session.")
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
#endif
} }
@ViewBuilder var hdrSection: some View { @ViewBuilder var hdrSection: some View {
@@ -385,13 +389,17 @@ extension SettingsView {
@ViewBuilder var statisticsSection: some View { @ViewBuilder var statisticsSection: some View {
Section { Section {
Toggle("Show statistics overlay", isOn: $hudEnabled) Picker("Statistics overlay", selection: $statsVerbosityRaw) {
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
Text(tier.label).tag(tier.rawValue)
}
}
Picker("Position", selection: $hudPlacement) { Picker("Position", selection: $hudPlacement) {
ForEach(HUDPlacement.allCases) { placement in ForEach(HUDPlacement.allCases) { placement in
Text(placement.label).tag(placement.rawValue) Text(placement.label).tag(placement.rawValue)
} }
} }
.disabled(!hudEnabled) .disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
} header: { } header: {
Text("Statistics") Text("Statistics")
} footer: { } footer: {
@@ -54,10 +54,14 @@ extension SettingsView {
// MARK: - Statistics // MARK: - Statistics
static var statisticsFooter: String { static var statisticsFooter: String {
let base = "Shows resolution, frame rate, throughput and latency in the chosen " let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
+ "corner while streaming." + "pill, Normal adds resolution and latency, Detailed adds the latency stage "
#if os(macOS) || os(iOS) + "breakdown."
return base + " Toggle it any time with ⌃⌥⇧S." #if os(macOS)
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
#elseif os(iOS)
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
+ "any time."
#else #else
return base return base
#endif #endif
@@ -38,7 +38,9 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true @AppStorage(DefaultsKey.micEnabled) var micEnabled = true
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2 @AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
@AppStorage(DefaultsKey.codec) var codec = "auto" @AppStorage(DefaultsKey.codec) var codec = "auto"
@AppStorage(DefaultsKey.hudEnabled) var hudEnabled = true // The overlay tier's raw string (the pickers tag by rawValue); the absent-key default runs
// the legacy-hudEnabled migration (same pattern as ContentView/StreamCommands).
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
@ObservedObject var gamepads = GamepadManager.shared @ObservedObject var gamepads = GamepadManager.shared
#if !os(tvOS) #if !os(tvOS)
@@ -282,6 +284,19 @@ struct SettingsView: View {
("4K @ 60", "3840x2160x60"), ("4K @ 60", "3840x2160x60"),
] ]
/// Stage-2 vs stage-3 present pacing (see SettingsView+Sections' presenterSection for the
/// rationale); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
private static var presenterOptions: [(label: String, tag: String)] {
var options: [(label: String, tag: String)] = [
("Stage 2 (default)", "stage2"),
("Stage 3 (experimental)", "stage3"),
]
#if DEBUG
options.append(("Stage 1 (debug)", "stage1"))
#endif
return options
}
private var modeTag: Binding<String> { private var modeTag: Binding<String> {
Binding( Binding(
get: { "\(width)x\(height)x\(hz)" }, get: { "\(width)x\(height)x\(hz)" },
@@ -294,10 +309,6 @@ struct SettingsView: View {
}) })
} }
private var hudEnabledTag: Binding<String> {
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
}
private var hdrEnabledTag: Binding<String> { private var hdrEnabledTag: Binding<String> {
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" }) Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
} }
@@ -334,12 +345,10 @@ struct SettingsView: View {
TVSelectionRow( TVSelectionRow(
title: "Compositor", options: SettingsOptions.compositors, title: "Compositor", options: SettingsOptions.compositors,
selection: $compositor) selection: $compositor)
#if DEBUG
TVSelectionRow( TVSelectionRow(
title: "Presenter (debug)", title: "Presenter",
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")], options: Self.presenterOptions,
selection: $presenter) selection: $presenter)
#endif
TVSelectionRow( TVSelectionRow(
title: "10-bit HDR", title: "10-bit HDR",
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag) options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
@@ -352,7 +361,7 @@ struct SettingsView: View {
.padding(.top, 8) .padding(.top, 8)
TVSelectionRow( TVSelectionRow(
title: "Statistics overlay", title: "Statistics overlay",
options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag) options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
TVSelectionRow( TVSelectionRow(
title: "Statistics position", options: SettingsOptions.hudPlacements, title: "Statistics position", options: SettingsOptions.hudPlacements,
selection: $hudPlacement) selection: $hudPlacement)
@@ -113,11 +113,11 @@ public final class InputCapture {
/// keyDown monitor only WHILE FORWARDING that's the state in which the app's menu (which /// keyDown monitor only WHILE FORWARDING that's the state in which the app's menu (which
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the /// carries the same key equivalents for discoverability) can't see them, so the monitor is the
/// captured-state delivery path; released, the events pass through and the menu handles them. /// captured-state delivery path; released, the events pass through and the menu handles them.
/// Q releases the captured mouse/keyboard; D disconnects; S toggles the stats /// Q releases the captured mouse/keyboard; D disconnects; S cycles the stats
/// overlay. Main queue. /// overlay tier (off compact normal detailed). Main queue.
public var onReleaseCapture: (() -> Void)? public var onReleaseCapture: (() -> Void)?
public var onDisconnect: (() -> Void)? public var onDisconnect: (() -> Void)?
public var onToggleStats: (() -> Void)? public var onCycleStats: (() -> Void)?
/// Fired when a newer InputCapture takes the process-global GC handler slots (the /// Fired when a newer InputCapture takes the process-global GC handler slots (the
/// singletons hold ONE handler each): the preempted owner must drop its capture /// singletons hold ONE handler each): the preempted owner must drop its capture
@@ -246,7 +246,7 @@ public final class InputCapture {
return nil return nil
case 1 /* S */: case 1 /* S */:
self.suppressedVK = 0x53 self.suppressedVK = 0x53
self.onToggleStats?() self.onCycleStats?()
return nil return nil
default: default:
break break
@@ -2,7 +2,8 @@
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel // 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 // 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 // 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: // (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
// (off compact normal detailed, matching Android):
// //
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's // * 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 // relative delta with mild acceleration swipe to nudge, lift and re-swipe to walk it
@@ -164,7 +165,7 @@ final class TouchMouse {
} else if !moved { } else if !moved {
switch maxFingers { switch maxFingers {
case 3...: case 3...:
Self.toggleHUD() // in-stream stats-overlay toggle, same as Android Self.cycleStats() // in-stream stats-tier cycle, same as Android
case 2: // two-finger tap right click case 2: // two-finger tap right click
send?(.mouseButton(Button.right, down: true)) send?(.mouseButton(Button.right, down: true))
send?(.mouseButton(Button.right, down: false)) send?(.mouseButton(Button.right, down: false))
@@ -274,12 +275,11 @@ final class TouchMouse {
} }
} }
/// Three-finger tap toggles the stats overlay through the shared `hudEnabled` default, /// Three-finger tap cycles the stats overlay tiers (off compact normal detailed)
/// which the app's HUD views observe via @AppStorage (so this needs no wiring to them). /// through the shared `statsVerbosity` default, which the app's HUD views observe via
private static func toggleHUD() { /// @AppStorage (so this needs no wiring to them). Same cycle as Android's triple-tap.
let defaults = UserDefaults.standard private static func cycleStats() {
let on = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true StatsVerbosity.store(StatsVerbosity.current.next())
defaults.set(!on, forKey: DefaultsKey.hudEnabled)
} }
} }
#endif #endif
@@ -30,6 +30,11 @@ public enum DefaultsKey {
/// discrete channel, and the default Nstereo downmix grabs channels 0/1 (silence when the mic /// 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. /// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
public static let micChannel = "punktfunk.micChannel" public static let micChannel = "punktfunk.micChannel"
/// Which presenter runs a session: "stage2" (default explicit decode + Metal present on
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing the experimental
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
/// system-layer fallback). Resolved once per session by SessionPresenter;
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
public static let presenter = "punktfunk.presenter" public static let presenter = "punktfunk.presenter"
/// macOS: V-Sync the stream's presents each decoded frame flips on the next display vsync /// macOS: V-Sync the stream's presents each decoded frame flips on the next display vsync
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes /// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
@@ -67,9 +72,15 @@ public enum DefaultsKey {
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.
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming" public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
/// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle /// LEGACY (pre-tiered overlay): the old boolean stats-overlay toggle. Kept ONLY as the
/// while streaming with S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware keyboard). /// migration fallback `StatsVerbosity.current` reads when `statsVerbosity` was never
/// written (absent-or-true .normal, explicit false .off). Never written anymore.
public static let hudEnabled = "punktfunk.hudEnabled" public static let hudEnabled = "punktfunk.hudEnabled"
/// The statistics overlay tier a `StatsVerbosity` raw value ("off"/"compact"/"normal"/
/// "detailed"). Absent migrated from the legacy `hudEnabled` bool (see above). Cycle it
/// while streaming with S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware
/// keyboard) or a three-finger tap (touch), matching the Android client.
public static let statsVerbosity = "punktfunk.statsVerbosity"
/// Which corner the statistics overlay sits in a `HUDPlacement` raw value /// Which corner the statistics overlay sits in a `HUDPlacement` raw value
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing. /// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
public static let hudPlacement = "punktfunk.hudPlacement" public static let hudPlacement = "punktfunk.hudPlacement"
@@ -0,0 +1,58 @@
// The stats overlay's verbosity tier a port of the Android client's 3-tier overlay semantics
// so the two clients feel identical: Off Compact (one-line pill) Normal (headline stats)
// Detailed (plus the latency stage equation). Persisted under `DefaultsKey.statsVerbosity`;
// the in-stream cycle surfaces (S, the three-finger tap) advance it with
// `store(current.next())`, and every UI reader observes the same default via @AppStorage.
//
// Lives in PunktfunkKit (not the app) because the kit's input paths (TouchMouse's three-finger
// tap, InputCapture's captured-state S) cycle it directly.
import Foundation
/// How much of the streaming statistics overlay to show. The raw values are stable on disk
/// rename the cases freely, never the strings.
public enum StatsVerbosity: String, CaseIterable, Sendable {
case off, compact, normal, detailed
/// User-facing tier label (Settings pickers, the gamepad settings row).
public var label: String {
switch self {
case .off: return "Off"
case .compact: return "Compact"
case .normal: return "Normal"
case .detailed: return "Detailed"
}
}
/// The next tier in the cycle: off compact normal detailed off (wrapping)
/// the S / three-finger-tap order, same as Android.
public func next() -> StatsVerbosity {
switch self {
case .off: return .compact
case .compact: return .normal
case .normal: return .detailed
case .detailed: return .off
}
}
/// The persisted tier. When `statsVerbosity` has never been written, migrates from the
/// legacy `hudEnabled` bool the pre-tiered clients stored: absent-or-true `.normal`
/// (the old "on" look minus the equation lines), explicit false `.off`.
public static var current: StatsVerbosity {
let defaults = UserDefaults.standard
if let raw = defaults.string(forKey: DefaultsKey.statsVerbosity),
let tier = StatsVerbosity(rawValue: raw) {
return tier
}
if let legacy = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool, !legacy {
return .off
}
return .normal
}
/// Persist a tier (the cycle surfaces write through here; the Settings pickers write the
/// same key via @AppStorage).
public static func store(_ tier: StatsVerbosity) {
UserDefaults.standard.set(tier.rawValue, forKey: DefaultsKey.statsVerbosity)
}
}
@@ -22,6 +22,30 @@ public final class DisplayLinkProxy: NSObject {
@objc public func tick(_ link: CADisplayLink) { onTick(link) } @objc public func tick(_ link: CADisplayLink) { onTick(link) }
} }
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
/// glass-gated present pacing (`PresentPacing` see Stage2Pipeline for the tradeoff, and why
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
enum PresenterChoice: Equatable {
case stage1
case stage2
case stage3
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
/// falls back to stage-2. `allowStage1` is false in release builds, where a leftover DEBUG
/// "stage1" value silently maps to stage-2 rather than reviving the freeze-prone fallback.
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
switch raw {
case "stage1": return allowStage1 ? .stage1 : .stage2
case "stage3": return .stage3
default: return .stage2
}
}
}
final class SessionPresenter { final class SessionPresenter {
private var pump: StreamPump? private var pump: StreamPump?
private var stage2: Stage2Pipeline? private var stage2: Stage2Pipeline?
@@ -50,18 +74,24 @@ final class SessionPresenter {
// Presenter choice stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a // Presenter choice stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where // CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is // stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-3 is
// reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1 // the same pipeline with glass-gated present pacing (the settings picker's live A/B see
// pump below stays the automatic fallback if Metal is missing). // PresentPacing). Stage-1 is reachable only via the DEBUG presenter value; release maps it
// back to stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
#if DEBUG #if DEBUG
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1" let allowStage1 = true
#else #else
let forceStage1 = false let allowStage1 = false
#endif #endif
if !forceStage1, let choice = PresenterChoice.resolve(
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
allowStage1: allowStage1)
if choice != .stage1,
let pipeline = Stage2Pipeline( let pipeline = Stage2Pipeline(
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter, endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter) { displayMeter: displayMeter,
pacing: choice == .stage3 ? .glass : .arrival) {
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().
@@ -18,6 +18,10 @@
// V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one // V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
// period ahead by construction, falling back to immediate when the link data is stale a // period ahead by construction, falling back to immediate when the link data is stale a
// schedule can never sit far in the future holding drawables hostage. // schedule can never sit far in the future holding drawables hostage.
// Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
// frame arrival; stage-3 additionally gates presents to ONE undisplayed drawable so the layer's
// FIFO image queue can never saturate see PresentPacing's doc for the full rationale.
// Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI). // Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
// //
// The render thread also stamps the unified latency stages (end-to-end captureon-glass + decode and // The render thread also stamps the unified latency stages (end-to-end captureon-glass + decode and
@@ -98,6 +102,79 @@ private final class VsyncClock: @unchecked Sendable {
} }
} }
/// When a ready frame is pushed to the layer the stage-2 vs stage-3 presenter split. Same decode
/// half, same newest-wins ring; only the present cadence differs.
///
/// - `arrival` (stage-2, the default): present the moment a frame is decoded. Lowest latency while
/// the layer's image queue is shallow but that queue is FIFO and consumed at one drawable per
/// refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
/// presents the same way when composited), so at stream rate refresh rate its depth is STICKY:
/// one early burst (session start, a Wi-Fi clump) fills it to `maximumDrawableCount` and with
/// arrivals and latches then running at the same rate it never drains. Every later frame rides
/// ~23 refreshes of queue (the measured 2930 ms display stage on 120 Hz ProMotion panels), and
/// the full-queue regime is where hostpanel clock drift turns into periodic repeats/drops (the
/// "fixed-interval" jitter reports).
/// - `glass` (stage-3, experimental): at most ONE presented-but-undisplayed drawable in flight
/// (`PresentGate`). The render thread presents only when the previous flip reached glass (the
/// drawable's presented handler reopens the gate and re-signals); frames decoded meanwhile
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
/// present instead of queueing them behind the display the hidden queue latency becomes
/// explicit, correct frame drops.
public enum PresentPacing: Sendable {
case arrival
case glass
}
/// Stage-3's present gate: admits one in-flight (presented, not yet on glass) drawable. The render
/// thread `tryAcquire`s before taking a frame; the drawable's presented handler `release`s and
/// re-signals the render thread. `staleAfter` is insurance against a present whose handler never
/// fires (the macOS "out-of-band presents aren't damage" hazard class see MetalVideoPresenter's
/// init post-mortem): rather than freezing the stream, a stuck gate force-opens after 100 ms, a
/// visible ~10 fps degradation that PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0
/// on healthy systems). Internal (not private) for unit tests. Sendable; lock-guarded the
/// releaser runs on a Metal callback thread.
final class PresentGate: @unchecked Sendable {
/// How long one pending present may hold the gate before it's presumed lost.
static let staleAfter: CFTimeInterval = 0.1
private let lock = NSLock()
private var pending = false
private var armedAt: CFTimeInterval = 0
private var forced = 0
/// Arm the gate for one present. False = a present is already in flight (and not stale)
/// leave the frame in the ring; the presented handler's release/re-signal (or the next
/// display-link tick) retries with the freshest frame then.
func tryAcquire(now: CFTimeInterval) -> Bool {
lock.lock()
defer { lock.unlock() }
if pending {
guard now - armedAt > Self.staleAfter else { return false }
forced += 1 // presumed-lost present reopen rather than stall the stream
}
pending = true
armedAt = now
return true
}
/// The in-flight present reached glass (or was dropped, or its render failed before a present
/// was registered) reopen. Idempotent: a late stale-path double-release is harmless.
func release() {
lock.lock()
pending = false
lock.unlock()
}
/// Take-and-reset the force-open count (PUNKTFUNK_PRESENT_DEBUG's `forced` stat).
func drainForced() -> Int {
lock.lock()
defer { lock.unlock() }
let n = forced
forced = 0
return n
}
}
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with /// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
/// the decode rate, render outcomes, the slowest render call ( nextDrawable wait) and the deltas /// the decode rate, render outcomes, the slowest render call ( nextDrawable wait) and the deltas
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period /// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
@@ -105,22 +182,39 @@ private final class VsyncClock: @unchecked Sendable {
private final class PresentDebugStats: @unchecked Sendable { private final class PresentDebugStats: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var last = CACurrentMediaTime() private var last = CACurrentMediaTime()
private var ok = 0, failed = 0, empty = 0, dropped = 0 private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0
private var maxRenderMs = 0.0 private var maxRenderMs = 0.0
private var lastGlassNs: Int64 = 0 private var lastGlassNs: Int64 = 0
private var glassDeltasMs: [Double] = [] private var glassDeltasMs: [Double] = []
/// Presented-but-not-yet-on-glass drawables right now / the window's peak the direct
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 should peg it at 1).
private var inFlight = 0
private var maxInFlight = 0
func emptyWake() { lock.lock(); empty += 1; lock.unlock() } func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
/// A wake that found the stage-3 gate closed (a present still in flight) the frame stays in
/// the ring for the handler's re-signal. Includes display-link ticks while gated; a high count
/// is normal, it just shows the gate working.
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
func renderReturned(ok rendered: Bool, tookMs: Double) { func renderReturned(ok rendered: Bool, tookMs: Double) {
lock.lock() lock.lock()
if rendered { ok += 1 } else { failed += 1 } if rendered {
ok += 1
inFlight += 1
maxInFlight = max(maxInFlight, inFlight)
} else {
failed += 1
}
maxRenderMs = max(maxRenderMs, tookMs) maxRenderMs = max(maxRenderMs, tookMs)
lock.unlock() lock.unlock()
} }
func presented(atNs: Int64?) { func presented(atNs: Int64?) {
lock.lock() lock.lock()
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
if let atNs { if let atNs {
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) } if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
lastGlassNs = atNs lastGlassNs = atNs
@@ -130,7 +224,7 @@ private final class PresentDebugStats: @unchecked Sendable {
lock.unlock() lock.unlock()
} }
func flushIfDue(ring: ReadyRing) { func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
lock.lock() lock.lock()
let now = CACurrentMediaTime() let now = CACurrentMediaTime()
guard now - last >= 1 else { lock.unlock(); return } guard now - last >= 1 else { lock.unlock(); return }
@@ -139,12 +233,15 @@ private final class PresentDebugStats: @unchecked Sendable {
let deltas = glassDeltasMs.sorted() let deltas = glassDeltasMs.sorted()
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2] let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
let dMax = deltas.last ?? 0 let dMax = deltas.last ?? 0
let inflightMax = maxInFlight
let line = String( let line = String(
format: "pf-present decoded=%d ok=%d fail=%d empty=%d dropped=%d " format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
+ "maxRenderMs=%.1f glassDeltaMs p50=%.2f max=%.2f n=%d", + "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
decoded, ok, failed, empty, dropped, maxRenderMs, p50, dMax, deltas.count) decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
ok = 0; failed = 0; empty = 0; dropped = 0 gate?.drainForced() ?? 0, p50, dMax, deltas.count)
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
maxRenderMs = 0 maxRenderMs = 0
maxInFlight = inFlight // the window peak restarts from the live depth
glassDeltasMs.removeAll(keepingCapacity: true) glassDeltasMs.removeAll(keepingCapacity: true)
lock.unlock() lock.unlock()
print(line) print(line)
@@ -156,6 +253,9 @@ 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
/// Present cadence `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
private let pacing: PresentPacing
private let endToEndMeter: LatencyMeter? private let endToEndMeter: LatencyMeter?
private let displayMeter: LatencyMeter? private let displayMeter: LatencyMeter?
private let recovery = KeyframeRecovery() private let recovery = KeyframeRecovery()
@@ -190,14 +290,17 @@ public final class Stage2Pipeline {
/// (receiveddecoded); `displayMeter` the display stage (decodedon-glass, the ring wait + /// (receiveddecoded); `displayMeter` the display stage (decodedon-glass, the ring wait +
/// render + vsync the tail stage-2 exists to shorten). All optional: metering never gates /// render + vsync the tail stage-2 exists to shorten). All optional: metering never gates
/// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) caller /// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) caller
/// falls back to the stage-1 presenter. /// falls back to the stage-1 presenter. `pacing` selects the stage-2 (arrival) vs stage-3
/// (glass-gated) present cadence see PresentPacing.
public init?( public init?(
endToEndMeter: LatencyMeter?, endToEndMeter: LatencyMeter?,
decodeMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil displayMeter: LatencyMeter? = nil,
pacing: PresentPacing = .arrival
) { ) {
guard let presenter = MetalVideoPresenter.make() else { return nil } guard let presenter = MetalVideoPresenter.make() else { return nil }
self.presenter = presenter self.presenter = presenter
self.pacing = pacing
self.endToEndMeter = endToEndMeter self.endToEndMeter = endToEndMeter
self.displayMeter = displayMeter self.displayMeter = displayMeter
let ring = ring let ring = ring
@@ -327,16 +430,29 @@ public final class Stage2Pipeline {
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync)) && UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
let debugStats = presentDebug ? PresentDebugStats() : nil let debugStats = presentDebug ? PresentDebugStats() : nil
let vsyncClock = vsyncClock let vsyncClock = vsyncClock
// Stage-3's one-in-flight present gate; nil = stage-2's present-on-arrival. A local (like
// the ring) so neither the render thread nor the presented handlers capture `self`.
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
let renderThread = Thread { let renderThread = Thread {
defer { renderStopped.signal() } defer { renderStopped.signal() }
while !token.isStopped { while !token.isStopped {
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut { if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
debugStats?.flushIfDue(ring: ring) debugStats?.flushIfDue(ring: ring, gate: gate)
continue
}
// Stage-3: while a present is in flight, don't take from the ring at all frames
// keep coalescing there (newest wins, the intended drop point) and the presented
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
// frame is never bounced through putBack.
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
debugStats?.gatedWake()
debugStats?.flushIfDue(ring: ring, gate: gate)
continue continue
} }
guard !token.isStopped, let frame = ring.take() else { guard !token.isStopped, let frame = ring.take() else {
gate?.release() // armed but nothing to render don't hold the gate stale
debugStats?.emptyWake() debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring) debugStats?.flushIfDue(ring: ring, gate: gate)
continue continue
} }
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link // V-Sync ON: flip on the next predicted vsync (< one period out, stale link
@@ -347,6 +463,12 @@ public final class Stage2Pipeline {
let rendered = presenter.render( let rendered = presenter.render(
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
) { presentedNs in ) { presentedNs in
// Stage-3: the flip reached glass (or was dropped) free the present slot,
// then re-signal so the freshest waiting ring frame goes out immediately.
if let gate {
gate.release()
renderSignal.signal()
}
// Fallback stamp for a dropped drawable (no system presentedTime): "now" on // Fallback stamp for a dropped drawable (no system presentedTime): "now" on
// the Metal callback, converted to the CLOCK_REALTIME the meters live in. // the Metal callback, converted to the CLOCK_REALTIME the meters live in.
let atNs = presentedNs let atNs = presentedNs
@@ -361,8 +483,11 @@ public final class Stage2Pipeline {
} }
debugStats?.renderReturned( debugStats?.renderReturned(
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000) ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
if !rendered { ring.putBack(frame) } if !rendered {
debugStats?.flushIfDue(ring: ring) gate?.release() // no present registered its handler will never fire
ring.putBack(frame)
}
debugStats?.flushIfDue(ring: ring, gate: gate)
} }
} }
renderThread.name = "punktfunk-stage2-render" renderThread.name = "punktfunk-stage2-render"
@@ -594,13 +594,12 @@ public final class StreamLayerView: NSView {
guard let self, self.window?.isKeyWindow == true else { return } guard let self, self.window?.isKeyWindow == true else { return }
self.onDisconnectRequest?() self.onDisconnectRequest?()
} }
capture.onToggleStats = { [weak self] in capture.onCycleStats = { [weak self] in
guard self?.window?.isKeyWindow == true else { return } guard self?.window?.isKeyWindow == true else { return }
// Flip the shared setting directly every @AppStorage reader (the HUD's visibility, // Advance the shared tier setting directly every @AppStorage reader (the HUD's
// the menu item's title) observes UserDefaults, so this is the same as the menu path. // visibility/content, the Settings pickers) observes UserDefaults, so this is the
let defaults = UserDefaults.standard // same as the menu path.
let current = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true StatsVerbosity.store(StatsVerbosity.current.next())
defaults.set(!current, forKey: DefaultsKey.hudEnabled)
} }
capture.start() capture.start()
inputCapture = capture inputCapture = capture
@@ -0,0 +1,83 @@
import XCTest
#if canImport(Metal)
import QuartzCore
@testable import PunktfunkKit
/// Stage-3 present pacing: the one-in-flight `PresentGate` and the stage-1/2/3 `PresenterChoice`
/// resolution (setting + PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate).
final class PresentPacingTests: XCTestCase {
// MARK: - PresentGate
/// The core invariant: one present in flight. A second acquire while pending must fail (the
/// frame stays in the ring for the presented handler's re-signal); release reopens.
func testGateAdmitsOneInFlightPresent() {
let gate = PresentGate()
XCTAssertTrue(gate.tryAcquire(now: 0), "an idle gate must admit the first present")
XCTAssertFalse(gate.tryAcquire(now: 0.001), "a pending present must close the gate")
gate.release()
XCTAssertTrue(gate.tryAcquire(now: 0.002), "release must reopen the gate")
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
}
/// The lost-handler insurance: a present whose handler never fires (the macOS "presents
/// aren't damage" hazard class) must not freeze the stream past `staleAfter` the gate
/// force-opens and counts the event for the PUNKTFUNK_PRESENT_DEBUG `forced` stat.
func testGateForceOpensAfterStaleTimeout() {
let gate = PresentGate()
XCTAssertTrue(gate.tryAcquire(now: 10))
// Within the stale window the gate stays closed.
XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter - 0.01))
// Past it, the pending present is presumed lost: reopen, and count the force-clear.
XCTAssertTrue(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.01))
XCTAssertEqual(gate.drainForced(), 1)
XCTAssertEqual(gate.drainForced(), 0, "drain resets the counter")
}
/// Release is idempotent (a late stale-path double-release must be harmless), and an
/// acquire-then-release with no present (empty ring after arming) leaves the gate clean.
func testGateReleaseIsIdempotent() {
let gate = PresentGate()
XCTAssertTrue(gate.tryAcquire(now: 0))
gate.release()
gate.release() // the stale-cleared present's handler firing late
XCTAssertTrue(gate.tryAcquire(now: 0.001))
XCTAssertEqual(gate.drainForced(), 0)
}
// MARK: - PresenterChoice
func testPresenterChoiceDefaultsToStage2() {
XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true), .stage2)
XCTAssertEqual(
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true), .stage2)
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage2", env: nil, allowStage1: true), .stage2)
}
func testPresenterChoiceResolvesStage3FromSettingAndEnv() {
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage3", env: nil, allowStage1: true), .stage3)
// The env override wins over the persisted setting (A/B without touching settings)
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage2", env: "stage3", allowStage1: true), .stage3)
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage3", env: "stage2", allowStage1: true), .stage2)
// but an EMPTY env var is "unset", not an override.
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3)
}
/// Stage-1 (the freeze-prone system-layer diagnostic) resolves only where allowed (DEBUG
/// builds); a leftover "stage1" value in a release build maps back to stage-2.
func testPresenterChoiceGatesStage1() {
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: true), .stage1)
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false), .stage2)
XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2)
}
}
#endif
+2
View File
@@ -76,6 +76,8 @@ restart is required for an out-of-band install to appear.
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. | | `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/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/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. | | `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/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`. |
+13 -10
View File
@@ -1,17 +1,20 @@
# punktfunk — Linux client # punktfunk — Linux client
The native **Linux** app for streaming a punktfunk host to your desktop, laptop, or Steam Deck. The native **Linux** app for streaming a punktfunk host to your desktop, laptop, or Steam Deck.
It's a clean GTK4/libadwaita app that finds hosts on your network, pairs with a PIN, and puts a It's a clean relm4/GTK4/libadwaita **shell** that finds hosts on your network, pairs with a PIN,
low-latency stream on glass at your display's own resolution and refresh rate. and manages your settings and library — the stream itself runs in the sibling
**`punktfunk-session`** Vulkan binary ([`clients/session`](../session/README.md)), which the shell
spawns, putting the picture on glass at your display's own resolution and refresh rate.
Built in Rust, it links the shared **`punktfunk-core`** directly (no C ABI) and speaks the fast Built in Rust end to end (no C ABI): the shell shares its plumbing with the session binary through
**`crates/pf-client-core`**, which links the **`punktfunk-core`** protocol crate and speaks the fast
**`punktfunk/1`** protocol — QUIC control plane, GF(2¹⁶) FEC + AES-GCM data plane. **`punktfunk/1`** protocol — QUIC control plane, GF(2¹⁶) FEC + AES-GCM data plane.
## Features ## Features
- **Zero-copy hardware decode**FFmpeg VAAPI decode → DRM-PRIME dmabuf → `GdkDmabufTexture` - **Zero-copy hardware decode**the session presenter decodes via **Vulkan Video** on every GPU
(Tier-1 zero-copy on Intel and AMD), with an automatic software-HEVC fallback on NVIDIA or when vendor (including NVIDIA), falling back to FFmpeg VAAPI → DRM-PRIME dmabuf and then software when
VAAPI is unavailable. Vulkan Video is unavailable.
- **Your display's native mode** — the host builds a virtual output at exactly your WxH@Hz; no - **Your display's native mode** — the host builds a virtual output at exactly your WxH@Hz; no
scaling, no letterboxing. Steady 60 fps at 1080p60, ~6 ms capture→decoded on the LAN. scaling, no letterboxing. Steady 60 fps at 1080p60, ~6 ms capture→decoded on the LAN.
- **Audio both ways** — PipeWire playback with a jitter ring, plus mic uplink to the host. - **Audio both ways** — PipeWire playback with a jitter ring, plus mic uplink to the host.
@@ -26,10 +29,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 - **Gamepad library launcher** (`--browse host`) — a console-style, controller-driven library view
a paired host's library (drifting aurora backdrop, center-focus posters, button hints): A plays of a paired host's games, rendered by the session binary's Skia console UI: A plays the focused
the focused title, B quits, L1/R1 jump. Built for the Steam Deck plugin's "Open library" launch; title, B quits, L1/R1 jump. Built for the Steam Deck plugin's "Open library" launch; session end
session end returns to the launcher. Arrow keys/Enter/Esc drive it too (no pad needed). returns to the launcher. Arrow keys/Enter/Esc drive it too (no pad needed).
## Get it ## Get it
+1 -1
View File
@@ -670,7 +670,7 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
</child> </child>
<child> <child>
<object class="GtkShortcutsShortcut"> <object class="GtkShortcutsShortcut">
<property name="title">Toggle statistics overlay</property> <property name="title">Cycle the statistics overlay (off · compact · normal · detailed)</property>
<property name="accelerator">&lt;Control&gt;&lt;Alt&gt;&lt;Shift&gt;s</property> <property name="accelerator">&lt;Control&gt;&lt;Alt&gt;&lt;Shift&gt;s</property>
</object> </object>
</child> </child>
+17 -7
View File
@@ -3,6 +3,7 @@
use crate::trust::Settings; use crate::trust::Settings;
use adw::prelude::*; use adw::prelude::*;
use pf_client_core::trust::StatsVerbosity;
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::rc::Rc; use std::rc::Rc;
@@ -324,10 +325,13 @@ pub fn show(
"Software", "Software",
], ],
); );
let stats_row = adw::SwitchRow::builder() let stats_row = ChoiceRow::new(
.title("Show statistics overlay") &dialog,
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live") inline,
.build(); "Statistics overlay",
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
&["Off", "Compact", "Normal", "Detailed"],
);
let fullscreen_row = adw::SwitchRow::builder() let fullscreen_row = adw::SwitchRow::builder()
.title("Start streams in fullscreen") .title("Start streams in fullscreen")
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out") .subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
@@ -338,7 +342,7 @@ pub fn show(
stream.add(compositor_row.widget()); stream.add(compositor_row.widget());
stream.add(decoder_row.widget()); stream.add(decoder_row.widget());
stream.add(&fullscreen_row); stream.add(&fullscreen_row);
stream.add(&stats_row); stream.add(stats_row.widget());
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 connected // Which physical controller forwards as pad 0: automatic = the most recently connected
@@ -483,7 +487,11 @@ pub fn show(
compositor_row.set_selected(comp_i as u32); compositor_row.set_selected(comp_i as u32);
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); let stats_i = StatsVerbosity::ALL
.iter()
.position(|v| *v == s.stats_verbosity())
.unwrap_or(0);
stats_row.set_selected(stats_i as u32);
fullscreen_row.set_active(s.fullscreen_on_stream); 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);
@@ -509,7 +517,9 @@ pub fn show(
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.set_stats_verbosity(
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
);
s.fullscreen_on_stream = fullscreen_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();
+24
View File
@@ -145,11 +145,35 @@ fn load_or_create_identity() -> Result<(String, String)> {
let dir = std::path::PathBuf::from(home).join(".config/punktfunk"); let dir = std::path::PathBuf::from(home).join(".config/punktfunk");
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem")); let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) { if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
// Re-lock a store an older build left world-readable (this key is shared with the other
// clients' `~/.config/punktfunk/client-key.pem`); best-effort.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
let _ = std::fs::set_permissions(&kp, std::fs::Permissions::from_mode(0o600));
}
return Ok((c, k)); return Ok((c, k));
} }
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?; let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
std::fs::create_dir_all(&dir)?; std::fs::create_dir_all(&dir)?;
// The certificate is public; the key is the mTLS credential a paired host authorizes for full
// remote control, so it must not be world-readable — create it 0600 (a plain `fs::write`
// honors the umask → typically 0644).
std::fs::write(&cp, &c)?; std::fs::write(&cp, &c)?;
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&kp)?;
f.write_all(k.as_bytes())?;
}
#[cfg(not(unix))]
std::fs::write(&kp, &k)?; std::fs::write(&kp, &k)?;
tracing::info!(cert = %cp.display(), "generated client identity"); tracing::info!(cert = %cp.display(), "generated client identity");
Ok((c, k)) Ok((c, k))
+11 -7
View File
@@ -21,7 +21,8 @@ Reads the same identity / known-hosts / settings stores as the desktop client
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store). connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
Stdout is the machine interface: `{"ready":true}` after the first presented frame, Stdout is the machine interface: `{"ready":true}` after the first presented frame,
`stats: …` once per second (Ctrl+Alt+Shift+S toggles, `--stats` forces on), one `stats: …` once per second while the overlay tier isn't Off (always the full detailed
text, whatever the OSD shows; `--stats` forces the overlay on), one
`{"error"|"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: `0` `{"error"|"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: `0`
clean end, `2` connect failed, `3` trust rejected / pairing required, `4` presenter clean end, `2` connect failed, `3` trust rejected / pairing required, `4` presenter
init failed. init failed.
@@ -31,21 +32,24 @@ releases), Ctrl+Alt+Shift+D disconnects, F11 toggles fullscreen; the controller
chord (L1+R1+Start+Select, hold to disconnect) works the same. chord (L1+R1+Start+Select, hold to disconnect) works the same.
The default build carries the Skia console UI (`ui` feature): the stats OSD and capture The default build carries the Skia console UI (`ui` feature): the stats OSD and capture
hint render in-window (Ctrl+Alt+Shift+S toggles both the OSD and the stdout mirror). hint render in-window. Ctrl+Alt+Shift+S cycles the OSD tier live — Off → Compact (one
line: fps · latency · Mb/s) → Normal (mode + end-to-end percentiles) → Detailed (decoder
path + per-stage latency equation); any tier but Off also emits the stdout mirror.
`--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout `--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout
only, no Skia anywhere in the dependency tree. only, no Skia anywhere in the dependency tree.
Decode follows the Settings preference (auto: Vulkan Video → VAAPI → software): Decode follows the Settings preference (auto: Vulkan Video → VAAPI → software on Linux,
FFmpeg's Vulkan Video decoder runs on the presenter's own device where the stack Vulkan Video → D3D11VA → software on Windows): FFmpeg's Vulkan Video decoder runs on the
supports it (every vendor, zero copy); VAAPI dmabufs import per-plane elsewhere; presenter's own device where the stack supports it (every vendor, zero copy); VAAPI
software is the universal fallback. 10-bit Main10 and HDR10 are advertised dmabufs import per-plane elsewhere (D3D11VA textures on Windows); software is the
universal fallback. 10-bit Main10 and HDR10 are advertised
(`VIDEO_CAP_10BIT|HDR`): P010 decodes through all three paths, and PQ streams present (`VIDEO_CAP_10BIT|HDR`): P010 decodes through all three paths, and PQ streams present
on an HDR10/ST.2084 swapchain when the desktop offers one (KDE HDR, gamescope) or on an HDR10/ST.2084 swapchain when the desktop offers one (KDE HDR, gamescope) or
tone-map in-shader to SDR when it doesn't (`PUNKTFUNK_TONEMAP_PEAK` tunes the rolloff, tone-map in-shader to SDR when it doesn't (`PUNKTFUNK_TONEMAP_PEAK` tunes the rolloff,
default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10BIT` default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10BIT`
policy. policy.
Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|software`, `PUNKTFUNK_PRESENT_MODE= Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software`, `PUNKTFUNK_PRESENT_MODE=
mailbox|immediate` (default FIFO), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and mailbox|immediate` (default FIFO), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
`PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike `PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike
demotion to software on healthy hardware). demotion to software on healthy hardware).
+5 -1
View File
@@ -135,7 +135,11 @@ pub fn run(target: Option<&str>) -> u8 {
), ),
fullscreen: fullscreen_mode(), fullscreen: fullscreen_mode(),
window_pos: window_pos(), window_pos: window_pos(),
print_stats: settings_at_start.show_stats || arg_flag("--stats"), // `--stats` forces the overlay visible without demoting a richer chosen tier.
stats_verbosity: match settings_at_start.stats_verbosity() {
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v,
},
json_status, json_status,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| { on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
trust::touch_last_used(&trust::hex(&fingerprint)); trust::touch_last_used(&trust::hex(&fingerprint));
+6 -1
View File
@@ -322,7 +322,12 @@ mod session_main {
window_title: format!("Punktfunk · {title}"), window_title: format!("Punktfunk · {title}"),
fullscreen, fullscreen,
window_pos: window_pos(), window_pos: window_pos(),
print_stats: settings.show_stats || arg_flag("--stats"), // `--stats` forces the overlay visible (tooling/debug runs) without
// demoting an explicitly chosen richer tier.
stats_verbosity: match settings.stats_verbosity() {
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v,
},
json_status: true, json_status: true,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| { on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
// This host's card carries the accent bar in the desktop client now. // This host's card carries the accent bar in the desktop client now.
+2 -1
View File
@@ -26,7 +26,8 @@ the fast **`punktfunk/1`** protocol.
(probe burst over the real data plane → recommended bitrate, applied in one tap) and **forget**. (probe burst over the real data plane → recommended bitrate, applied in one tap) and **forget**.
- **Polished shell** — host cards, settings (resolution / refresh / host compositor / decoder / - **Polished shell** — host cards, settings (resolution / refresh / host compositor / decoder /
codec / bitrate / HDR / forwarded controller / gamepad type / system shortcuts / audio channels / codec / bitrate / HDR / forwarded controller / gamepad type / system shortcuts / audio channels /
mic), a status-chip stream HUD, and the full trust surface. Stream input uses Win32 low-level mic / stats-overlay level), the tiered stats overlay (Off / Compact / Normal / Detailed —
Ctrl+Alt+Shift+S cycles it live in the session window), and the full trust surface. Stream input uses Win32 low-level
hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream
re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host. re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host.
+4 -1
View File
@@ -19,7 +19,10 @@ const STREAM_SHORTCUTS: &[(&str, &str)] = &[
"Release captured input (click the stream to recapture)", "Release captured input (click the stream to recapture)",
), ),
("Ctrl+Alt+Shift+D", "Disconnect"), ("Ctrl+Alt+Shift+D", "Disconnect"),
("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"), (
"Ctrl+Alt+Shift+S",
"Cycle the statistics overlay (off \u{00B7} compact \u{00B7} normal \u{00B7} detailed)",
),
( (
"LB+RB+Start+Back", "LB+RB+Start+Back",
"Controller: release input / leave fullscreen \u{2014} hold to disconnect", "Controller: release input / leave fullscreen \u{2014} hold to disconnect",
+17 -9
View File
@@ -4,6 +4,7 @@
use super::style::*; use super::style::*;
use super::{AppCtx, Screen}; use super::{AppCtx, Screen};
use crate::trust::Settings; use crate::trust::Settings;
use pf_client_core::trust::StatsVerbosity;
use punktfunk_core::config::GamepadPref; use punktfunk_core::config::GamepadPref;
use std::sync::Arc; use std::sync::Arc;
use windows_reactor::*; use windows_reactor::*;
@@ -46,6 +47,14 @@ const GAMEPADS: &[(&str, &str)] = &[
("xboxone", "Xbox One"), ("xboxone", "Xbox One"),
("dualshock4", "DualShock 4"), ("dualshock4", "DualShock 4"),
]; ];
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
(StatsVerbosity::Off, "Off"),
(StatsVerbosity::Compact, "Compact"),
(StatsVerbosity::Normal, "Normal"),
(StatsVerbosity::Detailed, "Detailed"),
];
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to /// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host. /// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
const COMPOSITORS: &[(&str, &str)] = &[ const COMPOSITORS: &[(&str, &str)] = &[
@@ -328,15 +337,14 @@ pub(crate) fn settings_page(
) )
.tooltip("Sends the default microphone to the host's virtual mic source."); .tooltip("Sends the default microphone to the host's virtual mic source.");
let hud_toggle = setting_toggle( let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
ctx, let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
"Show the stats overlay (HUD)", s.set_stats_verbosity(STATS_TIERS[i].0);
s.show_stats, })
|s, on| s.show_stats = on,
)
.tooltip( .tooltip(
"The in-stream overlay: mode, codec, fps, bitrate, latency, decode path. \ "How much the in-stream overlay shows: Compact (fps \u{00B7} latency \u{00B7} bitrate \
Ctrl+Alt+Shift+S toggles it live while streaming.", in one line) \u{2192} Normal \u{2192} Detailed (decode path and per-stage latency). \
Ctrl+Alt+Shift+S cycles the tiers live while streaming.",
); );
let licenses_button = { let licenses_button = {
@@ -368,7 +376,7 @@ pub(crate) fn settings_page(
codec_combo.into(), codec_combo.into(),
bitrate_box.into(), bitrate_box.into(),
hdr_toggle.into(), hdr_toggle.into(),
hud_toggle.into(), hud_combo.into(),
]); ]);
controls controls
}), }),
+138 -3
View File
@@ -34,16 +34,61 @@ pub fn load_or_create_identity() -> Result<(String, String)> {
let dir = config_dir()?; let dir = config_dir()?;
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem")); let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) { if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
// An older build wrote the key with a plain `fs::write`, which honors the umask and
// typically lands 0644 — world-readable. Re-lock an existing store on load so upgrades
// get fixed, not just fresh installs. Best-effort (a read-only store keeps what it has).
#[cfg(unix)]
lock_identity_perms(&dir, &kp);
return Ok((c, k)); return Ok((c, k));
} }
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?; let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
std::fs::create_dir_all(&dir)?; std::fs::create_dir_all(&dir)?;
// The private key authorizes this client for full remote control of a paired host, so it must
// never be world-readable: lock the dir to the owner (0700) and create the key 0600 from the
// start (`fs::write` alone honors the umask → typically 0644). The certificate is public. On
// non-Unix the %APPDATA% profile ACL already scopes the dir to the user, so std perms suffice.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
}
std::fs::write(&cp, &c)?; std::fs::write(&cp, &c)?;
std::fs::write(&kp, &k)?; write_private_key(&kp, k.as_bytes())?;
tracing::info!(cert = %cp.display(), "generated client identity"); tracing::info!(cert = %cp.display(), "generated client identity");
Ok((c, k)) Ok((c, k))
} }
/// Write the client's mTLS private key owner-only. On Unix the file is created with mode 0600 from
/// the outset — an `fs::write` + later `chmod` would briefly expose it at the umask default. On
/// other platforms std's default perms plus the %APPDATA% profile ACL scope it to the user.
fn write_private_key(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(bytes)?;
}
#[cfg(not(unix))]
std::fs::write(path, bytes)?;
Ok(())
}
/// Best-effort re-lock of an already-present identity (dir 0700, key 0600) — for stores written by
/// an older build that left the key world-readable. Errors are ignored: the worst case is the
/// pre-existing perms, which this never loosens.
#[cfg(unix)]
fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600));
}
pub fn hex(fp: &[u8; 32]) -> String { pub fn hex(fp: &[u8; 32]) -> String {
fp.iter().map(|b| format!("{b:02x}")).collect() fp.iter().map(|b| format!("{b:02x}")).collect()
} }
@@ -254,6 +299,50 @@ pub fn probe_reachable_many(
.collect() .collect()
} }
/// How much the on-stream statistics overlay shows — the Android client's tiers, shared
/// across every client (design/stats-unification.md): each tier is a strict superset of
/// the previous. Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StatsVerbosity {
Off,
/// One glanceable line: fps · end-to-end ms · Mb/s.
Compact,
/// Stream mode plus the end-to-end latency percentiles and loss counters.
Normal,
/// Everything: decoder path, HDR tags, and the per-stage latency equation.
Detailed,
}
impl StatsVerbosity {
/// Cycle order (also the settings pickers' option order).
pub const ALL: [StatsVerbosity; 4] = [
StatsVerbosity::Off,
StatsVerbosity::Compact,
StatsVerbosity::Normal,
StatsVerbosity::Detailed,
];
/// The next tier in the live cycle, wrapping back to Off.
pub fn next(self) -> StatsVerbosity {
match self {
StatsVerbosity::Off => StatsVerbosity::Compact,
StatsVerbosity::Compact => StatsVerbosity::Normal,
StatsVerbosity::Normal => StatsVerbosity::Detailed,
StatsVerbosity::Detailed => StatsVerbosity::Off,
}
}
pub fn label(self) -> &'static str {
match self {
StatsVerbosity::Off => "Off",
StatsVerbosity::Compact => "Compact",
StatsVerbosity::Normal => "Normal",
StatsVerbosity::Detailed => "Detailed",
}
}
}
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time. /// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@@ -301,10 +390,16 @@ pub struct Settings {
/// `default = true`: the Linux stores never carried this and always advertised. /// `default = true`: the Linux stores never carried this and always advertised.
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub hdr_enabled: bool, pub hdr_enabled: bool,
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S). /// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept
/// `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted this as `show_hud`. /// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same
/// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted
/// this as `show_hud`.
#[serde(alias = "show_hud")] #[serde(alias = "show_hud")]
pub show_stats: bool, pub show_stats: bool,
/// Stats overlay tier. `None` = a pre-tier store; resolve through
/// [`Settings::stats_verbosity`], which falls back to `show_stats`.
#[serde(skip_serializing_if = "Option::is_none")]
pub stats_verbosity: Option<StatsVerbosity>,
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge /// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless. /// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
pub fullscreen_on_stream: bool, pub fullscreen_on_stream: bool,
@@ -322,6 +417,23 @@ fn default_true() -> bool {
} }
impl Settings { impl Settings {
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
pub fn stats_verbosity(&self) -> StatsVerbosity {
self.stats_verbosity.unwrap_or(if self.show_stats {
StatsVerbosity::Normal
} else {
StatsVerbosity::Off
})
}
/// Set the tier, keeping the legacy `show_stats` bool coherent for pre-tier
/// binaries that read the same settings file.
pub fn set_stats_verbosity(&mut self, v: StatsVerbosity) {
self.stats_verbosity = Some(v);
self.show_stats = v != StatsVerbosity::Off;
}
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto). /// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
pub fn preferred_codec(&self) -> u8 { pub fn preferred_codec(&self) -> u8 {
match self.codec.as_str() { match self.codec.as_str() {
@@ -351,6 +463,7 @@ impl Default for Settings {
adapter: String::new(), adapter: String::new(),
hdr_enabled: true, hdr_enabled: true,
show_stats: true, show_stats: true,
stats_verbosity: None,
fullscreen_on_stream: true, fullscreen_on_stream: true,
library_enabled: false, library_enabled: false,
} }
@@ -432,6 +545,28 @@ mod tests {
assert!(!s.library_enabled); assert!(!s.library_enabled);
} }
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
#[test]
fn stats_verbosity_migrates_and_round_trips() {
let mut s: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(s.stats_verbosity(), StatsVerbosity::Normal);
let off: Settings = serde_json::from_str(r#"{"show_stats":false}"#).unwrap();
assert_eq!(off.stats_verbosity(), StatsVerbosity::Off);
s.set_stats_verbosity(StatsVerbosity::Compact);
assert!(s.show_stats);
s.set_stats_verbosity(StatsVerbosity::Off);
assert!(!s.show_stats);
s.set_stats_verbosity(StatsVerbosity::Detailed);
let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(round.stats_verbosity(), StatsVerbosity::Detailed);
// The tier serializes lowercase — the file stays human-readable.
assert!(serde_json::to_string(&s).unwrap().contains("\"detailed\""));
}
/// The WinUI shell's known-hosts shape (no `last_used` field) loads losslessly — same /// The WinUI shell's known-hosts shape (no `last_used` field) loads losslessly — same
/// filename, same directory, so on Windows the two clients genuinely share the store. /// filename, same directory, so on Windows the two clients genuinely share the store.
#[test] #[test]
+13 -3
View File
@@ -10,6 +10,7 @@ use crate::screens::{Ctx, Outbox};
use crate::theme::{Fonts, DIM, W}; use crate::theme::{Fonts, DIM, W};
use crate::widgets::{ListMsg, MenuList, RowSpec}; use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use pf_client_core::trust::StatsVerbosity;
use skia_safe::{Canvas, Rect}; use skia_safe::{Canvas, Rect};
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale /// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
@@ -241,7 +242,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
RowId::Stats => ( RowId::Stats => (
Some("Interface"), Some("Interface"),
"Statistics overlay", "Statistics overlay",
on_off(s.show_stats).into(), s.stats_verbosity().label().into(),
), ),
}; };
RowSpec { RowSpec {
@@ -274,7 +275,10 @@ fn detail(id: RowId) -> &'static str {
RowId::Mic => "Send this device's microphone to the host's virtual mic.", RowId::Mic => "Send this device's microphone to the host's virtual mic.",
RowId::Pad => "Which pad is forwarded to the host, as player 1.", RowId::Pad => "Which pad is forwarded to the host, as player 1.",
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.", RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
RowId::Stats => "Resolution, frame rate, throughput and latency while streaming.", RowId::Stats => {
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
Ctrl+Alt+Shift+S cycles it live while streaming."
}
} }
} }
@@ -332,7 +336,13 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone()) step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
} }
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap), RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
RowId::Stats => toggle(&mut s.show_stats, delta, wrap), RowId::Stats => {
let cur = StatsVerbosity::ALL
.iter()
.position(|v| *v == s.stats_verbosity());
step_option(cur, StatsVerbosity::ALL.len(), delta, wrap)
.map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i]))
}
} }
.is_some() .is_some()
} }
+1 -1
View File
@@ -13,4 +13,4 @@ Defining every wire struct here — with `const` size/offset asserts and `bytemu
host↔driver ABI drift into a **compile error** instead of a silent frame or IOCTL corruption. host↔driver ABI drift into a **compile error** instead of a silent frame or IOCTL corruption.
See the crate root ([`src/`](src/)) for the wire types; the Windows virtual-display design is in See the crate root ([`src/`](src/)) for the wire types; the Windows virtual-display design is in
[`design/windows-virtual-display-rust-port.md`](../../design/windows-virtual-display-rust-port.md). the internal planning repo (punktfunk-planning: `windows-virtual-display-rust-port.md`).
+145 -14
View File
@@ -8,8 +8,10 @@
//! library; the app quits only on B/window-close). //! library; the app quits only on B/window-close).
//! //!
//! Stdout is the machine interface (the shell↔session contract): one `{"ready":true}` //! Stdout is the machine interface (the shell↔session contract): one `{"ready":true}`
//! line after the first presented frame, `stats: …` lines once per window while enabled //! line after the first presented frame, `stats: …` lines once per window while the
//! (Ctrl+Alt+Shift+S toggles). Logs go to stderr (the binary configures tracing so). //! overlay tier isn't Off (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed;
//! the stdout line always carries the full Detailed text so parsers see a stable
//! shape). Logs go to stderr (the binary configures tracing so).
use crate::input::Capture; use crate::input::Capture;
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase}; use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
@@ -17,6 +19,7 @@ use crate::vk::{FrameInput, Presenter};
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use pf_client_core::gamepad::GamepadService; use pf_client_core::gamepad::GamepadService;
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats}; use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
use pf_client_core::trust::StatsVerbosity;
use pf_client_core::video::VulkanDecodeDevice; use pf_client_core::video::VulkanDecodeDevice;
use pf_client_core::video::{DecodedFrame, DecodedImage}; use pf_client_core::video::{DecodedFrame, DecodedImage};
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
@@ -37,8 +40,9 @@ pub struct SessionOpts {
/// changing content, not a window jumping displays). Fullscreen follows the display /// changing content, not a window jumping displays). Fullscreen follows the display
/// this lands on. /// this lands on.
pub window_pos: Option<(i32, i32)>, pub window_pos: Option<(i32, i32)>,
/// Print `stats:` lines (Ctrl+Alt+Shift+S toggles live). /// Stats overlay tier at start — gates the OSD panel AND the stdout `stats:` lines
pub print_stats: bool, /// (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live).
pub stats_verbosity: StatsVerbosity,
/// Emit the `{"ready":true}` stdout line after the first presented frame. /// Emit the `{"ready":true}` stdout line after the first presented frame.
pub json_status: bool, pub json_status: bool,
/// Called once on `Connected` with the host's fingerprint (trust persistence is the /// Called once on `Connected` with the host's fingerprint (trust persistence is the
@@ -162,8 +166,11 @@ struct StreamState {
// all) demotes the decoder to software via the shared flag — once per session. // all) demotes the decoder to software via the shared flag — once per session.
dmabuf_demoted: bool, dmabuf_demoted: bool,
hw_fails: u32, hw_fails: u32,
/// The OSD's text (multi-line; rebuilt each Stats window). /// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
osd_text: String, osd_text: String,
/// The last pump window, kept so a Ctrl+Alt+Shift+S tier cycle can re-render the
/// OSD immediately instead of waiting up to 1 s for the next Stats event.
last_stats: Option<Stats>,
} }
impl StreamState { impl StreamState {
@@ -207,6 +214,7 @@ impl StreamState {
dmabuf_demoted: false, dmabuf_demoted: false,
hw_fails: 0, hw_fails: 0,
osd_text: String::new(), osd_text: String::new(),
last_stats: None,
} }
} }
@@ -351,7 +359,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
let mouse = sdl.mouse(); let mouse = sdl.mouse();
let mut fullscreen = opts.fullscreen; let mut fullscreen = opts.fullscreen;
let mut print_stats = opts.print_stats; let mut stats_verbosity = opts.stats_verbosity;
let mut overlay_frame: Option<OverlayFrame> = None; let mut overlay_frame: Option<OverlayFrame> = None;
// SDL text input tracks the overlay's editing state (started = IME/`TextInput` // SDL text input tracks the overlay's editing state (started = IME/`TextInput`
// events on desktop, and the door Steam's on-screen keyboard types through under // events on desktop, and the door Steam's on-screen keyboard types through under
@@ -452,7 +460,24 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
continue; continue;
} }
if chord && sc == Scancode::S { if chord && sc == Scancode::S {
print_stats = !print_stats; stats_verbosity = stats_verbosity.next();
tracing::info!(tier = ?stats_verbosity, "chord: stats verbosity");
// Re-render the OSD from the last window immediately — waiting
// for the next Stats event would lag the keypress by up to 1 s.
if let Some(st) = &mut stream {
let text = match &st.last_stats {
Some(s) => stats_text(
stats_verbosity,
&st.mode_line,
s,
&st.presented,
st.hdr,
presenter.hdr_active(),
),
None => String::new(),
};
st.osd_text = text;
}
continue; continue;
} }
// F11 or Alt+Enter (some keyboards' Fn layer sends a media key for // F11 or Alt+Enter (some keyboards' Fn layer sends a media key for
@@ -647,15 +672,27 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
} }
SessionEvent::Stats(s) => { SessionEvent::Stats(s) => {
st.osd_text = stats_text( st.osd_text = stats_text(
stats_verbosity,
&st.mode_line, &st.mode_line,
&s, &s,
&st.presented, &st.presented,
st.hdr, st.hdr,
presenter.hdr_active(), presenter.hdr_active(),
); );
if print_stats { if stats_verbosity != StatsVerbosity::Off {
println!("stats: {}", st.osd_text.replace('\n', " | ")); // The stdout line is the machine interface (shell status card,
// scripts) — always the full Detailed text, whatever the OSD tier.
let full = stats_text(
StatsVerbosity::Detailed,
&st.mode_line,
&s,
&st.presented,
st.hdr,
presenter.hdr_active(),
);
println!("stats: {}", full.replace('\n', " | "));
} }
st.last_stats = Some(s);
} }
SessionEvent::Failed { SessionEvent::Failed {
msg, msg,
@@ -728,7 +765,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
_ => None, _ => None,
}; };
( (
(print_stats && !st.osd_text.is_empty()).then_some(st.osd_text.as_str()), (stats_verbosity != StatsVerbosity::Off && !st.osd_text.is_empty())
.then_some(st.osd_text.as_str()),
hint, hint,
) )
} }
@@ -996,20 +1034,43 @@ const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift
const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \ const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave"; Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";
/// The unified stats window (design/stats-unification.md) as OSD text — multi-line for /// The unified stats window (design/stats-unification.md) as OSD text at the given tier
/// the console-UI panel; the stdout `stats:` line joins it with `|`. /// (the Android client's vocabulary, each a strict superset of the previous):
/// Compact = one glanceable line, Normal = mode + end-to-end percentiles + loss,
/// Detailed = decoder path, HDR tag and the per-stage equation on top. Off reads empty.
/// Multi-line for the console-UI panel; the stdout `stats:` line joins Detailed with `|`.
/// ///
/// The HDR tag is honest about the display path: `HDR` only when the swapchain actually /// The HDR tag is honest about the display path: `HDR` only when the swapchain actually
/// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10 /// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10
/// format offered, HDR off in the compositor) shows `HDR→SDR` instead. /// format offered, HDR off in the compositor) shows `HDR→SDR` instead.
fn stats_text( fn stats_text(
verbosity: StatsVerbosity,
mode_line: &str, mode_line: &str,
s: &Stats, s: &Stats,
p: &PresentedWindow, p: &PresentedWindow,
hdr_stream: bool, hdr_stream: bool,
hdr_display: bool, hdr_display: bool,
) -> String { ) -> String {
let mut text = format!( match verbosity {
StatsVerbosity::Off => return String::new(),
StatsVerbosity::Compact => {
// fps · e2e ms · Mb/s — the latency term waits for the first presenter
// window (0 = no capture→displayed samples yet).
let mut text = format!("{:.0} fps", s.fps);
if p.e2e_p50_ms > 0.0 {
text.push_str(&format!(" · {:.1} ms", p.e2e_p50_ms));
}
text.push_str(&format!(" · {:.0} Mb/s", s.mbps));
if s.lost > 0 {
text.push_str(&format!(" · lost {}", s.lost));
}
return text;
}
StatsVerbosity::Normal | StatsVerbosity::Detailed => {}
}
let detailed = verbosity == StatsVerbosity::Detailed;
let mut text = if detailed {
format!(
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}", "{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
s.fps, s.fps,
s.mbps, s.mbps,
@@ -1019,11 +1080,15 @@ fn stats_text(
(true, false) => " · HDR→SDR", (true, false) => " · HDR→SDR",
_ => "", _ => "",
}, },
); )
} else {
format!("{mode_line} · {:.0} fps · {:.1} Mb/s", s.fps, s.mbps)
};
text.push_str(&format!( text.push_str(&format!(
"\ne2e {:.1}/{:.1} ms (p50/p95)", "\ne2e {:.1}/{:.1} ms (p50/p95)",
p.e2e_p50_ms, p.e2e_p95_ms p.e2e_p50_ms, p.e2e_p95_ms
)); ));
if detailed {
if s.split { if s.split {
text.push_str(&format!(" · host {:.1} · net {:.1}", s.host_ms, s.net_ms)); text.push_str(&format!(" · host {:.1} · net {:.1}", s.host_ms, s.net_ms));
} else { } else {
@@ -1033,8 +1098,74 @@ fn stats_text(
" · decode {:.1} · display {:.1} ms", " · decode {:.1} · display {:.1} ms",
s.decode_ms, p.display_ms s.decode_ms, p.display_ms
)); ));
}
if s.lost > 0 { if s.lost > 0 {
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct)); text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
} }
text text
} }
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> (Stats, PresentedWindow) {
(
Stats {
fps: 119.6,
mbps: 24.3,
host_net_ms: 2.1,
host_ms: 1.2,
net_ms: 0.9,
split: true,
decode_ms: 1.8,
lost: 3,
lost_pct: 0.4,
decoder: "vulkan",
},
PresentedWindow {
e2e_p50_ms: 6.4,
e2e_p95_ms: 9.1,
display_ms: 1.1,
},
)
}
/// The tier ladder: Off is empty, Compact is one line, Normal adds the mode + e2e
/// lines but no stage terms or decoder tag, Detailed carries everything.
#[test]
fn stats_text_tiers() {
let (s, p) = sample();
let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false);
assert_eq!(text(StatsVerbosity::Off), "");
let compact = text(StatsVerbosity::Compact);
assert_eq!(compact, "120 fps · 6.4 ms · 24 Mb/s · lost 3");
assert_eq!(compact.lines().count(), 1);
let normal = text(StatsVerbosity::Normal);
assert!(normal.starts_with("1920×1080@120 · 120 fps · 24.3 Mb/s\n"));
assert!(normal.contains("e2e 6.4/9.1 ms (p50/p95)"));
assert!(normal.contains("lost 3 (0.4%)"));
assert!(!normal.contains("vulkan"), "decoder tag is Detailed-only");
assert!(!normal.contains("decode"), "stage terms are Detailed-only");
let detailed = text(StatsVerbosity::Detailed);
assert!(detailed.contains("vulkan · HDR→SDR"));
assert!(detailed.contains("host 1.2 · net 0.9 · decode 1.8 · display 1.1 ms"));
assert!(detailed.contains("lost 3 (0.4%)"));
}
/// Compact omits the latency term until the presenter's first e2e window lands.
#[test]
fn compact_waits_for_e2e() {
let (mut s, _) = sample();
s.lost = 0;
let p = PresentedWindow::default();
assert_eq!(
stats_text(StatsVerbosity::Compact, "m", &s, &p, false, false),
"120 fps · 24 Mb/s"
);
}
}
+1 -1
View File
@@ -57,5 +57,5 @@ bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-tri
- **[`punktfunk-host`](../punktfunk-host/README.md)** — the streaming host built on this core - **[`punktfunk-host`](../punktfunk-host/README.md)** — the streaming host built on this core
- **[Clients](../../clients/)** — the apps that link this core over the C ABI (or directly, in Rust) - **[Clients](../../clients/)** — the apps that link this core over the C ABI (or directly, in Rust)
- **[`design/implementation-plan.md`](../../design/implementation-plan.md)** — why GF(2¹⁶) FEC, the - **punktfunk-planning: `implementation-plan.md`** (internal planning repo) — why GF(2¹⁶) FEC, the
latency budget, and the architecture thesis latency budget, and the architecture thesis
+48 -2
View File
@@ -1242,6 +1242,7 @@ async fn worker_main(args: WorkerArgs) {
welcome.chroma_format, welcome.chroma_format,
welcome.audio_channels, welcome.audio_channels,
welcome.codec, welcome.codec,
welcome.host_caps,
)) ))
}; };
@@ -1261,6 +1262,7 @@ async fn worker_main(args: WorkerArgs) {
chroma_format, chroma_format,
audio_channels, audio_channels,
codec, codec,
host_caps,
) = match setup.await { ) = match setup.await {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
@@ -1282,12 +1284,56 @@ async fn worker_main(args: WorkerArgs) {
codec, codec,
))); )));
// Input task: embedder events → QUIC datagrams. // Input task: embedder events → QUIC datagrams. Toward a host that advertised
// HOST_CAP_GAMEPAD_STATE, the per-transition gamepad events every embedder still emits are
// folded into idempotent, sequence-numbered full-state snapshots (`GamepadSnapshot`): the
// datagram plane drops and reorders (and sheds oldest-first at the 4 KiB send cap), so a lost
// per-transition event would corrupt held pad state until the *next* change — a held trigger
// stuck wrong indefinitely. Snapshots heal on the next send, the seq lets the host drop stale
// reorders, and a periodic refresh of every touched pad bounds any loss to one refresh
// interval — the same idempotent-state discipline as the host's 500 ms rumble refresh.
// Keyboard/mouse/touch events pass through unchanged; an older host (no caps bit) keeps
// getting the legacy per-transition gamepad events.
let input_conn = conn.clone(); let input_conn = conn.clone();
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
tokio::spawn(async move { tokio::spawn(async move {
while let Some(ev) = input_rx.recv().await { use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
// Touched pads only: an entry appears on the first gamepad event for that index, so the
// refresh never conjures a virtual pad the embedder didn't drive.
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
let mut refresh = tokio::time::interval(Duration::from_millis(100));
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
ev = input_rx.recv() => {
let Some(ev) = ev else { break };
let idx = ev.flags as usize;
if gamepad_snapshots
&& matches!(ev.kind, InputKind::GamepadButton | InputKind::GamepadAxis)
&& idx < MAX_PADS
{
let snap = pads[idx].get_or_insert(GamepadSnapshot {
pad: idx as u8,
..Default::default()
});
// Unknown axis ids don't send (the host's legacy fold drops them too).
if snap.fold(&ev) {
snap.seq = snap.seq.wrapping_add(1);
let _ = input_conn
.send_datagram(snap.to_event().encode().to_vec().into());
}
continue;
}
let _ = input_conn.send_datagram(ev.encode().to_vec().into()); let _ = input_conn.send_datagram(ev.encode().to_vec().into());
} }
_ = refresh.tick() => {
for snap in pads.iter_mut().flatten() {
snap.seq = snap.seq.wrapping_add(1);
let _ = input_conn.send_datagram(snap.to_event().encode().to_vec().into());
}
}
}
}
}); });
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss). // Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
+196 -2
View File
@@ -40,6 +40,17 @@ pub enum InputKind {
TouchMove = 10, TouchMove = 10,
/// Touch ends. Only `code` (the touch id) is used. /// Touch ends. Only `code` (the touch id) is used.
TouchUp = 11, TouchUp = 11,
/// Full gamepad state in one event ([`GamepadSnapshot`]) — idempotent, sequence-numbered.
///
/// The per-transition [`GamepadButton`](Self::GamepadButton)/[`GamepadAxis`](Self::GamepadAxis)
/// events are fragile on the unreliable datagram plane: a dropped or reordered event corrupts
/// the host's accumulated pad state until the *next* change (a held trigger stays wrong
/// indefinitely). A snapshot carries the whole pad, so loss heals on the next send and the
/// sequence number lets the host drop stale reorders — the same idempotent-state discipline
/// as the host→client rumble refresh. Sent only when the host advertised
/// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep
/// receiving the per-transition events.
GamepadState = 12,
} }
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`]. /// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
@@ -111,11 +122,122 @@ impl InputKind {
9 => TouchDown, 9 => TouchDown,
10 => TouchMove, 10 => TouchMove,
11 => TouchUp, 11 => TouchUp,
12 => GamepadState,
_ => return None, _ => return None,
}) })
} }
} }
/// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
/// client's snapshot fold and the host's per-pad accumulators.
pub const MAX_PADS: usize = 16;
/// One pad's complete state, packed into a single [`InputKind::GamepadState`] event — the
/// whole 18-byte wire layout is reused, nothing is appended:
///
/// - `code` = `buttons` (the [`gamepad`] `BTN_*` bitmask, extended bits included)
/// - `x` = `ls_x << 16 | ls_y` (two i16 halves, wire stick convention: **+y = up**)
/// - `y` = `rs_x << 16 | rs_y`
/// - `flags` = `seq << 24 | left_trigger << 16 | right_trigger << 8 | pad`
///
/// `seq` is a per-pad wrapping u8, bumped on every send (changes *and* refreshes); the host
/// applies a snapshot only when `seq` is newer than the last applied one (wrapping i8
/// compare), so a datagram the network reordered can't roll held state backwards. The wrap
/// window (128 sends) dwarfs any real reorder window, and the client's periodic refresh
/// heals the pathological case anyway.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct GamepadSnapshot {
/// Pad index 0..[`MAX_PADS`].
pub pad: u8,
/// Wrapping send counter (see the type docs for the reorder gate).
pub seq: u8,
/// [`gamepad`] `BTN_*` bitmask.
pub buttons: u32,
/// Triggers 0..255 (the [`gamepad::AXIS_LT`]/[`gamepad::AXIS_RT`] convention).
pub left_trigger: u8,
pub right_trigger: u8,
/// Sticks 32768..32767, **+y = up** (the wire convention).
pub ls_x: i16,
pub ls_y: i16,
pub rs_x: i16,
pub rs_y: i16,
}
impl GamepadSnapshot {
/// Pack into the fixed [`InputEvent`] layout (kind = [`InputKind::GamepadState`]).
pub fn to_event(&self) -> InputEvent {
InputEvent {
kind: InputKind::GamepadState,
_pad: [0; 3],
code: self.buttons,
x: ((self.ls_x as u16 as i32) << 16) | (self.ls_y as u16 as i32),
y: ((self.rs_x as u16 as i32) << 16) | (self.rs_y as u16 as i32),
flags: ((self.seq as u32) << 24)
| ((self.left_trigger as u32) << 16)
| ((self.right_trigger as u32) << 8)
| (self.pad as u32),
}
}
/// Unpack from a [`InputKind::GamepadState`] event; `None` for any other kind.
pub fn from_event(ev: &InputEvent) -> Option<GamepadSnapshot> {
if ev.kind != InputKind::GamepadState {
return None;
}
Some(GamepadSnapshot {
pad: ev.flags as u8,
seq: (ev.flags >> 24) as u8,
buttons: ev.code,
left_trigger: (ev.flags >> 16) as u8,
right_trigger: (ev.flags >> 8) as u8,
ls_x: (ev.x >> 16) as i16,
ls_y: ev.x as i16,
rs_x: (ev.y >> 16) as i16,
rs_y: ev.y as i16,
})
}
/// Fold one per-transition [`GamepadButton`](InputKind::GamepadButton) /
/// [`GamepadAxis`](InputKind::GamepadAxis) event into this snapshot (`seq`/`pad` untouched).
/// `false` = not a foldable event / unknown axis id (snapshot unchanged).
pub fn fold(&mut self, ev: &InputEvent) -> bool {
match ev.kind {
InputKind::GamepadButton => {
if ev.x != 0 {
self.buttons |= ev.code;
} else {
self.buttons &= !ev.code;
}
true
}
InputKind::GamepadAxis => {
let stick = ev.x.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
let trigger = ev.x.clamp(0, 255) as u8;
match ev.code {
gamepad::AXIS_LS_X => self.ls_x = stick,
gamepad::AXIS_LS_Y => self.ls_y = stick,
gamepad::AXIS_RS_X => self.rs_x = stick,
gamepad::AXIS_RS_Y => self.rs_y = stick,
gamepad::AXIS_LT => self.left_trigger = trigger,
gamepad::AXIS_RT => self.right_trigger = trigger,
_ => return false,
}
true
}
_ => false,
}
}
/// True when `seq` supersedes `last` (wrapping u8 distance, forward window of 127) — the
/// host's reorder gate. `None` (nothing applied yet) always accepts.
pub fn seq_newer(seq: u8, last: Option<u8>) -> bool {
match last {
None => true,
Some(l) => (seq.wrapping_sub(l) as i8) > 0,
}
}
}
/// A single input event. `#[repr(C)]` — shared verbatim with the C ABI as /// A single input event. `#[repr(C)]` — shared verbatim with the C ABI as
/// `PunktfunkInputEvent`. /// `PunktfunkInputEvent`.
#[repr(C)] #[repr(C)]
@@ -199,7 +321,79 @@ mod tests {
}; };
assert_eq!(InputEvent::decode(&e.encode()), Some(e)); assert_eq!(InputEvent::decode(&e.encode()), Some(e));
} }
// 12 (one past TouchUp) is not a valid kind. // 13 (one past GamepadState) is not a valid kind.
assert_eq!(InputKind::from_u8(12), None); assert_eq!(InputKind::from_u8(13), None);
}
#[test]
fn gamepad_snapshot_roundtrip() {
let s = GamepadSnapshot {
pad: 3,
seq: 200,
buttons: gamepad::BTN_A | gamepad::BTN_PADDLE4 | gamepad::BTN_MISC1,
left_trigger: 255,
right_trigger: 1,
ls_x: -32768,
ls_y: 32767,
rs_x: -1,
rs_y: 12345,
};
let ev = s.to_event();
assert_eq!(ev.kind, InputKind::GamepadState);
// Survives the wire encode/decode unchanged.
let dec = InputEvent::decode(&ev.encode()).unwrap();
assert_eq!(GamepadSnapshot::from_event(&dec), Some(s));
// Non-snapshot kinds unpack to None.
let axis = InputEvent {
kind: InputKind::GamepadAxis,
_pad: [0; 3],
code: gamepad::AXIS_LT,
x: 255,
y: 0,
flags: 0,
};
assert_eq!(GamepadSnapshot::from_event(&axis), None);
}
#[test]
fn gamepad_snapshot_fold() {
let mut s = GamepadSnapshot::default();
let ev = |kind: InputKind, code: u32, x: i32| InputEvent {
kind,
_pad: [0; 3],
code,
x,
y: 0,
flags: 0,
};
// Button down/up sets and clears its bit.
assert!(s.fold(&ev(InputKind::GamepadButton, gamepad::BTN_A, 1)));
assert!(s.fold(&ev(InputKind::GamepadButton, gamepad::BTN_RB, 1)));
assert_eq!(s.buttons, gamepad::BTN_A | gamepad::BTN_RB);
assert!(s.fold(&ev(InputKind::GamepadButton, gamepad::BTN_A, 0)));
assert_eq!(s.buttons, gamepad::BTN_RB);
// Axes land in their slots; triggers clamp to 0..255, sticks to i16.
assert!(s.fold(&ev(InputKind::GamepadAxis, gamepad::AXIS_LT, 300)));
assert_eq!(s.left_trigger, 255);
assert!(s.fold(&ev(InputKind::GamepadAxis, gamepad::AXIS_LS_Y, -40000)));
assert_eq!(s.ls_y, i16::MIN);
// Unknown axis / unrelated kind leave the snapshot untouched.
assert!(!s.fold(&ev(InputKind::GamepadAxis, 99, 1)));
assert!(!s.fold(&ev(InputKind::KeyDown, 30, 1)));
}
#[test]
fn gamepad_snapshot_seq_gate() {
// First snapshot always applies.
assert!(GamepadSnapshot::seq_newer(0, None));
// Strictly newer within the forward window applies; equal/older doesn't.
assert!(GamepadSnapshot::seq_newer(6, Some(5)));
assert!(!GamepadSnapshot::seq_newer(5, Some(5)));
assert!(!GamepadSnapshot::seq_newer(4, Some(5)));
// Wraps: 2 supersedes 250 (forward distance 8), not the reverse.
assert!(GamepadSnapshot::seq_newer(2, Some(250)));
assert!(!GamepadSnapshot::seq_newer(250, Some(2)));
// Exactly half the window away is treated as stale (i8 > 0 excludes -128).
assert!(!GamepadSnapshot::seq_newer(133, Some(5)));
} }
} }
+27 -1
View File
@@ -137,6 +137,13 @@ pub const QUIT_CLOSE_CODE: u32 = 0x51;
/// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree. /// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
pub const APP_EXITED_CLOSE_CODE: u32 = 0x52; pub const APP_EXITED_CLOSE_CODE: u32 = 0x52;
/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
/// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
/// lossy datagram plane, periodically refreshed) instead of the fragile per-transition
/// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
pub const HOST_CAP_GAMEPAD_STATE: u8 = 0x01;
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** /// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST /// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
/// advertise this. /// advertise this.
@@ -317,6 +324,11 @@ pub struct Welcome {
/// HEVC). Appended after `audio_channels` as a single trailing byte; an older host that omits it /// HEVC). Appended after `audio_channels` as a single trailing byte; an older host that omits it
/// decodes to [`CODEC_HEVC`] (every pre-negotiation host sent HEVC). /// decodes to [`CODEC_HEVC`] (every pre-negotiation host sent HEVC).
pub codec: u8, pub codec: u8,
/// Host input capabilities — a bitfield of [`HOST_CAP_GAMEPAD_STATE`]. The client picks the
/// wire form its gamepad events take from this (snapshots for a capable host, the legacy
/// per-transition events otherwise). Appended after `codec` as a single trailing byte; an
/// older host that omits it decodes to `0` (no capabilities — legacy events only).
pub host_caps: u8,
} }
/// `client → host`: data plane is bound, begin streaming. /// `client → host`: data plane is bound, begin streaming.
@@ -949,6 +961,8 @@ impl Welcome {
b.push(self.audio_channels); b.push(self.audio_channels);
// Resolved video codec at offset 66 — older clients stop before this → HEVC. // Resolved video codec at offset 66 — older clients stop before this → HEVC.
b.push(self.codec); b.push(self.codec);
// Host input caps at offset 67 — older clients stop before this → 0 (legacy input only).
b.push(self.host_caps);
b b
} }
@@ -1031,6 +1045,9 @@ impl Welcome {
Some(CODEC_AV1) => CODEC_AV1, Some(CODEC_AV1) => CODEC_AV1,
_ => CODEC_HEVC, _ => CODEC_HEVC,
}, },
// Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state
// snapshots; the client keeps sending legacy per-transition events).
host_caps: b.get(67).copied().unwrap_or(0),
}) })
} }
@@ -2228,6 +2245,7 @@ mod tests {
chroma_format: CHROMA_IDC_444, chroma_format: CHROMA_IDC_444,
audio_channels: 2, audio_channels: 2,
codec: CODEC_H264, // exercise a non-default codec through the roundtrip codec: CODEC_H264, // exercise a non-default codec through the roundtrip
host_caps: HOST_CAP_GAMEPAD_STATE,
}; };
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w); assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
} }
@@ -2329,6 +2347,7 @@ mod tests {
chroma_format: CHROMA_IDC_420, chroma_format: CHROMA_IDC_420,
audio_channels: 2, audio_channels: 2,
codec: CODEC_H264, codec: CODEC_H264,
host_caps: 0,
} }
.encode(), .encode(),
) )
@@ -2526,9 +2545,10 @@ mod tests {
chroma_format: CHROMA_IDC_444, chroma_format: CHROMA_IDC_444,
audio_channels: 6, // 5.1 — exercises the non-default trailing byte audio_channels: 6, // 5.1 — exercises the non-default trailing byte
codec: CODEC_HEVC, codec: CODEC_HEVC,
host_caps: HOST_CAP_GAMEPAD_STATE,
}; };
let wenc = w.encode(); let wenc = w.encode();
assert_eq!(wenc.len(), 67); // 60 base + 4 colour + 1 chroma + 1 audio-channels + 1 codec byte assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps
let legacy_w = Welcome::decode(&wenc[..53]).unwrap(); let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
assert_eq!(legacy_w.compositor, CompositorPref::Auto); assert_eq!(legacy_w.compositor, CompositorPref::Auto);
assert_eq!(legacy_w.gamepad, GamepadPref::Auto); assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
@@ -2571,6 +2591,12 @@ mod tests {
CHROMA_IDC_444 CHROMA_IDC_444
); // full form carries 4:4:4 ); // full form carries 4:4:4
assert_eq!(Welcome::decode(&wenc).unwrap().audio_channels, 6); // ...and 5.1 assert_eq!(Welcome::decode(&wenc).unwrap().audio_channels, 6); // ...and 5.1
// A pre-host-caps (67-byte) Welcome → 0 (legacy input only); the full form carries the bit.
assert_eq!(Welcome::decode(&wenc[..67]).unwrap().host_caps, 0);
assert_eq!(
Welcome::decode(&wenc).unwrap().host_caps,
HOST_CAP_GAMEPAD_STATE
);
} }
#[test] #[test]
+201 -5
View File
@@ -28,15 +28,18 @@ pub struct Frame {
/// One end of a stream. Constructed for a single [`Role`]; calling the other role's /// One end of a stream. Constructed for a single [`Role`]; calling the other role's
/// methods returns [`PunktfunkError::InvalidArg`]. /// methods returns [`PunktfunkError::InvalidArg`].
/// ///
/// Note: the AEAD layer authenticates each datagram but does **not** provide anti-replay. /// Anti-replay: the receive path runs each opened datagram's AEAD-authenticated sequence through a
/// Video replays are largely absorbed by the reassembler's per-frame dedup, but replayed /// sliding-window filter ([`ReplayWindow`]), so a captured, validly-sealed datagram can't be replayed
/// input events are not yet filtered. A sliding-window replay filter keyed on the /// by an on-path attacker — closing the input-replay gap that previously rested solely on the
/// authenticated sequence belongs with the pairing/handshake layer (the GameStream host); until then, /// LAN/VPN transport assumption (plan §1). Genuine reordering within the window is still accepted;
/// rely on the LAN/VPN transport assumption (plan §1). /// video additionally benefits from the reassembler's per-frame dedup.
pub struct Session { pub struct Session {
config: Config, config: Config,
coder: Box<dyn ErasureCoder>, coder: Box<dyn ErasureCoder>,
crypto: Option<SessionCrypto>, crypto: Option<SessionCrypto>,
/// Anti-replay window over the peer's authenticated sequence (receive side). `Some` exactly when
/// `crypto` is — the plaintext probe path carries no sequence to filter on.
replay: Option<ReplayWindow>,
transport: Box<dyn Transport>, transport: Box<dyn Transport>,
packetizer: Packetizer, packetizer: Packetizer,
reassembler: Reassembler, reassembler: Reassembler,
@@ -68,11 +71,15 @@ impl Session {
let crypto = config let crypto = config
.encrypt .encrypt
.then(|| SessionCrypto::new(&config.key, config.salt, config.role)); .then(|| SessionCrypto::new(&config.key, config.salt, config.role));
// A receive-side replay window exists exactly when the datagrams are sealed (they carry the
// authenticated sequence the window keys on). Both roles receive from their peer.
let replay = config.encrypt.then(ReplayWindow::new);
let packetizer = Packetizer::new(&config); let packetizer = Packetizer::new(&config);
let reassembler = Reassembler::new(ReassemblerLimits::from_config(&config)); let reassembler = Reassembler::new(ReassemblerLimits::from_config(&config));
Ok(Session { Ok(Session {
coder, coder,
crypto, crypto,
replay,
transport, transport,
packetizer, packetizer,
reassembler, reassembler,
@@ -130,6 +137,16 @@ impl Session {
} }
} }
/// Feed an opened datagram's authenticated sequence to the anti-replay window: `true` = fresh
/// (accept), `false` = a replay or older than the window (drop). Returns `true` when the session
/// isn't encrypting (no window, and no sequence on the wire to key on).
fn accept_seq(&mut self, seq: u64) -> bool {
match self.replay.as_mut() {
Some(w) => w.accept(seq),
None => true,
}
}
// -- Host path -------------------------------------------------------- // -- Host path --------------------------------------------------------
/// Host: FEC-protect, packetize, and seal one encoded access unit into wire packets WITHOUT /// Host: FEC-protect, packetize, and seal one encoded access unit into wire packets WITHOUT
@@ -225,6 +242,13 @@ impl Session {
Ok(p) => p, Ok(p) => p,
Err(_) => continue, // drop undecryptable noise Err(_) => continue, // drop undecryptable noise
}; };
// Anti-replay: a captured input datagram replayed by an on-path attacker opens cleanly
// (its sequence + tag are still valid) — the window is what rejects the second copy.
// `len >= 8` is guaranteed because the sealed-path open above succeeded.
if self.replay.is_some() && !self.accept_seq(seq_of(&wire)) {
StatsCounters::add(&self.stats.packets_dropped, 1);
continue;
}
StatsCounters::add(&self.stats.packets_received, 1); StatsCounters::add(&self.stats.packets_received, 1);
if let Some(ev) = InputEvent::decode(&pkt) { if let Some(ev) = InputEvent::decode(&pkt) {
return Ok(Some(ev)); return Ok(Some(ev));
@@ -276,6 +300,13 @@ impl Session {
Ok(p) => p, Ok(p) => p,
Err(_) => continue, Err(_) => continue,
}; };
// Anti-replay (same rationale as poll_input): reject a datagram whose authenticated
// sequence was already seen. Video also dedups per-frame downstream, but filtering here
// is uniform and cheap. `len >= 8` because the sealed-path open above succeeded.
if self.replay.is_some() && !self.accept_seq(seq_of(&self.recv_scratch[i][..len])) {
StatsCounters::add(&self.stats.packets_dropped, 1);
continue;
}
StatsCounters::add(&self.stats.packets_received, 1); StatsCounters::add(&self.stats.packets_received, 1);
StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64); StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64);
// The reassembler validates the packet via its parsed header (`magic`), // The reassembler validates the packet via its parsed header (`magic`),
@@ -347,3 +378,168 @@ impl Session {
Ok(()) Ok(())
} }
} }
/// Extract the AEAD-authenticated 8-byte big-endian sequence prefix from a sealed wire datagram.
/// Only called on the encrypted receive path, where a preceding successful open has already
/// established `wire.len() >= 8`.
fn seq_of(wire: &[u8]) -> u64 {
u64::from_be_bytes(wire[..8].try_into().unwrap())
}
/// Depth of the anti-replay window, in sequences. The sender advances its sequence once per
/// datagram, so at the data plane's packet rate 4096 is roughly 33 ms of reorder tolerance for the
/// video stream (well beyond any reordering still useful for a live frame) and effectively unbounded
/// for the sparse input stream — while bounding how far back a replay could hide.
const REPLAY_WINDOW: u64 = 4096;
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts
/// its datagrams from 0, and the protocol never legitimately re-sends a sequence (FEC recovery
/// shards get fresh ones), so a sequence seen twice is a replay. The AEAD tag already authenticates
/// the sequence — a forged one can't open — so this only has to reject *duplicates* of validly
/// sealed datagrams (and anything older than the window, which we can no longer prove is fresh).
/// Genuine reordering within the window is accepted. Bitmap-per-sequence, indexed `seq % WINDOW`.
struct ReplayWindow {
/// Highest sequence accepted so far; `seen` stays false until the first datagram.
highest: u64,
seen: bool,
/// One bit per in-window sequence in `(highest - WINDOW, highest]`.
bits: [u64; REPLAY_WORDS],
}
impl ReplayWindow {
fn new() -> ReplayWindow {
ReplayWindow {
highest: 0,
seen: false,
bits: [0; REPLAY_WORDS],
}
}
#[inline]
fn word_bit(seq: u64) -> (usize, u64) {
let idx = (seq % REPLAY_WINDOW) as usize;
(idx / 64, 1u64 << (idx % 64))
}
fn is_set(&self, seq: u64) -> bool {
let (w, b) = Self::word_bit(seq);
self.bits[w] & b != 0
}
fn set(&mut self, seq: u64) {
let (w, b) = Self::word_bit(seq);
self.bits[w] |= b;
}
fn unset(&mut self, seq: u64) {
let (w, b) = Self::word_bit(seq);
self.bits[w] &= !b;
}
/// Record `seq`, returning `true` if it's fresh (accept) or `false` if it's a replay / too old.
fn accept(&mut self, seq: u64) -> bool {
if !self.seen {
self.seen = true;
self.highest = seq;
self.set(seq);
return true;
}
if seq > self.highest {
// Advance the window. Sequences between the old and new high slide in unseen, so clear
// their (possibly stale, from a full window ago) slots — unless we jumped an entire
// window, in which case wipe the bitmap wholesale.
if seq - self.highest >= REPLAY_WINDOW {
self.bits = [0; REPLAY_WORDS];
} else {
let mut s = self.highest + 1;
while s < seq {
self.unset(s);
s += 1;
}
}
self.highest = seq;
self.set(seq);
true
} else if self.highest - seq >= REPLAY_WINDOW || self.is_set(seq) {
// Older than the window (can't prove it isn't a replay) or already seen (a duplicate) —
// either way, drop it.
false
} else {
self.set(seq); // in-window and not yet seen — a genuine reorder
true
}
}
}
#[cfg(test)]
mod replay_tests {
use super::*;
#[test]
fn accepts_in_order_and_rejects_duplicates() {
let mut w = ReplayWindow::new();
for seq in 0..1000 {
assert!(w.accept(seq), "fresh in-order seq {seq} must be accepted");
}
// Every one of those is now a replay.
for seq in 0..1000 {
assert!(!w.accept(seq), "replayed seq {seq} must be rejected");
}
}
#[test]
fn accepts_reorder_within_window_once() {
let mut w = ReplayWindow::new();
assert!(w.accept(100));
// Earlier-but-in-window sequences (a genuine reorder) are accepted exactly once.
assert!(w.accept(80));
assert!(!w.accept(80), "second copy of a reordered seq is a replay");
assert!(w.accept(99));
assert!(
!w.accept(100),
"the high-water seq itself can't be replayed"
);
}
#[test]
fn rejects_older_than_window() {
let mut w = ReplayWindow::new();
assert!(w.accept(REPLAY_WINDOW * 2));
// Anything a full window or more behind the high-water mark is dropped (can't prove fresh).
assert!(!w.accept(REPLAY_WINDOW * 2 - REPLAY_WINDOW));
assert!(!w.accept(0));
// But just inside the window is still accepted.
assert!(w.accept(REPLAY_WINDOW * 2 - (REPLAY_WINDOW - 1)));
}
#[test]
fn large_forward_jump_wipes_stale_bits() {
let mut w = ReplayWindow::new();
assert!(w.accept(5));
// Jump far forward (more than a window). The slot for an old seq that aliases 5 mod WINDOW
// must read as unseen afterward, i.e. the jump cleared it — so a NEW seq there is accepted.
let far = 10 * REPLAY_WINDOW + 5;
assert!(w.accept(far));
assert!(
!w.accept(5),
"the pre-jump seq is now far older than the window"
);
// A fresh seq aliasing 5 (mod WINDOW) but inside the new window is accepted, proving the
// stale bit was cleared rather than mistaken for a replay.
assert!(w.accept(far - REPLAY_WINDOW + 1));
}
#[test]
fn first_seq_need_not_be_zero() {
// Startup loss can mean the first datagram we ever open isn't seq 0.
let mut w = ReplayWindow::new();
assert!(w.accept(42));
assert!(!w.accept(42));
assert!(w.accept(43));
}
#[test]
fn seq_of_reads_the_big_endian_prefix() {
let mut wire = 0x0102_0304_0506_0708u64.to_be_bytes().to_vec();
wire.extend_from_slice(b"ciphertext-and-tag");
assert_eq!(seq_of(&wire), 0x0102_0304_0506_0708);
}
}
+2 -2
View File
@@ -73,7 +73,7 @@ src/
vdisplay/ per-compositor virtual outputs (kwin · gamescope · mutter · wlroots) vdisplay/ per-compositor virtual outputs (kwin · gamescope · mutter · wlroots)
capture/ · capture.rs screen/dmabuf capture (+ Windows IDD-push) capture/ · capture.rs screen/dmabuf capture (+ Windows IDD-push)
encode/ · encode.rs per-GPU encoders (nvenc · vaapi · ffmpeg_win (AMF/QSV) · sw) encode/ · encode.rs per-GPU encoders (nvenc · vaapi · ffmpeg_win (AMF/QSV) · sw)
zerocopy/ dmabuf → CUDA → NVENC bridges (EGL/GL tiled, Vulkan LINEAR) linux/zerocopy/ dmabuf → CUDA → NVENC bridges (EGL/GL tiled, Vulkan LINEAR)
inject/ · inject.rs input backends (libei · wlr · uinput gamepads · UHID DualSense/DS4) inject/ · inject.rs input backends (libei · wlr · uinput gamepads · UHID DualSense/DS4)
audio/ · audio.rs Opus out + virtual mic (PipeWire / WASAPI) audio/ · audio.rs Opus out + virtual mic (PipeWire / WASAPI)
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
@@ -88,4 +88,4 @@ src/
- **[`punktfunk-core`](../punktfunk-core/README.md)** — the shared protocol · FEC · crypto core - **[`punktfunk-core`](../punktfunk-core/README.md)** — the shared protocol · FEC · crypto core
- **[Clients](../../clients/)** — the apps that connect (Apple · Linux · Windows · Android · probe) - **[Clients](../../clients/)** — the apps that connect (Apple · Linux · Windows · Android · probe)
- **[Packaging](../../packaging/README.md)** & **[docs](https://docs.punktfunk.unom.io)** — install & operate - **[Packaging](../../packaging/README.md)** & **[docs](https://docs.punktfunk.unom.io)** — install & operate
- **[`design/`](../../design/README.md)** — architecture rationale and deep-dive plans - **punktfunk-planning** (internal planning repo) — architecture rationale and deep-dive plans
@@ -760,6 +760,56 @@ impl IddPushCapturer {
target: WinCaptureTarget, target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
client_10bit: bool, client_10bit: bool,
) -> Result<Self> {
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
// ADD, so on a healthy box they agree, and NVENC gets a device on a real GPU adapter.
// (`target.adapter_luid` is NOT that adapter: the ADD reply carries
// `IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` = the IddCx DISPLAY adapter — verified
// on-glass; it stays a last-resort fallback for a pickerless box only.) When the pick and
// the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and
// this open, or a stale kept monitor across an adapter re-init — the driver reports
// TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that.
let luid = crate::win_adapter::resolve_render_adapter_luid().unwrap_or(LUID {
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
HighPart: (target.adapter_luid >> 32) as i32,
});
match Self::open_on(target.clone(), preferred, client_10bit, luid) {
Ok(me) => Ok(me),
Err(e) => {
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
// adapter its swap-chain ACTUALLY renders on (a stale monitor across an adapter
// re-init, or a driver that ignored SET_RENDER_ADAPTER). Rebinding the ring to that
// adapter beats failing the session — the outer pipeline retries would repeat the
// exact same mismatch.
let driver_luid = e
.downcast_ref::<AttachTexFail>()
.map(|tf| tf.driver_luid)
.filter(|d| *d != 0 && *d != crate::capture::dxgi::pack_luid(luid));
let Some(packed) = driver_luid else {
return Err(e);
};
let drv = LUID {
LowPart: (packed & 0xffff_ffff) as u32,
HighPart: (packed >> 32) as i32,
};
tracing::warn!(
ring_adapter = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
driver_adapter = format!("{:08x}:{:08x}", drv.HighPart, drv.LowPart),
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
driver's reported adapter"
);
Self::open_on(target, preferred, client_10bit, drv)
.context("IDD-push rebind to the driver's reported render adapter")
}
}
}
fn open_on(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
luid: LUID,
) -> Result<Self> { ) -> Result<Self> {
let (pw, ph, _hz) = preferred let (pw, ph, _hz) = preferred
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?; .context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
@@ -829,10 +879,10 @@ impl IddPushCapturer {
} else { } else {
DXGI_FORMAT_B8G8R8A8_UNORM DXGI_FORMAT_B8G8R8A8_UNORM
}; };
// Create our device on the discrete render GPU (where NVENC runs); the driver must render // Our device (ring + zero-copy NVENC) lives on `luid` — the selected render GPU per
// the swap-chain on the SAME adapter for the shared textures to open (it reports its actual // `open_inner`; the driver must render the swap-chain on the SAME adapter for the
// render LUID into the header so we can detect a mismatch). // shared textures to open (it reports its actual render LUID into the header, which
let luid = resolve_render_adapter_luid_or(target.adapter_luid); // `open_inner` uses to rebind once if this mismatches).
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?; let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
let adapter: IDXGIAdapter1 = factory let adapter: IDXGIAdapter1 = factory
.EnumAdapterByLuid(luid) .EnumAdapterByLuid(luid)
@@ -991,13 +1041,31 @@ impl IddPushCapturer {
// diagnostics — the real handshake is the atomic `magic`/`latest` (same access as // diagnostics — the real handshake is the atomic `magic`/`latest` (same access as
// log_driver_status_once). // log_driver_status_once).
let st = unsafe { (*self.header).driver_status }; let st = unsafe { (*self.header).driver_status };
if matches!(st, DRV_STATUS_TEX_FAIL | DRV_STATUS_NO_DEVICE1) { if st == DRV_STATUS_TEX_FAIL {
// SAFETY: as above — in-bounds, aligned word reads of best-effort diagnostic fields
// through the owned, live header mapping; no reference into the shared region is formed.
// The driver wrote its render LUID BEFORE attempting the texture opens
// (frame_transport.rs step 2), so it is valid here.
let (detail, lo, hi) = unsafe {
(
(*self.header).driver_status_detail,
(*self.header).driver_render_luid_low,
(*self.header).driver_render_luid_high,
)
};
// Typed so `open_inner` can rebind the ring to the driver's adapter once.
return Err(anyhow::Error::new(AttachTexFail {
detail,
driver_luid: ((hi as i64) << 32) | (lo as i64 & 0xffff_ffff),
}));
}
if st == DRV_STATUS_NO_DEVICE1 {
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field // SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
// through the owned, live header mapping; no reference into the shared region is formed. // through the owned, live header mapping; no reference into the shared region is formed.
let detail = unsafe { (*self.header).driver_status_detail }; let detail = unsafe { (*self.header).driver_status_detail };
bail!( bail!(
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \ "IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
render-adapter mismatch?)" the driver has no ID3D11Device1 to open shared resources)"
); );
} }
// Attached AND a frame has been published — the publish token's seq advances past 0. // Attached AND a frame has been published — the publish token's seq advances past 0.
@@ -1415,17 +1483,31 @@ impl IddPushCapturer {
} }
} }
/// The selected render GPU LUID (where the encoder runs), falling back to the monitor's `OsAdapterLuid`. /// `wait_for_attach`'s DRV_STATUS_TEX_FAIL as a typed error: the driver could not open the ring
fn resolve_render_adapter_luid_or(fallback_packed: i64) -> LUID { /// textures, and `driver_luid` (packed, from the shared header) is the adapter its swap-chain
if let Some(l) = crate::win_adapter::resolve_render_adapter_luid() { /// ACTUALLY renders on — `open_inner` downcasts to this to rebind the ring there once.
return l; #[derive(Debug)]
} struct AttachTexFail {
LUID { detail: u32,
LowPart: (fallback_packed & 0xffff_ffff) as u32, driver_luid: i64,
HighPart: (fallback_packed >> 32) as i32, }
impl std::fmt::Display for AttachTexFail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"IDD-push driver failed to attach (driver_status={DRV_STATUS_TEX_FAIL} \
detail=0x{:08x}): it could not open the ring textures its swap-chain renders on \
adapter {:08x}:{:08x}, not the ring's (render-adapter mismatch)",
self.detail,
(self.driver_luid >> 32) as i32,
(self.driver_luid & 0xffff_ffff) as u32,
)
} }
} }
impl std::error::Error for AttachTexFail {}
impl Capturer for IddPushCapturer { impl Capturer for IddPushCapturer {
fn next_frame(&mut self) -> Result<CapturedFrame> { fn next_frame(&mut self) -> Result<CapturedFrame> {
let deadline = Instant::now() + Duration::from_secs(20); let deadline = Instant::now() + Duration::from_secs(20);
+9
View File
@@ -68,6 +68,13 @@ pub struct HostConfig {
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the /// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
/// shipped `host.env` and as a forward seam if a second backend is ever added. /// shipped `host.env` and as a forward seam if a second backend is ever added.
pub vdisplay: Option<String>, pub vdisplay: Option<String>,
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
/// or reboot). Typically `sudo -n systemctl restart gdm` with a matching NOPASSWD sudoers rule, or
/// `systemctl restart display-manager` under a polkit rule — with auto-login enabled the restart brings
/// the desktop back and the client's retry lands in it. Unset/empty = disabled (the default).
pub recover_session_cmd: Option<String>,
} }
impl HostConfig { impl HostConfig {
@@ -101,6 +108,8 @@ impl HostConfig {
compositor: val("PUNKTFUNK_COMPOSITOR"), compositor: val("PUNKTFUNK_COMPOSITOR"),
gamepad: val("PUNKTFUNK_GAMEPAD"), gamepad: val("PUNKTFUNK_GAMEPAD"),
vdisplay: val("PUNKTFUNK_VDISPLAY"), vdisplay: val("PUNKTFUNK_VDISPLAY"),
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
.filter(|s| !s.trim().is_empty()),
} }
} }
} }
+9 -1
View File
@@ -91,7 +91,8 @@ impl GpuInfo {
} }
/// Assign the stable `id` + `occurrence` fields after enumeration (occurrence = index among /// Assign the stable `id` + `occurrence` fields after enumeration (occurrence = index among
/// same-(vendor,device) twins, in enumeration order). /// same-(vendor,device) twins, in inventory order — Windows sorts the inventory by LUID first so
/// twin numbering is stable for the boot, see [`enumerate`]).
fn assign_ids(gpus: &mut [GpuInfo]) { fn assign_ids(gpus: &mut [GpuInfo]) {
for i in 0..gpus.len() { for i in 0..gpus.len() {
let occ = gpus[..i] let occ = gpus[..i]
@@ -268,6 +269,13 @@ pub(crate) fn enumerate() -> Vec<GpuInfo> {
}); });
} }
} }
// Deterministic inventory order. DXGI enumerates the adapter owning the primary display first,
// and the vdisplay flow itself MOVES the primary mid-session (exclusive mode turns the physical
// screens off) — which flipped every order-relative selection between IDENTICAL twin GPUs from
// one call to the next (the max-VRAM tie, the env-substring first match, occurrence numbering):
// monitor ADD pinned the driver to one twin, the ring open picked the other → TEX_FAIL. LUIDs
// are creation-ordered and stable for the boot, so sorting by them makes selection stable too.
out.sort_by_key(|g| ((g.handle.luid_high as i64) << 32) | g.handle.luid_low as i64);
assign_ids(&mut out); assign_ids(&mut out);
out out
} }
@@ -425,7 +425,7 @@ impl InputInjector for KwinFakeInjector {
self.fake.touch_frame(); self.fake.touch_frame();
} }
// Gamepads are injected through uinput, not the compositor. // Gamepads are injected through uinput, not the compositor.
InputKind::GamepadButton | InputKind::GamepadAxis => {} InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {}
} }
// Surface protocol errors / disconnects, then push the batch to the compositor. // Surface protocol errors / disconnects, then push the batch to the compositor.
self.queue self.queue
@@ -403,6 +403,7 @@ fn kind_bit(kind: InputKind) -> u32 {
InputKind::TouchUp => 9, InputKind::TouchUp => 9,
InputKind::GamepadButton => 10, InputKind::GamepadButton => 10,
InputKind::GamepadAxis => 11, InputKind::GamepadAxis => 11,
InputKind::GamepadState => 12,
}; };
1 << i 1 << i
} }
@@ -545,7 +546,7 @@ impl EiState {
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => { InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {
DeviceCapability::Touch DeviceCapability::Touch
} }
InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later) InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
}; };
self.injected += 1; self.injected += 1;
let n = self.injected; let n = self.injected;
@@ -692,7 +693,9 @@ impl EiState {
Some(t) => t.up(ev.code), Some(t) => t.up(ev.code),
None => emitted = false, None => emitted = false,
}, },
InputKind::GamepadButton | InputKind::GamepadAxis => emitted = false, InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {
emitted = false
}
} }
if emitted { if emitted {
@@ -254,7 +254,7 @@ impl InputInjector for WlrootsInjector {
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped"); tracing::debug!(vk = event.code, "unmapped VK keycode — dropped");
} }
} }
InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
// wlroots has no virtual-touch protocol wired here; touch is the libei path only. // wlroots has no virtual-touch protocol wired here; touch is the libei path only.
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {} InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
} }
@@ -297,9 +297,10 @@ impl InputInjector for SendInputInjector {
}; };
self.send(&[key(ki)]) self.send(&[key(ki)])
} }
// Gamepad goes through ViGEm (separate backend). Touch: no SendInput equivalent -> no-op. // Gamepad goes through the XUSB backend. Touch: no SendInput equivalent -> no-op.
InputKind::GamepadButton InputKind::GamepadButton
| InputKind::GamepadAxis | InputKind::GamepadAxis
| InputKind::GamepadState
| InputKind::TouchDown | InputKind::TouchDown
| InputKind::TouchMove | InputKind::TouchMove
| InputKind::TouchUp => Ok(()), | InputKind::TouchUp => Ok(()),
+194 -35
View File
@@ -84,12 +84,24 @@ struct Pending {
/// A live parked knock is a genuine device waiting for the operator — eviction skips it unless /// A live parked knock is a genuine device waiting for the operator — eviction skips it unless
/// every entry is parked, so a cert-rotating flood can't evict the device being onboarded (#13). /// every entry is parked, so a cert-rotating flood can't evict the device being onboarded (#13).
parked: bool, parked: bool,
/// Generation of the MOST RECENT knock for this fingerprint. A re-knock bumps it (and wakes
/// waiters), so a stale parked connection resolves [`PairingDecision::Superseded`] instead of
/// being admitted alongside the newest one — one Approve must admit exactly ONE session.
/// (Observed live: a client retried 3× while parked, one console Approve admitted all three,
/// and the three concurrent Mutter virtual monitors segfaulted gnome-shell.)
knock_seq: u32,
} }
#[derive(Default)] #[derive(Default)]
struct PendingState { struct PendingState {
next_id: u32, next_id: u32,
items: Vec<Pending>, items: Vec<Pending>,
/// Fingerprint → the knock generation an approval admitted, kept briefly after [`NativePairing::add`]
/// clears the pending entry. Closes the last double-admit window: a superseded waiter that only
/// polls AFTER the approval (entry gone, fingerprint paired) can't tell it lost from the entry
/// alone — this marker lets it resolve `Superseded` instead of a second `Approved`. Pruned on
/// the pending TTL and overwritten per fingerprint, so it stays a handful of tuples.
admitted: Vec<(String, u32, Instant)>,
} }
/// A pending-approval snapshot for the management API / web console. /// A pending-approval snapshot for the management API / web console.
@@ -114,6 +126,10 @@ pub enum PairingDecision {
Denied, Denied,
/// No decision within the wait window — reject; the device can knock again. /// No decision within the wait window — reject; the device can knock again.
TimedOut, TimedOut,
/// A NEWER knock from the same fingerprint replaced this one — close this connection; the
/// newest parked connection is the one an approval admits (a retrying client abandons its
/// older attempts, and admitting them all crashes compositors — see [`Pending::knock_seq`]).
Superseded,
} }
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be /// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
@@ -353,9 +369,25 @@ impl NativePairing {
return Err(e); return Err(e);
} }
} }
// A device that knocked and is now paired shouldn't linger in the approval list. // A device that knocked and is now paired shouldn't linger in the approval list. Record
// WHICH knock generation this pairing admits before clearing the entry: only the waiter
// holding that generation may return `Approved`; a superseded sibling that polls after the
// clear resolves `Superseded` off this marker (exactly-one-admission — see `admitted`).
{ {
let mut pending = self.pending.lock().unwrap(); let mut pending = self.pending.lock().unwrap();
let admitted_seq = pending
.items
.iter()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
.map(|p| p.knock_seq);
if let Some(seq) = admitted_seq {
pending
.admitted
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
pending
.admitted
.push((fp_hex.to_string(), seq, Instant::now()));
}
pending pending
.items .items
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex)); .retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
@@ -394,11 +426,16 @@ impl NativePairing {
// -- Delegated approval (roadmap §8b-1) -------------------------------- // -- Delegated approval (roadmap §8b-1) --------------------------------
/// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]). /// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]). The
/// admitted-generation markers share the TTL — they only matter while a superseded waiter
/// could still be parked, which is bounded by the approval wait (well under the TTL).
fn expire_pending(pending: &mut PendingState) { fn expire_pending(pending: &mut PendingState) {
pending pending
.items .items
.retain(|p| p.requested_at.elapsed() < PENDING_TTL); .retain(|p| p.requested_at.elapsed() < PENDING_TTL);
pending
.admitted
.retain(|(_, _, at)| at.elapsed() < PENDING_TTL);
} }
/// Pick the entry to evict, optionally restricted to a single source IP: the least-recently-active /// Pick the entry to evict, optionally restricted to a single source IP: the least-recently-active
@@ -419,12 +456,14 @@ impl NativePairing {
} }
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint /// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint
/// refresh the existing entry in place (same id; a connect-retry loop must not spam the list). A /// refresh the existing entry in place (same id; a connect-retry loop must not spam the list) and
/// bump its knock generation — the returned generation is what [`Self::wait_for_decision`] admits,
/// so the NEWEST connection wins and any older parked sibling resolves `Superseded`. A
/// fresh fingerprint gets a new id; the queue is bounded two ways so a flood can't crowd out a /// fresh fingerprint gets a new id; the queue is bounded two ways so a flood can't crowd out a
/// genuine knock (#13): a **per-source-IP cap** ([`MAX_PENDING_PER_IP`]) means one host can hold at /// genuine knock (#13): a **per-source-IP cap** ([`MAX_PENDING_PER_IP`]) means one host can hold at
/// most a few slots, and the global [`PENDING_CAP`] evicts the least-recently-active **non-parked** /// most a few slots, and the global [`PENDING_CAP`] evicts the least-recently-active **non-parked**
/// entry (never a live, held-open parked knock). The name is sanitized (untrusted). /// entry (never a live, held-open parked knock). The name is sanitized (untrusted).
pub fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option<IpAddr>) { pub fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option<IpAddr>) -> u32 {
let name = sanitize_device_name(name, fp_hex); let name = sanitize_device_name(name, fp_hex);
let mut pending = self.pending.lock().unwrap(); let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending); Self::expire_pending(&mut pending);
@@ -438,8 +477,19 @@ impl NativePairing {
if p.src_ip.is_none() { if p.src_ip.is_none() {
p.src_ip = src_ip; p.src_ip = src_ip;
} }
return; p.knock_seq = p.knock_seq.wrapping_add(1);
let seq = p.knock_seq;
drop(pending);
// Wake the previous knock's parked waiter so it sees it was superseded NOW instead of
// holding its dead connection open until the approval window lapses.
self.changed.notify_waiters();
return seq;
} }
// A fresh knock lifecycle: drop any admitted-generation marker left from a previous
// pair→unpair round of this fingerprint, or it would wrongly supersede the new waiter.
pending
.admitted
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
// Per-source-IP cap: a single host can't occupy more than MAX_PENDING_PER_IP slots — evict its // Per-source-IP cap: a single host can't occupy more than MAX_PENDING_PER_IP slots — evict its
// own oldest entry first so it can't crowd out other devices' knocks (#13). // own oldest entry first so it can't crowd out other devices' knocks (#13).
if let Some(ip) = src_ip { if let Some(ip) = src_ip {
@@ -471,22 +521,47 @@ impl NativePairing {
requested_at: Instant::now(), requested_at: Instant::now(),
src_ip, src_ip,
parked: false, parked: false,
knock_seq: 0,
}); });
0
} }
/// Mark/unmark the pending entry for `fp_hex` as having a live parked waiter (no-op if it's gone). /// Mark/unmark the pending entry for `fp_hex` as having a live parked waiter (no-op if it's gone).
/// A parked entry is protected from eviction under load (#13). /// A parked entry is protected from eviction under load (#13). Gated on `knock_seq` so a
fn set_parked(&self, fp_hex: &str, parked: bool) { /// superseded waiter's exit can't unmark the flag the NEWER waiter (a bumped generation) owns.
fn set_parked(&self, fp_hex: &str, knock_seq: u32, parked: bool) {
let mut pending = self.pending.lock().unwrap(); let mut pending = self.pending.lock().unwrap();
if let Some(p) = pending if let Some(p) = pending
.items .items
.iter_mut() .iter_mut()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex)) .find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex) && p.knock_seq == knock_seq)
{ {
p.parked = parked; p.parked = parked;
} }
} }
/// The current knock generation for `fp_hex`, `None` when no entry is pending. A parked waiter
/// compares this against its own generation to detect it was superseded by a re-knock.
fn knock_seq_of(&self, fp_hex: &str) -> Option<u32> {
let pending = self.pending.lock().unwrap();
pending
.items
.iter()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
.map(|p| p.knock_seq)
}
/// The knock generation the approval of `fp_hex` admitted, if one was recorded (see
/// [`PendingState::admitted`]).
fn admitted_seq(&self, fp_hex: &str) -> Option<u32> {
let pending = self.pending.lock().unwrap();
pending
.admitted
.iter()
.find(|(fp, _, _)| fp.eq_ignore_ascii_case(fp_hex))
.map(|(_, seq, _)| *seq)
}
/// The devices currently awaiting approval (for the management API). /// The devices currently awaiting approval (for the management API).
pub fn pending(&self) -> Vec<PendingRequest> { pub fn pending(&self) -> Vec<PendingRequest> {
let mut pending = self.pending.lock().unwrap(); let mut pending = self.pending.lock().unwrap();
@@ -560,29 +635,41 @@ impl NativePairing {
} }
/// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`. /// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`.
/// `knock_seq` is the generation [`Self::note_pending`] returned for THIS connection's knock.
/// Returns [`PairingDecision::Approved`] the instant the fingerprint is paired (console /// Returns [`PairingDecision::Approved`] the instant the fingerprint is paired (console
/// approve or a concurrent PIN ceremony), [`PairingDecision::Denied`] if its pending entry is /// approve or a concurrent PIN ceremony), [`PairingDecision::Superseded`] the instant a newer
/// dropped without pairing, or [`PairingDecision::TimedOut`] if the window lapses. Holds no /// knock from the same fingerprint replaces this one (a retrying client — only the newest
/// lock across the await. The QUIC accept path calls this right after [`Self::note_pending`] /// connection is admitted; three siblings admitted at once has crashed gnome-shell live),
/// to keep the knocking connection open until a human clicks Approve — so the device pairs and /// [`PairingDecision::Denied`] if its pending entry is dropped without pairing, or
/// streams with no reconnect (delegated approval, roadmap §8b-1). /// [`PairingDecision::TimedOut`] if the window lapses. Holds no lock across the await. The
pub async fn wait_for_decision(&self, fp_hex: &str, timeout: Duration) -> PairingDecision { /// QUIC accept path calls this right after [`Self::note_pending`] to keep the knocking
/// connection open until a human clicks Approve — so the device pairs and streams with no
/// reconnect (delegated approval, roadmap §8b-1).
pub async fn wait_for_decision(
&self,
fp_hex: &str,
knock_seq: u32,
timeout: Duration,
) -> PairingDecision {
// Mark this knock parked so a cert-rotating flood can't evict the genuine, held-open // Mark this knock parked so a cert-rotating flood can't evict the genuine, held-open
// connection out of the pending queue while the operator decides (#13). Cleared on every // connection out of the pending queue while the operator decides (#13). Cleared on every
// exit path by the guard's Drop. // exit path by the guard's Drop (generation-gated, so a superseded waiter's exit never
self.set_parked(fp_hex, true); // unmarks the newer waiter's flag).
self.set_parked(fp_hex, knock_seq, true);
struct ParkGuard<'a> { struct ParkGuard<'a> {
np: &'a NativePairing, np: &'a NativePairing,
fp: &'a str, fp: &'a str,
seq: u32,
} }
impl Drop for ParkGuard<'_> { impl Drop for ParkGuard<'_> {
fn drop(&mut self) { fn drop(&mut self) {
self.np.set_parked(self.fp, false); self.np.set_parked(self.fp, self.seq, false);
} }
} }
let _park = ParkGuard { let _park = ParkGuard {
np: self, np: self,
fp: fp_hex, fp: fp_hex,
seq: knock_seq,
}; };
let deadline = tokio::time::Instant::now() + timeout; let deadline = tokio::time::Instant::now() + timeout;
loop { loop {
@@ -592,16 +679,33 @@ impl NativePairing {
tokio::pin!(notified); tokio::pin!(notified);
notified.as_mut().enable(); notified.as_mut().enable();
// Superseded check FIRST: once a newer knock owns the fingerprint, this connection
// must never be admitted — not even if the approval lands before we wake.
match self.knock_seq_of(fp_hex) {
Some(cur) if cur != knock_seq => return PairingDecision::Superseded,
_ => {}
}
if self.is_paired(fp_hex) { if self.is_paired(fp_hex) {
return PairingDecision::Approved; // Paired with the pending entry already cleared: make sure the approval admitted
// OUR generation. A superseded waiter that first polls after `add()` sees the same
// paired/no-entry state as the winner — the admitted marker breaks the tie.
match self.admitted_seq(fp_hex) {
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
_ => return PairingDecision::Approved,
}
} }
if !self.pending_contains(fp_hex) { if !self.pending_contains(fp_hex) {
// Neither pending nor paired. This is almost always a denial — but it can also be // Neither pending nor paired. This is almost always a denial — but it can also be
// the tiny interval inside `add()` between pinning and clearing the pending entry. // the tiny interval inside `add()` between pinning and clearing the pending entry.
// Re-check `is_paired` once: because `add()` pins BEFORE it clears pending, a // Re-check `is_paired` once: because `add()` pins BEFORE it clears pending, a
// cleared-pending observation that is really an approval will now read as paired. // cleared-pending observation that is really an approval will now read as paired
// with the same generation tie-break as above (the admitted marker is written in
// the same critical section that clears the entry).
if self.is_paired(fp_hex) { if self.is_paired(fp_hex) {
return PairingDecision::Approved; match self.admitted_seq(fp_hex) {
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
_ => return PairingDecision::Approved,
}
} }
return PairingDecision::Denied; return PairingDecision::Denied;
} }
@@ -781,19 +885,19 @@ mod tests {
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap()); let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
// TimedOut: a parked knock with no decision returns TimedOut; the entry survives. // TimedOut: a parked knock with no decision returns TimedOut; the entry survives.
np.note_pending("Knocker", "ab01", None); let seq = np.note_pending("Knocker", "ab01", None);
let d = np let d = np
.wait_for_decision("ab01", Duration::from_millis(80)) .wait_for_decision("ab01", seq, Duration::from_millis(80))
.await; .await;
assert_eq!(d, PairingDecision::TimedOut); assert_eq!(d, PairingDecision::TimedOut);
assert!(np.pending_contains("ab01")); assert!(np.pending_contains("ab01"));
// Approved: approving WHILE parked wakes the waiter with Approved. // Approved: approving WHILE parked wakes the waiter with Approved.
let np2 = np.clone(); let np2 = np.clone();
let waiter = let waiter = tokio::spawn(async move {
tokio::spawn( np2.wait_for_decision("ab01", seq, Duration::from_secs(5))
async move { np2.wait_for_decision("ab01", Duration::from_secs(5)).await }, .await
); });
tokio::time::sleep(Duration::from_millis(30)).await; tokio::time::sleep(Duration::from_millis(30)).await;
let id = np let id = np
.pending() .pending()
@@ -806,12 +910,12 @@ mod tests {
assert!(np.is_paired("ab01")); assert!(np.is_paired("ab01"));
// Denied: denying WHILE parked wakes the waiter with Denied (not held until timeout). // Denied: denying WHILE parked wakes the waiter with Denied (not held until timeout).
np.note_pending("Knock2", "cd02", None); let seq = np.note_pending("Knock2", "cd02", None);
let np3 = np.clone(); let np3 = np.clone();
let waiter = let waiter = tokio::spawn(async move {
tokio::spawn( np3.wait_for_decision("cd02", seq, Duration::from_secs(5))
async move { np3.wait_for_decision("cd02", Duration::from_secs(5)).await }, .await
); });
tokio::time::sleep(Duration::from_millis(30)).await; tokio::time::sleep(Duration::from_millis(30)).await;
let id = np let id = np
.pending() .pending()
@@ -823,12 +927,67 @@ mod tests {
assert_eq!(waiter.await.unwrap(), PairingDecision::Denied); assert_eq!(waiter.await.unwrap(), PairingDecision::Denied);
assert!(!np.is_paired("cd02")); assert!(!np.is_paired("cd02"));
// Already paired before the call → immediate Approved (no waiting). // Already paired before the call (the PIN-ceremony race) → immediate Approved: the ab01
let d = np.wait_for_decision("ab01", Duration::from_secs(5)).await; // marker admitted generation 0, which is also what a fresh coincidental waiter holds.
let d = np
.wait_for_decision("ab01", 0, Duration::from_secs(5))
.await;
assert_eq!(d, PairingDecision::Approved); assert_eq!(d, PairingDecision::Approved);
let _ = std::fs::remove_file(&p); let _ = std::fs::remove_file(&p);
} }
/// One Approve must admit exactly ONE session: a re-knock supersedes the previous parked
/// waiter (it resolves `Superseded` immediately, not at timeout), the console list keeps a
/// single entry, and a stale-generation waiter that polls only AFTER the approval still
/// resolves `Superseded` off the admitted marker. (Live failure this pins down: a client
/// knocked 3×, one Approve admitted all three, and the three concurrent Mutter virtual
/// monitors segfaulted gnome-shell.)
#[tokio::test]
async fn newest_knock_supersedes_parked_waiter() {
use std::sync::Arc;
let p = temp();
let _ = std::fs::remove_file(&p);
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
let seq1 = np.note_pending("iPad Pro", "ee01", None);
let np1 = np.clone();
let waiter1 = tokio::spawn(async move {
np1.wait_for_decision("ee01", seq1, Duration::from_secs(5))
.await
});
tokio::time::sleep(Duration::from_millis(30)).await;
// The device retries: same fingerprint, new connection. The old waiter is superseded at
// once; the pending list still shows ONE entry.
let seq2 = np.note_pending("iPad Pro", "ee01", None);
assert_ne!(seq1, seq2);
assert_eq!(waiter1.await.unwrap(), PairingDecision::Superseded);
assert_eq!(np.pending().len(), 1);
let np2 = np.clone();
let waiter2 = tokio::spawn(async move {
np2.wait_for_decision("ee01", seq2, Duration::from_secs(5))
.await
});
tokio::time::sleep(Duration::from_millis(30)).await;
let id = np
.pending()
.into_iter()
.find(|x| x.fingerprint == "ee01")
.unwrap()
.id;
np.approve_pending(id, None).unwrap().unwrap();
assert_eq!(waiter2.await.unwrap(), PairingDecision::Approved);
// A stale-generation waiter polling only after the approval (entry cleared, fingerprint
// paired) must NOT read as a second Approved — the admitted marker resolves the tie.
let d = np
.wait_for_decision("ee01", seq1, Duration::from_millis(80))
.await;
assert_eq!(d, PairingDecision::Superseded);
let _ = std::fs::remove_file(&p);
}
/// #9: a window can be bound to one operator-selected fingerprint, so an unrelated (attacker) /// #9: a window can be bound to one operator-selected fingerprint, so an unrelated (attacker)
/// fingerprint can neither pair nor BURN the window (it's rejected without a PIN). /// fingerprint can neither pair nor BURN the window (it's rejected without a PIN).
#[test] #[test]
@@ -873,8 +1032,8 @@ mod tests {
// A genuine knock from a different IP, parked (a live held-open connection), survives a flood // A genuine knock from a different IP, parked (a live held-open connection), survives a flood
// from many distinct IPs that fills the global cap. // from many distinct IPs that fills the global cap.
let legit = IpAddr::from([192, 168, 1, 50]); let legit = IpAddr::from([192, 168, 1, 50]);
np.note_pending("Living Room", "legit01", Some(legit)); let seq = np.note_pending("Living Room", "legit01", Some(legit));
np.set_parked("legit01", true); np.set_parked("legit01", seq, true);
for i in 0..(PENDING_CAP * 2) { for i in 0..(PENDING_CAP * 2) {
let ip = IpAddr::from([10, 0, (i / 256) as u8, (i % 256) as u8]); let ip = IpAddr::from([10, 0, (i / 256) as u8, (i % 256) as u8]);
np.note_pending("flood2", &format!("g{i:04}"), Some(ip)); np.note_pending("flood2", &format!("g{i:04}"), Some(ip));
+136 -10
View File
@@ -700,13 +700,16 @@ async fn serve_session(
tracing::info!(name = %label, fingerprint = %fp_hex, tracing::info!(name = %label, fingerprint = %fp_hex,
"unpaired device knocked — parking connection for delegated approval in the console"); "unpaired device knocked — parking connection for delegated approval in the console");
// Record the QUIC-validated source IP so the pending queue's per-source cap can stop one // Record the QUIC-validated source IP so the pending queue's per-source cap can stop one
// host from flooding/evicting genuine knocks (#13). // host from flooding/evicting genuine knocks (#13). The returned knock generation makes
np.note_pending(&label, &fp_hex, Some(peer.ip())); // this connection the ONE an approval admits — a retrying client parks a fresh
// connection per knock, and admitting every parked sibling on a single Approve spun up
// three concurrent Mutter virtual monitors and segfaulted gnome-shell (2026-07-10).
let knock_seq = np.note_pending(&label, &fp_hex, Some(peer.ip()));
// Free the session slot while a human decides — a parked knock must not hold an NVENC // Free the session slot while a human decides — a parked knock must not hold an NVENC
// permit (a handful of parked knocks would otherwise block every real session). // permit (a handful of parked knocks would otherwise block every real session).
drop(permit); drop(permit);
let decision = tokio::select! { let decision = tokio::select! {
d = np.wait_for_decision(&fp_hex, PENDING_APPROVAL_WAIT) => d, d = np.wait_for_decision(&fp_hex, knock_seq, PENDING_APPROVAL_WAIT) => d,
// The client gave up (closed the connection) before a decision — stop waiting. // The client gave up (closed the connection) before a decision — stop waiting.
_ = conn.closed() => anyhow::bail!("client disconnected before pairing approval"), _ = conn.closed() => anyhow::bail!("client disconnected before pairing approval"),
}; };
@@ -720,6 +723,10 @@ async fn serve_session(
"pairing request not approved within {PENDING_APPROVAL_WAIT:?} \ "pairing request not approved within {PENDING_APPROVAL_WAIT:?} \
the device can knock again" the device can knock again"
), ),
PairingDecision::Superseded => anyhow::bail!(
"parked knock superseded by a newer connection from the same device — \
only the newest is admitted on approval"
),
} }
// Re-acquire a session slot for the now-approved session (waits if all slots are busy, // Re-acquire a session slot for the now-approved session (waits if all slots are busy,
// exactly like any freshly accepted client). // exactly like any freshly accepted client).
@@ -1038,6 +1045,9 @@ async fn serve_session(
// HEVC-precedence tie-break). The client builds its decoder from this instead of // HEVC-precedence tie-break). The client builds its decoder from this instead of
// assuming HEVC. // assuming HEVC.
codec: codec_bit, codec: codec_bit,
// This host applies sequence-gated gamepad-state snapshots (InputKind::GamepadState),
// so capable clients send those instead of the loss-fragile per-transition events.
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE,
}; };
io::write_msg(&mut send, &welcome.encode()).await?; io::write_msg(&mut send, &welcome.encode()).await?;
@@ -1537,7 +1547,8 @@ async fn serve_session(
/// Per-pad accumulated state: punktfunk/1 gamepad events are incremental (one button or axis /// Per-pad accumulated state: punktfunk/1 gamepad events are incremental (one button or axis
/// per datagram, see `punktfunk_core::input::gamepad`), the virtual xpad applies full frames. /// per datagram, see `punktfunk_core::input::gamepad`), the virtual xpad applies full frames.
#[derive(Clone, Copy, Default)] /// A snapshot-capable client replaces the whole state at once ([`PadState::set_snapshot`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct PadState { struct PadState {
buttons: u32, buttons: u32,
left_trigger: u8, left_trigger: u8,
@@ -1574,6 +1585,17 @@ impl PadState {
true true
} }
/// Replace the whole state from one client snapshot (the [`InputKind::GamepadState`] form).
fn set_snapshot(&mut self, s: &punktfunk_core::input::GamepadSnapshot) {
self.buttons = s.buttons;
self.left_trigger = s.left_trigger;
self.right_trigger = s.right_trigger;
self.ls_x = s.ls_x;
self.ls_y = s.ls_y;
self.rs_x = s.rs_x;
self.rs_y = s.rs_y;
}
fn frame(&self, index: usize, active_mask: u16) -> crate::gamestream::gamepad::GamepadFrame { fn frame(&self, index: usize, active_mask: u16) -> crate::gamestream::gamepad::GamepadFrame {
crate::gamestream::gamepad::GamepadFrame { crate::gamestream::gamepad::GamepadFrame {
index: index as i16, index: index as i16,
@@ -1589,9 +1611,9 @@ impl PadState {
} }
} }
/// Highest pad index addressable on the wire (`flags` field); the uinput manager caps /// Highest pad index addressable on the wire (`flags` field / snapshot `pad`); the uinput
/// actual pad creation at its own MAX_PADS. /// manager caps actual pad creation at its own MAX_PADS.
const MAX_WIRE_PADS: usize = 16; const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS;
/// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails /// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails
/// to open or its worker dies, so a persistently-unavailable resource isn't hammered. (The /// to open or its worker dies, so a persistently-unavailable resource isn't hammered. (The
@@ -1793,6 +1815,9 @@ fn input_thread(
let mut motion_window = std::time::Instant::now(); let mut motion_window = std::time::Instant::now();
let mut pad_state = [PadState::default(); MAX_WIRE_PADS]; let mut pad_state = [PadState::default(); MAX_WIRE_PADS];
let mut pad_mask = 0u16; let mut pad_mask = 0u16;
// Last applied snapshot seq per pad (`None` until the first one): the reorder gate for
// `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back.
let mut pad_seq: [Option<u8>; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS];
// Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams), // Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams),
// so re-send the current state of every rumbling-capable pad every 500 ms — a dropped // so re-send the current state of every rumbling-capable pad every 500 ms — a dropped
// transition (including a stop) heals on the next refresh. // transition (including a stop) heals on the next refresh.
@@ -1859,6 +1884,32 @@ fn input_thread(
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame)); pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
} }
} }
InputKind::GamepadState => {
// Idempotent full-state snapshot from a capable client (see
// `GamepadSnapshot`): applied only when its seq supersedes the last one, so
// a datagram the network reordered can't roll held state backwards. The
// client refreshes touched pads every ~100 ms, so an unchanged refresh is
// the common case — skip the frame emit then (an XInput packet-number bump
// for identical state is pure churn), but always advance the gate.
use punktfunk_core::input::GamepadSnapshot;
if let Some(snap) = GamepadSnapshot::from_event(&ev) {
let idx = snap.pad as usize;
if idx < MAX_WIRE_PADS && GamepadSnapshot::seq_newer(snap.seq, pad_seq[idx])
{
pad_seq[idx] = Some(snap.seq);
let before = pad_state[idx];
pad_state[idx].set_snapshot(&snap);
let first = pad_mask & (1 << idx) == 0;
if first || pad_state[idx] != before {
pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(
frame,
));
}
}
}
}
_ => { _ => {
// Track press/release so a mid-press disconnect can be undone below. // Track press/release so a mid-press disconnect can be undone below.
match ev.kind { match ev.kind {
@@ -2424,9 +2475,25 @@ fn resolve_compositor(
return Ok(Compositor::Gamescope); return Ok(Compositor::Gamescope);
} }
let available = crate::vdisplay::available(); let available = crate::vdisplay::available();
let chosen = pick_compositor(pref, &available, detected).ok_or_else(|| { let chosen = match pick_compositor(pref, &available, detected) {
anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)") Some(c) => c,
})?; None => {
// No live session — the state a compositor crash leaves behind (gnome-shell
// SIGSEGV → GDM greeter, whose auto-login is once-per-boot). If the operator
// configured a recovery hook, fire it (debounced) and tell the client to retry:
// its next knock lands in the recovered desktop.
if crate::vdisplay::try_recover_session() {
anyhow::bail!(
"no live graphical session for this uid — host session recovery launched \
(PUNKTFUNK_RECOVER_SESSION_CMD); retry in a few seconds"
);
}
anyhow::bail!(
"no usable compositor (no live graphical session for this uid; set \
PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)"
);
}
};
if !overridden { if !overridden {
// Point input at the same backend and resolve the gamescope sub-mode (managed where the // Point input at the same backend and resolve the gamescope sub-mode (managed where the
// session infra exists, attach to a foreign gamescope, else per-session bare spawn). // session infra exists, attach to a foreign gamescope, else per-session bare spawn).
@@ -4299,6 +4366,65 @@ fn build_pipeline(
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn pad_snapshot_replaces_state_and_seq_gates() {
use punktfunk_core::input::{gamepad, GamepadSnapshot};
let mut state = PadState::default();
let mut last_seq: Option<u8> = None;
// Legacy accumulation first (an older client), then a snapshot replaces it wholesale.
let axis = InputEvent {
kind: InputKind::GamepadAxis,
_pad: [0; 3],
code: gamepad::AXIS_LT,
x: 200,
y: 0,
flags: 0,
};
assert!(state.apply(&axis));
assert_eq!(state.left_trigger, 200);
let snap = GamepadSnapshot {
pad: 0,
seq: 1,
buttons: gamepad::BTN_A,
left_trigger: 255,
right_trigger: 0,
ls_x: 100,
ls_y: -100,
rs_x: 0,
rs_y: 0,
};
assert!(GamepadSnapshot::seq_newer(snap.seq, last_seq));
last_seq = Some(snap.seq);
state.set_snapshot(&snap);
assert_eq!(state.left_trigger, 255);
assert_eq!(state.buttons, gamepad::BTN_A);
assert_eq!((state.ls_x, state.ls_y), (100, -100));
// A reordered (stale) snapshot must not roll the trigger back.
let stale = GamepadSnapshot {
seq: 0,
left_trigger: 10,
..snap
};
assert!(!GamepadSnapshot::seq_newer(stale.seq, last_seq));
// The unchanged-refresh case the input thread skips the frame emit for: identical
// payload with a newer seq compares equal after apply.
let refresh = GamepadSnapshot { seq: 2, ..snap };
assert!(GamepadSnapshot::seq_newer(refresh.seq, last_seq));
let before = state;
state.set_snapshot(&refresh);
assert_eq!(state, before);
// The snapshot survives the wire roundtrip into the same PadState shape.
let dec =
GamepadSnapshot::from_event(&InputEvent::decode(&snap.to_event().encode()).unwrap())
.unwrap();
assert_eq!(dec, snap);
}
/// Feed [`RecoveryCadence`] a schedule of event offsets (ms from a common origin) and return /// Feed [`RecoveryCadence`] a schedule of event offsets (ms from a common origin) and return
/// what each `note` produced. /// what each `note` produced.
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<std::time::Duration>> { fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<std::time::Duration>> {
+60
View File
@@ -712,6 +712,17 @@ pub fn apply_session_env(active: &ActiveSession) {
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig), Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"), None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
} }
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
// connect's retarget, and the availability probes read them: after a gnome-shell crash
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
// `mutter::is_available()` true, so a client's explicit backend request routed into the dead
// session — 45 s create timeouts and a libei error loop instead of the crisp "no live
// graphical session" handshake error. Clear them so `available()` reports the truth and the
// client fails fast (and, when configured, `try_recover_session` can bring the desktop back).
if active.kind == ActiveKind::None {
std::env::remove_var("XDG_CURRENT_DESKTOP");
std::env::remove_var("WAYLAND_DISPLAY");
}
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read // Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
// [`effective_topology`] directly at create time — the console policy, else the legacy // [`effective_topology`] directly at create time — the console policy, else the legacy
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the // `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
@@ -721,6 +732,55 @@ pub fn apply_session_env(active: &ActiveSession) {
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub fn apply_session_env(_active: &ActiveSession) {} pub fn apply_session_env(_active: &ActiveSession) {}
/// Fire the operator's session-recovery hook (`PUNKTFUNK_RECOVER_SESSION_CMD`) because a client
/// connected while NO graphical session is live for this uid — the state a compositor crash
/// leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login only fires once per boot,
/// so the box would otherwise sit headless until a walk-up login or a reboot). The command runs
/// detached via `sh -c` (typically a display-manager restart — see the config docs) and is
/// debounced to one launch per minute so a retrying client can't stack restarts. Returns whether
/// a recovery is underway (just launched, or launched within the debounce window), letting the
/// handshake error tell the client to simply retry.
#[cfg(target_os = "linux")]
pub fn try_recover_session() -> bool {
let Some(cmd) = crate::config::config().recover_session_cmd.clone() else {
return false;
};
static LAST_LAUNCH: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(60);
let mut last = LAST_LAUNCH.lock().unwrap_or_else(|e| e.into_inner());
if last.is_some_and(|t| t.elapsed() < DEBOUNCE) {
return true; // a launch is already in flight — the retry lands in the recovered session
}
match std::process::Command::new("/bin/sh")
.arg("-c")
.arg(&cmd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(mut child) => {
*last = Some(std::time::Instant::now());
tracing::warn!(cmd = %cmd,
"no live graphical session — launched the operator's session-recovery command");
// Reap off-thread so the finished child never lingers as a zombie.
std::thread::spawn(move || {
let _ = child.wait();
});
true
}
Err(e) => {
tracing::error!(cmd = %cmd, error = %e,
"session-recovery command failed to launch");
false
}
}
}
#[cfg(not(target_os = "linux"))]
pub fn try_recover_session() -> bool {
false
}
/// On a **mid-stream** switch to a desktop, the xdg-desktop-portal (D-Bus-activated) and the systemd /// On a **mid-stream** switch to a desktop, the xdg-desktop-portal (D-Bus-activated) and the systemd
/// `--user` environment can still point at the OLD session, so the host's RemoteDesktop portal opens /// `--user` environment can still point at the OLD session, so the host's RemoteDesktop portal opens
/// against a half-stale env — it accepts events but they don't reach the compositor until a /// against a half-stale env — it accepts events but they don't reach the compositor until a
@@ -49,6 +49,18 @@ const APPLY_TEMPORARY: u32 = 1;
/// Mutter cursor mode: render the cursor into the stream (matches the KWin/gamescope backends). /// Mutter cursor mode: render the cursor into the stream (matches the KWin/gamescope backends).
const CURSOR_EMBEDDED: u32 = 1; const CURSOR_EMBEDDED: u32 = 1;
/// Serializes, process-wide, every Mutter operation that adds/removes a virtual monitor or applies
/// a monitor configuration. Each of these makes Mutter rebuild its monitor topology, and
/// *concurrent* rebuilds have segfaulted gnome-shell on-glass twice now: the teardown-side race is
/// documented at the teardown below, and on 2026-07-10 three simultaneous session setups (three
/// `RecordVirtual` calls within ~200 µs plus an `ApplyMonitorsConfig`) crashed the shell inside
/// `meta_monitor_manager_rebuild` — dropping the box to the GDM greeter until a DM restart. One
/// mutation at a time also keeps [`wait_virtual_connector`] sound: with two virtual outputs
/// appearing at once, "the connector absent from MY pre-snapshot" can name a sibling's monitor.
/// Each session runs on its own dedicated thread (see [`session_thread`]), so blocking on a std
/// mutex — including across the awaits of its single-threaded setup future — is safe.
static TOPOLOGY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a /// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a
/// keepalive thread owning the D-Bus sessions behind the virtual monitor. /// keepalive thread owning the D-Bus sessions behind the virtual monitor.
pub struct MutterDisplay { pub struct MutterDisplay {
@@ -146,7 +158,10 @@ impl VirtualDisplay for MutterDisplay {
}) })
.context("spawn Mutter virtual-output thread")?; .context("spawn Mutter virtual-output thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) { // 45 s (was 20 s): setups now queue on TOPOLOGY_LOCK, so a session behind a slow sibling
// (whose guard spans up to a ~10 s stream wait + 6 s connector wait + the apply) must
// outwait it plus its own handshake before this fires.
let node_id = match setup_rx.recv_timeout(Duration::from_secs(45)) {
Ok(Ok(v)) => v, Ok(Ok(v)) => v,
Ok(Err(e)) => bail!("Mutter virtual monitor failed: {e}"), Ok(Err(e)) => bail!("Mutter virtual monitor failed: {e}"),
Err(_) => bail!("timed out creating the Mutter virtual monitor"), Err(_) => bail!("timed out creating the Mutter virtual monitor"),
@@ -181,6 +196,11 @@ impl Drop for StopGuard {
/// `scale_key`/`remembered_scale` carry the per-client persisted scale: reapplied at connect, /// `scale_key`/`remembered_scale` carry the per-client persisted scale: reapplied at connect,
/// and the user's in-session changes are recorded back under the key (GNOME itself can't — see /// and the user's in-session changes are recorded back under the key (GNOME itself can't — see
/// [`identity::ScaleMap`](crate::vdisplay::identity)). /// [`identity::ScaleMap`](crate::vdisplay::identity)).
// TOPOLOGY_LOCK is deliberately held across the awaits of the setup/teardown sequences: each
// session owns this dedicated OS thread and its own single-future runtime, so the guard never
// blocks a shared executor — it blocks exactly the sibling session threads, which is the point
// (see TOPOLOGY_LOCK).
#[allow(clippy::await_holding_lock)]
fn session_thread( fn session_thread(
setup_tx: Sender<Result<u32, String>>, setup_tx: Sender<Result<u32, String>>,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
@@ -201,6 +221,11 @@ fn session_thread(
} }
}; };
rt.block_on(async move { rt.block_on(async move {
// The whole setup — pre-snapshot → RecordVirtual → ApplyMonitorsConfig — is one
// read-modify-write on Mutter's monitor state; hold TOPOLOGY_LOCK across it so concurrent
// sessions can't interleave rebuilds (gnome-shell SIGSEGV) or poison each other's
// connector diffs. Released before the keepalive park below.
let topology_guard = TOPOLOGY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// Display-management topology (Stage 2): the console policy's level, resolved to a concrete // Display-management topology (Stage 2): the console policy's level, resolved to a concrete
// value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes // value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes
// it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the // it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the
@@ -288,6 +313,8 @@ fn session_thread(
} }
} }
drop(topology_guard);
// Park, keeping `session` (and its zbus connection) alive until told to stop. Every ~5 s, // Park, keeping `session` (and its zbus connection) alive until told to stop. Every ~5 s,
// read the virtual output's logical-monitor scale and persist a change the user made (GNOME // read the virtual output's logical-monitor scale and persist a change the user made (GNOME
// Settings mid-stream) under the client's key — polled rather than teardown-only so a host // Settings mid-stream) under the client's key — polled rather than teardown-only so a host
@@ -317,6 +344,9 @@ fn session_thread(
// make_virtual_primary applied an APPLY_TEMPORARY config; Mutter reverts that on its own once // make_virtual_primary applied an APPLY_TEMPORARY config; Mutter reverts that on its own once
// the virtual output disappears and our DisplayConfig connection (in `tracked`) closes — so we // the virtual output disappears and our DisplayConfig connection (in `tracked`) closes — so we
// just drop it here and let the revert happen Mutter-side, never touching the layout ourselves. // just drop it here and let the revert happen Mutter-side, never touching the layout ourselves.
// The Stop (+ the revert it triggers) is a topology mutation too — take TOPOLOGY_LOCK so a
// sibling's teardown or setup can't interleave with the rebuild it causes.
let _topology_guard = TOPOLOGY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _ = session.rd_session.call_method("Stop", &()).await; let _ = session.rd_session.call_method("Stop", &()).await;
drop(tracked); drop(tracked);
}); });
@@ -71,7 +71,9 @@ pub(crate) trait VdisplayDriver: Send + Sync {
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>; unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>;
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and /// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
/// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). Returns the REMOVE /// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). Returns the REMOVE
/// key + target id + the adapter LUID the driver actually used. /// key + target id + the IddCx DISPLAY adapter LUID from the ADD reply
/// (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` — NOT the render GPU; the driver reports its render
/// adapter only in the shared frame header).
/// ///
/// # Safety /// # Safety
/// `dev` must be the live control handle from [`open`](Self::open). /// `dev` must be the live control handle from [`open`](Self::open).
@@ -100,7 +102,14 @@ pub(crate) trait VdisplayDriver: Send + Sync {
struct Monitor { struct Monitor {
key: MonitorKey, key: MonitorKey,
target_id: u32, target_id: u32,
/// IddCx DISPLAY adapter LUID from the ADD reply (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid`) —
/// NOT the render GPU the driver renders on (the driver reports that one in the shared frame
/// header only). Do not compare it against render-GPU picks.
luid: LUID, luid: LUID,
/// The render-GPU pin handed to SET_RENDER_ADAPTER at this monitor's ADD (`None` = no GPU was
/// selectable). The pin is never re-issued on reuse, so this is what the driver still renders
/// on — [`warn_if_pick_moved`] compares the CURRENT pick against it.
render_pin: Option<LUID>,
/// The driver's WUDFHost pid (from the ADD reply) — carried into [`WinCaptureTarget`] so the /// The driver's WUDFHost pid (from the ADD reply) — carried into [`WinCaptureTarget`] so the
/// IDD-push capturer knows where to duplicate the sealed frame channel's handles. /// IDD-push capturer knows where to duplicate the sealed frame channel's handles.
wudf_pid: u32, wudf_pid: u32,
@@ -475,6 +484,7 @@ impl VirtualDisplayManager {
backend = self.driver.name(), backend = self.driver.name(),
"virtual monitor reused (concurrent / reconfigure session)" "virtual monitor reused (concurrent / reconfigure session)"
); );
warn_if_pick_moved(mon);
return Ok(self.output_for(mon, quit)); return Ok(self.output_for(mon, quit));
} }
@@ -487,6 +497,7 @@ impl VirtualDisplayManager {
backend = self.driver.name(), backend = self.driver.name(),
"virtual monitor reused (reconnect to a kept monitor)" "virtual monitor reused (reconnect to a kept monitor)"
); );
warn_if_pick_moved(&mon);
if mon.mode != mode { if mon.mode != mode {
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live // SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
// display topology. `mon` is the local monitor just moved out of the `Lingering` // display topology. `mon` is the local monitor just moved out of the `Lingering`
@@ -562,13 +573,14 @@ impl VirtualDisplayManager {
crate::vdisplay::policy::Identity::PerClient, crate::vdisplay::policy::Identity::PerClient,
) )
.unwrap_or(0); .unwrap_or(0);
let render_pin = resolve_render_pin();
// SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control // SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control
// handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that. // handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.
// `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed // `render_pin` is an `Option<LUID>` by value (plain `Copy`), so no borrowed memory
// memory crosses the call. // crosses the call.
let added = unsafe { let added = unsafe {
self.driver self.driver
.add_monitor(dev, mode, resolve_render_pin(), preferred_id)? .add_monitor(dev, mode, render_pin, preferred_id)?
}; };
// Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down. // Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down.
@@ -724,6 +736,7 @@ impl VirtualDisplayManager {
key: added.key, key: added.key,
target_id: added.target_id, target_id: added.target_id,
luid: added.luid, luid: added.luid,
render_pin,
wudf_pid: added.wudf_pid, wudf_pid: added.wudf_pid,
gdi_name, gdi_name,
mode, mode,
@@ -1033,6 +1046,36 @@ fn resolve_render_pin() -> Option<LUID> {
crate::win_adapter::resolve_render_adapter_luid() crate::win_adapter::resolve_render_adapter_luid()
} }
/// A reused monitor keeps the render GPU the driver was pinned to at its ADD — the pin is never
/// re-issued on reuse. When the current pick has moved since then (an operator preference change,
/// or the max-VRAM tie shifting on identical twin GPUs), say so: the session self-heals (the
/// IDD-push open rebinds its ring to the driver's actual adapter on a mismatch), but the new pick
/// only takes effect on the next monitor CREATE, which the mgmt `/display/release` forces.
/// Compares the pick against the ADD-time PIN — `mon.luid` is the IddCx display adapter and must
/// not be compared with render-GPU picks.
fn warn_if_pick_moved(mon: &Monitor) {
let Some(pin) = mon.render_pin else { return };
let Some(sel) = crate::gpu::selected_gpu() else {
return;
};
let pick = sel.info.luid();
if (pick.LowPart, pick.HighPart) != (pin.LowPart, pin.HighPart) {
tracing::warn!(
pinned_adapter = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart),
current_pick = format!(
"{:08x}:{:08x} ({}, {})",
pick.HighPart,
pick.LowPart,
sel.info.name,
sel.source.tag()
),
"reused virtual monitor is pinned to a different render GPU than the current pick — \
the session follows the pinned GPU; free the display (mgmt /display/release) to \
recreate it on the picked one"
);
}
}
/// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal: /// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal:
/// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade /// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade
/// maps it into the wire shape. /// maps it into the wire shape.
@@ -539,17 +539,12 @@ impl VdisplayDriver for PfVdisplayDriver {
); );
} }
} }
if let Some(pin) = render_luid { // NOTE: `reply.adapter_luid` is the IddCx DISPLAY adapter
if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart { // (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid`), NOT the render GPU, so it can NOT validate
tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)"); // SET_RENDER_ADAPTER — a comparison against the pin here fired "DIFFERS from pinned" on
} else { // every ADD (verified on-glass: reply 0x22c05 vs pin 0x15b05 on a single-4090 box). The
tracing::warn!( // driver reports its ACTUAL render adapter in the shared frame header; the IDD-push
add = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart), // capturer checks it there and rebinds on a mismatch.
pinned = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart),
"pf-vdisplay ADD render adapter DIFFERS from pinned — driver ignored SET_RENDER_ADAPTER?"
);
}
}
Ok(AddedMonitor { Ok(AddedMonitor {
key: MonitorKey::Session(session_id), key: MonitorKey::Session(session_id),
target_id: reply.target_id, target_id: reply.target_id,
+1 -2
View File
@@ -4,8 +4,7 @@ The punktfunk documentation site: [Fumadocs](https://fumadocs.dev) on
[TanStack Start](https://tanstack.com/start) (Vite + Nitro/bun preset). [TanStack Start](https://tanstack.com/start) (Vite + Nitro/bun preset).
Content lives in [`content/docs/`](content/docs) as `.md`/`.mdx`. This site is the source of truth Content lives in [`content/docs/`](content/docs) as `.md`/`.mdx`. This site is the source of truth
for the **user-facing** guides; repo-internal design rationale lives in for the **user-facing** guides; design rationale lives in the internal punktfunk-planning repo.
[`../design/`](../design/README.md).
## API reference ## API reference
+12 -10
View File
@@ -35,9 +35,10 @@ protocol's FEC/encryption extensions, but for a healthy LAN that rarely matters.
## Linux desktop client (GTK4) ## Linux desktop client (GTK4)
`punktfunk-client` is the native graphical Linux client — a GTK4 / libadwaita app that speaks `punktfunk-client` is the native graphical Linux client — a GTK4 / libadwaita app that speaks
`punktfunk/1` directly, with hardware decode (VAAPI → dmabuf on Intel/AMD, software fallback), `punktfunk/1` directly, with hardware decode via Vulkan Video on every GPU vendor (including
PipeWire audio, and SDL3 controllers (rumble, lightbar, DualSense touchpad/motion). Like the Apple NVIDIA), falling back to VAAPI dmabuf and then software, PipeWire audio, and SDL3 controllers
app it discovers hosts on your network automatically, does PIN pairing, and pins reconnects. (rumble, lightbar, DualSense touchpad/motion). Like the Apple app it discovers hosts on your network
automatically, does PIN pairing, and pins reconnects.
It ships as a real package, not just a source build — full steps in It ships as a real package, not just a source build — full steps in
[Install a Client](/docs/install-client#linux-desktop-flatpak): [Install a Client](/docs/install-client#linux-desktop-flatpak):
@@ -73,14 +74,15 @@ The app is in **Google Play Internal Testing** — request a tester invite on ou
`punktfunk-client` for Windows (`clients/windows`) is the native graphical client for Windows — pure `punktfunk-client` for Windows (`clients/windows`) is the native graphical client for Windows — pure
Rust, the same `punktfunk/1` core as the Apple, Linux, and Android apps, with a **WinUI 3** UI (host Rust, the same `punktfunk/1` core as the Apple, Linux, and Android apps, with a **WinUI 3** UI (host
list, settings, PIN pairing) and the video on a `SwapChainPanel`. It does D3D11VA hardware decode list, settings, PIN pairing); the stream itself runs in punktfunk's Vulkan presenter. Its decoder
(software fallback), 10-bit/HDR present, WASAPI audio + mic, SDL3 controllers (rumble, lightbar, tries **Vulkan Video, then D3D11VA, then software**, with 10-bit/HDR present, WASAPI audio + mic,
DualSense), network discovery, and the full PIN-pairing trust surface. It builds for both `x86_64` SDL3 controllers (rumble, lightbar, DualSense), network discovery, and the full PIN-pairing trust
and `aarch64` and ships as a **signed MSIX**. Launch it and pick a host from the list, just like the surface. It builds for both `x86_64` and `aarch64` and ships as a **signed MSIX**. Launch it and
other native apps. pick a host from the list, just like the other native apps.
> The hardware-decode and HDR paths are complete but still pending validation on real GPU hardware. > Hardware decode is validated on NVIDIA and Intel GPUs; HDR10 is implemented with on-glass
> If anything misbehaves, **[Moonlight](/docs/moonlight)** is a proven alternative for Windows. > validation still pending. If anything misbehaves, **[Moonlight](/docs/moonlight)** is a proven
> alternative for Windows.
A headless CLI path exists for scripting/measurement: A headless CLI path exists for scripting/measurement:
+13
View File
@@ -75,6 +75,12 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. | | `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. |
| `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `1` | GNOME/Mutter equivalent of the above. | | `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `1` | GNOME/Mutter equivalent of the above. |
## Session recovery (Linux)
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_RECOVER_SESSION_CMD` | command | Operator hook fired (debounced) when a client connects while **no graphical session is live** for the host's user — the state a compositor crash leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login is once-per-boot). Typically `sudo -n systemctl restart gdm` with a matching NOPASSWD sudoers rule, or `systemctl restart display-manager` under a polkit rule; with auto-login enabled the restart brings the desktop back and the client's automatic retry lands in it. Unset/empty = disabled (the default). |
## Video quality ## Video quality
| Setting | Values | Meaning | | Setting | Values | Meaning |
@@ -110,6 +116,13 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| `PUNKTFUNK_RENDER_ADAPTER` | description substring | Multi-GPU boxes only: force the NVENC/capture GPU by adapter Description substring (e.g. `4090`). Leave unset on single-GPU machines. | | `PUNKTFUNK_RENDER_ADAPTER` | description substring | Multi-GPU boxes only: force the NVENC/capture GPU by adapter Description substring (e.g. `4090`). Leave unset on single-GPU machines. |
| `PUNKTFUNK_HOST_CMD` | e.g. `serve --gamestream` | The host subcommand the service launches. Default `serve --gamestream`; use `serve` for a secure native-only host. | | `PUNKTFUNK_HOST_CMD` | e.g. `serve --gamestream` | The host subcommand the service launches. Default `serve --gamestream`; use `serve` for a secure native-only host. |
## Network & discovery
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_MDNS` | `1` · `0` *(default on)* | mDNS adverts (native + GameStream). `0` skips them (same as `--no-mdns`) — for networks/containers where multicast doesn't work; add the host by address in the client instead. |
| `PUNKTFUNK_DATA_PORT` | port | Pin the per-session video data plane to a fixed UDP port and stream direct (no hole-punch) — open exactly that port in the host firewall. Same as `serve --data-port`; see [Troubleshooting](/docs/troubleshooting). Default: random port + hole-punch. |
## Auth, API & paths ## Auth, API & paths
| Setting | Values | Meaning | | Setting | Values | Meaning |
+4
View File
@@ -66,6 +66,10 @@ journalctl --user -u punktfunk-host -f # watch it come up and print its identi
Then bring up [The Web Console](/docs/web-console) to arm pairing and connect a Then bring up [The Web Console](/docs/web-console) to arm pairing and connect a
[client](/docs/clients). For an always-on box, see the [headless session](#headless-session) below. [client](/docs/clients). For an always-on box, see the [headless session](#headless-session) below.
Display scaling you set while streaming **sticks per client**: the host remembers each device's
scale and reapplies it on reconnect — see
[Persistent scaling](/docs/virtual-displays#persistent-scaling).
## Headless session ## Headless session
To run with no monitor and no login, keep a GNOME Wayland session up at all times and start the host To run with no monitor and no login, keep a GNOME Wayland session up at all times and start the host
+10 -6
View File
@@ -35,6 +35,7 @@ punktfunk-host serve --gamestream
| `--mgmt-bind <IP:PORT>` | Management API address (default `0.0.0.0:47990` — all interfaces, so paired clients can browse the game library over mTLS; pass `127.0.0.1:47990` to keep it loopback-only). | | `--mgmt-bind <IP:PORT>` | Management API address (default `0.0.0.0:47990` — all interfaces, so paired clients can browse the game library over mTLS; pass `127.0.0.1:47990` to keep it loopback-only). |
| `--mgmt-token <TOKEN>` | Override the bearer token for the management API. | | `--mgmt-token <TOKEN>` | Override the bearer token for the management API. |
| `--no-mdns` | Skip the mDNS adverts (native + GameStream) — for networks/containers where multicast doesn't work. Clients connect via a manually added host instead. Same as `PUNKTFUNK_MDNS=0`. | | `--no-mdns` | Skip the mDNS adverts (native + GameStream) — for networks/containers where multicast doesn't work. Clients connect via a manually added host instead. Same as `PUNKTFUNK_MDNS=0`. |
| `--data-port <PORT>` | Pin the per-session video data plane to this fixed UDP port and stream direct (no hole-punch) — open exactly that port in the host firewall. Same as `PUNKTFUNK_DATA_PORT`; default is a random port + hole-punch. |
These are the only flags `serve` accepts. These are the only flags `serve` accepts.
@@ -50,7 +51,8 @@ Every endpoint is documented in the interactive [**API Reference**](/api).
By default the host **requires pairing** — see [Pairing & Trust](/docs/pairing). On `serve` you By default the host **requires pairing** — see [Pairing & Trust](/docs/pairing). On `serve` you
**arm pairing from the web console** (or mgmt API); the host then displays a 4-digit PIN. Pass `--open` to **arm pairing from the web console** (or mgmt API); the host then displays a 4-digit PIN. Pass `--open` to
turn off the mandatory-pairing default and serve any device on the network (trusted single-user setups turn off the mandatory-pairing default and serve any device on the network (trusted single-user setups
only). The pairing flags below are `punktfunk1-host`-only and do **not** apply to `serve`. only). `punktfunk1-host` (below) requires pairing by default too; its `--allow-tofu` flag is the
test-host equivalent of `--open`.
## `punktfunk1-host` ## `punktfunk1-host`
@@ -68,14 +70,16 @@ punktfunk-host punktfunk1-host --source virtual
| `--seconds <N>` / `--frames <N>` | Bound each session by wall-clock seconds or frame count. | | `--seconds <N>` / `--frames <N>` | Bound each session by wall-clock seconds or frame count. |
| `--max-concurrent <N>` | Stream at most N sessions at once (default 4); overflow waits in the queue. | | `--max-concurrent <N>` | Stream at most N sessions at once (default 4); overflow waits in the queue. |
| `--max-sessions <N>` | Exit after N sessions (0 = serve forever). | | `--max-sessions <N>` | Exit after N sessions (0 = serve forever). |
| `--allow-pairing` | Accept PIN pairing; the host prints a PIN when a client pairs. | | `--allow-tofu` | Also accept **unpaired** clients (trust-on-first-use) and advertise pairing as optional. Pairing is required by default; trusted LANs only. (`--allow-pairing`/`--require-pairing` are the old names for the default behaviour and are accepted as no-ops.) |
| `--require-pairing` | Only serve paired devices (implies `--allow-pairing`). |
| `--pairing-pin <PIN>` | Use a fixed pairing PIN instead of a fresh random one per ceremony. For test harnesses/CI only — a guessable PIN defeats the ceremony's rate limit. | | `--pairing-pin <PIN>` | Use a fixed pairing PIN instead of a fresh random one per ceremony. For test harnesses/CI only — a guessable PIN defeats the ceremony's rate limit. |
| `--data-port <PORT>` | Pin the video data plane to this fixed UDP port and stream direct (no hole-punch). Same as `PUNKTFUNK_DATA_PORT`. |
| `--idle-timeout-ms <MS>` | Disconnect-detection latency — the QUIC control-connection idle timeout (default 8000). |
| `--no-mdns` | Skip the `_punktfunk._udp` advert; clients use `--connect HOST:PORT`. Same as `PUNKTFUNK_MDNS=0`. | | `--no-mdns` | Skip the `_punktfunk._udp` advert; clients use `--connect HOST:PORT`. Same as `PUNKTFUNK_MDNS=0`. |
`--max-concurrent`, `--allow-pairing`, and `--require-pairing` are **`punktfunk1-host`-only** — `serve` does not `--max-concurrent` and `--allow-tofu` are **`punktfunk1-host`-only** — `serve` does not accept them.
accept them. On `serve` you arm pairing from the web console instead, and concurrency is fixed at On `serve` you arm pairing from the web console instead (`--open` is its serve-any-device switch),
the built-in default (4 sessions) rather than settable from the command line. and concurrency is fixed at the built-in default (4 sessions) rather than settable from the command
line.
Both `serve` and `punktfunk1-host` advertise the host on the network so clients can discover it. List Both `serve` and `punktfunk1-host` advertise the host on the network so clients can discover it. List
hosts from another machine with `punktfunk-probe --discover`. Where multicast doesn't work (some hosts from another machine with `punktfunk-probe --discover`. Where multicast doesn't work (some
+3 -3
View File
@@ -99,9 +99,9 @@ certificate, so you import that certificate once before Windows will install the
3. Launch **Punktfunk** from the Start menu and pick your host. 3. Launch **Punktfunk** from the Start menu and pick your host.
> The Windows client's hardware-decode (D3D11VA) and HDR paths are complete but still pending > The Windows client's hardware decode is validated on NVIDIA and Intel GPUs; its HDR path is
> validation on real GPU hardware. If anything misbehaves, **[Moonlight](/docs/moonlight)** is a > complete but still pending on-glass validation. If anything misbehaves,
> solid alternative for Windows. > **[Moonlight](/docs/moonlight)** is a solid alternative for Windows.
## macOS ## macOS
+3 -2
View File
@@ -45,8 +45,9 @@ host's management console, click to arm pairing, and the host displays a 4-digit
list of paired devices. This works on a headless host over the network — there is no command-line flag list of paired devices. This works on a headless host over the network — there is no command-line flag
to arm pairing on `serve`. to arm pairing on `serve`.
(The standalone headless test host, `punktfunk1-host`, takes `--allow-pairing`/`--require-pairing` on its (The standalone headless test host, `punktfunk1-host`, requires pairing by default too and takes
command line instead; the production `serve` host arms pairing from the console.) `--allow-tofu` on its command line to accept unpaired clients; the production `serve` host arms
pairing from the console.)
Then, on the client: Then, on the client:
+7 -6
View File
@@ -16,9 +16,9 @@ see [Status & Progress](/docs/status).
| GameStream host (works with Moonlight) | ✅ | | GameStream host (works with Moonlight) | ✅ |
| Native `punktfunk/1` protocol | ✅ | | Native `punktfunk/1` protocol | ✅ |
| Linux host (KWin · GNOME · gamescope · Sway) | ✅ | | Linux host (KWin · GNOME · gamescope · Sway) | ✅ |
| Windows host (NVIDIA) | ✅ beta | | Windows host (NVIDIA · AMD · Intel) | ✅ beta |
| Apple client (macOS · iOS · iPadOS · tvOS) | ✅ | | Apple client (macOS · iOS · iPadOS · tvOS) | ✅ |
| Linux client (GTK4) | ✅ | | Linux client (GTK4 shell + Vulkan session) | ✅ |
| Android client (phone · TV) | ✅ | | Android client (phone · TV) | ✅ |
| Windows client | 🟡 | | Windows client | 🟡 |
| Web console + pairing | ✅ | | Web console + pairing | ✅ |
@@ -45,13 +45,14 @@ see [Status & Progress](/docs/status).
- **Secure by default** — SPAKE2 PIN pairing with pinned reconnects, one-click delegated approval from - **Secure by default** — SPAKE2 PIN pairing with pinned reconnects, one-click delegated approval from
the web console, and mDNS LAN auto-discovery. the web console, and mDNS LAN auto-discovery.
- **Tuned for latency** — concurrent sessions (stream one desktop to several devices at once), - **Tuned for latency** — concurrent sessions (stream one desktop to several devices at once),
mid-stream resolution renegotiation, a cross-machine clock-skew handshake, a 1 Gbps+ data plane, and mid-stream resolution renegotiation, a cross-machine clock-skew handshake, a 1 Gbps+ data plane,
an in-app network speed test that informs the bitrate picker. an in-app network speed test that informs the bitrate picker, and **automatic adaptive bitrate**
(the encoder re-targets mid-stream when bitrate is set to Automatic).
## 🟡 In progress ## 🟡 In progress
- **Windows client on-glass validation.** The hardware (D3D11VA) decode, HDR present, and GUI are - **Windows client on-glass validation.** Hardware decode and the GUI are validated on NVIDIA and
built and ship as a signed MSIX — they just need verification on real GPU hardware. Intel; the HDR present path still needs verification on real HDR hardware.
- **Apple presenter polish.** The lower-latency `VTDecompressionSession``CAMetalLayer` stage-2 - **Apple presenter polish.** The lower-latency `VTDecompressionSession``CAMetalLayer` stage-2
path is now the default; HDR brightness and 4:4:4 still need on-glass validation. path is now the default; HDR brightness and 4:4:4 still need on-glass validation.
- **Web console parity.** Surfacing the speed test and bitrate picker the apps already have. - **Web console parity.** Surfacing the speed test and bitrate picker the apps already have.
+17 -1
View File
@@ -20,6 +20,22 @@ life:
4. **displayed** — the picture is handed to the screen (as close to "photons" as the 4. **displayed** — the picture is handed to the screen (as close to "photons" as the
platform lets us measure). platform lets us measure).
## Detail levels
The overlay has four levels — **Off → Compact → Normal → Detailed** — that you cycle live
in-stream:
| Platform | Cycle with |
|---|---|
| Linux · Windows · Steam Deck | **Ctrl+Alt+Shift+S** |
| macOS / iPad (pointer or trackpad) | **⌃⌥⇧S** or a **three-finger tap** |
| Android · iPhone | a **three-finger tap** |
**Compact** is a one-line pill (fps · end-to-end ms · Mb/s, plus a loss flag when frames are being
lost). **Normal** adds the stream line and the p50/p95 headline. **Detailed** adds the decoder path,
HDR tag, and the per-stage breakdown. You can also set the level a stream starts at in each client's
Settings. The example below is the **Detailed** view.
## Reading the overlay ## Reading the overlay
``` ```
@@ -73,7 +89,7 @@ always spelled out rather than pretending:
| client | headline | why | | client | headline | why |
|---|---|---| |---|---|---|
| Windows, macOS/iOS (Metal presenter), Linux | `capture→on-glass` / `capture→displayed` | present instant available (GTK measures at hand-off to the compositor, which adds about one compositor cycle after it) | | Windows, macOS/iOS (Metal presenter), Linux | `capture→on-glass` / `capture→displayed` | present instant available (on Linux/Windows, measured right after the Vulkan swapchain present) |
| Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` | | Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` |
| macOS/iOS fallback presenter | `capture→received` | the system video layer hides decode and present timing entirely | | macOS/iOS fallback presenter | `capture→received` | the system video layer hides decode and present timing entirely |
+21 -11
View File
@@ -15,8 +15,8 @@ A high-level view of where punktfunk stands. The ordered plan of work is on the
| **Native protocol**`punktfunk/1` (QUIC control + UDP data, GF(2¹⁶) Leopard FEC + AES-GCM) | ✅ full session planes, validated live | | **Native protocol**`punktfunk/1` (QUIC control + UDP data, GF(2¹⁶) Leopard FEC + AES-GCM) | ✅ full session planes, validated live |
| **Windows host** (x64) | 🟡 implemented & shipping as a signed installer; NVIDIA/AMD/Intel encode, newer than the Linux host | | **Windows host** (x64) | 🟡 implemented & shipping as a signed installer; NVIDIA/AMD/Intel encode, newer than the Linux host |
| **macOS / iOS / iPadOS / tvOS client** | ✅ full client; on-glass-validated stage-2 presenter is the default | | **macOS / iOS / iPadOS / tvOS client** | ✅ full client; on-glass-validated stage-2 presenter is the default |
| **Linux client** (`punktfunk-client`, GTK4/libadwaita) | ✅ full client; VAAPI zero-copy decode + software fallback | | **Linux client** (`punktfunk-client`, relm4/GTK4 shell + Vulkan session) | ✅ full client; Vulkan Video → VAAPI → software decode |
| **Windows client** (`punktfunk-client`, WinUI 3) | ✅ stage 1 complete; ships as signed MSIX; on-glass hardware validation pending | | **Windows client** (`punktfunk-client`, WinUI 3) | ✅ ships as signed MSIX; hardware decode validated on NVIDIA + Intel; HDR on-glass validation pending |
| **Android client** (phone + Android TV) | ✅ full client; hardware HEVC decode + HDR10 | | **Android client** (phone + Android TV) | ✅ full client; hardware HEVC decode + HDR10 |
| **Web console** (over the management API) | ✅ status · devices · pairing | | **Web console** (over the management API) | ✅ status · devices · pairing |
@@ -54,10 +54,10 @@ host is newer than the Linux host.)
| Client | Highlights | | Client | Highlights |
|---|---| |---|---|
| **macOS / iOS / iPadOS / tvOS** | VideoToolbox HEVC decode, GameController capture, full DualSense feedback, mDNS discovery, PIN pairing + TOFU, network speed test, latency HUD. Stage-2 presenter (`VTDecompressionSession``CAMetalLayer`, ~11 ms p50 capture→present) is validated on glass and is the default (stage 1 remains the fallback when Metal is unavailable). Ships as one universal TestFlight build / App Store listing. | | **macOS / iOS / iPadOS / tvOS** | VideoToolbox HEVC + AV1 decode (AV1 on hardware that decodes it — M3-class Macs, A17 Pro-class iPhones), GameController capture, full DualSense feedback, mDNS discovery, PIN pairing + TOFU, network speed test, latency HUD. Stage-2 presenter (`VTDecompressionSession``CAMetalLayer`, ~11 ms p50 capture→present) is validated on glass and is the default (stage 1 remains the fallback when Metal is unavailable). Ships as one universal TestFlight build / App Store listing. |
| **Linux** (`punktfunk-client`) | GTK4/libadwaita. FFmpeg decode with VAAPI → DRM-PRIME dmabuf zero-copy (Intel/AMD; software fallback on NVIDIA), PipeWire audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, PIN pairing + TOFU, speed test. Ships as Flatpak, apt, rpm, and Arch packages. | | **Linux** (`punktfunk-client`) | relm4/GTK4 shell; streams run in the spawned Vulkan session presenter. Hardware decode via Vulkan Video (all vendors, incl. NVIDIA) with VAAPI dmabuf and software fallbacks, PipeWire audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, PIN pairing + TOFU, speed test, Skia console UI + tiered stats overlay. Ships as Flatpak, apt, rpm, and Arch packages. |
| **Windows** (`punktfunk-client`) | WinUI 3. D3D11VA zero-copy decode, HDR10, WASAPI audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, and the full PIN/TOFU trust surface are all implemented. Ships as a signed MSIX (x86_64 + ARM64). **Stage 1 complete; D3D11VA decode, HDR present, and the GUI are pending on-glass validation on real GPU hardware.** | | **Windows** (`punktfunk-client`) | WinUI 3 shell; streams run in the Vulkan session presenter with a Vulkan Video → D3D11VA → software decode chain, HDR10, WASAPI audio + mic, SDL3 gamepads incl. DualSense, mDNS discovery, and the full PIN/TOFU trust surface. Ships as a signed MSIX (x86_64 + ARM64). **Hardware decode validated on NVIDIA and Intel; HDR present pending on-glass validation.** |
| **Android** (phone + Android TV) | Kotlin app with a Rust core over JNI. NDK `AMediaCodec` hardware HEVC decode + HDR10 (Main10/BT.2020 PQ), Opus/Oboe audio + mic, gamepad input with rumble/HID feedback, mDNS discovery, PIN pairing + TOFU (Keystore identity), live stats HUD, and D-pad/controller focus navigation for TV. Ships to the Google Play Internal Testing track. | | **Android** (phone + Android TV) | Kotlin app with a Rust core over JNI. NDK `AMediaCodec` hardware HEVC decode + HDR10 (Main10/BT.2020 PQ), Opus/Oboe audio + mic, gamepad input with rumble/HID feedback, mDNS discovery, PIN pairing + TOFU (Keystore identity), tiered stats overlay (three-finger tap), custom resolutions, and D-pad/controller focus navigation for TV. Ships to the Google Play Internal Testing track. |
`punktfunk-probe` is a headless reference and measurement client (for testing and `punktfunk-probe` is a headless reference and measurement client (for testing and
benchmarking, not everyday use). benchmarking, not everyday use).
@@ -68,7 +68,7 @@ The stack has been validated live on a range of hosts and clients:
- **Hosts:** Ubuntu (GNOME / KDE), Fedora KDE, and Bazzite (gamescope) on machines with - **Hosts:** Ubuntu (GNOME / KDE), Fedora KDE, and Bazzite (gamescope) on machines with
RTX-class NVIDIA GPUs, across the KWin, gamescope, Mutter, and Sway/wlroots backends. RTX-class NVIDIA GPUs, across the KWin, gamescope, Mutter, and Sway/wlroots backends.
- **Clients:** native macOS, Linux, and Android clients against live hosts, plus stock - **Clients:** native macOS, Linux, Windows, and Android clients against live hosts, plus stock
Moonlight clients over the GameStream path. Moonlight clients over the GameStream path.
- **Cross-machine latency** is measured and skew-corrected (a wall-clock handshake removes - **Cross-machine latency** is measured and skew-corrected (a wall-clock handshake removes
the inter-machine clock offset), so capture-to-present numbers are valid across the LAN. the inter-machine clock offset), so capture-to-present numbers are valid across the LAN.
@@ -77,11 +77,21 @@ The stack has been validated live on a range of hosts and clients:
Notable capabilities that have landed, newest first: Notable capabilities that have landed, newest first:
- **Tiered stats overlay everywhere.** One shared stats vocabulary with **Compact / Normal /
Detailed** levels on every client, cycled live in-stream — see
[Understanding the Stats Overlay](/docs/stats).
- **Per-client display scaling on GNOME.** The host persists each client's scale itself and
reapplies it on reconnect (GNOME's virtual-monitor API exposes no stable identity to key it on).
- **Adaptive bitrate.** With bitrate set to **Automatic**, the host re-targets the encoder
mid-stream as the link's measured capacity changes.
- **Gamepad console shell.** The session client carries a full controller-driven shell — host
list, PIN pairing, settings, screen transitions, and an on-screen keyboard — usable with no
desktop at all (e.g. gamescope on a Steam Deck).
- **Native Linux client (stage 1).** GTK4/libadwaita app that links `punktfunk-core` - **Native Linux client (stage 1).** GTK4/libadwaita app that links `punktfunk-core`
directly: mDNS host list, TOFU + SPAKE2 PIN pairing, FFmpeg HEVC decode, PipeWire audio directly: mDNS host list, TOFU + SPAKE2 PIN pairing, FFmpeg HEVC decode, PipeWire audio
with mic uplink, SDL3 gamepad capture with rumble/lightbar feedback, layout-independent with mic uplink, SDL3 gamepad capture with rumble/lightbar feedback, layout-independent
keyboard, absolute mouse, fullscreen, and a stats overlay. VAAPI → `GdkDmabufTexture` keyboard, absolute mouse, fullscreen, and a stats overlay. (Since re-architected: streams
zero-copy decode on Intel/AMD with a proven software fallback. now run in a spawned Vulkan session presenter with Vulkan Video decode on all vendors.)
- **Delegated pairing approval.** An unpaired device that knocks on a pairing-required host - **Delegated pairing approval.** An unpaired device that knocks on a pairing-required host
appears as a pending request in the web console's Pairing page; one click approves and appears as a pending request in the web console's Pairing page; one click approves and
pins its certificate — no PIN fetched out of band. pins its certificate — no PIN fetched out of band.
@@ -112,7 +122,7 @@ See the [Roadmap](/docs/roadmap) for the ordered list. Near-term:
- **True glass-to-glass latency** — combine the client present-stamp (decode → present) - **True glass-to-glass latency** — combine the client present-stamp (decode → present)
with the host render → capture term for a complete end-to-end number. with the host render → capture term for a complete end-to-end number.
- **On-glass validation of the Windows client** (D3D11VA decode, HDR present, GUI) on real - **On-glass validation of the Windows client's HDR present path** (hardware decode and the
GPU hardware. GUI are already validated on NVIDIA and Intel).
- **gamescope multi-user isolation** — per-session input/audio so concurrent sessions can - **gamescope multi-user isolation** — per-session input/audio so concurrent sessions can
be fully independent desktops (the shared-desktop multi-view case already works). be fully independent desktops (the shared-desktop multi-view case already works).
+4 -4
View File
@@ -19,9 +19,9 @@ opened on.
> you also use in person, or a multi-monitor workstation. > you also use in person, or a multi-monitor workstation.
> **What's live today:** **keep-alive** (linger, or **forever**), **topology** (extend / primary / > **What's live today:** **keep-alive** (linger, or **forever**), **topology** (extend / primary /
> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows *and* > exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows, KDE/KWin
> KDE/KWin), and **multi-monitor layout** (several clients as monitors of one desktop) are all > *and* GNOME/Mutter), and **multi-monitor layout** (several clients as monitors of one desktop) are
> enforced. A reconnect always resumes the kept display — even a fast one — instead of spawning a > all enforced. A reconnect always resumes the kept display — even a fast one — instead of spawning a
> second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, Sway > second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, Sway
> `exclusive`, and multi-display for a *single* client (that last is the next stage). > `exclusive`, and multi-display for a *single* client (that last is the next stage).
@@ -153,7 +153,7 @@ client a *stable display identity*, so your desktop environment keys its per-mon
|---|---|---| |---|---|---|
| **Windows** | ✅ today | Connect, set scaling in Settings while streaming — Windows remembers it per client. | | **Windows** | ✅ today | Connect, set scaling in Settings while streaming — Windows remembers it per client. |
| **KDE / KWin** | ✅ today | Set scaling in System Settings while streaming; KWin keys it to a stable per-client output name and reapplies it on reconnect. Validated live (150 %/125 % survive a full disconnect + reconnect). | | **KDE / KWin** | ✅ today | Set scaling in System Settings while streaming; KWin keys it to a stable per-client output name and reapplies it on reconnect. Validated live (150 %/125 % survive a full disconnect + reconnect). |
| **GNOME / Mutter** | | GNOME's virtual-monitor API exposes no stable identity to key config on. | | **GNOME / Mutter** | ✅ today | GNOME's virtual-monitor API exposes no stable identity to key config on, so the **host persists the scale itself**: set scaling in Settings while streaming — the host captures the change, remembers it per client, and reapplies it on reconnect. |
| **Sway / wlroots** | ❌ | Headless outputs can't carry a stable identity; pin scale in your sway config instead. | | **Sway / wlroots** | ❌ | Headless outputs can't carry a stable identity; pin scale in your sway config instead. |
## Legacy environment knobs ## Legacy environment knobs
+24
View File
@@ -164,6 +164,10 @@
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields). // Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
#define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4) #define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
// client's snapshot fold and the host's per-pad accumulators.
#define MAX_PADS 16
#define PUNKTFUNK_BTN_DPAD_UP 1 #define PUNKTFUNK_BTN_DPAD_UP 1
#define PUNKTFUNK_BTN_DPAD_DOWN 2 #define PUNKTFUNK_BTN_DPAD_DOWN 2
@@ -295,6 +299,15 @@
#define APP_EXITED_CLOSE_CODE 82 #define APP_EXITED_CLOSE_CODE 82
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
// lossy datagram plane, periodically refreshed) instead of the fragile per-transition
// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
#define HOST_CAP_GAMEPAD_STATE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** // [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST // encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
@@ -550,6 +563,17 @@ enum PunktfunkInputKind
PUNKTFUNK_INPUT_KIND_TOUCH_MOVE = 10, PUNKTFUNK_INPUT_KIND_TOUCH_MOVE = 10,
// Touch ends. Only `code` (the touch id) is used. // Touch ends. Only `code` (the touch id) is used.
PUNKTFUNK_INPUT_KIND_TOUCH_UP = 11, PUNKTFUNK_INPUT_KIND_TOUCH_UP = 11,
// Full gamepad state in one event ([`GamepadSnapshot`]) — idempotent, sequence-numbered.
//
// The per-transition [`GamepadButton`](Self::GamepadButton)/[`GamepadAxis`](Self::GamepadAxis)
// events are fragile on the unreliable datagram plane: a dropped or reordered event corrupts
// the host's accumulated pad state until the *next* change (a held trigger stays wrong
// indefinitely). A snapshot carries the whole pad, so loss heals on the next send and the
// sequence number lets the host drop stale reorders — the same idempotent-state discipline
// as the host→client rumble refresh. Sent only when the host advertised
// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep
// receiving the per-transition events.
PUNKTFUNK_INPUT_KIND_GAMEPAD_STATE = 12,
}; };
#ifndef __cplusplus #ifndef __cplusplus
#if __STDC_VERSION__ >= 202311L #if __STDC_VERSION__ >= 202311L
+1 -1
View File
@@ -339,7 +339,7 @@ control port (UDP 9777) + mDNS + the mgmt/library API (TCP 47990, HTTPS + mTLS).
host runs `serve --gamestream` (both planes). The host runs `serve --gamestream` (both planes). The
per-port breakdown below is for reference (or for opening ports by hand); the ports are the code per-port breakdown below is for reference (or for opening ports by hand); the ports are the code
constants (`crates/punktfunk-host/src/gamestream/mod.rs`, `mgmt.rs`) and the GameStream-host port-map constants (`crates/punktfunk-host/src/gamestream/mod.rs`, `mgmt.rs`) and the GameStream-host port-map
(`design/gamestream-host-plan.md`). (punktfunk-planning: `gamestream-host-plan.md`).
**GameStream / Moonlight ports** (fixed; Moonlight derives them from the HTTP base). These only apply **GameStream / Moonlight ports** (fixed; Moonlight derives them from the HTTP base). These only apply
when the host runs `serve --gamestream` (the bundled unit's default); on a bare-`serve` native-only when the host runs `serve --gamestream` (the bundled unit's default); on a bare-`serve` native-only
+2 -1
View File
@@ -2,7 +2,8 @@
`punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public `punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public
`unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds `unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds
and publishes on every push to `main` (a rolling `0.5.0~ciN.g<sha>` build to the **`canary`** apt and publishes on every push to `main` (a rolling `<next-minor>~ciN.g<sha>` build the base is
derived from the latest stable tag by `scripts/ci/pf-version.sh` — to the **`canary`** apt
distribution) and on `vX.Y.Z` tags (a clean `X.Y.Z` to the **`stable`** distribution, plus attached distribution) and on `vX.Y.Z` tags (a clean `X.Y.Z` to the **`stable`** distribution, plus attached
to the unified Gitea Release). The two are separate apt distributions, so a stable box never jumps to the unified Gitea Release). The two are separate apt distributions, so a stable box never jumps
to a canary build — see [Release Channels](https://punktfunk.unom.io/docs/channels). The repo line to a canary build — see [Release Channels](https://punktfunk.unom.io/docs/channels). The repo line
+13 -4
View File
@@ -4,7 +4,8 @@ The native Linux **client** — the shell (crate `punktfunk-client-linux`, binar
`punktfunk-client`) plus the Vulkan session binary it execs for streaming (crate `punktfunk-client`) plus the Vulkan session binary it execs for streaming (crate
`punktfunk-client-session`, binary `punktfunk-session`) — is `punktfunk-client-session`, binary `punktfunk-session`) — is
published two ways by CI (`.gitea/workflows/flatpak.yml`), on every push to `main` (a rolling published two ways by CI (`.gitea/workflows/flatpak.yml`), on every push to `main` (a rolling
`0.0.1-ciN.<sha>` build) and on `v*` tags (a clean `X.Y.Z`): `<next-minor>-ciN.g<sha>` build, base derived from the latest stable tag by
`scripts/ci/pf-version.sh`) and on `v*` tags (a clean `X.Y.Z`):
1. **Hosted OSTree repo at `https://flatpak.unom.io`** (recommended) — a GPG-signed Flatpak 1. **Hosted OSTree repo at `https://flatpak.unom.io`** (recommended) — a GPG-signed Flatpak
remote served by a static Caddy container on unom-1, so users **install once and then remote served by a static Caddy container on unom-1, so users **install once and then
@@ -58,11 +59,11 @@ per-user (no root, survives SteamOS updates). This is what the Decky plugin uses
repo above is the better path for a human on the Deck: repo above is the better path for a human on the Deck:
```sh ```sh
# Pick a version: a tag like 1.2.3, or the newest main build's 0.0.1-ciN.gSHA. # Pick a version: a tag like 1.2.3, or the newest main build's <next-minor>-ciN.gSHA.
VER=1.2.3 VER=1.2.3
URL="https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/$VER/punktfunk-client-$VER.flatpak" URL="https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/$VER/punktfunk-client-$VER.flatpak"
# Flathub must be enabled (it is on the Deck) so the GNOME runtime + ffmpeg-full pull in: # Flathub must be enabled (it is on the Deck) so the GNOME runtime + codecs-extra extension pull in:
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
curl -fL -o /tmp/punktfunk-client.flatpak "$URL" curl -fL -o /tmp/punktfunk-client.flatpak "$URL"
@@ -138,11 +139,19 @@ extension point (auto-downloaded with the runtime; no app-side codec declaration
`--device=all` (GPU/VAAPI render node + evdev + the hidraw char-devices SDL3 needs for DualSense) `--device=all` (GPU/VAAPI render node + evdev + the hidraw char-devices SDL3 needs for DualSense)
+ `--socket=pulseaudio` (PipeWire-pulse: playback + mic) + `--share=network`. Alongside it: + `--socket=pulseaudio` (PipeWire-pulse: playback + mic) + `--share=network`. Alongside it:
`io.unom.Punktfunk.desktop`, `io.unom.Punktfunk.metainfo.xml`, `io.unom.Punktfunk.svg` (all `io.unom.Punktfunk.desktop`, `io.unom.Punktfunk.metainfo.xml`, `io.unom.Punktfunk.svg` (all
installed by the manifest). `cargo-sources.json` (the offline crate cache) is a pure function of installed by the manifest). A `vulkan-headers` module supplies what the session binary's ash/Vulkan
build needs. `cargo-sources.json` (the offline crate cache) is a pure function of
`Cargo.lock`; CI regenerates it each build and it is **gitignored** — generate it on any box with `Cargo.lock`; CI regenerates it each build and it is **gitignored** — generate it on any box with
network + `python3`/`aiohttp`/`tomlkit` (`build-flatpak.sh` does this automatically) and, for a network + `python3`/`aiohttp`/`tomlkit` (`build-flatpak.sh` does this automatically) and, for a
build host that lacks those (the Deck), rsync the generated file in alongside the manifest. build host that lacks those (the Deck), rsync the generated file in alongside the manifest.
**Offline Skia:** the session binary's Skia console UI (`pf-console-ui``skia-safe`) normally
downloads prebuilt `libskia` binaries at build time, which is dead in the offline sandbox — so the
manifest pins a `skia-binaries-….tar.gz` source and points the build at it with
`SKIA_BINARIES_URL: file://…`. When bumping the `skia-safe`/`skia-bindings` crate version, update
that pinned tarball (URL + sha256) to the matching `skia-binaries` release or the build breaks
offline.
## Hosting the repo (unom-1) + one-time setup ## Hosting the repo (unom-1) + one-time setup
The OSTree repo flatpak-builder produces is GPG-signed in CI and rsynced to unom-1, where a tiny The OSTree repo flatpak-builder produces is GPG-signed in CI and rsynced to unom-1, where a tiny
+1 -1
View File
@@ -12,7 +12,7 @@ binds the kernel `hid-steam` driver, but **Steam Input will not manage it**: Ste
controller to USB **interface 2**, and a UHID device has no USB interface number (`Interface: -1` in controller to USB **interface 2**, and a UHID device has no USB interface number (`Interface: -1` in
Steam's `controller.txt`), so Steam enumerates it but never promotes it. A single-interface DualSense Steam's `controller.txt`), so Steam enumerates it but never promotes it. A single-interface DualSense
is accepted at `-1` (no ambiguity), but the multi-interface Deck specifically needs interface 2. See is accepted at `-1` (no ambiguity), but the multi-interface Deck specifically needs interface 2. See
`design/steam-controller-deck-support.md` §11. punktfunk-planning: `steam-controller-deck-support.md` §11.
A real multi-interface USB device with the controller on interface 2 requires a **USB gadget**. A real multi-interface USB device with the controller on interface 2 requires a **USB gadget**.
SteamOS ships every piece (`CONFIG_USB_DUMMY_HCD=m`, `CONFIG_USB_RAW_GADGET=m`, SteamOS ships every piece (`CONFIG_USB_DUMMY_HCD=m`, `CONFIG_USB_RAW_GADGET=m`,
@@ -17,7 +17,7 @@ sudo modprobe vhci_hcd
sudo usbip attach -r 127.0.0.1 -b 0-0-0 sudo usbip attach -r 127.0.0.1 -b 0-0-0
``` ```
See `design/steam-deck-passthrough-plan.md` for the production build plan (vendor-trim the crate to See punktfunk-planning: `steam-deck-passthrough-plan.md` for the production build plan (vendor-trim the crate to
drop the `rusb`/libusb dep; in-process `vhci_hcd` attach to avoid the `usbip` CLI; transport-select drop the `rusb`/libusb dep; in-process `vhci_hcd` attach to avoid the `usbip` CLI; transport-select
`raw_gadget``usbip`→UHID). `usbip` crate API: a custom `UsbInterfaceHandler` `raw_gadget``usbip`→UHID). `usbip` crate API: a custom `UsbInterfaceHandler`
`get_class_specific_descriptor()` = the 9-byte HID descriptor; `handle_urb()` dispatches EP0 `get_class_specific_descriptor()` = the 9-byte HID descriptor; `handle_urb()` dispatches EP0
+2 -1
View File
@@ -3,7 +3,8 @@
`punktfunk-host` is published as an RPM to **Gitea's RPM package registry** in the public `unom` `punktfunk-host` is published as an RPM to **Gitea's RPM package registry** in the public `unom`
org (stable groups `bazzite`/`fedora-44`, canary groups `bazzite-canary`/`fedora-44-canary`), so org (stable groups `bazzite`/`fedora-44`, canary groups `bazzite-canary`/`fedora-44-canary`), so
Bazzite / Fedora Atomic hosts layer and update it with `rpm-ostree`. CI (`.gitea/workflows/rpm.yml`) Bazzite / Fedora Atomic hosts layer and update it with `rpm-ostree`. CI (`.gitea/workflows/rpm.yml`)
builds and publishes on every push to `main` (a rolling `0.5.0-0.ciN.g<sha>` build to the `*-canary` builds and publishes on every push to `main` (a rolling `<next-minor>-0.ciN.g<sha>` build the base
is derived from the latest stable tag by `scripts/ci/pf-version.sh` — to the `*-canary`
groups) and on `vX.Y.Z` tags (a clean `X.Y.Z-1` to the base groups, plus attached to the unified groups) and on `vX.Y.Z` tags (a clean `X.Y.Z-1` to the base groups, plus attached to the unified
Gitea Release) — separate repos, so a stable box never jumps to a canary build (see Gitea Release) — separate repos, so a stable box never jumps to a canary build (see
[Release Channels](https://punktfunk.unom.io/docs/channels)). The `baseurl` below subscribes to the [Release Channels](https://punktfunk.unom.io/docs/channels)). The `baseurl` below subscribes to the
+4 -3
View File
@@ -3,7 +3,7 @@
A one-file, signed `setup.exe` for the punktfunk streaming **host** on Windows, published to Gitea's A one-file, signed `setup.exe` for the punktfunk streaming **host** on Windows, published to Gitea's
generic package registry (`punktfunk-host-windows`) by `.gitea/workflows/windows-host.yml`. generic package registry (`punktfunk-host-windows`) by `.gitea/workflows/windows-host.yml`.
> Full picture (drivers-from-source, toolchain, CI, dev loop): **[`design/windows-build-and-packaging.md`](../../design/windows-build-and-packaging.md)**. This README is the `packaging/windows/` file index. > Full picture (drivers-from-source, toolchain, CI, dev loop): **punktfunk-planning: `windows-build-and-packaging.md`** (internal planning repo). This README is the `packaging/windows/` file index.
## Windows 11 22H2+ only (no Windows 10) ## Windows 11 22H2+ only (no Windows 10)
@@ -135,7 +135,7 @@ fresh install uses the generated random console password — read it from
> needed). Building from source keeps `.dll`/`.inf`/`.cat` in lockstep. nefcon (the device-node tool - > needed). Building from source keeps `.dll`/`.inf`/`.cat` in lockstep. nefcon (the device-node tool -
> the install creates the `root\pf_vdisplay` node with it, **never** `devgen`, which leaves persistent > the install creates the `root\pf_vdisplay` node with it, **never** `devgen`, which leaves persistent
> phantom devices) is fetched + SHA-256-verified from its pinned release in `stage-pf-vdisplay.ps1`. See > phantom devices) is fetched + SHA-256-verified from its pinned release in `stage-pf-vdisplay.ps1`. See
> [`design/windows-build-and-packaging.md`](../../design/windows-build-and-packaging.md) for the toolchain > punktfunk-planning: `windows-build-and-packaging.md` (internal planning repo) for the toolchain
> + signing details. > + signing details.
## Dev iteration on the test box (driver) ## Dev iteration on the test box (driver)
@@ -179,4 +179,5 @@ Push a `vX.Y.Z` tag — one tag releases every platform (see
[Release Channels](https://punktfunk.unom.io/docs/channels)). The workflow builds, signs, and [Release Channels](https://punktfunk.unom.io/docs/channels)). The workflow builds, signs, and
publishes `punktfunk-host-setup-X.Y.Z.exe` + the public `.cer`, refreshes the stable `latest/` publishes `punktfunk-host-setup-X.Y.Z.exe` + the public `.cer`, refreshes the stable `latest/`
alias, and attaches the installer to the unified Gitea Release. Main pushes publish rolling alias, and attaches the installer to the unified Gitea Release. Main pushes publish rolling
`0.3.<run>` **canary** builds to the `canary/` alias. `<next-minor>.<run>` **canary** builds (base derived from the latest stable tag by
`scripts/ci/pf-version.ps1`) to the `canary/` alias.
@@ -87,7 +87,7 @@ silently breaks them:
WUDFHost (so the per-instance statics don't collide), and the driver reads its pad index from the WUDFHost (so the per-instance statics don't collide), and the driver reads its pad index from the
device Location (`WdfDeviceAllocAndQueryProperty`) to poll its own `*-boot-<index>` bootstrap device Location (`WdfDeviceAllocAndQueryProperty`) to poll its own `*-boot-<index>` bootstrap
mailbox (the DATA section itself is unnamed — the sealed pad channel, mailbox (the DATA section itself is unnamed — the sealed pad channel,
`design/gamepad-channel-sealing.md` — and its `pad_index` is validated against this index on punktfunk-planning: `gamepad-channel-sealing.md` — and its `pad_index` is validated against this
attach). index on attach).
- Port of the WDK `vhidmini2` UMDF2 sample; the DualSense identity + 273-byte descriptor + feature - Port of the WDK `vhidmini2` UMDF2 sample; the DualSense identity + 273-byte descriptor + feature
blobs `0x05`/`0x09`/`0x20` come from `crates/punktfunk-host/src/inject/dualsense.rs`. blobs `0x05`/`0x09`/`0x20` come from `crates/punktfunk-host/src/inject/proto/dualsense_proto.rs`.
+1 -1
View File
@@ -15,7 +15,7 @@ instance (= player slot 03) with `CreateFile`, and polls it with buffered IOC
- registers the XUSB interface with `WdfDeviceCreateDeviceInterface(device, &XUSB_GUID, NULL)`; - registers the XUSB interface with `WdfDeviceCreateDeviceInterface(device, &XUSB_GUID, NULL)`;
- answers the XUSB IOCTLs (all `METHOD_BUFFERED`, delivered to user mode by the reflector) from - answers the XUSB IOCTLs (all `METHOD_BUFFERED`, delivered to user mode by the reflector) from
controller state the host publishes into an **unnamed** shared DATA section reached over the controller state the host publishes into an **unnamed** shared DATA section reached over the
**sealed pad channel** (`design/gamepad-channel-sealing.md`): the host duplicates the section **sealed pad channel** (punktfunk-planning: `gamepad-channel-sealing.md`): the host duplicates the section
handle into this driver's WUDFHost, bootstrapped via the named `Global\pfxusb-boot-<index>` handle into this driver's WUDFHost, bootstrapped via the named `Global\pfxusb-boot-<index>`
mailbox (`pf_driver_proto::gamepad::PadBootstrap`); a game's rumble (`SET_STATE`) is published mailbox (`pf_driver_proto::gamepad::PadBootstrap`); a game's rumble (`SET_STATE`) is published
back for the host to forward to the client. back for the host to forward to the client.
+74
View File
@@ -0,0 +1,74 @@
#!/bin/sh
# Post-link floor check for libpunktfunk_android.so: every undefined dynamic symbol must be
# defined in the NDK's minSdk-level stub libraries, for every built ABI.
#
# Why this exists: a Rust cdylib links fine with dangling undefined symbols (lld only rejects them
# under --no-undefined, which rustc does not pass), so a hard import of an above-floor NDK entry
# point sails through `cargo ndk --platform 28` and only explodes at `System.loadLibrary` on every
# device below the symbol's introduction level — 0.9.0 shipped exactly this
# (AMediaCodec_setOnFrameRenderedCallback, API 33) and bricked the client on all pre-Android-13
# devices. Above-floor entry points must be dlsym-resolved at runtime instead (see
# decode::try_set_frame_rate / decode::install_render_callback / adpf). This script makes the
# violation a build failure. Wired into clients/android/kit/build.gradle.kts after each cargo-ndk
# build (local + CI).
#
# Usage: check-android-jni-imports.sh <ndk-dir> <jniLibs-dir> <api-level>
set -eu
NDK=$1
JNILIBS=$2
API=$3
# Host-agnostic prebuilt dir (linux-x86_64 on CI and the dev box, darwin-* on a Mac).
PREBUILT=$(echo "$NDK"/toolchains/llvm/prebuilt/*)
NM="$PREBUILT/bin/llvm-nm"
[ -x "$NM" ] || { echo "check-android-jni-imports: llvm-nm not found under $NDK" >&2; exit 1; }
triple_for_abi() {
case "$1" in
arm64-v8a) echo aarch64-linux-android ;;
armeabi-v7a) echo arm-linux-androideabi ;;
x86_64) echo x86_64-linux-android ;;
x86) echo i686-linux-android ;;
*) echo "" ;;
esac
}
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
checked=0
fail=0
for so in "$JNILIBS"/*/libpunktfunk_android.so; do
[ -f "$so" ] || continue
abi=$(basename "$(dirname "$so")")
triple=$(triple_for_abi "$abi")
if [ -z "$triple" ]; then
echo "check-android-jni-imports: unknown ABI dir '$abi' — extend triple_for_abi" >&2
exit 1
fi
stubs="$PREBUILT/sysroot/usr/lib/$triple/$API"
[ -d "$stubs" ] || { echo "check-android-jni-imports: no stub dir $stubs" >&2; exit 1; }
# Strong undefined imports only ('U'; weak 'w'/'v' resolve to null and are fine), and the
# stubs' exports, both with the @VERSION / @@VERSION suffix stripped.
"$NM" -D --undefined-only "$so" | awk '$1 == "U" { print $2 }' | sed 's/@.*//' | sort -u \
> "$TMP/undef"
for stub in "$stubs"/*.so; do
"$NM" -D --defined-only "$stub" 2>/dev/null | awk '{ print $NF }' | sed 's/@.*//'
done | sort -u > "$TMP/defined"
missing=$(comm -23 "$TMP/undef" "$TMP/defined")
if [ -n "$missing" ]; then
echo "check-android-jni-imports: $abi libpunktfunk_android.so imports symbols absent from" >&2
echo "the API-$API sysroot — System.loadLibrary would fail on devices at the minSdk floor." >&2
echo "dlsym-resolve these at runtime instead of hard-linking them:" >&2
echo "$missing" | sed 's/^/ /' >&2
fail=1
fi
checked=$((checked + 1))
done
[ "$checked" -gt 0 ] || { echo "check-android-jni-imports: no libpunktfunk_android.so under $JNILIBS" >&2; exit 1; }
[ "$fail" -eq 0 ] && echo "check-android-jni-imports: $checked ABI(s) clean at the API-$API floor"
exit "$fail"
@@ -34,13 +34,25 @@ if (Test-Path $rustup) {
# shipped installer/MSIX stay consistent with punktfunk's MIT OR Apache-2.0 posture. # shipped installer/MSIX stay consistent with punktfunk's MIT OR Apache-2.0 posture.
# MIGRATION: a runner previously provisioned with the old *gpl-shared* trees must be # MIGRATION: a runner previously provisioned with the old *gpl-shared* trees must be
# re-provisioned - delete C:\Users\Public\ffmpeg and C:\Users\Public\ffmpeg-arm64, then re-run. # re-provisioned - delete C:\Users\Public\ffmpeg and C:\Users\Public\ffmpeg-arm64, then re-run.
# These DLLs are bundled verbatim into the code-signed host installer/MSIX, so the download is
# SHA-256-pinned (like VB-CABLE below): BtbN's `latest` tag is a ROLLING release whose assets are
# re-uploaded over time, so an unverified fetch would let a hijacked/MITM'd upstream asset land
# signed DLLs in users' installs. The pins below were captured 2026-07-10 from the then-current
# n7.1 lgpl-shared build. When BtbN re-rolls `latest`, this fetch FAILS CLOSED (hash mismatch) —
# that is intentional: re-download, re-verify the new archive, and update the two pins here.
# Refresh a pin: (Get-FileHash .\ffmpeg-<tag>.zip -Algorithm SHA256).Hash
function Get-BtbnFfmpeg { function Get-BtbnFfmpeg {
param([string]$Dir, [string]$ZipTag) # ZipTag: 'win64' (x64) or 'winarm64' (ARM64 cross tree) param([string]$Dir, [string]$ZipTag, [string]$Sha) # ZipTag: 'win64' (x64) or 'winarm64' (ARM64 cross tree)
if (Test-Path (Join-Path $Dir 'lib\avcodec.lib')) { info "FFmpeg ($ZipTag) already present at $Dir"; return } if (Test-Path (Join-Path $Dir 'lib\avcodec.lib')) { info "FFmpeg ($ZipTag) already present at $Dir"; return }
info "fetching FFmpeg ($ZipTag, BtbN lgpl-shared)" info "fetching FFmpeg ($ZipTag, BtbN lgpl-shared, SHA-256 pinned)"
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-$ZipTag-lgpl-shared-7.1.zip" $url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-$ZipTag-lgpl-shared-7.1.zip"
$zip = "$Dir.zip"; $tmp = "$Dir-extract" $zip = "$Dir.zip"; $tmp = "$Dir-extract"
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
$got = (Get-FileHash $zip -Algorithm SHA256).Hash
if ($got -ne $Sha) {
Remove-Item $zip -Force
throw "FFmpeg ($ZipTag) download hash mismatch (got $got, pinned $Sha). BtbN re-rolled the 'latest' build; re-verify the new archive and update the pinned SHA in this script before shipping."
}
if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp } if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp }
Expand-Archive -Path $zip -DestinationPath $tmp -Force # BtbN zips have one top-level folder Expand-Archive -Path $zip -DestinationPath $tmp -Force # BtbN zips have one top-level folder
$inner = Get-ChildItem $tmp -Directory | Select-Object -First 1 $inner = Get-ChildItem $tmp -Directory | Select-Object -First 1
@@ -48,8 +60,8 @@ function Get-BtbnFfmpeg {
Move-Item -Path $inner.FullName -Destination $Dir Move-Item -Path $inner.FullName -Destination $Dir
Remove-Item -Force $zip; Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue Remove-Item -Force $zip; Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
} }
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg" -ZipTag 'win64' Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg" -ZipTag 'win64' -Sha '89F3469706E5D53AEA5CF34AEE63E62CE746E6159D7AEE473D330B02A47558E6'
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg-arm64" -ZipTag 'winarm64' Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg-arm64" -ZipTag 'winarm64' -Sha 'D96B4CE08CEBDCC6AD0E3934A3F962915E440EEFB9D73831AFEA4D80E35129A5'
# --- Vulkan-Headers (pf-ffvk's bindgen: libavutil/hwcontext_vulkan.h includes <vulkan/vulkan.h>, # --- Vulkan-Headers (pf-ffvk's bindgen: libavutil/hwcontext_vulkan.h includes <vulkan/vulkan.h>,
# and Windows has no system copy). Headers only - the loader (vulkan-1.dll) is a GPU-driver # and Windows has no system copy). Headers only - the loader (vulkan-1.dll) is a GPU-driver
+27
View File
@@ -0,0 +1,27 @@
# shellcheck shell=bash
# Run a command, retrying with linear backoff: bash scripts/ci/retry.sh <attempts> <cmd> [args…]
#
# WHY THIS EXISTS: the runner box executes many jobs in parallel and its network drops
# packets under that load — observed as instant "[6] Could not resolve hostname" from
# flatpak/curl (dropped UDP DNS) and "dial tcp: i/o timeout" from the deploy steps
# (dropped TCP SYNs to unom-1), each seconds after other network calls in the same job
# succeeded. Tools with built-in mirror retries (dnf) ride it out; single-shot fetches
# (flatpak remote-add, rsync, ssh) killed the whole heavy flatpak build on one dropped
# packet. Wrap every single-shot network command in CI with this instead.
#
# Backoff is attempt*10s (10/20/30/…), so 5 attempts spread over ~1.5 min — long enough
# to outlive a load burst, short enough not to eat the job timeout. The wrapped command's
# stdout passes through untouched (safe for $(…) capture); retry chatter goes to stderr.
set -u
attempts="$1"; shift
rc=1
for i in $(seq 1 "$attempts"); do
"$@" && exit 0
rc=$?
[ "$i" -eq "$attempts" ] && break
echo "::warning::attempt $i/$attempts failed (rc=$rc): $* — retrying in $((i * 10))s" >&2
sleep $((i * 10))
done
echo "::error::all $attempts attempts failed (rc=$rc): $*" >&2
exit "$rc"
+9
View File
@@ -60,6 +60,15 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
# Lower (e.g. 3000) to reclaim kept displays sooner after an # Lower (e.g. 3000) to reclaim kept displays sooner after an
# ungraceful drop; clamped ≥1s, keep-alive ping scales with it so a # ungraceful drop; clamped ≥1s, keep-alive ping scales with it so a
# live session never false-disconnects. A deliberate quit is instant. # live session never false-disconnects. A deliberate quit is instant.
# Session recovery hook: fired (debounced, ≥60 s apart) when a client connects while NO graphical
# session is live for this uid — e.g. a compositor crash dropped the box to the GDM greeter, whose
# auto-login only runs once per boot, so the box would otherwise need a walk-up login or a reboot.
# Restarting the display manager re-runs auto-login and the client's retry lands in the recovered
# desktop. Runs detached via `sh -c` as the host's user, so it needs passwordless privilege for
# exactly this action (sudoers drop-in: `youruser ALL=(root) NOPASSWD: /usr/bin/systemctl restart
# gdm`, or a polkit rule + plain `systemctl restart display-manager`). Unset = disabled.
#PUNKTFUNK_RECOVER_SESSION_CMD=sudo -n systemctl restart gdm
# Full-chroma 4:4:4 (HEVC Range Extensions) — sharper text/desktop, no chroma loss. Honored only on # Full-chroma 4:4:4 (HEVC Range Extensions) — sharper text/desktop, no chroma loss. Honored only on
# the punktfunk/1 native path when the client advertises 4:4:4 AND the GPU supports it (probed; else # the punktfunk/1 native path when the client advertises 4:4:4 AND the GPU supports it (probed; else
# the session stays 4:2:0). HEVC-only; independent of 10-bit. NVENC (NVIDIA) is the validated path; # the session stays 4:2:0). HEVC-only; independent of 10-bit. NVENC (NVIDIA) is the validated path;
+1 -1
View File
@@ -3,7 +3,7 @@
Run a punktfunk **host** on a Steam Deck — stream its Game Mode (or KDE desktop) *to* other devices. Run a punktfunk **host** on a Steam Deck — stream its Game Mode (or KDE desktop) *to* other devices.
(Streaming *to* a Deck is the client; use the Flatpak + [Decky plugin](../../clients/decky/) instead.) (Streaming *to* a Deck is the client; use the Flatpak + [Decky plugin](../../clients/decky/) instead.)
User-facing guide: **docs-site → "Steam Deck (Host)"** (`docs-site/content/docs/steam-deck-host.md`). User-facing guide: **docs-site → "SteamOS (Host)"** (`docs-site/content/docs/steamos-host.md`).
This README is the deep reference for what the scripts do and how to operate them by hand. This README is the deep reference for what the scripts do and how to operate them by hand.
## Why build on-device (not a package or prebuilt binary) ## Why build on-device (not a package or prebuilt binary)
+1 -1
View File
@@ -51,7 +51,7 @@ powershell -ExecutionPolicy Bypass -File scripts\windows\build-web.ps1
``` ```
`bun install && bun run build` (Nitro `noExternals` -> a self-contained `.output`, no `bun install && bun run build` (Nitro `noExternals` -> a self-contained `.output`, no
`node_modules`/`.npmrc`), then restarts the `PunktfunkWeb` task and checks `:3000/login`. Use `node_modules`/`.npmrc`), then restarts the `PunktfunkWeb` task and checks `:47992/login`. Use
this to iterate on the console against an installed host - `punktfunk-host.exe web setup` (or a this to iterate on the console against an installed host - `punktfunk-host.exe web setup` (or a
fresh install) is what creates the task in the first place. fresh install) is what creates the task in the first place.
+1 -1
View File
@@ -1,6 +1,6 @@
# latency-probe # latency-probe
A **glass-to-glass latency** measurement tool (design/implementation-plan §10): it renders a A **glass-to-glass latency** measurement tool (punktfunk-planning: `implementation-plan.md` §10): it renders a
timestamp/QR on the host, reads it back off the client's capture (or a photodiode, for true photons), timestamp/QR on the host, reads it back off the client's capture (or a photodiode, for true photons),
and tracks p50/p99 — so latency regressions are quantifiable rather than felt. and tracks p50/p99 — so latency regressions are quantifiable rather than felt.
+1 -1
View File
@@ -12,5 +12,5 @@ builds. Use it to sanity-check the FEC before reaching for the real `punktfunk/1
cargo run -p loss-harness # from the repo root cargo run -p loss-harness # from the repo root
``` ```
Part of the measurement tooling (design/implementation-plan §10), alongside Part of the measurement tooling (punktfunk-planning: `implementation-plan.md` §10), alongside
[`latency-probe`](../latency-probe/README.md). [`latency-probe`](../latency-probe/README.md).
+12 -4
View File
@@ -121,11 +121,19 @@ export function isPublicPath(pathname: string): boolean {
return false; return false;
} }
/** Validate a post-login redirect target: a same-origin path only. Rejects protocol- /** Validate a post-login redirect target: a same-origin path only. Resolves `next` against a
* relative (`//evil.com`) and absolute URLs to prevent an open redirect. */ * sentinel origin and keeps it only if it stays same-origin rejecting absolute (`https://evil.com`),
* protocol-relative (`//evil.com`) AND backslash/tab variants (`/\evil.com`, which the WHATWG URL
* parser folds to `//evil.com`) that a plain `startsWith("//")` guard lets through. */
export function safeNextPath(next: string | undefined): string { export function safeNextPath(next: string | undefined): string {
if (!next?.startsWith("/") || next.startsWith("//")) return "/"; if (!next) return "/";
return next; try {
const base = "http://pf.invalid";
const u = new URL(next, base);
return u.origin === base ? u.pathname + u.search + u.hash : "/";
} catch {
return "/";
}
} }
export interface SessionData { export interface SessionData {

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