Compare commits

..
Author SHA1 Message Date
enricobuehler 87e7c82cbc chore(release): bump workspace version to 0.17.2
audit / bun-audit (push) Failing after 18s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m2s
apple / swift (push) Successful in 1m23s
audit / cargo-audit (push) Successful in 2m44s
ci / bench (push) Successful in 6m26s
apple / screenshots (push) Successful in 6m38s
ci / rust (push) Successful in 24m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m10s
decky / build-publish (push) Successful in 22s
android-screenshots / screenshots (push) Successful in 3m7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
deb / build-publish (push) Successful in 10m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m3s
release / apple (push) Successful in 10m50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 8m41s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8m4s
deb / build-publish-host (push) Successful in 12m44s
android / android (push) Successful in 14m35s
docker / deploy-docs (push) Successful in 23s
arch / build-publish (push) Successful in 15m35s
web-screenshots / screenshots (push) Successful in 3m1s
linux-client-screenshots / screenshots (push) Successful in 7m31s
windows-host / package (push) Successful in 13m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m24s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m32s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m39s
flatpak / build-publish (push) Successful in 6m40s
2026-07-21 23:33:53 +02:00
enricobuehlerandClaude Opus 4.8 b2e3d1b540 fix(steamdeck/install): add cmake dep + acquire sudo in preflight
ci / web (push) Successful in 1m20s
ci / docs-site (push) Successful in 1m52s
apple / swift (push) Successful in 1m22s
android / android (push) Successful in 13m4s
ci / bench (push) Successful in 8m2s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
deb / build-publish (push) Successful in 9m14s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m14s
apple / screenshots (push) Successful in 6m51s
arch / build-publish (push) Successful in 18m54s
docker / deploy-docs (push) Successful in 27s
ci / rust (push) Successful in 19m17s
deb / build-publish-host (push) Successful in 9m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m29s
pyrowave-sys's build.rs runs cmake to build the vendored PyroWave C library,
but cmake wasn't in the distrobox apt list — so `cargo build -p punktfunk-host`
dies on every SteamOS host with "is cmake not installed?". Add it.

Also move sudo acquisition from step 4 (after the ~15-min build) into preflight
and keep the timestamp warm across the build. Before, a non-interactive run
(`ssh host 'bash install.sh'`) silently skipped the UDP-buffer/udev/vhci-hcd/
input-group tuning AND enable-linger — a "successful" host with no gamepad
passthrough, lossy streaming, and no headless persistence (dies on logout).
Now sudo is prompted once up front (authorize and walk away), and a no-TTY /
no-password run fails loudly before the build instead of silently at the end.

Validated end-to-end on SteamOS 3.8 in a VM with Radeon 780M passthrough:
full host + web build, all planes listening, VAAPI render node, headless
auto-start via linger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:50:00 +02:00
enricobuehlerandClaude Fable 5 d36bec6e9d feat(core+host): negotiate ChaCha20-Poly1305 as the session cipher for soft-AES clients
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Failing after 13s
audit / cargo-audit (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Successful in 10m1s
windows-host / package (push) Successful in 11m20s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m28s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m39s
Lifts the ~100 Mbps decrypt ceiling on clients without hardware AES — the
armv7 soft-AES targets (webOS TVs), where AES-128-GCM resolves to fixsliced
software AES + software GHASH (~50-100 cpb) while ChaCha20-Poly1305's ARX
construction runs ~10-17 cpb portable, a 4-7x lift that PyroWave-on-TV needs
(design/chacha20-session-cipher.md).

Phase 1 (core crypto, no wire change): SessionKey merges cipher choice and
key material (invalid combinations unrepresentable, zeroize + redacted-Debug
discipline kept); SessionCrypto dispatches both aead-0.5 ciphers per call —
the salt||seq nonce scheme, per-direction salts, seq-as-AAD and replay
window carry over verbatim (same 96-bit nonce / 16-byte tag, const-asserted).
Config.key becomes SessionKey; validate's zero-key rejection follows the
active variant. The C ABI keeps its fixed 16-byte key mapped to AES — no
ABI_VERSION bump.

Phase 2 (negotiation): VIDEO_CAP_CHACHA20 (0x40) — support-plus-request in
one bit, the VIDEO_CAP_444 precedent. Welcome grows cipher@68 +
key_chacha@69..101, emitted only when non-zero so an AES session's Welcome
stays byte-identical to the pre-cipher form; decode is fail-closed (short
key or unknown id -> Err, never a silent AES fallback). No WIRE_VERSION
bump; downgrade resistance inherited from the pinned-TLS control channel.

Phase 3 (host): grant only when the client advertised the bit and the
PUNKTFUNK_CHACHA20 kill-switch (default on, documented) allows; fresh
32-byte per-session key from the same RNG discipline, legacy key field
stays independently random; resolved cipher logged at session start.

Verification: seal/open suites parameterized over both ciphers + a
cross-cipher tamper case; Welcome roundtrip/truncation/fail-closed tests;
ChaCha lossy-loopback soak (loss/replay is cipher-independent); bench
gains _chacha20 series (AES ids unchanged for CI history) — host-side
sealing line-rate-trivial on both x86 (~640 MiB/s) and Apple Silicon
(~535 MiB/s). punktfunk-probe drives the interop matrix via
PUNKTFUNK_CLIENT_CHACHA20=1 and logs the negotiated cipher.

Phase 4 (pf-webos pin bump + unconditional cap bit) follows the next
core release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:50:00 +02:00
enricobuehlerandClaude Fable 5 abc54a7d13 fix(decky): actually ship shortcut artwork + Steam Input layout in the published plugin
apple / swift (push) Successful in 1m23s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 56s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (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
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8m58s
The CI workflow assembled the store zip by hand and silently drifted from
scripts/package.sh when 60d46530 added assets/ and controller_config/ — every
published build (canary and stable through 0.17.1) was missing both, so the
Steam shortcut a fresh install creates had no images and no controller layout
(shortcut_art() found nothing to send; apply_controller_config failed with
template-missing). CI now stages through scripts/package.sh — the plugin file
list lives in one place — and a backstop verifies the runtime-loaded pieces are
in the zip before publishing.

Frontend hardening for installs poisoned by those builds: applyArtwork stamped
its per-appId ART_VERSION marker even when zero assets were applied, so
shipping the files alone would never heal existing shortcuts — only mark done
when something actually landed, and bump ART_VERSION 2→3 to force one re-apply.
Same guard for the controller-config marker (ok with zero account configset
dirs is not done), plus one deferred artwork retry for the fresh-AddShortcut
registration race setShortcutHidden already defers around.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:36:55 +02:00
enricobuehlerandClaude Fable 5 309e37f1e1 fix(host/linux): scope the GPU clock pin to live client sessions
The PUNKTFUNK_PIN_CLOCKS clock pin (AMD power_dpm_force_performance_level=high / NVIDIA nvmlDeviceSetGpuLockedClocks) was armed once at host start and released only at host exit, so the box's clocks stayed pinned the whole time the host ran — even with no client connected.

Move the pin behind a box-wide refcount (gpuclocks::session_pin()) shared across both streaming planes (native + GameStream): it arms on the first live client and releases on the last disconnect. on_host_start() now only installs the process-scoped NVIDIA P2-cap application profile. No behavior change when PUNKTFUNK_PIN_CLOCKS is unset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:36:55 +02:00
enricobuehlerandClaude Fable 5 56d21f9445 fix(client/wol): retry the QUIC dial across the connect budget so wake-from-sleep launches survive
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m3s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 40s
ci / bench (push) Successful in 6m51s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m6s
deb / build-publish (push) Successful in 9m54s
release / apple (push) Successful in 9m5s
deb / build-publish-host (push) Successful in 9m35s
flatpak / build-publish (push) Failing after 8m19s
android / android (push) Successful in 16m57s
apple / screenshots (push) Successful in 6m29s
arch / build-publish (push) Successful in 17m35s
windows-host / package (push) Successful in 18m46s
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-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
docker / deploy-docs (push) Successful in 26s
ci / rust (push) Successful in 25m24s
Launching from the Decky plugin against a sleeping host fired the
Wake-on-LAN packet and dialed immediately — but the dial was a single
quinn attempt that gave up after the transport's 8 s idle window, far
shorter than a suspend-to-RAM resume. The host woke to find the client
already gone (its retransmitted Initials show up host-side as
"QUIC accept failed error=timed out"), the stream shortcut exited, and
the launch looked cancelled.

- punktfunk-core: dial in a loop until the embedder's connect budget is
  spent — short 3 s attempts keep the Initial cadence dense, so the
  connect lands within ~a second of the host's network returning. Only
  silence is retried: pin mismatches, typed rejections, and any real
  answer surface immediately, and the shutdown flag stops the loop.
- decky: stretch the budget to 75 s when a magic packet actually went
  out (PF_CONNECT_TIMEOUT → --connect-timeout via the wrapper), so the
  retry window covers the resume; an awake host still connects in <1 s.
- host: a clean close before the control handshake is a reachability
  probe (hosts-page online pips), not a failed session — log it at
  debug instead of WARN "session ended with error", which buried the
  real failures in the reporter's log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:26:10 +02:00
enricobuehlerandClaude Opus 4.8 f36d13e371 fix(steamdeck/install): prompt for sudo instead of requiring passwordless
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 31s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
ci / bench (push) Successful in 6m5s
apple / screenshots (push) Successful in 6m34s
deb / build-publish (push) Successful in 9m43s
docker / deploy-docs (push) Successful in 48s
deb / build-publish-host (push) Successful in 10m9s
android / android (push) Successful in 18m18s
arch / build-publish (push) Successful in 19m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m16s
ci / rust (push) Successful in 28m27s
The system-tuning block (UDP buffers, gamepad udev rule, vhci-hcd autoload,
input group) was gated on `sudo -n true` — passwordless sudo — so on a stock
SteamOS box (the deck account needs a password) the whole block was silently
skipped: /dev/uhid stayed root-only so the gamepad degraded to an Xbox 360 pad,
and the UDP buffers stayed at the 416 KB default. Video was unaffected because
Game Mode/gamescope needs none of it, which masked the skip on-glass.

- install.sh + update.sh: acquire sudo interactively (`sudo -v` prompt on a TTY)
  instead of requiring passwordless; skip only when there's no TTY or auth fails,
  and then print the exact manual block plus a `passwd` hint — a stock SteamOS
  'deck' account has no password, so sudo can't work until one is set.
- update.sh: also retrofit the UDP-buffer sysctl for older/skipped installs, and
  loudly nag to reboot when the input group was just added (a --user restart does
  not pick up the new group; only a fresh login does).
- steamos-host.md: note this step prompts for the sudo password.

Root is unavoidable: the udev rule, input group, and vhci-hcd module are all
kernel operations with no user-space alternative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:56:20 +02:00
enricobuehlerandClaude Opus 4.8 aacd610b68 fix(steamdeck/install): set up controller passthrough + surface the required reboot
ci / docs-site (push) Successful in 1m12s
ci / web (push) Successful in 1m13s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
ci / bench (push) Successful in 6m35s
apple / screenshots (push) Successful in 6m13s
deb / build-publish (push) Successful in 10m21s
docker / deploy-docs (push) Successful in 34s
deb / build-publish-host (push) Successful in 9m58s
android / android (push) Successful in 17m7s
arch / build-publish (push) Successful in 17m47s
ci / rust (push) Successful in 19m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m2s
A fresh SteamOS host install failed on-glass two ways: KWin denied Desktop-mode
capture (zkde_screencast_unstable_v1) so every session died in the pipeline build,
and the native Steam Deck pad degraded to an Xbox 360 controller. Both stem from
setup that only a fresh login applies — the host user-service keeps the
pre-install group set and KWin reads its .desktop grant at session start — plus a
missing vhci-hcd autoload the pad's USB/IP transport needs (the deb/rpm/arch
packages ship it; the Deck installer did not).

- install.sh: install punktfunk-modules.conf (+ modprobe vhci-hcd now) for the
  native Deck controller transport, seed the KDE RemoteDesktop grant
  (kde-authorized) for Desktop-mode input, and print a loud "reboot before
  streaming" notice when a relogin is actually required (input group added or the
  .desktop grant first installed).
- update.sh: retrofit the udev rule, vhci-hcd autoload, input group, and grant so
  existing installs pick them up on the next update without a reinstall.
- steamos-host.md: document the first-install reboot and the native controller
  passthrough requirements (input group + vhci-hcd + client-side Steam Input Off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:22:08 +02:00
enricobuehlerandClaude Opus 4.8 081ff64087 docs: explain how to actually run the VirtualHere passthrough example
apple / swift (push) Successful in 1m22s
ci / web (push) Successful in 1m11s
ci / docs-site (push) Successful in 1m11s
ci / bench (push) Successful in 5m39s
apple / screenshots (push) Successful in 6m38s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
deb / build-publish (push) Successful in 9m0s
deb / build-publish-host (push) Successful in 9m20s
windows-host / package (push) Successful in 9m46s
android / android (push) Successful in 18m37s
arch / build-publish (push) Successful in 18m48s
docker / deploy-docs (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m44s
ci / rust (push) Successful in 26m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m2s
The VirtualHere DualSense recipe linked the `virtualhere-dualsense.ts` SDK
example as a deployable recipe but skipped everything needed to run it: that
VirtualHere is a server (couch) + client (host) pair, and that the example —
like every example — imports `../src/index.js`, which only resolves inside the
SDK repo. A user copying it out had no way to know they must
`bun add @punktfunk/host` and swap that import.

- automation.md: rewrite the recipe into a full walkthrough — the two-sided
  VirtualHere setup, the `-t` verbs, the zero-code hooks version, and a new
  scripted section with exact deploy steps, env vars, and running it as a
  service (its own SIGTERM handler makes `systemctl stop` release the pad).
- sdk/README.md: say how to run an example in-repo vs deployed on a host.
- virtualhere-dualsense.ts: header note on the import swap + service setup,
  since that file is where the doc link lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:28:23 +02:00
enricobuehlerandClaude Opus 4.8 675fd24cce chore(release): bump workspace version to 0.17.1
audit / cargo-audit (push) Successful in 3m22s
audit / bun-audit (push) Failing after 13s
apple / swift (push) Successful in 1m23s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m11s
ci / bench (push) Successful in 5m12s
apple / screenshots (push) Successful in 6m55s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m55s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m39s
ci / rust (push) Successful in 24m5s
decky / build-publish (push) Successful in 30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
android-screenshots / screenshots (push) Successful in 3m34s
linux-client-screenshots / screenshots (push) Successful in 8m32s
release / apple (push) Successful in 11m16s
deb / build-publish-host (push) Successful in 12m51s
deb / build-publish (push) Successful in 12m57s
docker / deploy-docs (push) Successful in 27s
web-screenshots / screenshots (push) Successful in 3m13s
windows-host / package (push) Successful in 14m6s
android / android (push) Successful in 15m33s
arch / build-publish (push) Successful in 16m3s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m43s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m16s
flatpak / build-publish (push) Successful in 7m9s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:37:32 +02:00
enricobuehlerandClaude Fable 5 0e60e58cab perf(apple): windowed macOS presents — off-main transactional commit, surface prototype, safe-present setting
ci / docs-site (push) Successful in 52s
ci / web (push) Successful in 56s
decky / build-publish (push) Successful in 21s
apple / swift (push) Successful in 1m24s
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 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / bench (push) Successful in 5m53s
release / apple (push) Successful in 9m19s
deb / build-publish (push) Successful in 12m27s
arch / build-publish (push) Successful in 12m52s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 13m18s
apple / screenshots (push) Successful in 6m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m51s
android / android (push) Successful in 18m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m15s
ci / rust (push) Successful in 27m38s
Task 1 (latency): the transactional present now commits from the RENDER
thread inside an explicit CATransaction + flush() instead of hopping to
main. The present harness (2026-07-21, 240 Hz Studio, full-size window)
measured the main hop batching presents at runloop-iteration rate when
main is busy — an active implicit transaction NESTS the explicit one —
which is the field's presents=55 @ fps=240 / display_p50 18.6 ms.
Off-main commits measured immune to main churn: glass p50 ~10 ms,
cadence a clean 4.17 ms at 240. flush() is load-bearing: without it the
render thread's own implicit transaction (drawableSize/colour writes)
swallows the explicit commit and NOTHING reaches glass — the harness
reproduced the exact freeze (every present dropped). Legacy main-hop
kept as PUNKTFUNK_TXN_PRESENT=main for field A/B. New pf-windowed
Console line (subsystem io.unom.punktfunk, category presenter)
decomposes the issue side per second.

Task 1 lever D: the f407f418 IOSurface contents-swap path is
resurrected format-aware as WindowedPresentMode.surface — bgra8 SDR /
rgba16Float+PQ-tagged HDR pool, swap committed off-main from the
completion handler; EDR anchored by the metal layer underneath.
Env-only prototype (PUNKTFUNK_WINDOWED_PRESENT=surface) until the HDR
composite is eyeballed on glass.

Task 2 (setting): punktfunk.windowedSafePresent (default ON =
transactional mitigation; OFF = the fast async path whose panic the
saga is about — the caption says so in plain words). Rows in the touch
Settings (Presentation) and gamepad settings, macOS-only.
PUNKTFUNK_WINDOWED_PRESENT=async|transaction|surface overrides for dev
A/B. maximumDrawableCount stays 3 — the harness measured 2 starving the
render loop (172/s) and worsening glass p50.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:58:47 +02:00
enricobuehlerandClaude Fable 5 71faf38a85 fix(apple): stats os_log used %d for 64-bit Swift Int — macOS 26 drops the line
Swift `Int` is 64-bit (Foundation infers %lld); the stats mirror's format
string used %d (32-bit C int) for fps/presents/lost. macOS 26's strict
String(format:) validator rejects the mismatch and drops the whole line
(the error also cascades onto the float args, mis-blaming floor_p50). Use
%lld for the three Int fields. Pre-existing, unrelated to the DCP work;
surfaced while reading presenter logs during the panic fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:14:41 +02:00
enricobuehlerandClaude Fable 5 f49d38826b fix(apple): windowed macOS DCP panic via presentsWithTransaction — keep real HDR, drop the SDR IOSurface path
The windowed-macOS "mismatched swapID's" @UnifiedPipeline.cpp kernel
panic is the CAMetalLayer ASYNC image queue (commandBuffer.present,
mandatory with displaySyncEnabled=false) diverging from WindowServer's
compositor on a high-refresh composited session — it survived glass
pacing (PyroWave, 2026-07-18) and hit windowed HEVC on the same 240 Hz
Mac Studio (2026-07-21), so it's the async queue itself, at any codec
or pacing.

f407f418's mitigation routed windowed PyroWave through a BGRA8 IOSurface
layer.contents pool, which cannot carry HDR — extending it to HEVC would
have tone-mapped windowed HDR down to SDR. Instead, present the drawable
through a Core Animation transaction (CAMetalLayer.presentsWithTransaction)
for every windowed session: commit, waitUntilScheduled, then drawable.present()
inside a CATransaction, so the swap commits with the layer tree and stays
in lockstep with the compositor instead of racing it. The full
rgba16Float / PQ / EDR render path is untouched — windowed HDR is real
HDR again. Fullscreen keeps the async path (direct scanout, lowest latency,
no panic there).

Removes the IOSurface surface pool, its surfaceLayer sibling, and the
macOS windowed PQ->SDR tone-map (tvOS keeps its no-headroom tone-map).
Net -150 lines. swift build + 169 tests green. Needs on-glass soak on
the 240 Hz Mac Studio (kernel race — only real hardware confirms).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:45:11 +02:00
enricobuehlerandClaude Fable 5 42198eb1b6 feat(linux/vulkan-encode): default-on native NV12 capture + RGB true-extent
ci / web (push) Successful in 48s
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m20s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 25s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m59s
apple / screenshots (push) Successful in 6m17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m29s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
deb / build-publish (push) Successful in 12m58s
deb / build-publish-host (push) Successful in 13m40s
windows-host / package (push) Successful in 10m46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m52s
android / android (push) Successful in 16m18s
arch / build-publish (push) Successful in 16m20s
docker / deploy-docs (push) Successful in 30s
ci / rust (push) Successful in 19m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m19s
Flip both zero-copy levers from opt-in to default:

- The capture negotiation now PREFERS gamescope's producer-side NV12 pod by
  default (PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation).
  Codec-aware gating rides a new OutputFormat::nv12_native ->
  ZeroCopyPolicy::native_nv12_session edge resolved by the host facade from
  the session plan (pf_encode::linux_native_nv12_ok): only H265/AV1 sessions
  whose backend can open the raw Vulkan Video encoder ever see the NV12 pod
  -- an H264/Moonlight session (libav VAAPI, which would misread the
  two-plane buffer) keeps today's BGRx negotiation, as do the
  GameStream-resolve and portal-mirror paths, PyroWave, and NVENC prefs.
- RGB-direct's unaligned modes default to the true-extent direct import
  (PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0 restores the padded-copy staging).
  Guarded-tested on Van Gogh with the kernel journal watched: clean, and at
  5.38 ms p50 the fastest 1080p encode path measured on that hardware. The
  EFC only exists on Mesa >= 26, where the codedExtent-driven session_init
  padding is guaranteed (verified back to Mesa 24.2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:01:28 +02:00
enricobuehlerandClaude Fable 5 b4fbc94e36 feat(linux/vulkan-encode): PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT bring-up gate
Opt-in alternative to RGB-direct's padded-copy staging at unaligned modes:
direct-import the visible-size capture and declare the TRUE-SIZE source
codedExtent. RADV has derived the VCN session_init firmware padding from
srcPictureResource.codedExtent since Mesa 24.2, so the EFC is told the source
lacks the alignment rows and the hardware edge-extends them internally --
which also reframes the 2026-07-20 field GPU reset: that crash passed the
ALIGNED extent as the source codedExtent (firmware padding zero) over a
visible-size buffer. Off by default until a guarded live test proves the EFC
front-end honors the padding like the YUV fetch path does; the padded-copy
staging remains the shipped behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:01:28 +02:00
enricobuehlerandClaude Fable 5 004f0cacb8 perf(linux/vulkan-encode): true-size headers make native NV12 zero-copy at every mode
Drop the padded-copy staging for producer-native NV12 and direct-import the
visible-size buffer at unaligned modes (1080p) too. Safe by the driver's own
contract rather than by allocation luck: native sessions now author the H265
SPS / AV1 sequence header at the RENDER size and pass the matching codedExtent
on every picture resource. RADV rounds the bitstream SPS up itself (with a
conformance window -- radv_video_patch_encode_session_parameters, per the
VK_KHR_video_encode_h265 proposal's "implementations may override" clause) and
programs the VCN session_init with the true extent plus nonzero firmware
padding, so the hardware edge-extends the alignment rows internally and never
fetches past the source's real extent -- the exact mechanism VAAPI/radeonsi has
always used to encode 1080p. The driver-emitted header NALs already carry the
patched SPS, so the wire format is unchanged.

The CSC and RGB-direct paths keep the app-aligned convention (their sources
genuinely cover the aligned extent); RGB padded-copy staging is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:01:28 +02:00
enricobuehlerandClaude Fable 5 49c6dd00c9 fix(presenter): CPU frames never take the HDR10 passthrough surface
audit / bun-audit (push) Failing after 15s
ci / docs-site (push) Successful in 56s
ci / web (push) Successful in 58s
apple / swift (push) Successful in 1m22s
audit / cargo-audit (push) Successful in 2m20s
decky / build-publish (push) Successful in 32s
ci / bench (push) Successful in 7m0s
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 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m18s
release / apple (push) Successful in 9m28s
deb / build-publish-host (push) Successful in 10m32s
deb / build-publish (push) Successful in 11m48s
docker / deploy-docs (push) Successful in 23s
android / android (push) Successful in 14m36s
arch / build-publish (push) Successful in 15m4s
windows-host / package (push) Successful in 16m11s
flatpak / build-publish (push) Failing after 8m19s
apple / screenshots (push) Successful in 6m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m11s
ci / rust (push) Successful in 19m38s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m39s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m36s
Software decode uploads swscale RGBA with no CSC/tonemap pass. On the new
desktop-compositor HDR10 surfaces (GNOME 48 / Plasma 6 + Mesa >= 25.1 offer
ST2084 even on SDR desktops) that sRGB-encoded content was composed as PQ —
the field-reported psychedelic picture (Fedora clients decode HEVC in
software because stock Mesa strips it; reproduced + fix live-validated on
GNOME Wayland: mode-0 soup -> HDR→SDR washed-out-but-correct).

The CPU lane keeps the SDR swapchain and its known untonemapped-PQ gap; a
real PQ→sRGB pass for CPU frames is the follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:30:35 +02:00
enricobuehlerandClaude Fable 5 e7c6c96e5f fix(presenter): set the Wayland app_id so the session window gets its icon
SDL_APP_ID = io.unom.Punktfunk — compositors match it against the shipped
.desktop for the window icon; without it KWin/mutter show the generic Wayland
icon (field-noticed on the Deck). Linux analog of win32 AppUserModelID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:30:35 +02:00
enricobuehlerandClaude Fable 5 da134ad045 feat(presenter): PUNKTFUNK_HDR10=0 refuses the HDR10 swapchain (SDR tonemap pin)
GNOME 48 / Plasma 6 with Mesa >= 25.1 now offer HDR10_ST2084 surfaces even on
SDR desktops, silently flipping PQ streams into the mode-0 passthrough lane —
previously unreachable on desktop compositors and never validated there. The
hatch pins the shader tonemap for diagnosis and as the field escape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:30:35 +02:00
enricobuehlerandClaude Fable 5 428bd2519c test(qsv): converter→ring-profile-P010→encoder live e2e (the RTV-written seam)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:30:35 +02:00
enricobuehlerandClaude Fable 5 87d7eceabf fix(encode/qsv): write the colour VUI unconditionally + Intel-lane colour diagnostics
The native QSV encoder only attached mfxExtVideoSignalInfo on HDR sessions —
an SDR stream carried no colour description at all (NVENC writes 709-limited
unconditionally; qsv.rs now mirrors it).

Diagnostics for the field-reported blue/magenta Intel-host colours:
- hdr-p010-selftest takes an optional WxH + GPU vendor (intel|nvidia|amd) and
  prints which adapter it tested — 64x64 on the default GPU proved nothing on
  dual-GPU boxes, and the field heights (1080/1400) are not 16-aligned.
- pf-capture: ignored live test running the converter at 1920x1080 pinned to
  the Intel adapter.
- pf-encode: qsv_live_p010_1080_colorbars_dump — known P010 bars through the
  UNALIGNED-height ingest copy (1080 src -> align16 1088 pool, the seam no
  640x480 test exercises) to Main10 HEVC, dumped for off-box decode checks.
- dxgi.rs: drop the stale 'falls back to the R10 path' claim (no such path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:30:35 +02:00
enricobuehlerandClaude Fable 5 1d4795666e feat(linux/vulkan-encode): opt-in gamescope producer-native NV12 encode source
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 57s
decky / build-publish (push) Successful in 19s
apple / swift (push) Successful in 1m21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 6m53s
apple / screenshots (push) Successful in 6m16s
windows-host / package (push) Successful in 9m38s
deb / build-publish (push) Successful in 9m55s
docker / deploy-docs (push) Successful in 26s
arch / build-publish (push) Successful in 12m52s
android / android (push) Successful in 12m58s
deb / build-publish-host (push) Successful in 14m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m0s
ci / rust (push) Successful in 28m32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m41s
With PUNKTFUNK_PIPEWIRE_NV12=1 (bring-up gate), the PipeWire negotiation
offers an NV12 LINEAR DMA-BUF pod (BT.709 limited pinned MANDATORY) ahead
of the BGRx one, and gamescope's producer-side RGB->YUV pass replaces the
host CSC entirely: the encoder imports the two-plane buffer as a profiled
VIDEO_ENCODE_SRC image and the VCN encodes it directly. Contributed
measurements: encode p99 2.9 ms (from ~4-4.5 ms via EFC RGB-direct),
60 fps capture, 0 send drops.

Hardening on top of the contributed patch:
- unaligned modes (1080p!) stage through a padded aligned NV12 copy (edge
  rows/columns duplicated, transfer-only) instead of direct-importing the
  visible-size buffer -- a direct import would make the VCN read past the
  producer allocation, the exact OOB class behind the 2026-07-20 field GPU
  reset; the encode extents return to the aligned coded extent everywhere
- the UV plane layout honors the producer's plane-1 chunk (offset/stride)
  when the SPA buffer carries one (same-BO verified by inode), with the
  contiguous-plane contract as fallback
- PyroWave sessions are excluded from the gate (their Vulkan compute CSC
  ingests packed RGB), and a native-NV12 session that resolves to libav
  VAAPI (H264 codec, PUNKTFUNK_VULKAN_ENCODE=0, feature off, or a failed
  Vulkan open) refuses at open instead of streaming garbage chroma
- pad staging images carry TRANSFER_SRC (the width-padding pass self-copies
  the staging image -- previously missing on 1366-wide modes)
- metadata-cursor one-shot warn (parity with RGB-direct) and a padded-NV12
  PUNKTFUNK_PERF split label; the padded RGB copy gets its timestamps too

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 11:57:42 +02:00
enricobuehlerandClaude Opus 4.8 58b27acbd5 fix(vdisplay/session): scrub the dead desktop's WAYLAND_DISPLAY from the user-manager env
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m14s
apple / swift (push) Successful in 1m22s
decky / build-publish (push) Successful in 27s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 19s
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
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / bench (push) Successful in 5m44s
apple / screenshots (push) Successful in 6m18s
android / android (push) Successful in 13m0s
docker / deploy-docs (push) Successful in 27s
deb / build-publish (push) Successful in 13m36s
deb / build-publish-host (push) Successful in 14m44s
windows-host / package (push) Successful in 16m32s
arch / build-publish (push) Successful in 17m49s
ci / rust (push) Successful in 19m58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m42s
settle_desktop_portal pushes the live desktop's WAYLAND_DISPLAY into the
systemd --user manager (import-environment) so a re-activated portal inherits
the right session — but the import PERSISTS after that desktop dies. Every
later user unit then inherits the stale socket, including
gamescope-session.target: gamescope sees WAYLAND_DISPLAY, runs NESTED, and
aborts with "Failed to connect to wayland socket: wayland-0" — observed live
on a Deck, where Game Mode could not start at all (autologin crash-looped)
until the var was unset by hand.

Two-layer fix, mirroring the protection launch_session already gives its
transient unit:
- observe_session_instance: when a desktop compositor instance goes away,
  unset WAYLAND_DISPLAY/DISPLAY from the manager env (a desktop bounce is
  harmless — the next portal settle re-imports).
- write_steamos_dropin: UnsetEnvironment=DISPLAY WAYLAND_DISPLAY on the
  takeover drop-in, unit-scoped belt-and-suspenders for host-initiated
  restarts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:06:55 +02:00
enricobuehlerandClaude Opus 4.8 333c8979e9 fix(vdisplay/gamescope): capture-loss rebuild probes must not steal the box session
Observed live on a Deck host: the user switched Game Mode → Desktop, KDE came
up in 0.8 s — and the capture-loss rebuild's FIRST detection ran inside that
gap, still said Gaming, and its gamescope re-acquire restarted
gamescope-session.target. On SteamOS that steals the seat: the user was
yanked out of the KDE session they had just chosen, the two session managers
fought (job canceled / dependency failed), no gamescope node appeared within
30 s, and the stream died at the 40 s rebuild budget.

A rebuild probe acting on possibly-stale detection must never stop, relaunch,
or take over box sessions. New pf_vdisplay::rebuild_probe_scope (RAII,
counted): while held, the gamescope managed/takeover create paths only attach
to a live node and fail fast otherwise — no stop_autologin_sessions, no
session-plus relaunch, no gamescope-session.target restart. The capture-loss
loop holds it for the first 4 s after a loss (the detection-ambiguity
window); after that, destructive rebuilds are allowed again, so a genuine
switch INTO Game Mode still gets its headless takeover. Probe failures are
instant, so the loop now paces itself at ~2 Hz instead of spinning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:00:02 +02:00
enricobuehlerandClaude Opus 4.8 46d09ed973 fix(host/linux): stop burning retry budget on doomed pipeline builds
Two session-transition stalls found live on a SteamOS Deck host, one cause:
the pipeline retry loop couldn't tell a wait-it-out failure from a
retry-now-and-it-works one.

- Gamescope first-frame race: a PipeWire stream connected while gamescope
  re-initializes its headless takeover negotiates a format, reaches
  Streaming, and never receives a buffer — while a fresh connect delivers
  within ~0.5 s. Every gamescope bring-up ate the full 10 s first-frame
  budget on attempt 1 (17 s bring-ups; KWin: 1.2 s). The retry loop's FIRST
  attempt now waits 2.5 s (Capturer::next_frame_within), so the reconnect
  that fixes the race happens at ~3 s. Later attempts keep the patient 10 s
  — the documented 30-60 s Big Picture cold start still fits the budget.

- Capture-loss rebuild vs session switch: the rebuild loop re-detects the
  active session between build_pipeline_with_retry calls, but each call
  burned 8 attempts (~13 s) against a compositor that no longer exists (a
  Desktop→Gaming switch spent 13 of its 27 s retrying gone-KWin). The
  capture-loss path now passes max_attempts=2, turning the outer loop into
  ~1 s detect-and-retry cycles that follow the box to the new session.

The resize path's direct build_pipeline call keeps the default budget (no
retry wrapper there to absorb an early bail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:34:49 +02:00
enricobuehlerandClaude Opus 4.8 946f1b3d69 fix(steamdeck): build the host with vulkan-encode, and wire up what it needs to actually engage
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 57s
apple / swift (push) Successful in 1m20s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 6m43s
apple / screenshots (push) Successful in 6m32s
deb / build-publish (push) Successful in 9m57s
docker / deploy-docs (push) Successful in 26s
arch / build-publish (push) Successful in 12m46s
android / android (push) Successful in 13m40s
deb / build-publish-host (push) Successful in 13m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m59s
ci / rust (push) Successful in 26m41s
The SteamOS scripts built the host featureless, so PlatformAuto could never
pick the Vulkan Video backend (default-on since 84329205) and every session
silently ran libav VAAPI — unlike the deb/arch/rpm/sysext builds, which all
pass the feature. Three gaps, found live on a Deck host:

- install.sh/update.sh: cargo build with --features punktfunk-host/vulkan-encode
  (matches the packaged builds; pure-Rust ash, no new system dep).
- host.env: RADV_PERFTEST=video_encode — Van Gogh RADV still gates
  VK_KHR_video_encode_* behind it, so without it the backend can't open and
  falls back to VAAPI. Harmless where encode is exposed by default.
- io.unom.Punktfunk.Host.desktop (Exec rewritten to this install's binary):
  KWin only grants zkde_screencast_unstable_v1/org_kde_kwin_fake_input to an
  exe a .desktop authorizes — without it Desktop-Mode capture fails outright
  and a mid-stream Game↔Desktop switch strands the stream on the old backend.

update.sh retrofits the last two idempotently so existing installs get them
without a reinstall.

Validated on a SteamOS 3.8.14 Deck (Van Gogh): Vulkan HEVC opened on both the
KWin and gamescope paths at 5120x1440 — in-place ABR reconfigure (no IDR, no
encoder rebuild) vs the VAAPI path's full teardown per ABR step, and Desktop
capture came up without a re-login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:14:31 +02:00
enricobuehlerandClaude Fable 5 7c0313a7cb fix(web): flush card content, so a full-bleed table stops doubling its inset
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m18s
ci / bench (push) Successful in 6m42s
apple / screenshots (push) Successful in 6m21s
ci / rust (push) Successful in 24m18s
android-screenshots / screenshots (push) Successful in 3m2s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
android / android (push) Successful in 15m7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
windows-host / package (push) Successful in 10m7s
release / apple (push) Successful in 10m12s
flatpak / build-publish (push) Successful in 6m55s
deb / build-publish (push) Successful in 11m44s
linux-client-screenshots / screenshots (push) Successful in 7m40s
docker / deploy-docs (push) Successful in 28s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m22s
web-screenshots / screenshots (push) Successful in 3m27s
deb / build-publish-host (push) Successful in 12m25s
arch / build-publish (push) Successful in 15m42s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m41s
`<CardContent className="p-0">` never removed the padding it looked like it
removed: tailwind-merge resolves conflicts within a variant, so `p-0` cancels
`p-4` but leaves `sm:p-6` standing. Every call site that used it nests a
CardHeader inside, which brings its own `sm:p-6` — so at >=640px the title sat
24px further in than its neighbours'. Visible on the Plugins page as 'Installed
plugins' not lining up with 'Plugin runner'.

Give CardContent a `flush` prop that omits the padding outright, and use it at
the three call sites (Store, Pairing, Stats) rather than fixing the symptom
three times and leaving the footgun in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:25:31 +02:00
enricobuehlerandClaude Fable 5 04c975e9cb fix(store): uninstall must refuse a plugin's framework, not just odd names
apple / swift (push) Successful in 1m26s
apple / screenshots (push) Successful in 6m47s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m5s
windows-host / package (push) Successful in 9m45s
android / android (push) Successful in 12m4s
arch / build-publish (push) Successful in 12m4s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 5m57s
docker / deploy-docs (push) Successful in 24s
deb / build-publish (push) Successful in 9m0s
deb / build-publish-host (push) Successful in 9m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m15s
ci / rust (push) Successful in 24m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m55s
The guard checked the package NAME shape, and `@punktfunk/plugin-kit` — the
framework every kit-built plugin depends on — matches `@scope/plugin-*`
exactly. Windows on-glass accepted an uninstall of it; bun no-ops removing a
non-dependency so nothing broke, but the store was offering to pull a library
out from under the plugins using it.

Require membership in the top-level dependency list, the same authority that
already keeps plugin-kit out of the installed listing. Same blind spot, two
call sites — the listing was fixed during implementation, this one wasn't.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:10:49 +02:00
enricobuehler 402ec90edc fix(web/store): restore the plugin cards' top padding, and capitalise platform names
ci / web (push) Successful in 1m5s
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m24s
decky / build-publish (push) Successful in 19s
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 31s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 5m34s
apple / screenshots (push) Successful in 6m33s
windows-host / package (push) Successful in 9m46s
deb / build-publish (push) Successful in 9m19s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 9m48s
android / android (push) Successful in 17m6s
arch / build-publish (push) Successful in 18m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m30s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m38s
ci / rust (push) Successful in 27m42s
Two things looked wrong in the store:

The cards had no space above the icon. `CardContent` zeroes its own top padding
(`pt-0`/`sm:pt-0`) because it normally sits under a `CardHeader` that supplies
it — but these cards have no header, so the top padding simply vanished while
the other three sides kept `p-card`. `p-card` does not undo it: `card` is a
custom `--spacing-*` token, which tailwind-merge does not recognise as a
spacing value and so never dedupes against `pt-0`, leaving the longhand to win.
Both header-less cards in this section now restore it explicitly, at both
breakpoints.

Platform chips read "windows" / "linux". Those are the catalog's platform
IDENTIFIERS (the index validator pins `linux | windows | macos`), which is right
for the wire and wrong on screen; the card now maps them to display names —
Linux, Windows, macOS. Proper nouns, so deliberately not routed through i18n.

Adds Store/StoreCard stories covering the padding, the platform labels, and the
installed / update / incompatible / blocked / external states, so the card can
be eyeballed without a host or a catalog.
2026-07-21 00:00:37 +02:00
enricobuehler d783d5445c chore(release): bump workspace version to 0.17.0
apple / swift (push) Successful in 1m20s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
audit / bun-audit (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --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
2026-07-20 23:55:51 +02:00
enricobuehlerandClaude Fable 5 67b7981096 feat(encode/nvenc-linux): LN1 phase-3 — multi-slice sub-frame encode DEFAULT-ON for Linux direct-NVENC
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m15s
windows-host / package (push) Successful in 9m39s
ci / web (push) Successful in 1m10s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
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
Gates recorded (nvenc-subframe-slice-output.md Phase 3): loss-harness curve
identical streamed-vs-whole; adversarial security review clean; live .21
3-leg A/B — streamed 0 corrupted frames, p99 8527→5363 µs vs sliced-whole,
first_slice_us ~500 µs of a ~1250 µs encode reaching the send thread; wire
bytes rate-governed under CBR + 1-frame VBV (the ~1-2 % slice-header cost is
absorbed by rate control, not added to the wire). GameStream: plane has zero
code diff (own RTP packetizer; shares only untouched send_pacing); a live
Moonlight re-test joins the standing owed Moonlight item.

- resolve_slices(codec, default): env 1..=32 wins (1 = the NEW explicit
  single-slice escape), else the backend default — 4 on Linux direct-NVENC,
  1 elsewhere. resolve_subframe(default_on): PUNKTFUNK_NVENC_SUBFRAME
  tri-state (0 = never, 1 = force, unset = backend default).
- Linux nvenc_cuda: resolves both ONCE in query_caps — the sub-frame default
  is gated on the GPU's SUBFRAME_READBACK cap so an unsupporting GPU never
  has enableSubFrameWrite forced into its init params — and feeds the same
  resolved values to build_config, build_init_params (open AND in-place
  reconfigure) and the chunked-poll latch.
- Windows: env-only as before (async path untouched, byte-identical config
  for unset env); LowLatencyConfig.slices + the explicit subframe param
  replace the shared env reads.
- Tests: chunked e2e now runs at the DEFAULTS (no knobs); the fallback test
  becomes the escape test (SLICES=1 disarms; SUBFRAME=0 disarms while
  4-slice encode continues on the plain poll path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:44:41 +02:00
enricobuehlerandClaude Fable 5 e6cdd79f71 fix(loss-harness): drop the needless mut on send_wires — workspace clippy is -D warnings
apple / swift (push) Successful in 1m17s
apple / screenshots (push) Has been cancelled
android / android (push) Has been cancelled
deb / build-publish-host (push) Failing after 5s
ci / web (push) Successful in 1m14s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
ci / docs-site (push) Successful in 1m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 5m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 10m44s
deb / build-publish (push) Successful in 12m34s
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
Slipped through 421e8112: the on-box clippy runs covered punktfunk-core and
punktfunk-host but not the tools crates; CI's workspace-wide pass caught it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:44:37 +02:00
enricobuehlerandClaude Fable 5 7e5ec7eba9 chore(store): repin the official plugin-index signing key
apple / swift (push) Successful in 1m17s
apple / screenshots (push) Successful in 6m43s
ci / web (push) Successful in 1m8s
ci / docs-site (push) Successful in 1m13s
arch / build-publish (push) Successful in 12m43s
deb / build-publish-host (push) Failing after 25s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
ci / rust (push) Failing after 9m38s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 6m58s
android / android (push) Successful in 16m41s
docker / deploy-docs (push) Successful in 11s
windows-host / package (push) Successful in 10m9s
deb / build-publish (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
The bootstrap key generated during implementation was lost, and its
replacement was pasted into the wrong repository's secret store — Gitea
secrets neither cross repos nor read back, so neither is usable. The private
half of this one lives in punktfunk-plugin-index's own INDEX_SIGNING_KEY,
which is the only thing that signs the catalog.

Nothing has shipped pinning any of these, so this is a straight replacement
of slot 0; the second slot stays reserved for a genuine rotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:25:16 +02:00
enricobuehlerandClaude Opus 4.8 c6caeca5bc feat(encode): RGB-direct (EFC) is now the DEFAULT on capable hosts — gated by a session cursor-blend hint
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
android / android (push) Has been cancelled
ci / web (push) Successful in 55s
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
ci / docs-site (push) Successful in 55s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 56s
ci / bench (push) Successful in 7m8s
ci / rust (push) Failing after 9m14s
windows-host / package (push) Successful in 10m24s
arch / build-publish (push) Successful in 12m58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m25s
docker / deploy-docs (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 20m25s
B2 default-on (design/vulkan-rgb-direct-encode.md): wherever the probe
passes, Vulkan Video sessions now take the EFC RGB source by default —
direct import on aligned modes, padded-copy staging on unaligned ones
(1080p). PUNKTFUNK_VULKAN_RGB_DIRECT becomes the override: =0 disables,
=1 forces, unset = the default below.

The one session class that must NOT default on: cursor-as-metadata
captures (every non-gamescope compositor), where the CSC shader's blend
IS the visible pointer — the EFC cannot composite, and defaulting there
would silently drop the cursor from the stream. The hint rides the
existing plumbing:

- SessionPlan gains cursor_blend, resolved once where the compositor is
  known (gamescope embeds the pointer itself → false; kwin/mutter/
  wlroots/hyprland → true), and shows up in the logged plan line.
- open_video/open_video_backend thread it through (native pump: all
  three encoder-open sites read plan.cursor_blend; GameStream monitor
  capture: true — it negotiates metadata cursor; spike: false).
- VulkanVideoEncoder::open resolves: env override, else ON iff the
  session never hands us cursor bitmaps. The warn-once for a cursor on
  an RGB session (forced via =1) stays.

Verified on-hw box (Linux): pf-encode + punktfunk-host compile, clippy
clean, unit suite green. The GPU paths themselves are unchanged from the
smoke-validated 96e19986 — this commit only changes which sessions
select them by default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:24:46 +02:00
enricobuehlerandClaude Fable 5 3736bbdc73 fix(core): streamed-AU hardening from the security-review pass
apple / swift (push) Successful in 1m19s
docker / deploy-docs (push) Has been cancelled
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
release / apple (push) Successful in 8m41s
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (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
Review verdict: no exploitable memory-safety / amplification / splice /
resurrection issue; nonce order and two-lane seal byte-identical for the
legacy path. Actionable findings applied:
- explicit per-block data_shards cross-check next to the recovery_shards
  one (defense-in-depth — the geometry invariants make it unreachable
  today, but have_data indexing assumes it)
- partial delivery skips an UNPINNED streamed frame (frame_bytes still 0)
  instead of truncating its max-sized buffer to an empty "partial"
- regression tests for the two untested load-bearing guards: the
  final-first out-of-range-sentinel drop (the exact-sized-buffer guard)
  and second-final-with-different-totals rejection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:13:59 +02:00
enricobuehlerandClaude Fable 5 421e811225 feat(loss-harness): sweep the streamed-AU wire shape alongside whole-AU
The Phase-2 gate: more, smaller wire units must not regress FEC recovery.
Adds a GF16 streamed column (three chunk pushes + finish per AU — sentinel
blocks then real totals). Measured: identical to whole-AU at every loss
rate (50/50 through 1/6, the same 25%-budget cliff).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:59:09 +02:00
enricobuehlerandClaude Fable 5 79913a430e feat(probe): advertise VIDEO_CAP_STREAMED_AU
The probe's reassembler is the shared core one, and the probe is the tool
that measures the streamed-AU overlap win — advertise the cap so a host
with chunked encode streams to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:55:34 +02:00
enricobuehlerandClaude Fable 5 c5402cb1f7 feat(core+host): LN1 phase-2 — VIDEO_CAP_STREAMED_AU streamed access units
The wire half of sub-frame slice output (latency §7 LN1, planning
design/nvenc-subframe-slice-output.md Phase 2): toward a client that
advertises the new cap bit (0x20), a chunked-poll encoder session ships each
AU's completed FEC blocks while the tail of the frame is still encoding —
the AU's last packet leaves the host as the encode finishes instead of
after it.

Wire semantics (negotiated; zero change for anyone else):
- Non-final blocks ride SENTINEL headers: block_count = 0 (a value no legacy
  sender emits) + frame_bytes = 0 + exactly max_data_per_block data shards,
  so the receiver's shard-offset formula needs no total.
- The final block's headers carry the real frame_bytes/block_count (+
  FLAG_EOF) and RETRO-VALIDATE the whole frame: totals under which a
  received sentinel block is out of range or not full-K kill the frame
  wholesale (no spliced delivery) and the index can't be resurrected.
- Firewall: sentinels are bounded by the negotiated limits (full-K exactly,
  never the last block the limits allow, no total to lie about); the exact
  derived-geometry check runs unchanged on every non-sentinel packet and
  retroactively at pinning. Sentinel opens commit a max_frame_bytes buffer,
  bounded by the existing IN_FLIGHT_BUF_FACTOR budget (amplification test).
- Order-agnostic like legacy: a reversed frame (final block first) opens
  legacy-shaped and still accepts its sentinels against the pinned totals.
- Small/empty streamed AUs degenerate to byte-identical legacy headers.

Host: Packetizer::{begin,push,finish}_streamed seal full-K blocks (data +
parity per block) as chunks arrive; Session::seal_streamed_* share the
pooled-wire + two-lane seal machinery via the new seal_run; the send thread
paces each flush under the frame's existing deadline (pace_sealed split out
of paced_submit) and runs the whole-AU accounting at the last chunk; the
encode pump forwards poll_chunk output as ChunkMsg when the client has the
cap AND the encoder chunks (re-queried per AU — an escalation falls back
seamlessly). Probes never run mid-AU. PUNKTFUNK_STREAMED_AU=0 = host escape
hatch. Client core ORs the cap into Hello (the shared reassembler carries
the support). Sampled first_slice_us vs encode_us PERF log measures the
overlap; the 0xCF stage-field extension stays a follow-up.

Core tests: streamed round-trip (clean/loss/reorder/duplicate, both orders),
sentinel firewall bounds, lying-final wholesale kill + no-resurrect,
open-amplification budget, header-shape pins. Gates still owed before
default-on: security review pass, loss-harness curve, GameStream smoke
(plane untouched structurally), bitrate A/B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:53:53 +02:00
enricobuehlerandClaude Fable 5 9d3b114fd6 feat(encode/nvenc): LN1 phase-1 — slice-boundary chunked encoder poll (poll_chunk/AuChunk)
apple / swift (push) Successful in 1m20s
apple / screenshots (push) Successful in 6m24s
windows-host / package (push) Successful in 10m45s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
The encoder half of sub-frame slice output (latency §7 LN1, planning
design/nvenc-subframe-slice-output.md Phase 1): with PUNKTFUNK_NVENC_SLICES=N +
PUNKTFUNK_NVENC_SUBFRAME=1 on a sync depth-1 session, Encoder::poll_chunk hands
the in-flight AU out as slice-boundary chunks read through doNotWait sub-frame
locks while the tail is still encoding — the readback loop the on-hw probe
validated (~200 µs slice spacing on the 5070 Ti), productionized.

- codec.rs: AuChunk (AU metadata on the first chunk, `last` closes the AU;
  chunks concatenate to exactly the poll() bytes) + supports_chunked_poll /
  poll_chunk trait surface. Default impl wraps poll() as one self-closing
  chunk, so a chunk-driven consumer works against every backend.
- nvenc_cuda: chunked readback cut at slice boundaries only (bitstream size at
  n reported slices = end of slice n, Annex-B contiguous); completion is NEVER
  numSlices alone — one finishing BLOCKING lock is the authority and the wedge
  watchdog, so the final chunk blocks exactly like sync poll (the depth-1 pump
  contract; 6dc195f9 bug class). Keyframe on early chunks is the submit-time
  IDR prediction (exact under P-only/infinite-GOP), cross-checked at finish.
  Debug builds shadow-check emitted chunks against the finished AU prefix.
  Mutually exclusive with pipelined retrieve (gated off when async_rt exists,
  dropped by the escalation rebuild); composes with stream-ordered submit.
- nvenc_core: slices_env/subframe_requested shared parses so the config author
  and the chunked-poll arming can't disagree.
- TrackedEncoder forwards both new methods (the set_wire_chunking trap class).

Host loop untouched — Phase 2 (VIDEO_CAP_STREAMED_AU seal/send) consumes this.
On-hw: nvenc_cuda_chunked_poll_end_to_end + nvenc_cuda_chunked_poll_fallback_whole_au.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:53:32 +02:00
enricobuehlerandClaude Fable 5 411c6dc06a fix(encode): forward set_pipelined through TrackedEncoder — the LN3 escalation no-oped through the wrapper
Every session encoder is boxed in TrackedEncoder (open_video), and the
wrapper never forwarded Encoder::set_pipelined — so the host loop's
contention escalation (ae673158) hit the trait default, which returns
false ("backend can't pipeline, stop asking"), and the pipelined-retrieve
stage could never engage adaptively. Only the explicit
PUNKTFUNK_NVENC_ASYNC=1 open-time path worked. The exact defaulted-method
trap the wrapper's own comment documents for set_wire_chunking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:53:32 +02:00
enricobuehlerandClaude Fable 5 833f3348a0 feat(store): console plugin store, index repo, and the fixes on-glass found
apple / swift (push) Successful in 1m22s
release / apple (push) Successful in 9m59s
windows-host / package (push) Successful in 9m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m20s
apple / screenshots (push) Successful in 6m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
audit / cargo-audit (push) Successful in 2m58s
audit / bun-audit (push) Successful in 13s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 49s
flatpak / build-publish (push) Successful in 7m53s
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Successful in 11m41s
deb / build-publish-host (push) Successful in 13m32s
Console: a Plugins section (Browse / Installed / Sources) on a static nav
entry, with install friction proportional to trust — a plain confirm for a
verified entry, a warning naming the curator for an external one, and a
danger dialog that makes you retype the spec for a raw package. Tier badges
are permanent and follow the plugin onto its own UI page.

Index: unom/punktfunk-plugin-index published and served from Gitea's raw
endpoint (real HTTPS, byte-exact, no vhost to stand up) — merge to main is
publish, which resolves the design's open hosting question.

Four things only running it could find:
- runner discovery matched @punktfunk/plugin-* only, so a third-party
  scoped plugin (which D8 requires) would install and never run
- ...and that convention also matches @punktfunk/plugin-kit, a plugin's
  own framework: it listed as installed and would have been imported as a
  unit. Both now key off the plugins dir's top-level dependencies, with an
  emptied dependency list meaning 'nothing installed' rather than falling
  back to the naming convention
- the store must not pass new flags to the runner: the scripting package
  ships separately and an older one reads an unknown flag's value as a
  package name. The host writes the bunfig scope mapping itself
- ureq reports only >= 400 as Err, so a conditional request's 304 arrives
  as Ok with an empty body — handled as an error it made every refresh
  after the first verify a signature over zero bytes and sit stale

Also: the console's first Tabs use exposed an @unom/ui theme gap that
rendered inactive tabs invisible (caught in a browser pass, not by types).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:48:02 +02:00
enricobuehlerandClaude Fable 5 45c3b96907 feat(store): plugin store host module — signed catalogs, tiered trust, install jobs
The index is the verification gate: a catalog entry pins one exact version
plus that version's tarball integrity hash, so 'verified on every release'
is a property of the data rather than a promise about process. Nothing can
express 'track latest' for a catalogued plugin.

- store/index.rs   signed index parse + ed25519 verify (ring), validate-and-drop
                   per entry, semver minHost/advisory ranges
- store/sources.rs built-in unom source (compiled-in URL + two key slots for
                   rotation) + operator sources in plugin-sources.json
- store/catalog.rs https fetch with size/timeout/redirect caps, signature before
                   parse, last-good disk cache (stale-but-usable when offline)
- store/jobs.rs    single-flight install/uninstall: registry-integrity preflight
                   against the pin, spawn the runner CLI with live log capture,
                   post-install version check with rollback, provenance record,
                   runner restart (discovery is startup-only)
- store/manifest.rs install provenance; absence means CLI-installed
- mgmt/store.rs    12 routes under /api/v1/store, denied to the plugin token
                   (a plugin that can install plugins is an escalation primitive)

Also generalizes runner discovery and listInstalled from @punktfunk/plugin-*
to ANY scope's plugin-*: catalog entries must be scoped so the scope can map
to that entry's registry, so a third-party plugin necessarily arrives under
its own scope and would otherwise install but never run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:48:02 +02:00
enricobuehlerandClaude Opus 4.8 96e19986bc feat(encode): RGB-direct padded-copy — unaligned modes (1080p) get the EFC path safely
apple / swift (push) Successful in 1m20s
apple / screenshots (push) Successful in 6m42s
windows-host / package (push) Successful in 9m24s
android / android (push) Failing after 18s
ci / web (push) Successful in 1m8s
ci / docs-site (push) Successful in 1m43s
arch / build-publish (push) Successful in 11m48s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (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-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 7m33s
deb / build-publish (push) Successful in 9m1s
deb / build-publish-host (push) Successful in 13m6s
ci / rust (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
Lifts 3aacec53's alignment gate into a mode select: an aligned mode keeps
the true zero-copy direct import; an unaligned one (1080p!) now blits the
visible frame into a per-slot ALIGNED BGRA staging image and duplicates
the edge rows/columns into the 64x16 padding — transfer-only regions in
one vkCmdCopyImage (1080p: visible + 8 row regions; width padding adds a
second self-copy pass in GENERAL), no compute shader. The encode reads
the staging image, never the capture buffer, so the EFC can never read
past a producer allocation (the field GPU hang). Still one ~8 MB copy vs
the CSC path's ~17 MB + dispatch + plane copies. Verdict line:
active(padded-copy). The staging import drops the video profile entirely
(TRANSFER_SRC only).

CPU-payload paths made honest on the way (they were the smoke baseline
AND the software-capture fallback):
- rgb mode: the staging upload is padded CPU-side (edge duplication) so
  the aligned encode-src is fully defined;
- CSC mode: the sampled image is now SOURCE-sized with a matching copy
  extent — the old aligned-size image + tightly-packed buffer sheared
  rows and left garbage rows at unaligned modes (black-bar artifacts;
  YMIN=16 in every smoke frame), which also masked as a 24 dB PSNR
  'regression' against the (correct) padded output.

On-glass (780M, host Mesa 26.0.4): all four smokes pass at 256x256
(direct) AND 250x250 (padded); padded-EFC frames decode perfectly
uniform (YMIN==YMAX) at the exact 709-narrow values (79/148/60 for the
first three fills); CSC-vs-padded PSNR 49.9 dB avg after the baseline
fix. clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:38:55 +02:00
enricobuehlerandClaude Opus 4.8 3aacec53d8 fix(encode): RGB-direct must not engage on unaligned modes — the EFC reads past the capture buffer (GPU hang)
apple / swift (push) Successful in 1m23s
apple / screenshots (push) Successful in 6m27s
windows-host / package (push) Successful in 9m29s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m15s
ci / bench (push) Successful in 6m8s
android / android (push) Successful in 15m47s
arch / build-publish (push) Successful in 14m49s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 25s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
deb / build-publish (push) Successful in 9m32s
ci / rust (push) Successful in 19m0s
deb / build-publish-host (push) Successful in 13m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m27s
docker / deploy-docs (push) Successful in 13s
Field report (commit 25624443, RDNA2/VCN3 host, 1080p): repeated VCN0 VM
protection faults from punktfunk-host, vcn_enc_0.0 ring timeouts, two ring
resets, then a failed reset escalating to a full MODE1 GPU reset with VRAM
loss. Root cause is B1's RGB-direct source: the session's coded extent is
64x16-aligned (1920x1088) but the captured dmabuf is only the real mode
size (1920x1080) — the VCN EFC reads the 8 padding rows past the end of
the buffer (8 x 7680 B = 61 KB, matching the faulting page span exactly).
The CSC shader absorbs alignment by clamping reads and duplicating edges;
RGB-direct has no such stage. Every prior validation surface happened to
be aligned (256x256 smoke, 1440p live box) — 1080p is the common mode
that is not. The stall watchdog's rebuild-and-refault loop then turned a
per-session fault into the GPU death spiral.

Two layers:
- Open-time gate: RGB-direct engages only when the real mode equals the
  aligned coded extent (720p/1440p/4K yes, 1080p no); otherwise the CSC
  path runs and the verdict line says why (unaligned-mode). The padded-
  copy variant that re-enables 1080p is design-doc B2 work.
- Submit-time check: an RGB-direct frame that does not cover the coded
  extent errors out (encoder-rebuild path) instead of importing an
  undersized source.

On-glass (780M, host Mesa 26.0.4): all four smokes pass aligned; at
PF_SMOKE_W/H=250 the gate refuses RGB-direct and the rgb smokes soft-skip.
clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:23:29 +02:00
112 changed files with 13170 additions and 945 deletions
+24 -30
View File
@@ -6,17 +6,12 @@
# #
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT # The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT
# need the Decky CLI (which requires Docker + rust-nightly only to compile native backends). # need the Decky CLI (which requires Docker + rust-nightly only to compile native backends).
# We build the frontend with pnpm and assemble the store-layout zip by hand: # We build the frontend with pnpm and stage the store-layout tree with the SAME script local
# # builds use (clients/decky/scripts/package.sh) — the plugin's file list lives in exactly ONE
# punktfunk.zip # place, so a file added there (bin/, assets/, controller_config/, …) can never be silently
# punktfunk/ <- single top-level dir == plugin.json "name" # missing from the published build. (Hand-assembling the zip here is how the shipped plugin
# plugin.json [required] # lost the shortcut artwork + Steam Input layout for a while.) CI only adds `update.json` on
# package.json [required; CI stamps "version" — Decky reads the installed version here] # top: the {channel, manifest} pointer the plugin's self-update check polls.
# main.py [required: python backend]
# dist/index.js [required: rollup output]
# update.json [CI-baked {channel, manifest}: where the plugin's self-update check polls]
# README.md (recommended)
# LICENSE [required by the plugin store]
# #
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel # SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed # `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
@@ -90,28 +85,27 @@ jobs:
- name: Assemble store-layout zip - name: Assemble store-layout zip
working-directory: ${{ gitea.workspace }} working-directory: ${{ gitea.workspace }}
run: | run: |
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null # node:22-bookworm ships python3 (a package.sh dep) but not zip; install both anyway
STAGE="$RUNNER_TEMP/decky" # so an image change can't silently break the build.
DEST="$STAGE/$PLUGIN" apt-get update && apt-get install -y --no-install-recommends zip python3 >/dev/null
rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin" # Stage the canonical plugin tree (dist/, main.py, bin/, assets/, controller_config/,
cp clients/decky/plugin.json "$DEST/" # LICENSE, …) with the same script local/sideload builds use — see the header comment.
cp clients/decky/package.json "$DEST/" # Runs AFTER the version stamp, so the staged package.json carries $VERSION.
cp clients/decky/main.py "$DEST/" bash clients/decky/scripts/package.sh
cp clients/decky/dist/index.js "$DEST/dist/" DEST="clients/decky/out/$PLUGIN"
cp clients/decky/README.md "$DEST/" # CI-only addition: the self-update channel pointer the backend reads (main.py
# The stream-launch wrapper (target of the Steam shortcut); keep it executable # check_update). It points at THIS channel's manifest.json (published below); that
# (runner_info() also re-chmods at runtime in case the zip/extract drops the bit). # manifest in turn points at the immutable per-version zip, so its sha256 stays valid
cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/" # across future alias re-uploads.
chmod 0755 "$DEST/bin/punktfunkrun.sh"
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
cp LICENSE-MIT "$DEST/LICENSE"
# Self-update channel pointer the backend reads (main.py check_update). It points at
# THIS channel's manifest.json (published below); that manifest in turn points at the
# immutable per-version zip, so its sha256 stays valid across future alias re-uploads.
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json" printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" ) ( cd clients/decky/out && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
ls -lh "$RUNNER_TEMP/punktfunk.zip" ls -lh "$RUNNER_TEMP/punktfunk.zip"
unzip -l "$RUNNER_TEMP/punktfunk.zip" unzip -l "$RUNNER_TEMP/punktfunk.zip"
# Backstop against packaging drift: the runtime-loaded pieces MUST be in the zip.
for f in main.py dist/index.js bin/punktfunkrun.sh assets/grid.png \
controller_config/punktfunk.vdf update.json; do
unzip -l "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN/$f" >/dev/null || { echo "MISSING $f" >&2; exit 1; }
done
# The update manifest the plugin polls: the immutable per-version artifact + its # The update manifest the plugin polls: the immutable per-version artifact + its
# sha256 (Decky's installer verifies the download against this hash, aborting on # sha256 (Decky's installer verifies the download against this hash, aborting on
# mismatch — so it MUST be the per-version URL, never the mutable alias). # mismatch — so it MUST be the per-version URL, never the mutable alias).
Generated
+67 -27
View File
@@ -656,6 +656,30 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]] [[package]]
name = "ciborium" name = "ciborium"
version = "0.2.2" version = "0.2.2"
@@ -691,6 +715,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [ dependencies = [
"crypto-common", "crypto-common",
"inout", "inout",
"zeroize",
] ]
[[package]] [[package]]
@@ -2159,7 +2184,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.16.0" version = "0.17.2"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2264,7 +2289,7 @@ dependencies = [
[[package]] [[package]]
name = "libvpl-sys" name = "libvpl-sys"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
@@ -2299,7 +2324,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2788,7 +2813,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pf-capture" name = "pf-capture"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2808,7 +2833,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-client-core" name = "pf-client-core"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2832,7 +2857,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-clipboard" name = "pf-clipboard"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2850,7 +2875,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-console-ui" name = "pf-console-ui"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2871,7 +2896,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-encode" name = "pf-encode"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2881,6 +2906,7 @@ dependencies = [
"libvpl-sys", "libvpl-sys",
"nvidia-video-codec-sdk", "nvidia-video-codec-sdk",
"openh264", "openh264",
"pf-capture",
"pf-frame", "pf-frame",
"pf-gpu", "pf-gpu",
"pf-host-config", "pf-host-config",
@@ -2894,7 +2920,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-ffvk" name = "pf-ffvk"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"ash", "ash",
"bindgen", "bindgen",
@@ -2903,7 +2929,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-frame" name = "pf-frame"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",
@@ -2915,7 +2941,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-gpu" name = "pf-gpu"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-host-config", "pf-host-config",
@@ -2929,11 +2955,11 @@ dependencies = [
[[package]] [[package]]
name = "pf-host-config" name = "pf-host-config"
version = "0.16.0" version = "0.17.2"
[[package]] [[package]]
name = "pf-inject" name = "pf-inject"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2961,14 +2987,14 @@ dependencies = [
[[package]] [[package]]
name = "pf-paths" name = "pf-paths"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"tracing", "tracing",
] ]
[[package]] [[package]]
name = "pf-presenter" name = "pf-presenter"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2983,7 +3009,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-vdisplay" name = "pf-vdisplay"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -3013,7 +3039,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-win-display" name = "pf-win-display"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-paths", "pf-paths",
@@ -3025,7 +3051,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-zerocopy" name = "pf-zerocopy"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -3136,6 +3162,17 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "polyval" name = "polyval"
version = "0.6.2" version = "0.6.2"
@@ -3221,7 +3258,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -3237,7 +3274,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3253,7 +3290,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-session" name = "punktfunk-client-session"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-client-core", "pf-client-core",
@@ -3268,7 +3305,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"ffmpeg-next", "ffmpeg-next",
@@ -3287,11 +3324,12 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
"cbindgen", "cbindgen",
"chacha20poly1305",
"criterion", "criterion",
"fec-rs", "fec-rs",
"hmac", "hmac",
@@ -3318,7 +3356,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3363,11 +3401,13 @@ dependencies = [
"rand 0.8.6", "rand 0.8.6",
"rcgen", "rcgen",
"reis", "reis",
"ring",
"roxmltree", "roxmltree",
"rsa", "rsa",
"rusqlite", "rusqlite",
"rustls", "rustls",
"rusty_enet", "rusty_enet",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -3400,7 +3440,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3414,7 +3454,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
@@ -3437,7 +3477,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]] [[package]]
name = "pyrowave-sys" name = "pyrowave-sys"
version = "0.16.0" version = "0.17.2"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
+1 -1
View File
@@ -48,7 +48,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" } ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package] [workspace.package]
version = "0.16.0" version = "0.17.2"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+1133 -1
View File
File diff suppressed because it is too large Load Diff
@@ -705,8 +705,11 @@ final class SessionModel: ObservableObject {
// captured before the 2026-07 floor policy); the appended trio carries the // captured before the 2026-07 floor policy); the appended trio carries the
// measured OS present floor and the floor-shaved values the HUD displays. // measured OS present floor and the floor-shaved values the HUD displays.
let line = String( let line = String(
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f " // Swift Int is 64-bit %lld, NOT %d (which is a 32-bit C int); macOS 26's
+ "decode_p50=%.1f display_p50=%.1f lost=%d " // strict String(format:) validator rejects the %d/Int mismatch and drops
// the whole line (a cascade error that also mis-blames the float args).
format: "fps=%lld presents=%lld e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
+ "decode_p50=%.1f display_p50=%.1f lost=%lld "
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f", + "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f",
frames, frames,
displayWindow?.count ?? 0, displayWindow?.count ?? 0,
@@ -43,6 +43,9 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.presentPriority) private var presentPriority = @AppStorage(DefaultsKey.presentPriority) private var presentPriority =
SettingsOptions.presentPriorityDefault SettingsOptions.presentPriorityDefault
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0 @AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
#if os(macOS)
@AppStorage(DefaultsKey.windowedSafePresent) private var windowedSafePresent = true
#endif
#if os(iOS) #if os(iOS)
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false @AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
#endif #endif
@@ -345,6 +348,22 @@ struct GamepadSettingsView: View {
detail: "Turn off to use the touch interface even with a controller connected.", detail: "Turn off to use the touch interface even with a controller connected.",
value: $gamepadUIEnabled), value: $gamepadUIEnabled),
] ]
#if os(macOS)
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
// the Video group) macOS only, mirroring the touch SettingsView's Presentation row
// (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent).
if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) {
list.insert(
toggleRow(
id: "windowedSafePresent", icon: "macwindow.badge.plus",
label: "Safe windowed presentation",
detail: "Windowed streams present in step with the compositor — avoids a "
+ "macOS display-driver crash on high-refresh displays, at a small "
+ "latency cost. Fullscreen always uses the fastest path.",
value: $windowedSafePresent),
at: at + 1)
}
#endif
#if os(iOS) #if os(iOS)
// The device-rumble mirror slots in after "Controller type" (staying inside the // The device-rumble mirror slots in after "Controller type" (staying inside the
// Controller group the next row carries the "Interface" header). iPhone only in // Controller group the next row carries the "Interface" header). iPhone only in
@@ -300,6 +300,18 @@ extension SettingsView {
+ "of added latency. Off shows frames as soon as they're ready.") { + "of added latency. Off shows frames as soon as they're ready.") {
Toggle("V-Sync", isOn: $vsync) Toggle("V-Sync", isOn: $vsync)
} }
// The DCP swapID-panic mitigation's user handle (see DefaultsKey.windowedSafePresent
// for the saga). Default ON: turning it off re-arms a WHOLE-MACHINE kernel panic on
// affected setups, so the caption says so in plain words.
described(windowedSafePresent
? "Windowed streams present in step with the system compositor — avoids a macOS "
+ "display-driver crash seen on high-refresh displays, at a small latency "
+ "cost. Fullscreen always uses the fastest path."
: "Windowed streams use the fastest present path. On some high-refresh setups "
+ "this can crash macOS itself (kernel panic) — turn back on if your Mac "
+ "restarts during windowed streaming.") {
Toggle("Safe windowed presentation", isOn: $windowedSafePresent)
}
#endif #endif
} }
} }
@@ -40,6 +40,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0 @AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
#if os(macOS) #if os(macOS)
@AppStorage(DefaultsKey.vsync) var vsync = false @AppStorage(DefaultsKey.vsync) var vsync = false
@AppStorage(DefaultsKey.windowedSafePresent) var windowedSafePresent = true
#endif #endif
#if !os(tvOS) #if !os(tvOS)
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true @AppStorage(DefaultsKey.allowVRR) var allowVRR = true
@@ -23,6 +23,34 @@ import os
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter") private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
#if os(macOS)
/// HOW a windowed (composited) macOS session pushes finished frames to glass the DCP
/// "mismatched swapID's" kernel-panic saga's mechanism picker. Fullscreen always presents
/// `async` (direct-scanout promotion, lowest latency, no panic reports there); the windowed
/// mechanism is resolved per session by SessionPresenter (user setting +
/// PUNKTFUNK_WINDOWED_PRESENT env override) and routed here via `setWindowedPresent`.
///
/// - `async`: the CAMetalLayer image queue (`commandBuffer.present`) the fastest composited
/// path and the PANIC TRIGGER on high-refresh displays (the out-of-band swaps race
/// WindowServer's compositor; it survived glass pacing and every codec).
/// - `transaction`: `CAMetalLayer.presentsWithTransaction` the swap commits WITH the layer
/// tree, in lockstep with the compositor (Apple's documented remedy; validated no-panic on
/// the 240 Hz repro machine). The present is committed from the RENDER thread inside an
/// explicit CATransaction + flush see `encodePresent` for why that beats the original
/// main-thread hop.
/// - `surface`: no image queue at all render into a pooled IOSurface and swap it into a plain
/// CALayer's `contents` (the f407f418 PyroWave mitigation, resurrected format-aware:
/// rgba16Float + PQ tagging keeps HDR). WindowServer treats it as ordinary layer damage on
/// its own composite cadence. PROTOTYPE: whether the compositor honors PQ/EDR for plain-layer
/// IOSurface contents still needs an on-glass eyeball the metal layer stays underneath with
/// `wantsExtendedDynamicRangeContent` as the EDR anchor.
enum WindowedPresentMode: String, Sendable {
case async
case transaction
case surface
}
#endif
/// HDR reference white (BT.2408 "HDR Reference White"): the absolute luminance, in nits, that the /// HDR reference white (BT.2408 "HDR Reference White"): the absolute luminance, in nits, that the
/// PQ signal's diffuse white sits at. Passed to `CAEDRMetadata.hdr10(opticalOutputScale:)`, it anchors /// PQ signal's diffuse white sits at. Passed to `CAEDRMetadata.hdr10(opticalOutputScale:)`, it anchors
/// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the /// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the
@@ -198,8 +226,8 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.) // in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
// The shared PQdisplay-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084 // The shared PQdisplay-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084
// EOTF 203-nit-anchored scene light BT.2020709 primaries extended-Reinhard rolloff // EOTF 203-nit-anchored scene light BT.2020709 primaries extended-Reinhard rolloff
// BT.709 OETF. Used by the tvOS biplanar tone-map and the planar (PyroWave) tone-map the // BT.709 OETF. Used by the tvOS biplanar tone-map and the tvOS planar (PyroWave) tone-map (the
// latter also on macOS windowed sessions, whose IOSurface present path is BGRA8-only. // no-HDR-headroom fallback). macOS keeps real HDR windowed now see `WindowedPresentMode`.
static inline float3 pqToSdr(float3 pq) { static inline float3 pqToSdr(float3 pq) {
const float m1 = 2610.0/16384.0; const float m1 = 2610.0/16384.0;
const float m2 = 78.84375; const float m2 = 78.84375;
@@ -230,8 +258,7 @@ fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
// PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows // PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows
// fold in depth-10 MSB packing) PQ RGB the shared SDR tail. Used when a PQ pyrowave // fold in depth-10 MSB packing) PQ RGB the shared SDR tail. Used when a PQ pyrowave
// stream must land on an 8-bit surface: tvOS without HDR headroom, and macOS WINDOWED sessions // stream must land on an 8-bit surface: tvOS without HDR headroom. The passthrough planar
// (the IOSurface present path the DCP-panic mitigation is BGRA8). The passthrough planar
// HDR pipeline reuses pf_frag_planar itself on an rgba16Float drawable (identical math the // HDR pipeline reuses pf_frag_planar itself on an rgba16Float drawable (identical math the
// layer's itur_2100_PQ colour space + EDR metadata do the interpretation). // layer's itur_2100_PQ colour space + EDR metadata do the interpretation).
fragment float4 pf_frag_planar_tm(VOut in [[stage_in]], fragment float4 pf_frag_planar_tm(VOut in [[stage_in]],
@@ -259,21 +286,51 @@ public final class MetalVideoPresenter {
public let layer: CAMetalLayer public let layer: CAMetalLayer
#if os(macOS) #if os(macOS)
/// The WINDOWED-mode PyroWave present target: a plain CALayer sized like `layer` (installed /// WINDOWED-mode present coordination the macOS DCP KERNEL PANIC mitigation.
/// as a sibling ABOVE it), fed IOSurfaces via `contents` inside ordinary CATransactions.
/// ///
/// Why this exists the macOS DCP KERNEL PANIC ("mismatched swapID's" @UnifiedPipeline.cpp, /// The panic ("mismatched swapID's" @UnifiedPipeline.cpp, WindowServer dies, machine reboots):
/// WindowServer dies, machine reboots): out-of-band CAMetalLayer image-queue swaps into a /// the CAMetalLayer's ASYNCHRONOUS image queue (`commandBuffer.present(drawable)` an
/// COMPOSITED (windowed) session race WindowServer's own swap submissions on high-refresh /// out-of-band flip, mandatory with `displaySyncEnabled=false`) diverges from WindowServer's
/// displays, and the race survives glass pacing a fully serialized one-in-flight present /// compositor on a high-refresh COMPOSITED (windowed) session the compositor's notion of the
/// stream still panicked a 240 Hz Mac Studio (2026-07-18, twice). So in windowed mode we stop /// current swap and the layer's queued swap disagree, and the DCP asserts. It survived glass
/// using the image queue entirely and present the way video players do: render the planar CSC /// pacing: a fully serialized one-in-flight present stream still panicked a 240 Hz Mac Studio
/// into an IOSurface pool and swap `contents` on main WindowServer treats it as ordinary /// (2026-07-18, PyroWave), and a windowed HEVC session panicked the same machine 2026-07-21
/// damage on its own composite cadence, coalescing faster-than-refresh updates instead of /// so it is the async image queue itself, at any pacing or codec, not a present rate.
/// latching queue swaps mid-cycle. Fullscreen keeps the CAMetalLayer path (direct-scanout ///
/// promotion, no compositing, no panic reports). Contents updates are transparent to the /// The fix keeps the full render path (rgba16Float / PQ / EDR real HDR is preserved) and
/// layer below when nil, so flipping modes just covers/uncovers the metal layer. /// only changes HOW the drawable is presented: `CAMetalLayer.presentsWithTransaction`. With it
public let surfaceLayer: CALayer = { /// set, we don't hand the drawable to the command buffer; we commit, wait until scheduled, then
/// call `drawable.present()` INSIDE a CATransaction the present is enrolled in Core
/// Animation's transaction and committed together with the layer tree, so the swap stays in
/// lockstep with the compositor instead of racing it (Apple's documented remedy for Metal
/// presentation drifting out of sync with CA). Fullscreen keeps the async path (direct-scanout
/// promotion, lowest latency, no compositor and no panic reports there).
///
/// 2026-07-21 latency rework: the mitigation MECHANISM is now a three-way pick
/// (`WindowedPresentMode`) and the transactional present commits from the RENDER thread
/// see `encodePresent`. Staged under `stagingLock` (main pushes it via
/// `setComposited``setWindowedPresent`); the render thread drains it and toggles the layer
/// property + present style. `Active` is the render-thread copy so the layer property flips
/// exactly once per mode change.
private var windowedPresentStaged: WindowedPresentMode = .async
private var windowedPresentActive: WindowedPresentMode = .async
/// PUNKTFUNK_TXN_PRESENT=main the ORIGINAL transactional present (commit
/// waitUntilScheduled hop to the MAIN thread and present inside its CATransaction), kept
/// as a field A/B lever. The default is the render-thread commit: the present harness
/// (2026-07-21, this saga) measured the main hop landing a runloop turn late on a busy main
/// thread, and an ACTIVE implicit transaction there NESTS the explicit one presents batch
/// at runloop-iteration rate (the field's presents=55 @ fps=240, display_p50 18.6 ms).
/// Off-main commits measured immune to main-thread churn (~10 ms glass p50 at 240 Hz
/// full-size vs 14+ ms under a churned main hop).
private let txnPresentOnMain =
ProcessInfo.processInfo.environment["PUNKTFUNK_TXN_PRESENT"] == "main"
/// The WINDOWED-mode `surface` present target: a plain CALayer sized like `layer` (installed
/// as a sibling ABOVE it by SessionPresenter), fed IOSurfaces via `contents` inside explicit
/// CATransactions. Transparent (nil contents) whenever surface mode is off, so the metal
/// layer below shows through. See `WindowedPresentMode.surface`.
let surfaceLayer: CALayer = {
let l = CALayer() let l = CALayer()
l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout
l.isOpaque = true l.isOpaque = true
@@ -281,8 +338,8 @@ public final class MetalVideoPresenter {
return l return l
}() }()
/// One IOSurface-backed render target of the windowed present pool. All pool state is /// One IOSurface-backed render target of the windowed surface-present pool. All pool state
/// RENDER-THREAD confined; only the immutable surface refs cross to main (contents swap). /// is RENDER-THREAD confined; only the immutable surface refs cross threads (contents swap).
private struct SurfaceSlot { private struct SurfaceSlot {
let surface: IOSurfaceRef let surface: IOSurfaceRef
let texture: MTLTexture let texture: MTLTexture
@@ -292,15 +349,52 @@ public final class MetalVideoPresenter {
private var surfacePool: [SurfaceSlot] = [] private var surfacePool: [SurfaceSlot] = []
private var surfacePoolSize: CGSize = .zero private var surfacePoolSize: CGSize = .zero
private var surfacePoolHDR = false
private var surfaceSeq: UInt64 = 0 private var surfaceSeq: UInt64 = 0
/// Index of the slot most recently handed to the layer never rewritten next, even if its /// Index of the slot most recently handed to the layer never rewritten next, even if its
/// use count already dropped (the compositor may still be scanning out the previous frame). /// use count already dropped (the compositor may still be scanning out the previous frame).
private var lastHandedOff: Int? private var lastHandedOff: Int?
/// Staged (under `stagingLock`, like every cross-thread input): the hosting view's windowed
/// vs fullscreen state, pushed from main via `setSurfacePresents`. Drained in `renderPlanar`. /// Once-per-second decomposition of the ACTIVE windowed present path (the field-diagnosis
private var surfacePresentsStaged = false /// half of the DCP-latency work): scheduled/completed wait + commit/flush cost per present,
/// Render-thread copy, so pool teardown happens exactly once on a mode flip. /// and how many presents/swaps were issued. The pf-present line shows the GLASS side
private var surfacePresentsActive = false /// (latchMs / dropped); this shows the ISSUE side. Logged via `presenterLog` only while a
/// windowed mechanism is active (zero cost fullscreen). Lock-guarded: transaction mode
/// records from the render thread, surface mode from Metal completion threads.
private final class WindowedPresentDiag: @unchecked Sendable {
private let lock = NSLock()
private var presents = 0
private var schedMs: [Double] = []
private var commitMs: [Double] = []
private var last = CACurrentMediaTime()
func record(schedMs sched: Double, commitMs commit: Double, mode: WindowedPresentMode) {
lock.lock()
presents += 1
schedMs.append(sched)
commitMs.append(commit)
let now = CACurrentMediaTime()
guard now - last >= 1 else {
lock.unlock()
return
}
last = now
let sSched = schedMs.sorted()
let sCommit = commitMs.sorted()
let line = String(
format: "pf-windowed mode=%@ presents=%d schedMs p50=%.2f max=%.2f "
+ "commitMs p50=%.2f max=%.2f",
mode.rawValue, presents, sSched[sSched.count / 2], sSched.last ?? 0,
sCommit[sCommit.count / 2], sCommit.last ?? 0)
presents = 0
schedMs.removeAll(keepingCapacity: true)
commitMs.removeAll(keepingCapacity: true)
lock.unlock()
presenterLog.info("\(line, privacy: .public)")
}
}
private let windowedDiag = WindowedPresentDiag()
#endif #endif
private let device: MTLDevice private let device: MTLDevice
@@ -316,7 +410,7 @@ public final class MetalVideoPresenter {
private let pipelinePlanar: MTLRenderPipelineState private let pipelinePlanar: MTLRenderPipelineState
/// PyroWave planar HDR passthrough (pf_frag_planar rgba16Float; the layer's PQ colour /// PyroWave planar HDR passthrough (pf_frag_planar rgba16Float; the layer's PQ colour
/// space + EDR interpret the samples) and the planar PQSDR tone-map (pf_frag_planar_tm /// space + EDR interpret the samples) and the planar PQSDR tone-map (pf_frag_planar_tm
/// bgra8; tvOS without headroom + macOS windowed IOSurface presents). /// bgra8; tvOS without HDR headroom).
private let pipelinePlanarHDR: MTLRenderPipelineState private let pipelinePlanarHDR: MTLRenderPipelineState
private let pipelinePlanarToneMap: MTLRenderPipelineState private let pipelinePlanarToneMap: MTLRenderPipelineState
private var textureCache: CVMetalTextureCache? private var textureCache: CVMetalTextureCache?
@@ -591,13 +685,14 @@ public final class MetalVideoPresenter {
} }
#if os(macOS) #if os(macOS)
/// Park the windowed-vs-fullscreen present routing (MAIN thread the hosting view pushes its /// Park the windowed present mechanism (MAIN thread the hosting view pushes its window
/// window state on every layout). true = PyroWave frames present via `surfaceLayer` contents /// state on every layout; SessionPresenter resolves the mechanism per session). `.async` =
/// (the DCP swapID-panic mitigation see `surfaceLayer`); false = the CAMetalLayer path. /// FULLSCREEN (or the user opted out of the mitigation): the image queue. `.transaction` /
/// `.surface` = COMPOSITED (windowed) mitigation mechanisms see `WindowedPresentMode`.
/// Applied by the render thread on the next frame, like every other staged value here. /// Applied by the render thread on the next frame, like every other staged value here.
public func setSurfacePresents(_ on: Bool) { func setWindowedPresent(_ mode: WindowedPresentMode) {
stagingLock.lock() stagingLock.lock()
surfacePresentsStaged = on windowedPresentStaged = mode
stagingLock.unlock() stagingLock.unlock()
} }
#endif #endif
@@ -734,36 +829,12 @@ public final class MetalVideoPresenter {
) -> Bool { ) -> Bool {
stagingLock.lock() stagingLock.lock()
let targetFromLayout = drawableTarget let targetFromLayout = drawableTarget
#if os(macOS)
let surfaceMode = surfacePresentsStaged
#endif
stagingLock.unlock() stagingLock.unlock()
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path; // A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path
// macOS WINDOWED sessions stay on the SDR layer (the IOSurface path tone-maps in-shader). // including macOS windowed sessions, which keep real HDR (the DCP mitigation is the
#if os(macOS) // transactional present in `encodePresent`, not a colour downgrade).
configure(hdr: planes.pq && !surfaceMode)
#else
configure(hdr: planes.pq) configure(hdr: planes.pq)
#endif
var csc = planes.csc var csc = planes.csc
#if os(macOS)
if surfaceMode != surfacePresentsActive {
surfacePresentsActive = surfaceMode
presenterLog.info(
"stage2: windowed surface presents \(surfaceMode ? "ON" : "OFF", privacy: .public) (PyroWave DCP-panic mitigation)")
if !surfaceMode {
// Back to the metal path (fullscreen): drop the pool at 5K it holds >100 MB,
// and re-entering windowed mode rebuilds it in one frame.
surfacePool.removeAll()
surfacePoolSize = .zero
lastHandedOff = nil
}
}
if surfaceMode {
return renderPlanarToSurface(
planes, targetFromLayout: targetFromLayout, csc: &csc, onPresented: onPresented)
}
#endif
// PQ passthrough needs the HDR drawable; a PQ frame while the drawable is (still) // PQ passthrough needs the HDR drawable; a PQ frame while the drawable is (still)
// 8-bit tvOS without display headroom, or a not-yet-flipped layer tone-maps // 8-bit tvOS without display headroom, or a not-yet-flipped layer tone-maps
// in-shader instead (the pipeline must match the drawable's pixel format). // in-shader instead (the pipeline must match the drawable's pixel format).
@@ -792,118 +863,6 @@ public final class MetalVideoPresenter {
} }
} }
#if os(macOS)
/// The windowed-mode present tail (see `surfaceLayer` for why this path exists): render the
/// planar CSC into a pooled IOSurface and hand it to `surfaceLayer.contents` on MAIN inside a
/// plain CATransaction an ordinary damaged-layer update on WindowServer's own composite
/// cadence, no CAMetalLayer image-queue swap anywhere. `presentAtMediaTime` doesn't apply
/// (the compositor paces); `onPresented` fires after the contents swap is committed, stamped
/// with CLOCK_REALTIME then the closest observable analogue of "reached glass" here (the
/// composite follows within a refresh, so the meters' display stage reads slightly optimistic).
private func renderPlanarToSurface(
_ planes: WaveletPlanes, targetFromLayout: CGSize, csc: inout CscUniform,
onPresented: ((Int64?) -> Void)?
) -> Bool {
let decodedSize = CGSize(width: planes.width, height: planes.height)
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
? targetFromLayout : decodedSize
ensureSurfacePool(size: targetSize)
guard let slotIndex = takeSurfaceSlot(),
let commandBuffer = queue.makeCommandBuffer()
else { return false }
let slot = surfacePool[slotIndex]
let pass = MTLRenderPassDescriptor()
pass.colorAttachments[0].texture = slot.texture
pass.colorAttachments[0].loadAction = .clear
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
pass.colorAttachments[0].storeAction = .store
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
return false
}
encoder.setRenderPipelineState(planes.pq ? pipelinePlanarToneMap : pipelinePlanar)
encoder.setFragmentTexture(planes.y, index: 0)
encoder.setFragmentTexture(planes.cb, index: 1)
encoder.setFragmentTexture(planes.cr, index: 2)
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
encoder.endEncoding()
let surface = slot.surface
let surfaceLayer = surfaceLayer // captured directly the handler must not retain self
let keepAlive: [Any] = [planes.y, planes.cb, planes.cr]
commandBuffer.addCompletedHandler { _ in
_ = keepAlive // ring textures pinned until the GPU finished sampling
DispatchQueue.main.async {
CATransaction.begin()
CATransaction.setDisableActions(true)
surfaceLayer.contents = surface
CATransaction.commit()
onPresented?(
Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
}
}
commandBuffer.commit()
lastHandedOff = slotIndex
return true
}
/// (Re)build the pool at `size` 4 BGRA8 IOSurface render targets (one on glass, one queued
/// in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the pool empty;
/// the caller returns false and the ring's putBack + display-link retry take over.
private func ensureSurfacePool(size: CGSize) {
guard size != surfacePoolSize else { return }
surfacePool.removeAll()
surfacePoolSize = size
lastHandedOff = nil
let w = Int(size.width)
let h = Int(size.height)
guard w > 0, h > 0 else { return }
// 256-byte row alignment satisfies both IOSurface and Metal linear-texture rules.
let bytesPerRow = ((w * 4) + 255) & ~255
let props: [String: Any] = [
kIOSurfaceWidth as String: w,
kIOSurfaceHeight as String: h,
kIOSurfaceBytesPerElement as String: 4,
kIOSurfaceBytesPerRow as String: bytesPerRow,
kIOSurfacePixelFormat as String: kCVPixelFormatType_32BGRA,
]
let desc = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .bgra8Unorm, width: w, height: h, mipmapped: false)
desc.usage = [.renderTarget]
desc.storageMode = .shared
for _ in 0..<4 {
guard let surface = IOSurfaceCreate(props as CFDictionary),
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
else {
surfacePool.removeAll()
return
}
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
}
}
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
/// stalling a visible glitch at worst, never a queue-up. RENDER THREAD.
private func takeSurfaceSlot() -> Int? {
guard !surfacePool.isEmpty else { return nil }
var free: Int?
var busy: Int?
for i in surfacePool.indices where i != lastHandedOff {
if !IOSurfaceIsInUse(surfacePool[i].surface) {
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
} else {
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
}
}
guard let pick = free ?? busy else { return nil }
surfaceSeq += 1
surfacePool[pick].seq = surfaceSeq
return pick
}
#endif
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one /// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule /// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
/// the present and the on-glass callback. /// the present and the on-glass callback.
@@ -936,6 +895,36 @@ public final class MetalVideoPresenter {
#if DEBUG #if DEBUG
logSizeIfChanged(decoded: decodedSize, drawable: targetSize) logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
#endif #endif
#if os(macOS)
// Windowed (composited) the DCP swapID-panic mitigation mechanism (see
// `WindowedPresentMode`). Toggle the layer property BEFORE vending a drawable so the
// vend matches how it will be presented; drained here on the render thread, flipped
// exactly once per mode change.
stagingLock.lock()
let windowedMode = windowedPresentStaged
stagingLock.unlock()
if windowedMode != windowedPresentActive {
windowedPresentActive = windowedMode
layer.presentsWithTransaction = windowedMode == .transaction
if windowedMode != .surface, !surfacePool.isEmpty {
// Leaving surface mode (fullscreen entry / mechanism A/B): drop the pool at 5K
// it holds >100 MB, and re-entering rebuilds it in one frame. SessionPresenter
// clears the surface layer's contents on main.
surfacePool.removeAll()
surfacePoolSize = .zero
lastHandedOff = nil
}
presenterLog.info(
"stage2: windowed present mode \(windowedMode.rawValue, privacy: .public) (DCP swapID-panic mitigation)")
}
if windowedMode == .surface {
// No image queue at all: render into a pooled IOSurface and swap it into the
// sibling layer's contents. The drawable/queue tail below never runs.
return encodeToSurface(
targetSize: targetSize, pipeline: pipeline, onPresented: onPresented,
keepAlive: keepAlive, bind: bind)
}
#endif
if let providedDrawable, if let providedDrawable,
providedDrawable.texture.pixelFormat != layer.pixelFormat { providedDrawable.texture.pixelFormat != layer.pixelFormat {
return false // config outran the vend (HDR flip) next vend has the new format return false // config outran the vend (HDR flip) next vend has the new format
@@ -974,6 +963,61 @@ public final class MetalVideoPresenter {
} }
#endif #endif
} }
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
#if os(macOS)
if windowedPresentActive == .transaction {
// Windowed DCP mitigation: present the drawable THROUGH a Core Animation transaction
// (`presentsWithTransaction`, set above) instead of the async image queue, so the swap
// commits with the layer tree and stays in lockstep with the compositor (no out-of-band
// flip to race WindowServer's swaps). Wait until the GPU work is scheduled (contents
// will be ready p50 ~0.1 ms), then present inside an EXPLICIT CATransaction ON THIS
// RENDER THREAD and `flush()`. `presentAtMediaTime` does not apply the transaction
// paces.
//
// Threading history, because BOTH failure modes shipped or nearly shipped:
// A bare `present()` from this thread (no transaction) never flushes nothing
// commits a runloop-less thread's implicit transaction, so drawables are never
// released; after maximumDrawableCount vends `nextDrawable()` blocks forever and
// the stream FREEZES (the fullscreenwindowed switch did exactly this).
// The explicit begin/commit alone is NOT enough either: this thread has an ACTIVE
// implicit transaction (the layer mutations above drawableSize/colour created
// it), so the explicit transaction NESTS inside it and its commit defers to the
// implicit one that never comes. The harness reproduced the exact freeze: every
// present reported presentedTime=0, nothing reached glass. `CATransaction.flush()`
// pushes the implicit transaction (present included) to the render server NOW.
// The original fix hopped to MAIN and presented there correct, but slow in the
// field (presents=55 @ fps=240, display_p50 18.6 ms on the 240 Hz Studio): each
// present lands a runloop turn late, and main's own implicit transaction batches
// enrolled presents at runloop-iteration rate. Kept as PUNKTFUNK_TXN_PRESENT=main.
// The off-main commit measured immune to main-thread churn in the harness
// (2026-07-21: glass p50 ~10 ms at 240 Hz full-size, cadence a clean 4.17 ms).
commandBuffer.commit()
let schedStart = CACurrentMediaTime()
commandBuffer.waitUntilScheduled()
let schedMs = (CACurrentMediaTime() - schedStart) * 1000
let commitStart = CACurrentMediaTime()
if txnPresentOnMain {
let presentedDrawable = drawable
DispatchQueue.main.async {
CATransaction.begin()
CATransaction.setDisableActions(true)
presentedDrawable.present()
CATransaction.commit()
}
} else {
CATransaction.begin()
CATransaction.setDisableActions(true)
drawable.present()
CATransaction.commit()
CATransaction.flush()
}
windowedDiag.record(
schedMs: schedMs, commitMs: (CACurrentMediaTime() - commitStart) * 1000,
mode: .transaction)
return true
}
#endif
// Scheduled on the vsync when the pipeline gave us the link's target (see the doc comment); // Scheduled on the vsync when the pipeline gave us the link's target (see the doc comment);
// immediate otherwise. A target already in the past presents immediately same thing. // immediate otherwise. A target already in the past presents immediately same thing.
if let presentAtMediaTime { if let presentAtMediaTime {
@@ -981,12 +1025,146 @@ public final class MetalVideoPresenter {
} else { } else {
commandBuffer.present(drawable) commandBuffer.present(drawable)
} }
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
commandBuffer.commit() commandBuffer.commit()
return true return true
} }
#if os(macOS)
/// The WINDOWED `surface` present tail (see `WindowedPresentMode.surface`): render with the
/// same per-frame pipeline into a pooled IOSurface and hand it to `surfaceLayer.contents`
/// from the command buffer's COMPLETION handler, inside an explicit CATransaction + flush
/// (the same off-main commit discipline as the transactional present an ordinary
/// damaged-layer update on WindowServer's own composite cadence, no image queue anywhere).
/// RENDER THREAD. `onPresented` is stamped right after the contents swap commits the
/// closest observable analogue of "reached glass" here (the composite follows within a
/// refresh, so the display-stage meters read slightly OPTIMISTIC in this mode).
///
/// The pool tracks `hdrActive`: bgra8 for SDR, rgba16Float tagged BT.2100 PQ for HDR
/// `configure` already ran, so the caller's `pipeline` attachment format always matches.
/// HDR OPEN RISK (why this whole mode is a prototype): whether the compositor honors the
/// PQ tag + EDR for plain-CALayer IOSurface contents needs an on-glass eyeball; the metal
/// layer underneath keeps `wantsExtendedDynamicRangeContent` as the EDR anchor (the harness
/// measured the display's EDR headroom engaging with this arrangement).
private func encodeToSurface(
targetSize: CGSize, pipeline: MTLRenderPipelineState,
onPresented: ((Int64?) -> Void)?,
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
) -> Bool {
ensureSurfacePool(size: targetSize, hdr: hdrActive)
guard let slotIndex = takeSurfaceSlot(),
let commandBuffer = queue.makeCommandBuffer()
else { return false }
let slot = surfacePool[slotIndex]
let pass = MTLRenderPassDescriptor()
pass.colorAttachments[0].texture = slot.texture
pass.colorAttachments[0].loadAction = .clear
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
pass.colorAttachments[0].storeAction = .store
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
return false
}
encoder.setRenderPipelineState(pipeline)
bind(encoder)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
encoder.endEncoding()
let surface = slot.surface
let surfaceLayer = surfaceLayer // captured directly the handler must not retain self
let diag = windowedDiag
let commitStamp = CACurrentMediaTime()
commandBuffer.addCompletedHandler { _ in
_ = keepAlive // sources pinned until the GPU finished sampling
let completedAt = CACurrentMediaTime()
// Swap on THIS Metal completion thread: explicit transaction + flush, so the commit
// reaches the render server now, independent of main (completion handlers for one
// queue fire in execution order, so swaps can't reorder).
CATransaction.begin()
CATransaction.setDisableActions(true)
surfaceLayer.contents = surface
CATransaction.commit()
CATransaction.flush()
diag.record(
schedMs: (completedAt - commitStamp) * 1000,
commitMs: (CACurrentMediaTime() - completedAt) * 1000, mode: .surface)
onPresented?(Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
}
commandBuffer.commit()
lastHandedOff = slotIndex
return true
}
/// (Re)build the pool at `size`/`hdr` 4 IOSurface render targets (one on glass, one
/// committed in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the
/// pool empty; the caller returns false and the ring's putBack + display-link retry take
/// over.
private func ensureSurfacePool(size: CGSize, hdr: Bool) {
guard size != surfacePoolSize || hdr != surfacePoolHDR else { return }
surfacePool.removeAll()
surfacePoolSize = size
surfacePoolHDR = hdr
lastHandedOff = nil
let w = Int(size.width)
let h = Int(size.height)
guard w > 0, h > 0 else { return }
// rgba16Float (8 B/px) carries the PQ-encoded HDR samples; bgra8 the SDR ones. 256-byte
// row alignment satisfies both IOSurface and Metal linear-texture rules.
let bytesPerElement = hdr ? 8 : 4
let bytesPerRow = ((w * bytesPerElement) + 255) & ~255
let props: [String: Any] = [
kIOSurfaceWidth as String: w,
kIOSurfaceHeight as String: h,
kIOSurfaceBytesPerElement as String: bytesPerElement,
kIOSurfaceBytesPerRow as String: bytesPerRow,
kIOSurfacePixelFormat as String: hdr
? kCVPixelFormatType_64RGBAHalf : kCVPixelFormatType_32BGRA,
]
let desc = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: hdr ? .rgba16Float : .bgra8Unorm, width: w, height: h, mipmapped: false)
desc.usage = [.renderTarget]
desc.storageMode = .shared
for _ in 0..<4 {
guard let surface = IOSurfaceCreate(props as CFDictionary),
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
else {
surfacePool.removeAll()
return
}
if hdr, let name = CGColorSpace(name: CGColorSpace.itur_2100_PQ)?.name {
// Tag the surface BT.2100 PQ so the compositor interprets the half-float
// samples as PQ-encoded HDR (the CALayer-contents analogue of the metal
// layer's colorspace).
IOSurfaceSetValue(surface, "IOSurfaceColorSpace" as CFString, name)
}
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
}
// The EDR request rides the SURFACE layer too (its contents are what composite); the
// metal layer underneath keeps its own from configureColor as the anchor. Layer flags
// are committed by the next swap's transaction flush.
surfaceLayer.wantsExtendedDynamicRangeContent = hdr
}
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
/// stalling a visible glitch at worst, never a queue-up. RENDER THREAD.
private func takeSurfaceSlot() -> Int? {
guard !surfacePool.isEmpty else { return nil }
var free: Int?
var busy: Int?
for i in surfacePool.indices where i != lastHandedOff {
if !IOSurfaceIsInUse(surfacePool[i].surface) {
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
} else {
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
}
}
guard let pick = free ?? busy else { return nil }
surfaceSeq += 1
surfacePool[pick].seq = surfaceSeq
return pick
}
#endif
/// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past the /// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past the
/// draw the MTLTexture is only valid while its CVMetalTexture is retained. /// draw the MTLTexture is only valid while its CVMetalTexture is retained.
private func makeTexture( private func makeTexture(
@@ -142,20 +142,17 @@ enum PresentPriority: Equatable {
final class SessionPresenter { final class SessionPresenter {
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2 /// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
/// default, macOS PyroWave sessions ALSO get glass gating a kernel-panic mitigation, not a /// default, macOS PyroWave sessions ALSO get glass gating for SMOOTHNESS, not as the panic
/// latency tweak. macOS's DCP panics ("mismatched swapID's" @UnifiedPipeline.cpp, the whole /// fix (that is the windowed transactional present see `setComposited`). PyroWave's wavelet
/// machine dies) when WindowServer's swap submissions race, and the reliable trigger is /// decode is near-instant Metal compute, so a network clump presents within the same
/// out-of-band CAMetalLayer presents (displaySyncEnabled=false mandatory for us, see /// millisecond, and it is the codec that sustains stream rates above the panel's refresh; the
/// MetalVideoPresenter's init) arriving faster than the compositor latches them in a /// glass gate admits one presented-but-undisplayed swap at a time (serialized on the on-glass
/// COMPOSITED (windowed) session. Arrival pacing does exactly that with PyroWave: the wavelet /// callback, 100 ms stale backstop) so those bursts coalesce in the newest-wins ring instead
/// decode is near-instant Metal compute, so a network clump of frames presents within the /// of flooding the queue. (Glass pacing was ALSO the original DCP-panic mitigation attempt
/// same millisecond, and PyroWave is the codec that sustains stream rates above the panel's /// disproven: a fully serialized stream still panicked, which is why the real fix moved to the
/// refresh. The glass gate admits one presented-but-undisplayed swap at a time (serialized on /// present mechanism.) An explicit stage-2 pick (setting/env) still forces arrival pacing
/// the on-glass callback, 100 ms stale backstop), which removes the racing pattern outright; /// that A/B lever must stay honest. VideoToolbox codecs keep arrival pacing: decode latency
/// frames the panel couldn't have shown anyway coalesce in the newest-wins ring. An explicit /// spaces their presents.
/// stage-2 pick (setting/env) still forces arrival pacing that A/B lever must stay honest.
/// VideoToolbox codecs keep arrival pacing: decode latency spaces their presents, and years
/// of stage-2 defaults there predate any panic report.
static func pacing( static func pacing(
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
) -> PresentPacing { ) -> PresentPacing {
@@ -178,10 +175,25 @@ final class SessionPresenter {
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing /// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing
/// that can't queue at all that's stage-4 (`PresentPacing.deadline`), not a deeper gate. /// that can't queue at all that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
/// ///
#if os(macOS)
/// Resolve the windowed (composited) present MECHANISM for this session the DCP
/// swapID-panic mitigation picker (see `WindowedPresentMode`). The
/// `PUNKTFUNK_WINDOWED_PRESENT=async|transaction|surface` env lever wins (dev A/B);
/// otherwise the user's safe-present setting: ON/unset `.transaction` (the validated
/// mitigation), OFF `.async` (the fast pre-mitigation path the panic returns on
/// affected high-refresh setups; the Settings caption says so). `.surface` is currently
/// env-only (prototype HDR-composite verification owed). Fullscreen always presents
/// async regardless (`setComposited`). Internal (not private) for unit tests.
static func windowedPresentMode(setting: Bool?, env: String?) -> WindowedPresentMode {
if let env, let mode = WindowedPresentMode(rawValue: env) { return mode }
return (setting ?? true) ? .transaction : .async
}
#endif
/// `PUNKTFUNK_GATE_DEPTH` (13) still overrides on iOS/tvOS so the standing-queue ladder /// `PUNKTFUNK_GATE_DEPTH` (13) still overrides on iOS/tvOS so the standing-queue ladder
/// stays reproducible on-device; macOS is pinned to 1, env ignored glass pacing exists /// stays reproducible on-device; macOS is pinned to 1, env ignored a deeper gate only builds
/// there as the DCP swapID kernel-panic mitigation (see `pacing`), and STRICT present /// a standing queue (see above), and macOS glass pacing exists for PyroWave smoothness
/// serialization is its point. Internal (not private) for unit tests. /// (see `pacing`), where depth 1 is the point. Internal (not private) for unit tests.
static func gateDepth(env: String?) -> Int { static func gateDepth(env: String?) -> Int {
#if os(macOS) #if os(macOS)
return 1 return 1
@@ -196,10 +208,16 @@ final class SessionPresenter {
private var stage2Link: CADisplayLink? private var stage2Link: CADisplayLink?
private var metalLayer: CAMetalLayer? private var metalLayer: CAMetalLayer?
#if os(macOS) #if os(macOS)
/// The windowed-mode PyroWave present target (sibling above `metalLayer`) and the last /// The windowed present MECHANISM this session runs while composited (resolved once per
/// routing pushed to the pipeline see `setComposited`. Main-thread only, like all of this. /// session in `start` the user's safe-present setting + the PUNKTFUNK_WINDOWED_PRESENT
/// dev override) and the routing last pushed to the pipeline see `setComposited` (the DCP
/// swapID-panic mitigation). Main-thread only, like all of this.
private var windowedMode: WindowedPresentMode = .transaction
private var windowedPresentApplied: WindowedPresentMode = .async
/// The windowed `surface` present target (sibling above `metalLayer`, transparent while
/// unused) installed whenever stage-2 runs so a mechanism flip never has to mutate the
/// layer tree mid-session.
private var surfaceLayer: CALayer? private var surfaceLayer: CALayer?
private var surfacePresentsActive = false
#endif #endif
private var connection: PunktfunkConnection? private var connection: PunktfunkConnection?
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's /// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
@@ -283,11 +301,17 @@ final class SessionPresenter {
baseLayer.addSublayer(metal) baseLayer.addSublayer(metal)
metalLayer = metal metalLayer = metal
#if os(macOS) #if os(macOS)
// The windowed-PyroWave present target sits ABOVE the metal layer: transparent (nil windowedPresentApplied = .async
// contents) while the metal path presents, covering it while surface presents run. // Resolve THIS session's windowed mechanism once (setting + dev env lever)
// `setComposited` routes between it and fullscreen-async from every layout.
windowedMode = Self.windowedPresentMode(
setting: UserDefaults.standard.object(
forKey: DefaultsKey.windowedSafePresent) as? Bool,
env: ProcessInfo.processInfo.environment["PUNKTFUNK_WINDOWED_PRESENT"])
// The surface present target sits ABOVE the metal layer: transparent (nil contents)
// unless the surface mechanism actually presents, covering it while it does.
baseLayer.addSublayer(pipeline.surfaceLayer) baseLayer.addSublayer(pipeline.surfaceLayer)
surfaceLayer = pipeline.surfaceLayer surfaceLayer = pipeline.surfaceLayer
surfacePresentsActive = false
#endif #endif
stage2 = pipeline stage2 = pipeline
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger // The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
@@ -432,19 +456,23 @@ final class SessionPresenter {
#if os(macOS) #if os(macOS)
/// Route presents for the window's composited state (MAIN thread the view pushes it on /// Route presents for the window's composited state (MAIN thread the view pushes it on
/// every layout, which fullscreen transitions always trigger). PyroWave sessions in a /// every layout, which fullscreen transitions always trigger). A COMPOSITED (windowed)
/// COMPOSITED (windowed) session present via `surfaceLayer` contents instead of the /// session presents through this session's resolved mitigation mechanism (`windowedMode`
/// CAMetalLayer image queue the DCP "mismatched swapID's" kernel-panic mitigation (see /// transactional by default, see `windowedPresentMode`) instead of the async image queue
/// `MetalVideoPresenter.surfaceLayer`; the metal-swap race survives glass pacing, so pacing /// the DCP "mismatched swapID's" kernel-panic mitigation (see `MetalVideoPresenter`; the
/// alone was not enough). VT codecs keep the metal path: no panic reports there, and their /// async-swap race survives glass pacing, so pacing alone was not enough). ALL codecs:
/// HDR/EDR presentation has no surface-contents equivalent wired. /// PyroWave hit it 2026-07-18 and windowed HEVC hit the same 240 Hz Mac Studio 2026-07-21
/// it is the async image queue itself, not any codec or present rate. Fullscreen keeps the
/// async path (direct scanout, lowest latency, no panic there). The full HDR/EDR render
/// path is preserved in every mechanism.
func setComposited(_ composited: Bool) { func setComposited(_ composited: Bool) {
guard let stage2, let connection else { return } guard let stage2 else { return }
let wantsSurface = composited && connection.videoCodec == .pyrowave let mode: WindowedPresentMode = composited ? windowedMode : .async
guard wantsSurface != surfacePresentsActive else { return } guard mode != windowedPresentApplied else { return }
surfacePresentsActive = wantsSurface let wasSurface = windowedPresentApplied == .surface
stage2.setSurfacePresents(wantsSurface) windowedPresentApplied = mode
if !wantsSurface { stage2.setWindowedPresent(mode)
if wasSurface {
// Uncover the metal layer NOW (its last drawable is still attached, so fullscreen // Uncover the metal layer NOW (its last drawable is still attached, so fullscreen
// entry shows the previous frame until the next present no black flash). // entry shows the previous frame until the next present no black flash).
CATransaction.begin() CATransaction.begin()
@@ -471,7 +499,7 @@ final class SessionPresenter {
#if os(macOS) #if os(macOS)
surfaceLayer?.removeFromSuperlayer() surfaceLayer?.removeFromSuperlayer()
surfaceLayer = nil surfaceLayer = nil
surfacePresentsActive = false windowedPresentApplied = .async
#endif #endif
connection = nil connection = nil
} }
@@ -1114,15 +1114,15 @@ public final class Stage2Pipeline {
} }
#if os(macOS) #if os(macOS)
/// The windowed-mode PyroWave present target (see `MetalVideoPresenter.surfaceLayer` the /// Forward the windowed present mechanism (MAIN thread see
/// DCP swapID-panic mitigation). The hosting view installs it as a sibling above `layer`. /// `MetalVideoPresenter.setWindowedPresent`, the DCP swapID-panic mitigation).
public var surfaceLayer: CALayer { presenter.surfaceLayer } func setWindowedPresent(_ mode: WindowedPresentMode) {
presenter.setWindowedPresent(mode)
/// Forward the windowed-vs-fullscreen present routing (MAIN thread see
/// `MetalVideoPresenter.setSurfacePresents`).
public func setSurfacePresents(_ on: Bool) {
presenter.setSurfacePresents(on)
} }
/// The windowed `surface` present target the hosting SessionPresenter installs as a sibling
/// ABOVE `layer` (transparent while unused see `MetalVideoPresenter.surfaceLayer`).
var surfaceLayer: CALayer { presenter.surfaceLayer }
#endif #endif
/// Forward the display's current EDR headroom to the presenter (MAIN thread a `UIScreen` /// Forward the display's current EDR headroom to the presenter (MAIN thread a `UIScreen`
@@ -700,9 +700,9 @@ public final class StreamLayerView: NSView {
private func layoutPresenter() { private func layoutPresenter() {
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1) presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
// Present routing tracks the window's composited state (fullscreen transitions always // Present routing tracks the window's composited state (fullscreen transitions always
// re-layout, so this stays current): windowed PyroWave presents via surface contents // re-layout, so this stays current): a windowed session presents through a Core Animation
// the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited). A view // transaction the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited).
// not yet in a window counts as composited (the safe default). // A view not yet in a window counts as composited (the safe default).
presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false)) presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false))
// Feed the follower only once in a window (backing scale is real then) and with real // Feed the follower only once in a window (backing scale is real then) and with real
// bounds a pre-window layout would report point-sized dimensions. // bounds a pre-window layout would report point-sized dimensions.
@@ -70,6 +70,16 @@ public enum DefaultsKey {
/// (lowest latency the default, OFF). Resolved once per session; /// (lowest latency the default, OFF). Resolved once per session;
/// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header. /// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header.
public static let vsync = "punktfunk.vsync" public static let vsync = "punktfunk.vsync"
/// macOS: present WINDOWED sessions in lockstep with the system compositor (the DCP
/// "mismatched swapID's" kernel-panic mitigation see SessionPresenter.windowedPresentMode
/// and the MetalVideoPresenter saga notes). ON/unset (the default): windowed presents ride
/// a Core Animation transaction validated panic-free on the 240 Hz repro machine, at a
/// small display-latency cost vs the raw path. OFF: windowed sessions keep the fast async
/// image queue ON AFFECTED SETUPS (high-refresh displays) THAT PATH KERNEL-PANICS THE
/// WHOLE MAC, which is why the default is ON. Fullscreen always presents async (fast path)
/// regardless. Resolved once per session; PUNKTFUNK_WINDOWED_PRESENT=async|transaction|
/// surface overrides it for dev A/B.
public static let windowedSafePresent = "punktfunk.windowedSafePresent"
/// Allow variable refresh rate: hand the display link a wide frame-rate RANGE (low floor, /// Allow variable refresh rate: hand the display link a wide frame-rate RANGE (low floor,
/// preferred = stream rate) so a ProMotion / adaptive-sync display can vary its physical /// preferred = stream rate) so a ProMotion / adaptive-sync display can vary its physical
/// refresh to match the stream. On by default; a no-op on fixed-refresh displays. When off, /// refresh to match the stream. On by default; a no-op on fixed-refresh displays. When off,
@@ -316,6 +316,41 @@ final class PresentPacingTests: XCTestCase {
SessionPresenter.pacing(for: .stage4, explicit: .stage4, codec: .pyrowave), .deadline) SessionPresenter.pacing(for: .stage4, explicit: .stage4, codec: .pyrowave), .deadline)
} }
// MARK: - Windowed present mechanism (the macOS DCP swapID-panic mitigation picker)
#if os(macOS)
/// The safe-present setting: ON/unset the validated transactional mitigation; an explicit
/// OFF the fast async path (the user accepted the affected-setup panic risk). The
/// PUNKTFUNK_WINDOWED_PRESENT env lever overrides both ways, `surface` is env-only (the
/// prototype mechanism), and garbage/empty env values are "unset", not an override.
func testWindowedPresentModeResolution() {
XCTAssertEqual(
SessionPresenter.windowedPresentMode(setting: nil, env: nil), .transaction,
"unset defaults to the panic mitigation")
XCTAssertEqual(
SessionPresenter.windowedPresentMode(setting: true, env: nil), .transaction)
XCTAssertEqual(
SessionPresenter.windowedPresentMode(setting: false, env: nil), .async,
"an explicit opt-out gets the fast async path")
// The dev env lever wins over the setting, both directions.
XCTAssertEqual(
SessionPresenter.windowedPresentMode(setting: true, env: "async"), .async)
XCTAssertEqual(
SessionPresenter.windowedPresentMode(setting: false, env: "transaction"),
.transaction)
XCTAssertEqual(
SessionPresenter.windowedPresentMode(setting: true, env: "surface"), .surface,
"the surface prototype is reachable via env only")
XCTAssertEqual(
SessionPresenter.windowedPresentMode(setting: false, env: "surface"), .surface)
// Garbage/empty env = unset.
XCTAssertEqual(
SessionPresenter.windowedPresentMode(setting: nil, env: "garbage"), .transaction)
XCTAssertEqual(
SessionPresenter.windowedPresentMode(setting: false, env: ""), .async)
}
#endif
// MARK: - Glass-gate depth // MARK: - Glass-gate depth
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue /// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue
+11 -2
View File
@@ -16,6 +16,8 @@
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games) # PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect) # PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
# PF_MGMT management-API port for --browse (optional; client defaults to 47990) # PF_MGMT management-API port for --browse (optional; client defaults to 47990)
# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after
# firing Wake-on-LAN so the connect survives the host's resume)
# PF_APPID flatpak app id (default io.unom.Punktfunk) # PF_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) # PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
# #
@@ -61,10 +63,17 @@ if [ -z "${PF_HOST:-}" ]; then
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2 echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
exit 2 exit 2
fi fi
# Trailing args shared by both streaming execs. A stretched connect budget rides along when the
# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak
# without --connect-timeout ignores the flag harmlessly (hand-scanned argv).
set -- --fullscreen
if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@"
fi
if [ -n "${PF_LAUNCH:-}" ]; then if [ -n "${PF_LAUNCH:-}" ]; then
# A pinned game: the id rides the session Hello and the host launches that title. # A pinned game: the id rides the session Hello and the host launches that title.
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2 echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" --fullscreen exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
fi fi
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2 echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --fullscreen exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" "$@"
+33 -8
View File
@@ -70,7 +70,9 @@ function setShortcutHidden(appId: number, hidden: boolean): void {
}; };
// Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId). // Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId).
const ART_VERSION = 2; // v3: CI zips through 0.17.1 shipped no assets/ at all, yet v2 was still recorded as applied
// on those installs — the bump makes them re-apply once on the first build that has the files.
const ART_VERSION = 3;
function artKey(appId: number): string { function artKey(appId: number): string {
return `punktfunk:shortcutArt:${appId}`; return `punktfunk:shortcutArt:${appId}`;
} }
@@ -79,7 +81,7 @@ function artKey(appId: number): string {
* Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per * Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per
* appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call. * appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call.
*/ */
async function applyArtwork(appId: number): Promise<void> { async function applyArtwork(appId: number, isRetry = false): Promise<void> {
try { try {
if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) { if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
return; return;
@@ -91,16 +93,29 @@ async function applyArtwork(appId: number): Promise<void> {
[art.logo, 2], [art.logo, 2],
[art.gridwide, 3], [art.gridwide, 3],
]; ];
let applied = false;
for (const [data, assetType] of assets) { for (const [data, assetType] of assets) {
if (data) { if (data) {
await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType); await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType);
applied = true;
} }
} }
if (art.icon_path) { if (art.icon_path) {
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path); SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
applied = true;
}
// Only record "done" when something actually landed — a plugin build whose assets/ is
// missing/empty must keep retrying on later mounts instead of poisoning the marker.
if (applied) {
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
} }
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
} catch (e) { } catch (e) {
// A shortcut fresh out of AddShortcut may not be registered yet (the same race
// setShortcutHidden defers around) — one deferred second attempt, then leave it to
// the next mount.
if (!isRetry) {
setTimeout(() => void applyArtwork(appId, true), 2500);
}
console.warn("punktfunk: shortcut artwork not applied", e); console.warn("punktfunk: shortcut artwork not applied", e);
} }
} }
@@ -157,7 +172,9 @@ async function ensureControllerConfig(): Promise<void> {
return; return;
} }
const r = await applyControllerConfig(SHORTCUT_NAME); const r = await applyControllerConfig(SHORTCUT_NAME);
if (r?.ok) { // `ok` alone isn't done: with zero account configset dirs (fresh Steam) the backend
// succeeds without pointing any account at the template — keep retrying until one lands.
if (r?.ok && (r.applied ?? []).some((a) => a.startsWith("configset:"))) {
localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`); localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`);
} else { } else {
console.warn("punktfunk: controller config not fully applied", r); console.warn("punktfunk: controller config not fully applied", r);
@@ -283,13 +300,21 @@ export async function launchStream(
opts: LaunchOpts = {}, opts: LaunchOpts = {},
): Promise<void> { ): Promise<void> {
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now // Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
// so it races with the shortcut setup (near-zero added latency), and awaited just before RunGame. // so it races with the shortcut setup (near-zero added latency); its outcome is needed below
// (the connect budget), and RunGame follows the await either way, so nothing is slower for it.
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is // Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
// known), and the connect that follows has its own retry window, so a failure never blocks launch. // known), and the connect that follows has its own retry window, so a failure never blocks launch.
const waking = wake(host, port).catch(() => ({ ok: false })); const waking = wake(host, port).catch(() => ({ ok: false }));
const { appId, runner } = await ensureStreamShortcut(); const [{ appId, runner }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
const target = port && port !== 9777 ? `${host}:${port}` : host; const target = port && port !== 9777 ? `${host}:${port}` : host;
const env = [`PF_HOST=${target}`]; const env = [`PF_HOST=${target}`];
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
// already-awake host the connect still lands in under a second, so this costs nothing.
if (woke.ok) {
env.push("PF_CONNECT_TIMEOUT=75");
}
if (opts.browse) { if (opts.browse) {
env.push("PF_BROWSE=1"); env.push("PF_BROWSE=1");
if (opts.mgmt) { if (opts.mgmt) {
@@ -303,9 +328,9 @@ export async function launchStream(
env.push(`PF_LAUNCH=${opts.launchId}`); env.push(`PF_LAUNCH=${opts.launchId}`);
} }
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper // KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
// script rides behind it as an argument and reads PF_* from the environment. // script rides behind it as an argument and reads PF_* from the environment. The wake was
// awaited above, so the magic packet is out before the connect attempt.
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`); SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
await waking; // ensure the magic packet is out before the connect attempt
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100); SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
} }
+16 -1
View File
@@ -487,14 +487,24 @@ async fn session(args: Args) -> Result<()> {
// host/network split is exactly what it exists to report. Old hosts ignore the bit. // host/network split is exactly what it exists to report. Old hosts ignore the bit.
// PROBE_SEQ: the shared-core reassembler windows probe-space frames, so the probe // PROBE_SEQ: the shared-core reassembler windows probe-space frames, so the probe
// qualifies for `--speed-test` bursts; without the bit the host declines them. // qualifies for `--speed-test` bursts; without the bit the host declines them.
// STREAMED_AU: the same shared reassembler accepts sentinel-headed streamed
// blocks, and the probe is exactly the tool that measures the overlap win.
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ; | punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ
| punktfunk_core::quic::VIDEO_CAP_STREAMED_AU;
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() { if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT; caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
} }
if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() { if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() {
caps |= punktfunk_core::quic::VIDEO_CAP_444; caps |= punktfunk_core::quic::VIDEO_CAP_444;
} }
// PUNKTFUNK_CLIENT_CHACHA20=1 advertises VIDEO_CAP_CHACHA20 — drives the
// host's ChaCha20-Poly1305 session-cipher resolution (the soft-AES armv7
// negotiation, design/chacha20-session-cipher.md §7) without a webOS build;
// the negotiated cipher is reported in the welcome log line below.
if std::env::var_os("PUNKTFUNK_CLIENT_CHACHA20").is_some() {
caps |= punktfunk_core::quic::VIDEO_CAP_CHACHA20;
}
caps caps
}, },
// `--audio-channels` (default stereo); the probe multistream-decodes + validates the // `--audio-channels` (default stereo); the probe multistream-decodes + validates the
@@ -532,6 +542,11 @@ async fn session(args: Args) -> Result<()> {
chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444, chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
chroma_format_idc = welcome.chroma_format, chroma_format_idc = welcome.chroma_format,
codec = codec_ext(welcome.codec), codec = codec_ext(welcome.codec),
cipher = if welcome.cipher == punktfunk_core::quic::CIPHER_CHACHA20_POLY1305 {
"chacha20-poly1305"
} else {
"aes-128-gcm"
},
"session offer" "session offer"
); );
+16
View File
@@ -24,6 +24,16 @@ use pf_frame::DmabufFrame;
pub trait Capturer: Send { pub trait Capturer: Send {
fn next_frame(&mut self) -> Result<CapturedFrame>; fn next_frame(&mut self) -> Result<CapturedFrame>;
/// [`next_frame`](Self::next_frame) with a caller-chosen first-frame budget instead of the
/// backend's default. The pipeline retry loop shortens its FIRST attempt's wait: a PipeWire
/// stream connected while gamescope re-inits its headless takeover can negotiate a format,
/// reach `Streaming`, and still never receive a buffer — a fresh connect then delivers within
/// ~0.5 s, so waiting out the full default budget on a doomed stream just delays the retry
/// that fixes it. Backends without an internal wait budget ignore it (the default delegates).
fn next_frame_within(&mut self, _budget: std::time::Duration) -> Result<CapturedFrame> {
self.next_frame()
}
/// Non-blocking: the freshest frame available since the last call, or `None` if none has /// Non-blocking: the freshest frame available since the last call, or `None` if none has
/// arrived (the caller reuses its last frame to hold a steady output rate). The default /// arrived (the caller reuses its last frame to hold a steady output rate). The default
/// just produces a frame each call — fine for instant synthetic sources; the portal /// just produces a frame each call — fine for instant synthetic sources; the portal
@@ -249,6 +259,12 @@ pub struct ZeroCopyPolicy {
/// passthrough (like the VAAPI backend) instead of the EGL→CUDA import whose payloads only /// passthrough (like the VAAPI backend) instead of the EGL→CUDA import whose payloads only
/// NVENC can consume. Per-session (the codec is negotiated), unlike `backend_is_vaapi`. /// NVENC can consume. Per-session (the codec is negotiated), unlike `backend_is_vaapi`.
pub pyrowave_session: bool, pub pyrowave_session: bool,
/// THIS session's encoder can ingest a producer-native NV12 capture (the Linux raw Vulkan
/// Video backend on an H265/AV1 session — resolved by the host facade via
/// `pf_encode::linux_native_nv12_ok`). Gates whether the negotiation PREFERS gamescope's
/// producer-side NV12 pod: libav VAAPI (H264's backend) would misread the two-plane buffer,
/// so H264/GameStream/PyroWave sessions must never see NV12 frames.
pub native_nv12_session: bool,
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc, /// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc,
/// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA, /// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise. /// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
+156 -50
View File
@@ -299,29 +299,11 @@ fn spawn_pipewire(
impl Capturer for PortalCapturer { impl Capturer for PortalCapturer {
fn next_frame(&mut self) -> Result<CapturedFrame> { fn next_frame(&mut self) -> Result<CapturedFrame> {
// First frame can lag behind format negotiation; later frames arrive at ~fps. Wait in self.frame_within(Duration::from_secs(10))
// short slices so a GPU-import poison (worker death) fails the capture within ~0.5 s }
// instead of sitting out the full first-frame budget.
let deadline = std::time::Instant::now() + Duration::from_secs(10); fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
loop { self.frame_within(budget)
if self.broken.load(Ordering::Relaxed) {
return Err(anyhow!(
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
failed repeatedly rebuilding capture",
self.node_id
));
}
if let Some(f) = self.pending.take() {
return Ok(f); // a wait_arrival stash outranks the channel (it's older)
}
let slice = Duration::from_millis(500)
.min(deadline.saturating_duration_since(std::time::Instant::now()));
match self.frames.recv_timeout(slice) {
Ok(frame) => return Ok(frame),
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
Err(e) => return self.next_frame_timed_out(e),
}
}
} }
fn supports_arrival_wait(&self) -> bool { fn supports_arrival_wait(&self) -> bool {
@@ -417,9 +399,41 @@ impl Capturer for PortalCapturer {
} }
impl PortalCapturer { impl PortalCapturer {
/// The [`Capturer::next_frame`] budget expired (or the thread ended) — turn it into the /// The blocking first-frame wait behind [`Capturer::next_frame`] /
/// diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged. /// [`Capturer::next_frame_within`]. First frame can lag behind format negotiation; later
fn next_frame_timed_out(&self, err: RecvTimeoutError) -> Result<CapturedFrame> { /// frames arrive at ~fps. Wait in short slices so a GPU-import poison (worker death) fails
/// the capture within ~0.5 s instead of sitting out the full first-frame budget.
fn frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
let deadline = std::time::Instant::now() + budget;
loop {
if self.broken.load(Ordering::Relaxed) {
return Err(anyhow!(
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
failed repeatedly rebuilding capture",
self.node_id
));
}
if let Some(f) = self.pending.take() {
return Ok(f); // a wait_arrival stash outranks the channel (it's older)
}
let slice = Duration::from_millis(500)
.min(deadline.saturating_duration_since(std::time::Instant::now()));
match self.frames.recv_timeout(slice) {
Ok(frame) => return Ok(frame),
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
Err(e) => return self.next_frame_timed_out(e, budget),
}
}
}
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
fn next_frame_timed_out(
&self,
err: RecvTimeoutError,
budget: Duration,
) -> Result<CapturedFrame> {
let within = budget.as_secs_f32();
match err { match err {
RecvTimeoutError::Timeout => { RecvTimeoutError::Timeout => {
// Split the two black-screen root causes apart so the operator gets a cause, not // Split the two black-screen root causes apart so the operator gets a cause, not
@@ -427,9 +441,10 @@ impl PortalCapturer {
// not (no acceptable format / node never emitted a param)? // not (no acceptable format / node never emitted a param)?
if self.negotiated.load(Ordering::Relaxed) { if self.negotiated.load(Ordering::Relaxed) {
Err(anyhow!( Err(anyhow!(
"no PipeWire frame within 10s (node {}): format negotiated but no buffers \ "no PipeWire frame within {within}s (node {}): format negotiated but no \
arrived the compositor produced no frames (virtual output idle/unmapped, \ buffers arrived the compositor produced no frames (virtual output \
or capture never started)", idle/unmapped, capture never started, or a stream bound during a \
compositor (re)start that will never deliver a reconnect fixes that)",
self.node_id self.node_id
)) ))
} else if self.hdr_offer { } else if self.hdr_offer {
@@ -440,10 +455,10 @@ impl PortalCapturer {
// auto-reconnects) negotiates SDR instead of re-running this same timeout. // auto-reconnects) negotiates SDR instead of re-running this same timeout.
super::note_hdr_capture_failed(); super::note_hdr_capture_failed();
Err(anyhow!( Err(anyhow!(
"no PipeWire frame within 10s (node {}): the compositor never accepted \ "no PipeWire frame within {within}s (node {}): the compositor never \
the HDR (10-bit PQ/BT.2020 dmabuf) offer is the mirrored monitor in \ accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer is the mirrored \
HDR mode on GNOME 50+? Downgrading this host to SDR capture; reconnect \ monitor in HDR mode on GNOME 50+? Downgrading this host to SDR capture; \
to stream SDR", reconnect to stream SDR",
self.node_id self.node_id
)) ))
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() { } else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
@@ -452,14 +467,15 @@ impl PortalCapturer {
// retries on the CPU offer instead of failing this same negotiation forever. // retries on the CPU offer instead of failing this same negotiation forever.
pf_zerocopy::note_vaapi_dmabuf_failed(); pf_zerocopy::note_vaapi_dmabuf_failed();
Err(anyhow!( Err(anyhow!(
"no PipeWire frame within 10s (node {}): the compositor never accepted \ "no PipeWire frame within {within}s (node {}): the compositor never \
the LINEAR-dmabuf offer (VAAPI zero-copy) downgrading this host to the \ accepted the LINEAR-dmabuf offer (VAAPI zero-copy) downgrading this \
CPU capture path; the pipeline rebuild will renegotiate without dmabuf", host to the CPU capture path; the pipeline rebuild will renegotiate \
without dmabuf",
self.node_id self.node_id
)) ))
} else { } else {
Err(anyhow!( Err(anyhow!(
"no PipeWire frame within 10s (node {}): format negotiation never \ "no PipeWire frame within {within}s (node {}): format negotiation never \
completed the compositor offered no format this consumer accepts \ completed the compositor offered no format this consumer accepts \
(pixel-format/modifier mismatch) or the node never emitted a Format param", (pixel-format/modifier mismatch) or the node never emitted a Format param",
self.node_id self.node_id
@@ -824,6 +840,7 @@ mod pipewire {
VideoFormat::RGBA => PixelFormat::Rgba, VideoFormat::RGBA => PixelFormat::Rgba,
VideoFormat::RGB => PixelFormat::Rgb, VideoFormat::RGB => PixelFormat::Rgb,
VideoFormat::BGR => PixelFormat::Bgr, VideoFormat::BGR => PixelFormat::Bgr,
VideoFormat::NV12 => PixelFormat::Nv12,
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10; only ever negotiated by // The GNOME 50+ HDR screencast formats (packed 2:10:10:10; only ever negotiated by
// the `want_hdr` offer, whose MANDATORY colorimetry props pin them to PQ/BT.2020). // the `want_hdr` offer, whose MANDATORY colorimetry props pin them to PQ/BT.2020).
VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10, VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10,
@@ -989,10 +1006,10 @@ mod pipewire {
.into_inner()) .into_inner())
} }
/// Build a BGRx dmabuf `EnumFormat` pod advertising the EGL-importable `modifiers` as a /// Build a LINEAR/modifier DMA-BUF `EnumFormat` pod. Packed BGRx is the existing import path;
/// mandatory enum Choice; the compositor fixates to one of them that it can allocate, which /// NV12 is gamescope's producer-side RGB→YUV path (opt-in during bring-up).
/// we read back in `param_changed`.
fn build_dmabuf_format( fn build_dmabuf_format(
format: VideoFormat,
modifiers: &[u64], modifiers: &[u64],
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
) -> Result<Vec<u8>> { ) -> Result<Vec<u8>> {
@@ -1003,7 +1020,7 @@ mod pipewire {
pw::spa::param::ParamType::EnumFormat, pw::spa::param::ParamType::EnumFormat,
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video), pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw), pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, VideoFormat::BGRx), pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
pw::spa::pod::property!( pw::spa::pod::property!(
FormatProperties::VideoSize, FormatProperties::VideoSize,
Choice, Choice,
@@ -1032,6 +1049,22 @@ mod pipewire {
pw::spa::utils::Fraction { num: 240, denom: 1 } pw::spa::utils::Fraction { num: 240, denom: 1 }
), ),
); );
if format == VideoFormat::NV12 {
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorMatrix,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
pw::spa::sys::SPA_VIDEO_COLOR_MATRIX_BT709,
)),
});
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorRange,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
pw::spa::sys::SPA_VIDEO_COLOR_RANGE_16_235,
)),
});
}
obj.properties.push(pw::spa::pod::Property { obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier, key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
flags: pw::spa::pod::PropertyFlags::MANDATORY, flags: pw::spa::pod::PropertyFlags::MANDATORY,
@@ -1604,8 +1637,8 @@ mod pipewire {
} }
} }
// VAAPI zero-copy passthrough: hand the raw dmabuf straight to the encoder, which imports // Raw DMA-BUF passthrough: packed RGB is imported for GPU CSC; producer-native NV12 can
// it into a VA surface and does RGB→NV12 on the GPU video engine. No CUDA importer here. // be consumed by the Vulkan Video encoder without another color conversion.
if ud.vaapi_passthrough { if ud.vaapi_passthrough {
if let Some(fmt) = ud.format { if let Some(fmt) = ud.format {
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf { if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
@@ -1613,9 +1646,41 @@ mod pipewire {
let chunk = datas[0].chunk(); let chunk = datas[0].chunk();
let offset = chunk.offset(); let offset = chunk.offset();
let stride = chunk.stride().max(0) as u32; let stride = chunk.stride().max(0) as u32;
// Native NV12 usually arrives as a two-plane SPA buffer over ONE buffer
// object; plane 1's chunk carries the REAL UV offset/stride (compositors
// may align the Y plane before UV). Pass it through instead of assuming
// contiguity. Each spa_data holds its own (dup'd) fd, so BO identity is
// by inode, not fd number; a genuinely two-BO frame cannot travel through
// the single-fd import — drop it with a diagnosis instead of streaming
// garbage chroma.
let plane1 =
if fmt == PixelFormat::Nv12 && datas.len() >= 2 && datas[1].fd() > 0 {
// SAFETY: zeroed `libc::stat` is a valid POD initializer; both fds are
// owned by the live PipeWire buffer for this callback, and `fstat`
// only writes the out-param structs, whose fields are read only after
// the `== 0` success checks.
let same_bo = unsafe {
let mut s0: libc::stat = std::mem::zeroed();
let mut s1: libc::stat = std::mem::zeroed();
libc::fstat(datas[0].fd() as i32, &mut s0) == 0
&& libc::fstat(datas[1].fd() as i32, &mut s1) == 0
&& (s0.st_dev, s0.st_ino) == (s1.st_dev, s1.st_ino)
};
if !same_bo {
warn_once(
"NV12 planes live in different buffer objects — frames \
dropped (single-fd import only)",
);
return;
}
let c1 = datas[1].chunk();
Some((c1.offset(), c1.stride().max(0) as u32))
} else {
None
};
// dup the fd so it survives the SPA buffer recycle — the encode thread // dup the fd so it survives the SPA buffer recycle — the encode thread
// imports it. (Content stability across the brief map+CSC window relies on // imports it. Content stability across the brief import/encode window relies
// the compositor's buffer-pool depth, like any zero-copy capture.) // on the compositor's buffer-pool depth, like any zero-copy capture.
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid // SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd, // for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1). // touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
@@ -1642,9 +1707,10 @@ mod pipewire {
modifier: ud.modifier, modifier: ud.modifier,
offset, offset,
stride, stride,
plane1,
}), }),
// Cursor-as-metadata: the encoder blends this into its owned VA // Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope
// surface (raw dmabuf never touched). // embeds its pointer in the produced pixels, so native NV12 has none.
cursor: ud.cursor.overlay(), cursor: ud.cursor.overlay(),
}); });
static ONCE: std::sync::atomic::AtomicBool = static ONCE: std::sync::atomic::AtomicBool =
@@ -1655,7 +1721,12 @@ mod pipewire {
h, h,
modifier = ud.modifier, modifier = ud.modifier,
fourcc = format_args!("{:#010x}", fourcc), fourcc = format_args!("{:#010x}", fourcc),
"zero-copy: handing the raw dmabuf to the encoder (GPU import + CSC)" source = if fmt == PixelFormat::Nv12 {
"producer-native NV12"
} else {
"packed RGB (encoder GPU CSC)"
},
"zero-copy: handing the raw DMA-BUF to the encoder"
); );
} }
return; return;
@@ -2015,6 +2086,28 @@ mod pipewire {
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw // PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
// dmabuf straight to the encoder. // dmabuf straight to the encoder.
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough; let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough;
// Producer-side NV12 (default-on; PUNKTFUNK_PIPEWIRE_NV12=0 escapes): gamescope offers a
// one-fd LINEAR NV12 image when the consumer asks — its compositor pass does the RGB→YUV,
// and the Vulkan Video encoder imports the buffer as its encode source directly (no host
// CSC at all). `native_nv12_session` restricts this to sessions whose encoder can ingest
// it (Linux vulkan-encode H265/AV1 — never H264/libav-VAAPI, GameStream-resolve, or
// PyroWave, whose Vulkan compute CSC ingests packed RGB only). Raw passthrough is
// required because the CUDA importer expects packed RGB, and 4:4:4/HDR must not be
// silently subsampled/downconverted. Non-NV12 compositors (KWin/GNOME) simply match the
// packed-RGB fallback pod.
let prefer_native_nv12 = std::env::var("PUNKTFUNK_PIPEWIRE_NV12").as_deref() != Ok("0")
&& policy.native_nv12_session
&& backend_is_vaapi
&& vaapi_passthrough
&& !policy.pyrowave_session
&& !want_444
&& !want_hdr;
if prefer_native_nv12 {
tracing::info!(
"zero-copy: preferring gamescope producer-side NV12 LINEAR DMA-BUF (no host \
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
);
}
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR // Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via // (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only: // CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
@@ -2054,7 +2147,9 @@ mod pipewire {
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path"); tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() { } else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
tracing::info!( tracing::info!(
"zero-copy: advertising LINEAR dmabuf for direct VAAPI import (GPU CSC)" native_nv12_preferred = prefer_native_nv12,
"zero-copy: advertising LINEAR DMA-BUF for encoder import (native NV12 first \
when enabled, packed RGB fallback)"
); );
} else if want_dmabuf && !vaapi_passthrough { } else if want_dmabuf && !vaapi_passthrough {
tracing::info!( tracing::info!(
@@ -2394,7 +2489,18 @@ mod pipewire {
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?, build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
] ]
} else if want_dmabuf { } else if want_dmabuf {
vec![build_dmabuf_format(&modifiers, preferred)?] let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
if prefer_native_nv12 {
// First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning
// BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry.
pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?);
}
pods.push(build_dmabuf_format(
VideoFormat::BGRx,
&modifiers,
preferred,
)?);
pods
} else { } else {
vec![serialize_pod(obj)?] vec![serialize_pod(obj)?]
}; };
+209 -12
View File
@@ -308,8 +308,9 @@ float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
/// Plane writes use per-plane render-target views of the single P010 texture: an `R16_UNORM` RTV /// Plane writes use per-plane render-target views of the single P010 texture: an `R16_UNORM` RTV
/// selects plane 0 (luma, full WxH), an `R16G16_UNORM` RTV selects plane 1 (chroma, W/2 x H/2). This /// selects plane 0 (luma, full WxH), an `R16G16_UNORM` RTV selects plane 1 (chroma, W/2 x H/2). This
/// planar-RTV mechanism needs a D3D11.3+ runtime + driver support; [`HdrP010Converter::convert`] /// planar-RTV mechanism needs a D3D11.3+ runtime + driver support; [`HdrP010Converter::convert`]
/// surfaces a clear error if `CreateRenderTargetView` rejects the plane format so the caller can fall /// surfaces a clear error if `CreateRenderTargetView` rejects the plane format. (There is no runtime
/// back to the existing R10 path. /// fallback — the error propagates through `try_consume` and ends the session; the "R10 path" the
/// original design referenced was never kept.)
pub(crate) struct HdrP010Converter { pub(crate) struct HdrP010Converter {
vs: ID3D11VertexShader, vs: ID3D11VertexShader,
ps_y: ID3D11PixelShader, ps_y: ID3D11PixelShader,
@@ -737,14 +738,157 @@ fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL. /// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn hdr_p010_selftest() -> Result<()> { pub fn hdr_p010_selftest() -> Result<()> {
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE; hdr_p010_selftest_at(64, 64, None)
use windows::Win32::Graphics::Dxgi::IDXGIAdapter; }
// 64x64, even dims. A 4x4 grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → /// [`hdr_p010_selftest`] at an arbitrary even size and (optionally) on a specific GPU vendor
// exact chroma comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus /// (PCI vendor id, e.g. `0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD). The size matters on
// a couple of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only). /// top of the 64×64 default because the field sessions run at capture resolutions whose height
const W: u32 = 64; /// is NOT 16-aligned (1080 → the encoder's align16 pool seam) and a driver may treat the planar
const H: u32 = 64; /// RTVs differently at real sizes; the vendor pin matters on dual-GPU boxes where the default
/// adapter is not the one the session encodes on.
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
/// (325,448,598) (226,650,535) (64,512,512).
#[cfg(target_os = "windows")]
#[doc(hidden)]
pub fn hdr_p010_convert_bars_on_luid(
luid: [u8; 8],
w: u32,
h: u32,
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
}
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
const BARS: [(f32, f32, f32); 8] = [
(1.0, 1.0, 1.0),
(1.0, 1.0, 0.0),
(0.0, 1.0, 1.0),
(0.0, 1.0, 0.0),
(1.0, 0.0, 1.0),
(1.0, 0.0, 0.0),
(0.0, 0.0, 1.0),
(0.0, 0.0, 0.0),
];
let bar_w = (w / 8).max(1) as usize;
let mut fp16 = vec![0u16; (w * h * 4) as usize];
for y in 0..h as usize {
for x in 0..w as usize {
let (r, g, b) = BARS[(x / bar_w).min(7)];
let i = (y * w as usize + x) * 4;
fp16[i] = f32_to_f16(r);
fp16[i + 1] = f32_to_f16(g);
fp16[i + 2] = f32_to_f16(b);
fp16[i + 3] = f32_to_f16(1.0);
}
}
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
// their references.
unsafe {
let luid = windows::Win32::Foundation::LUID {
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
};
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice(
&adapter,
D3D_DRIVER_TYPE_UNKNOWN,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
.context("D3D11CreateDevice(luid) for bars convert")?;
let device = device.context("null device")?;
let context = context.context("null context")?;
let src_desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
..Default::default()
};
let init = D3D11_SUBRESOURCE_DATA {
pSysMem: fp16.as_ptr() as *const c_void,
SysMemPitch: w * 8,
SysMemSlicePitch: 0,
};
let mut src_tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
.context("CreateTexture2D(fp16 bars)")?;
let src_tex = src_tex.context("null src tex")?;
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
.context("CreateShaderResourceView(fp16 bars)")?;
let src_srv = src_srv.context("null src srv")?;
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
let p010_desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_P010,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
..Default::default()
};
let mut p010: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
.context("CreateTexture2D(P010 bars dst)")?;
let p010 = p010.context("null p010 tex")?;
let conv = HdrP010Converter::new(&device)?;
conv.convert(&device, &context, &src_srv, &p010, w, h)?;
Ok((device, p010))
}
}
#[cfg(target_os = "windows")]
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
}
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
#[allow(non_snake_case)]
let (W, H) = (w, h);
const BLK: u32 = 16; const BLK: u32 = 16;
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values. // (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
let named: [(&str, f32, f32, f32); 8] = [ let named: [(&str, f32, f32, f32); 8] = [
@@ -797,12 +941,36 @@ pub fn hdr_p010_selftest() -> Result<()> {
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are // `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
// proven individually at the `read_u16` closure below. // proven individually at the `read_u16` closure below.
unsafe { unsafe {
// Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test). // Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
// the GPU it actually tested.
let adapter: Option<IDXGIAdapter> = match vendor {
None => None,
Some(want) => {
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
let mut found = None;
for i in 0.. {
let Ok(a) = factory.EnumAdapters(i) else {
break;
};
let desc = a.GetDesc().context("adapter desc")?;
if desc.VendorId == want {
found = Some(a);
break;
}
}
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
}
};
let mut device: Option<ID3D11Device> = None; let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None; let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice( D3D11CreateDevice(
None::<&IDXGIAdapter>, adapter.as_ref(),
D3D_DRIVER_TYPE_HARDWARE, if adapter.is_some() {
D3D_DRIVER_TYPE_UNKNOWN
} else {
D3D_DRIVER_TYPE_HARDWARE
},
HMODULE::default(), HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]), Some(&[D3D_FEATURE_LEVEL_11_0]),
@@ -814,6 +982,22 @@ pub fn hdr_p010_selftest() -> Result<()> {
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?; .context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
let device = device.context("null device")?; let device = device.context("null device")?;
let context = context.context("null context")?; let context = context.context("null context")?;
{
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
device.cast().context("device -> IDXGIDevice")?;
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
let name = String::from_utf16_lossy(
&desc.Description[..desc
.Description
.iter()
.position(|&c| c == 0)
.unwrap_or(desc.Description.len())],
);
println!(
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
);
}
// Source FP16 texture (initialized) + SRV. // Source FP16 texture (initialized) + SRV.
let src_desc = D3D11_TEXTURE2D_DESC { let src_desc = D3D11_TEXTURE2D_DESC {
@@ -1175,3 +1359,16 @@ impl VideoConverter {
blt.context("VideoProcessorBlt") blt.context("VideoProcessorBlt")
} }
} }
#[cfg(test)]
mod hdr_selftests {
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
#[test]
#[ignore]
fn hdr_p010_selftest_intel_1080_live() {
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
}
}
+5
View File
@@ -25,6 +25,11 @@ tracing = "0.1"
# A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`). # A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`).
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[target.'cfg(target_os = "windows")'.dev-dependencies]
# The QSV live e2e drives the REAL HdrP010Converter output (an RTV-written, ring-profile P010
# texture) into the encoder — the one seam the CPU-upload tests can't reach.
pf-capture = { path = "../pf-capture" }
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies] [target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
# Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms. # Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms.
openh264 = "0.9" openh264 = "0.9"
+77
View File
@@ -32,6 +32,46 @@ pub struct EncodedFrame {
pub chunk_aligned: bool, pub chunk_aligned: bool,
} }
/// One slice-boundary chunk of an encoded AU, emitted by a chunked-poll backend
/// ([`Encoder::poll_chunk`], latency plan §7 LN1): the encoder hands out completed slices while
/// the rest of the frame is still encoding, so packetize/FEC/pacing can overlap the encode tail.
/// The chunks of one AU concatenate to exactly the bytes [`Encoder::poll`] would have returned,
/// and every cut lands on an Annex-B NAL boundary (slice starts). AU-level metadata
/// (`pts_ns`/`keyframe`/`recovery_anchor`/`chunk_aligned`) is authoritative on the FIRST chunk
/// (`first`) — the host opens the wire frame from it; `last` closes the AU. `keyframe` on a
/// non-final chunk is the encoder's own prediction (exact under the P-only/infinite-GOP config —
/// the driver only ever emits an IDR we asked for); the final chunk re-checks it against the
/// driver's reported picture type.
pub struct AuChunk {
pub data: Vec<u8>,
pub pts_ns: u64,
pub keyframe: bool,
/// See [`EncodedFrame::recovery_anchor`].
pub recovery_anchor: bool,
/// See [`EncodedFrame::chunk_aligned`].
pub chunk_aligned: bool,
/// Opens the AU (carries the authoritative AU metadata).
pub first: bool,
/// Closes the AU (the concatenation is complete; the encoder's in-flight slot is released).
pub last: bool,
}
impl AuChunk {
/// A whole AU as a single self-closing chunk — what every non-chunked backend's
/// [`Encoder::poll_chunk`] default emits, so a chunk consumer needs no per-backend fork.
pub fn whole(f: EncodedFrame) -> Self {
AuChunk {
data: f.data,
pts_ns: f.pts_ns,
keyframe: f.keyframe,
recovery_anchor: f.recovery_anchor,
chunk_aligned: f.chunk_aligned,
first: true,
last: true,
}
}
}
/// Codec selection negotiated with the client. /// Codec selection negotiated with the client.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Codec { pub enum Codec {
@@ -280,6 +320,26 @@ pub trait Encoder: Send {
} }
/// Pull the next encoded AU if one is ready. /// Pull the next encoded AU if one is ready.
fn poll(&mut self) -> Result<Option<EncodedFrame>>; fn poll(&mut self) -> Result<Option<EncodedFrame>>;
/// Whether [`poll_chunk`](Self::poll_chunk) currently emits sub-AU chunks — i.e. the LIVE
/// session has slice-level readback armed (Linux direct-NVENC with the
/// `PUNKTFUNK_NVENC_SLICES` and `PUNKTFUNK_NVENC_SUBFRAME` knobs on a sync depth-1
/// retrieve). Dynamic, not static: a pipelined-retrieve escalation or a session rebuild can
/// turn it off — re-query per AU, never cache across frames. `false` (the default) means
/// `poll_chunk` degrades to one whole-AU chunk per frame.
fn supports_chunked_poll(&self) -> bool {
false
}
/// Pull the next slice-boundary chunk of the oldest in-flight AU (latency plan §7 LN1).
/// Semantics when chunking is live: BLOCKS until the next chunk is readable, and the final
/// (`last`) chunk blocks exactly like [`poll`](Self::poll) does — the depth-1 pump treats
/// `None` as re-poll-next-tick, so a non-blocking tail would ride the AU one tick late (the
/// `6dc195f9` Vulkan bug class). `Ok(None)` only when no AU is in flight. Each AU must be
/// drained through ONE method: calling `poll` on a partially-chunked AU is a caller bug (the
/// backend errors rather than double-emit bytes). Default: delegates to `poll`, wrapping the
/// whole AU as a single `first && last` chunk.
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
Ok(self.poll()?.map(AuChunk::whole))
}
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's /// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV /// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns /// driver stops emitting AUs or accepting frames without ever returning an error). Returns
@@ -436,6 +496,23 @@ mod tests {
} }
} }
/// The whole-AU chunk (every non-chunked backend's `poll_chunk` shape) must carry the AU's
/// metadata verbatim and be self-closing (`first && last`).
#[test]
fn whole_au_chunk_is_self_closing() {
let c = AuChunk::whole(EncodedFrame {
data: vec![0, 0, 0, 1, 0x40],
pts_ns: 42,
keyframe: true,
recovery_anchor: true,
chunk_aligned: false,
});
assert_eq!(c.data, vec![0, 0, 0, 1, 0x40]);
assert_eq!(c.pts_ns, 42);
assert!(c.keyframe && c.recovery_anchor && !c.chunk_aligned);
assert!(c.first && c.last);
}
/// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits. /// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
#[test] #[test]
fn codec_wire_roundtrip_and_label() { fn codec_wire_roundtrip_and_label() {
+447 -10
View File
@@ -44,6 +44,15 @@
//! the retrieve thread the same way it would hang the encode thread today (Linux has no //! the retrieve thread the same way it would hang the encode thread today (Linux has no
//! event-timeout escape) — no regression, just no new watchdog either. //! event-timeout escape) — no regression, just no new watchdog either.
//! //!
//! **Sub-frame chunked poll** (latency plan §7 LN1 — **default-on since Phase 3**: 4 slices +
//! sub-frame readback on every session whose GPU advertises `SUBFRAME_READBACK`; escapes are
//! `PUNKTFUNK_NVENC_SLICES=1` and `PUNKTFUNK_NVENC_SUBFRAME=0`): on a sync depth-1 session,
//! [`Encoder::poll_chunk`] hands the in-flight AU out as slice-boundary chunks read through
//! `doNotWait` sub-frame locks while the tail is still encoding; one final blocking lock closes
//! the AU (the completion authority — `numSlices` alone is not trusted across driver branches).
//! Mutually exclusive with the pipelined retrieve (the escalated rebuild drops it); composes
//! with stream-ordered submit (both are sync depth-1 features).
//!
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise); compiles GPU-less and //! Needs a real NVIDIA GPU at runtime (session creation fails otherwise); compiles GPU-less and
//! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly //! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly
//! and the VAAPI/software backends carry the session). //! and the VAAPI/software backends carry the session).
@@ -52,10 +61,11 @@
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{ use super::nvenc_core::{
apply_low_latency_config, build_init_params, codec_guid, LowLatencyConfig, NvStatusExt, RFI_DPB, apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
LowLatencyConfig, NvStatusExt, RFI_DPB,
}; };
use super::nvenc_status; use super::nvenc_status;
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use pf_frame::{CapturedFrame, FramePayload}; use pf_frame::{CapturedFrame, FramePayload};
use pf_zerocopy::cuda::{self, InputSurface}; use pf_zerocopy::cuda::{self, InputSurface};
@@ -425,6 +435,41 @@ struct RingSlot {
reg: nv::NV_ENC_REGISTERED_PTR, reg: nv::NV_ENC_REGISTERED_PTR,
} }
/// `doNotWait` sampling cadence inside [`Encoder::poll_chunk`] — the probe measured ~200 µs
/// between slice completions on the 5070 Ti, so 50 µs keeps the added per-chunk delivery delay
/// well under one slice time without hammering the driver.
const CHUNK_SAMPLE_INTERVAL: std::time::Duration = std::time::Duration::from_micros(50);
/// Progress of a sub-frame chunked readback (§7 LN1 Phase 1) for the FRONT in-flight AU: how
/// much of the bitstream has already been handed out as chunks. `Some` from an AU's first
/// emitted chunk until its `last` — [`Encoder::poll`] refuses to run while it exists (a plain
/// poll would re-emit the already-shipped prefix).
struct ChunkState {
/// Bytes emitted so far — also the next chunk's start offset (always a slice boundary).
emitted: usize,
/// Completed slices already covered by emitted chunks.
slices_out: u32,
/// The AU-opening chunk (`AuChunk::first`) has been handed out.
opened: bool,
/// Debug-build shadow of every emitted byte, cross-checked against the finishing blocking
/// lock's full AU — a mis-cut chunk fails loudly in the on-hw tests instead of silently
/// corrupting the wire. Compiled out of release builds.
#[cfg(debug_assertions)]
shadow: Vec<u8>,
}
impl ChunkState {
fn new() -> Self {
ChunkState {
emitted: 0,
slices_out: 0,
opened: false,
#[cfg(debug_assertions)]
shadow: Vec::new(),
}
}
}
pub struct NvencCudaEncoder { pub struct NvencCudaEncoder {
encoder: *mut c_void, encoder: *mut c_void,
/// The shared process-wide `CUcontext` the session is bound to (from `zerocopy::cuda::context`). /// The shared process-wide `CUcontext` the session is bound to (from `zerocopy::cuda::context`).
@@ -456,11 +501,14 @@ pub struct NvencCudaEncoder {
/// sampled `PUNKTFUNK_PERF` submit-split log cadence, mirroring the VAAPI backend's counter. /// sampled `PUNKTFUNK_PERF` submit-split log cadence, mirroring the VAAPI backend's counter.
frames: u64, frames: u64,
bitstreams: Vec<nv::NV_ENC_OUTPUT_PTR>, bitstreams: Vec<nv::NV_ENC_OUTPUT_PTR>,
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per /// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor,
/// in-flight encode. The fourth field tags the first frame encoded after a successful /// IDR-predicted) per in-flight encode. The fourth field tags the first frame encoded after a
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor P-frame the /// successful [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor
/// client lifts its post-loss freeze on. /// P-frame the client lifts its post-loss freeze on. The fifth is the submit-time keyframe
pending: VecDeque<(nv::NV_ENC_OUTPUT_PTR, nv::NV_ENC_INPUT_PTR, u64, bool)>, /// prediction (forced/opening IDR) that chunked poll stamps on chunks emitted before the
/// driver reports the real picture type — exact under P-only + infinite GOP (the driver only
/// emits IDRs we asked for); the finishing blocking lock cross-checks it.
pending: VecDeque<(nv::NV_ENC_OUTPUT_PTR, nv::NV_ENC_INPUT_PTR, u64, bool, bool)>,
/// The frame number of the NEXT submission (also its `inputTimeStamp`). Pinned per frame by /// The frame number of the NEXT submission (also its `inputTimeStamp`). Pinned per frame by
/// [`Encoder::submit_indexed`] to the WIRE frame index the AU will carry, so the DPB timestamps /// [`Encoder::submit_indexed`] to the WIRE frame index the AU will carry, so the DPB timestamps
/// `invalidate_ref_frames` compares client frame numbers against stay 1:1 with the wire across /// `invalidate_ref_frames` compares client frame numbers against stay 1:1 with the wire across
@@ -512,6 +560,24 @@ pub struct NvencCudaEncoder {
/// Stream-ordered submit armed for the live session (sync-retrieve mode only; see /// Stream-ordered submit armed for the live session (sync-retrieve mode only; see
/// [`stream_ordered_requested`]). The per-frame gate additionally requires `pending` empty. /// [`stream_ordered_requested`]). The per-frame gate additionally requires `pending` empty.
stream_ordered: bool, stream_ordered: bool,
/// Slice count the live session was configured with ([`resolve_slices`] — env override,
/// else the Linux direct-NVENC default of 4 since Phase 3; 1 = the preset's single slice).
/// Chunked poll needs ≥ 2 to have boundaries to cut at. Latched at init, consumed by
/// `build_config` (so an in-place reconfigure presents the same slicing).
slices: u32,
/// `NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK` from the caps probe — gates the DEFAULT-on
/// sub-frame arming (an unsupported GPU must not have `enableSubFrameWrite` forced into its
/// init params, which could fail the session open). `PUNKTFUNK_NVENC_SUBFRAME=1` overrides.
subframe_cap: bool,
/// Sub-frame readback resolved for the live session ([`resolve_subframe`] over
/// [`subframe_cap`](Self::subframe_cap)); consumed by every `build_init_params` call so the
/// open and the in-place reconfigure present identical init params.
subframe_on: bool,
/// Sub-frame chunked poll armed for the live session (§7 LN1 Phase 1): multi-slice +
/// sub-frame readback configured AND sync retrieve at init. See [`Encoder::poll_chunk`].
subframe_chunks: bool,
/// In-progress chunked readback of the front in-flight AU. See [`ChunkState`].
chunk: Option<ChunkState>,
} }
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext` // SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
@@ -594,6 +660,11 @@ impl NvencCudaEncoder {
want_async: false, want_async: false,
io_stream: ptr::null_mut(), io_stream: ptr::null_mut(),
stream_ordered: false, stream_ordered: false,
slices: 1,
subframe_cap: false,
subframe_on: false,
subframe_chunks: false,
chunk: None,
}) })
} }
@@ -635,7 +706,7 @@ impl NvencCudaEncoder {
} }
} }
// Unmap any in-flight inputs, unregister every ring surface, destroy the bitstreams. // Unmap any in-flight inputs, unregister every ring surface, destroy the bitstreams.
for (_, map, _, _) in &self.pending { for (_, map, _, _, _) in &self.pending {
if !map.is_null() { if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, *map); let _ = (api().unmap_input_resource)(self.encoder, *map);
} }
@@ -664,6 +735,10 @@ impl NvencCudaEncoder {
self.io_stream = ptr::null_mut(); self.io_stream = ptr::null_mut();
} }
self.stream_ordered = false; self.stream_ordered = false;
// Chunked-poll state is per session: a half-chunked AU dies with its in-flight frame
// (the forfeit contract), and the next session re-latches the arming at init.
self.subframe_chunks = false;
self.chunk = None;
self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
self.bitstreams.clear(); self.bitstreams.clear();
self.pending.clear(); self.pending.clear();
@@ -748,6 +823,15 @@ impl NvencCudaEncoder {
} }
self.rfi_supported = rfi != 0; self.rfi_supported = rfi != 0;
self.custom_vbv = custom_vbv != 0; self.custom_vbv = custom_vbv != 0;
self.subframe_cap = subframe != 0;
// Phase-3 default-on (nvenc-subframe-slice-output.md): 4 slices + sub-frame readback on
// every Linux direct-NVENC session, resolved HERE (before the session opens) so the
// config author, the init params and the chunked-poll latch all agree; the caps probe
// gates the sub-frame default so a GPU without SUBFRAME_READBACK never has it forced
// into its init params. PUNKTFUNK_NVENC_SLICES=1 / PUNKTFUNK_NVENC_SUBFRAME=0 are the
// escapes.
self.slices = resolve_slices(self.codec, 4);
self.subframe_on = resolve_subframe(self.subframe_cap);
tracing::info!( tracing::info!(
rfi = self.rfi_supported, rfi = self.rfi_supported,
custom_vbv = self.custom_vbv, custom_vbv = self.custom_vbv,
@@ -861,6 +945,7 @@ impl NvencCudaEncoder {
av1_input_depth_minus8: 0, av1_input_depth_minus8: 0,
hdr: self.hdr, hdr: self.hdr,
rfi_supported: self.rfi_supported, rfi_supported: self.rfi_supported,
slices: self.slices,
}, },
); );
Ok(cfg) Ok(cfg)
@@ -901,6 +986,7 @@ impl NvencCudaEncoder {
&mut cfg, &mut cfg,
split_mode, split_mode,
false, false,
self.subframe_on,
); );
match (api().initialize_encoder)(enc, &mut init).nv_ok() { match (api().initialize_encoder)(enc, &mut init).nv_ok() {
@@ -1124,6 +1210,20 @@ impl NvencCudaEncoder {
} }
} }
} }
// Sub-frame chunked poll (§7 LN1 Phase 1; default-on since Phase 3): armed iff this
// session was CONFIGURED multi-slice + sub-frame readback (`self.slices` /
// `self.subframe_on` were resolved once in `query_caps` and consumed by
// `build_config` / `build_init_params`, so the latch can't disagree with the session
// config) and the retrieve is sync — chunked poll is a depth-1 sync feature; a
// pipelined session's non-blocking poll owns the bitstream from the retrieve thread
// instead (the sub-frame write itself stays armed there; it's harmless).
self.subframe_chunks = self.slices >= 2 && self.subframe_on && self.async_rt.is_none();
if self.subframe_chunks {
tracing::info!(
slices = self.slices,
"NVENC sub-frame chunked poll armed (poll_chunk emits slice-boundary AU chunks)"
);
}
tracing::info!( tracing::info!(
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps), mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
bit_depth = self.bit_depth, bit_depth = self.bit_depth,
@@ -1172,7 +1272,7 @@ impl NvencCudaEncoder {
/// in-flight entry, cross-check FIFO pairing, unmap its input HERE (the encode thread — the /// in-flight entry, cross-check FIFO pairing, unmap its input HERE (the encode thread — the
/// retrieve thread never touches input resources), and queue the finished AU. /// retrieve thread never touches input resources), and queue the finished AU.
fn absorb_done(&mut self, done: RetrieveDone) -> Result<()> { fn absorb_done(&mut self, done: RetrieveDone) -> Result<()> {
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else { let Some((bs, map, pts_ns, anchor, _)) = self.pending.pop_front() else {
bail!("NVENC retrieve: completion with no in-flight frame (pairing bug)"); bail!("NVENC retrieve: completion with no in-flight frame (pairing bug)");
}; };
if bs as usize != done.bs { if bs as usize != done.bs {
@@ -1471,6 +1571,10 @@ impl Encoder for NvencCudaEncoder {
mp.mappedResource, mp.mappedResource,
captured.pts_ns, captured.pts_ns,
anchor, anchor,
// The chunked-poll keyframe prediction: exactly the SEI gate's is_idr (forced
// flags or the session-opening frame) — under P-only + infinite GOP the driver
// never emits an IDR on its own, so this matches the eventual pictureType.
is_idr,
)); ));
} }
if sample { if sample {
@@ -1580,6 +1684,11 @@ impl Encoder for NvencCudaEncoder {
} }
fn poll(&mut self) -> Result<Option<EncodedFrame>> { fn poll(&mut self) -> Result<Option<EncodedFrame>> {
// A partially-chunked AU must be finished through `poll_chunk`: its emitted prefix is
// already with the caller, so a whole-AU poll here would double-emit those bytes.
if self.chunk.is_some() {
bail!("NVENC poll() called mid-chunked-AU — drain it via poll_chunk (caller bug)");
}
// Two-thread mode: drain whatever the retrieve thread has finished (non-blocking) and // Two-thread mode: drain whatever the retrieve thread has finished (non-blocking) and
// hand out the oldest ready AU. `None` = nothing completed yet — the session loop keeps // hand out the oldest ready AU. `None` = nothing completed yet — the session loop keeps
// the frame in flight and re-polls next tick; capture never blocks on the encode wait. // the frame in flight and re-polls next tick; capture never blocks on the encode wait.
@@ -1600,7 +1709,7 @@ impl Encoder for NvencCudaEncoder {
.ready .ready
.pop_front()); .pop_front());
} }
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else { let Some((bs, map, pts_ns, anchor, _)) = self.pending.pop_front() else {
return Ok(None); return Ok(None);
}; };
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session // SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
@@ -1645,6 +1754,172 @@ impl Encoder for NvencCudaEncoder {
} }
} }
fn supports_chunked_poll(&self) -> bool {
// Dynamic on purpose: a pipelined-retrieve escalation rebuilds the session with
// `async_rt` present (and `teardown` drops the latch), so a caller re-querying per AU
// sees the mode fall away instead of chunk-polling a session that can't serve it.
self.subframe_chunks && self.async_rt.is_none()
}
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
// Not a chunked session (knobs off, AV1, escalated to pipelined retrieve): degrade to a
// single whole-AU chunk so a chunk-driven caller works against every session shape. The
// `chunk.is_none()` arm is defensive — a mid-AU state must always finish below.
if !self.supports_chunked_poll() && self.chunk.is_none() {
return Ok(self.poll()?.map(AuChunk::whole));
}
let Some(&(bs, _, pts_ns, anchor, idr_hint)) = self.pending.front() else {
return Ok(None);
};
// Sampling budget: if this driver branch never publishes intermediate slices, stop
// burning CPU after ~2 frame intervals and finish through the blocking lock — worst
// case poll_chunk behaves like sync `poll` plus a few failed doNotWait attempts.
let budget = std::time::Duration::from_micros(2_000_000 / self.fps.max(1) as u64);
let t0 = std::time::Instant::now();
let mut offsets = [0u32; 32];
loop {
let emitted = self.chunk.as_ref().map_or(0, |c| c.emitted);
let slices_out = self.chunk.as_ref().map_or(0, |c| c.slices_out);
// SAFETY: `bs` is the front `pending` entry's pool bitstream (a prior
// `encode_picture` targeted it, and `teardown` clears `pending` whenever it nulls
// the session), the session is live, and this runs on the encode thread. `lock`
// (version set, doNotWait) and `offsets` are live stack locals across the
// synchronous call; `reportSliceOffsets` was armed at init so the driver may write
// up to `numSlices` ≤ 32 offsets (`sliceModeData` is clamped 2..=32 by
// `resolve_slices`). On a successful sub-frame lock `bitstreamBufferPtr` holds
// `bitstreamSizeInBytes` readable bytes of COMPLETED slices (enableSubFrameWrite
// publishes them mid-encode; proven by the on-hw probe) valid until the matching
// unlock; the emitted range is copied out BEFORE the unlock. Every successful lock
// is unlocked exactly once on all paths through the body.
unsafe {
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
outputBitstream: bs,
sliceOffsets: offsets.as_mut_ptr(),
..Default::default()
};
lock.set_doNotWait(1);
if (api().lock_bitstream)(self.encoder, &mut lock)
.nv_ok()
.is_ok()
{
let n = lock.numSlices;
let bytes = lock.bitstreamSizeInBytes as usize;
if n >= self.slices {
// Every slice is readable — fall through to the finishing blocking
// lock (the completion authority; `numSlices` alone is not trusted
// across driver branches).
let _ = (api().unlock_bitstream)(self.encoder, bs);
break;
}
if n > slices_out && bytes > emitted {
// New completed slice(s): cut `[emitted..bytes)`. `bytes` with `n`
// reported slices is the end of slice n (slices are contiguous
// Annex-B), so the cut lands on a NAL boundary.
let data =
std::slice::from_raw_parts(lock.bitstreamBufferPtr as *const u8, bytes)
[emitted..]
.to_vec();
(api().unlock_bitstream)(self.encoder, bs)
.nv_ok()
.map_err(|e| nvenc_status::call_err("unlock_bitstream (chunk)", e))?;
let cs = self.chunk.get_or_insert_with(ChunkState::new);
#[cfg(debug_assertions)]
cs.shadow.extend_from_slice(&data);
let first = !cs.opened;
cs.opened = true;
cs.emitted = bytes;
cs.slices_out = n;
return Ok(Some(AuChunk {
data,
pts_ns,
keyframe: idr_hint,
recovery_anchor: anchor,
chunk_aligned: false,
first,
last: false,
}));
}
let _ = (api().unlock_bitstream)(self.encoder, bs);
}
// Non-SUCCESS (LOCK_BUSY on other branches) = not ready — never an error here;
// the finishing blocking lock below owns real failures.
}
if t0.elapsed() > budget {
break;
}
std::thread::sleep(CHUNK_SAMPLE_INTERVAL);
}
// Finish: ONE blocking lock — the completion authority and the wedge-watchdog hook,
// exactly like sync `poll` (so the final chunk blocks and the AU tail never rides a
// +1 tick — the depth-1 pump contract). Emits whatever the sampler hadn't handed out.
let (bs, map, pts_ns, anchor, idr_hint) =
self.pending.pop_front().expect("front() checked above");
// SAFETY: same contract as `poll`'s blocking lock: `bs` is the popped in-flight pool
// bitstream on the live session (encode thread); the blocking `lock_bitstream` (version
// set) returns when the encode finished, yielding `bitstreamSizeInBytes` CPU-readable
// bytes at `bitstreamBufferPtr` valid until `unlock_bitstream` — every read (tail copy
// + debug prefix check) happens BEFORE the unlock. `map` (paired with `bs` in `pending`)
// is unmapped here, after completion, exactly once.
unsafe {
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
outputBitstream: bs,
..Default::default()
};
(api().lock_bitstream)(self.encoder, &mut lock)
.nv_ok()
.map_err(|e| nvenc_status::call_err("lock_bitstream (chunk finish)", e))?;
let total = lock.bitstreamSizeInBytes as usize;
let full = std::slice::from_raw_parts(lock.bitstreamBufferPtr as *const u8, total);
let cs = self.chunk.take().unwrap_or_else(ChunkState::new);
if cs.emitted > total {
let _ = (api().unlock_bitstream)(self.encoder, bs);
bail!(
"NVENC chunked poll: {} bytes already emitted but the finished AU is only \
{} sub-frame readback reported bytes the final lock disowns",
cs.emitted,
total
);
}
#[cfg(debug_assertions)]
if cs.shadow.as_slice() != &full[..cs.emitted] {
let _ = (api().unlock_bitstream)(self.encoder, bs);
bail!("NVENC chunked poll: emitted chunks diverge from the finished AU prefix");
}
let data = full[cs.emitted..].to_vec();
let keyframe = matches!(
lock.pictureType,
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
);
(api().unlock_bitstream)(self.encoder, bs)
.nv_ok()
.map_err(|e| nvenc_status::call_err("unlock_bitstream (chunk finish)", e))?;
if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, map);
}
if cs.opened && keyframe != idr_hint {
// Can't happen under P-only + infinite GOP; if a driver branch ever proves
// otherwise, the earlier chunks carried the wrong flag — make it visible.
tracing::warn!(
predicted = idr_hint,
actual = keyframe,
"NVENC chunked poll: picture type diverged from the submit-time prediction"
);
}
Ok(Some(AuChunk {
data,
pts_ns,
keyframe,
recovery_anchor: anchor,
chunk_aligned: false,
first: !cs.opened,
last: true,
}))
}
}
fn reset(&mut self) -> bool { fn reset(&mut self) -> bool {
// SAFETY: `teardown` requires the encode thread with no NVENC call in flight and a session // SAFETY: `teardown` requires the encode thread with no NVENC call in flight and a session
// whose cached resources belong to `self.encoder` — all hold here (reset is called from the // whose cached resources belong to `self.encoder` — all hold here (reset is called from the
@@ -1683,6 +1958,7 @@ impl Encoder for NvencCudaEncoder {
&mut cfg, &mut cfg,
self.split_mode, self.split_mode,
false, false,
self.subframe_on,
), ),
..Default::default() ..Default::default()
}; };
@@ -2328,4 +2604,165 @@ mod tests {
enc.submit_indexed(&frame, 2).expect("submit follow-up"); enc.submit_indexed(&frame, 2).expect("submit follow-up");
enc.poll().expect("poll").expect("follow-up AU"); enc.poll().expect("poll").expect("follow-up AU");
} }
/// Every chunk must be cut at an Annex-B NAL boundary (slice starts carry a start code).
fn starts_with_start_code(d: &[u8]) -> bool {
d.starts_with(&[0, 0, 0, 1]) || d.starts_with(&[0, 0, 1])
}
/// ON-HARDWARE (RTX box `.21`): LN1 Phase 1 — the chunked poll end to end, at the Phase-3
/// DEFAULTS (no env knobs: 4 slices + sub-frame readback arm on their own on a
/// SUBFRAME_READBACK-capable GPU). `poll_chunk` must (a) report the mode armed, (b) hand
/// every AU out as chunks whose first chunk opens the AU with the right metadata and whose
/// `last` closes it, (c) cut every chunk at an Annex-B start code, and (d) reassemble byte-
/// identically to the finishing blocking lock's AU (enforced by the debug-build shadow check
/// inside `poll_chunk` — a mismatch errors the test). At least one frame must actually chunk
/// (>1 chunk) — the 5070 Ti probe shows every frame does. Run single-threaded (env vars are
/// process-global): `-- --ignored --test-threads=1`.
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_chunked_poll_end_to_end() {
const W: u32 = 1920;
const H: u32 = 1080;
// Defaults under test — make sure another test's knobs aren't leaking in.
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
W,
H,
60,
20_000_000,
true,
8,
ChromaFormat::Yuv420,
)
.expect("open NVENC CUDA session");
let mut multi_chunk_frames = 0usize;
let mut total_chunks = 0usize;
for i in 0..6u32 {
let frame = nv12_frame(W, H, i);
enc.submit_indexed(&frame, i).expect("submit");
assert!(
enc.supports_chunked_poll(),
"4 slices + subframe on a sync session must arm chunked poll"
);
let mut au = Vec::new();
let mut chunks = 0usize;
loop {
let c = enc
.poll_chunk()
.expect("poll_chunk")
.expect("an AU is in flight — poll_chunk must block, never None");
if chunks == 0 {
assert!(c.first, "the first chunk must open the AU");
assert_eq!(
c.keyframe,
i == 0,
"only the session-opening frame is an IDR"
);
}
assert_eq!(c.pts_ns, i as u64 * 16_666_667, "pts rides every chunk");
assert!(!c.recovery_anchor, "no RFI happened");
if !c.data.is_empty() {
assert!(
starts_with_start_code(&c.data),
"chunk cut must land on an Annex-B start code (frame {i}, chunk {chunks})"
);
}
au.extend_from_slice(&c.data);
chunks += 1;
if c.last {
break;
}
}
assert!(!au.is_empty(), "frame {i} produced an empty AU");
assert!(
enc.chunk.is_none(),
"chunk state must be cleared once the AU closes"
);
if chunks > 1 {
multi_chunk_frames += 1;
}
total_chunks += chunks;
println!("frame {i}: {chunks} chunks, {} bytes", au.len());
}
assert!(
multi_chunk_frames >= 1,
"sub-frame readback yielded no multi-chunk frame — incremental slice readback \
regressed (the probe shows ~200 µs slice spacing on this GPU)"
);
println!(
"nvenc_cuda chunked poll: {total_chunks} chunks over 6 frames, \
{multi_chunk_frames} frames chunked"
);
// Mode-mix across frames is legal: a fully-drained chunked AU leaves poll() usable.
let frame = nv12_frame(W, H, 6);
enc.submit_indexed(&frame, 6)
.expect("submit plain-poll frame");
let au = enc.poll().expect("poll").expect("AU");
assert!(!au.data.is_empty());
}
/// ON-HARDWARE (RTX box `.21`): the Phase-3 default-on ESCAPES — `PUNKTFUNK_NVENC_SLICES=1`
/// must fully disarm chunked poll (and `poll_chunk` degrades
/// to exactly one self-closing whole-AU chunk (the default-path contract every non-chunked
/// session shares). Run with `--test-threads=1` (env vars are process-global).
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_chunked_poll_fallback_whole_au() {
const W: u32 = 1280;
const H: u32 = 720;
struct EnvGuard;
impl Drop for EnvGuard {
fn drop(&mut self) {
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
}
}
let _guard = EnvGuard;
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
// Escape 1: explicit single slice — no boundaries to cut, chunked poll disarmed.
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "1");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
let mut enc = open_h265();
let frame = nv12_frame(W, H, 0);
enc.submit_indexed(&frame, 0).expect("submit");
assert!(
!enc.supports_chunked_poll(),
"PUNKTFUNK_NVENC_SLICES=1 → chunked poll must not arm"
);
let c = enc
.poll_chunk()
.expect("poll_chunk")
.expect("whole-AU chunk");
assert!(c.first && c.last, "fallback chunk must be self-closing");
assert!(c.keyframe, "opening AU is the session IDR");
assert!(!c.data.is_empty());
assert!(
enc.poll_chunk().expect("poll_chunk").is_none(),
"nothing in flight → None"
);
drop(enc);
// Escape 2: sub-frame readback vetoed — slices stay (default 4) but chunked poll
// disarms and the plain poll path carries the session.
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
let mut enc = open_h265();
let frame = nv12_frame(W, H, 0);
enc.submit_indexed(&frame, 0).expect("submit");
assert!(
!enc.supports_chunked_poll(),
"PUNKTFUNK_NVENC_SUBFRAME=0 → chunked poll must not arm"
);
let au = enc.poll().expect("poll").expect("AU");
assert!(au.keyframe && !au.data.is_empty());
}
} }
+25 -7
View File
@@ -37,9 +37,11 @@ pub(crate) fn fourcc_to_vk(fourcc: u32) -> Option<vk::Format> {
const AR24: u32 = 0x3432_5241; // ARGB8888 const AR24: u32 = 0x3432_5241; // ARGB8888
const XB24: u32 = 0x3432_4258; // XBGR8888 const XB24: u32 = 0x3432_4258; // XBGR8888
const AB24: u32 = 0x3432_4241; // ABGR8888 const AB24: u32 = 0x3432_4241; // ABGR8888
const NV12: u32 = 0x3231_564e; // DRM_FORMAT_NV12
match fourcc { match fourcc {
XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM), XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM),
XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM), XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM),
NV12 => Some(vk::Format::G8_B8R8_2PLANE_420_UNORM),
_ => None, _ => None,
} }
} }
@@ -90,9 +92,10 @@ pub(crate) unsafe fn import_rgb_dmabuf(
) )
} }
/// [`import_rgb_dmabuf`] with the image usage explicit and an optional video-profile list /// [`import_rgb_dmabuf`] with the image usage explicit and an optional video-profile list.
/// (chained into the image create) — the RGB-direct encode path imports the captured buffer /// Despite the historical name, this also imports gamescope's one-fd LINEAR NV12: the UV
/// as a profiled `VIDEO_ENCODE_SRC` image instead of a sampled one. /// subresource layout comes from the producer's plane-1 chunk when it reported one, falling
/// back to the shared-stride contiguous-plane contract.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) unsafe fn import_rgb_dmabuf_as( pub(crate) unsafe fn import_rgb_dmabuf_as(
device: &ash::Device, device: &ash::Device,
@@ -108,12 +111,27 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
use std::os::fd::IntoRawFd; use std::os::fd::IntoRawFd;
let fmt = fourcc_to_vk(d.fourcc) let fmt = fourcc_to_vk(d.fourcc)
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?; .with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
let plane = [vk::SubresourceLayout::default() let planes: Vec<vk::SubresourceLayout> = if fmt == vk::Format::G8_B8R8_2PLANE_420_UNORM {
.offset(d.offset as u64) let (uv_offset, uv_stride) = d.plane1.map(|(o, s)| (o as u64, s as u64)).unwrap_or((
.row_pitch(d.stride as u64)]; d.offset as u64 + d.stride as u64 * ch as u64,
d.stride as u64,
));
vec![
vk::SubresourceLayout::default()
.offset(d.offset as u64)
.row_pitch(d.stride as u64),
vk::SubresourceLayout::default()
.offset(uv_offset)
.row_pitch(uv_stride),
]
} else {
vec![vk::SubresourceLayout::default()
.offset(d.offset as u64)
.row_pitch(d.stride as u64)]
};
let mut drm = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default() let mut drm = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
.drm_format_modifier(d.modifier) .drm_format_modifier(d.modifier)
.plane_layouts(&plane); .plane_layouts(&planes);
let mut ext = vk::ExternalMemoryImageCreateInfo::default() let mut ext = vk::ExternalMemoryImageCreateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT); .handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
let mut ci = vk::ImageCreateInfo::default() let mut ci = vk::ImageCreateInfo::default()
File diff suppressed because it is too large Load Diff
+48 -14
View File
@@ -35,6 +35,38 @@ pub(super) fn codec_guid(codec: Codec) -> nv::GUID {
} }
} }
/// Resolved per-frame slice count for a session (latency plan §7 LN1, Phase 3): the
/// `PUNKTFUNK_NVENC_SLICES` env override wins (1..=32; **1 = the explicit single-slice
/// escape**, needed now that a backend can default higher), else the backend's
/// `default_slices` — 4 on Linux direct-NVENC since the Phase-3 default-on, 1 everywhere else
/// (the Windows async path is deliberately untouched). H.264/HEVC only (AV1 partitions via
/// tiles). ONE parse shared by the config author ([`apply_low_latency_config`] via
/// [`LowLatencyConfig::slices`]) and the Linux backend's chunked-poll arming, so the two can
/// never disagree about whether a session is multi-slice.
pub(super) fn resolve_slices(codec: Codec, default_slices: u32) -> u32 {
if !matches!(codec, Codec::H264 | Codec::H265) {
return 1;
}
std::env::var("PUNKTFUNK_NVENC_SLICES")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.filter(|n| (1..=32).contains(n))
.unwrap_or(default_slices)
}
/// Resolved sub-frame readback (`enableSubFrameWrite` + `reportSliceOffsets`; sync sessions
/// only, see [`build_init_params`]): `PUNKTFUNK_NVENC_SUBFRAME` tri-state — `0` = never (the
/// default-on escape), `1` = force (even where the caps probe says unsupported — an operator
/// explicitly testing), unset = the backend's `default_on` (Linux direct-NVENC passes its
/// SUBFRAME_READBACK caps-probe result since Phase 3; Windows passes `false`).
pub(super) fn resolve_subframe(default_on: bool) -> bool {
match std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref() {
Ok("0") => false,
Ok("1") => true,
_ => default_on,
}
}
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated /// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each /// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames` /// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
@@ -66,6 +98,9 @@ pub(super) struct LowLatencyConfig {
pub hdr: bool, pub hdr: bool,
/// This GPU supports reference-frame invalidation (a deeper DPB for graceful loss recovery). /// This GPU supports reference-frame invalidation (a deeper DPB for graceful loss recovery).
pub rfi_supported: bool, pub rfi_supported: bool,
/// Resolved per-frame slice count ([`resolve_slices`] — env override, else the backend
/// default). ≤ 1 leaves the preset's single slice untouched.
pub slices: u32,
} }
/// Author the shared `NV_ENC_INITIALIZE_PARAMS` (P1/ULL preset, PTD, the session dimensions/rate) /// Author the shared `NV_ENC_INITIALIZE_PARAMS` (P1/ULL preset, PTD, the session dimensions/rate)
@@ -82,6 +117,7 @@ pub(super) fn build_init_params(
cfg: &mut nv::NV_ENC_CONFIG, cfg: &mut nv::NV_ENC_CONFIG,
split_mode: u32, split_mode: u32,
enable_async: bool, enable_async: bool,
subframe: bool,
) -> nv::NV_ENC_INITIALIZE_PARAMS { ) -> nv::NV_ENC_INITIALIZE_PARAMS {
let mut init = nv::NV_ENC_INITIALIZE_PARAMS { let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
version: nv::NV_ENC_INITIALIZE_PARAMS_VER, version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
@@ -101,12 +137,13 @@ pub(super) fn build_init_params(
}; };
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field. // splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
init.set_splitEncodeMode(split_mode); init.set_splitEncodeMode(split_mode);
// Sub-frame readback (latency plan §7 LN1 groundwork — EXPERIMENTAL, default off): the driver // Sub-frame readback (latency plan §7 LN1; default-on for Linux direct-NVENC since Phase 3 —
// writes each slice into the output buffer as it completes and reports per-slice offsets, so a // the caller resolves `subframe` via [`resolve_subframe`] + its caps probe): the driver
// sync-mode consumer can read slices out while the frame is still encoding. Pair with // writes each slice into the output buffer as it completes and reports per-slice offsets, so
// `PUNKTFUNK_NVENC_SLICES` (a single-slice frame yields nothing to read early). // a sync-mode consumer can read slices out while the frame is still encoding. Pair with
// `reportSliceOffsets` requires `enableEncodeAsync = 0`, so async (Windows) sessions never arm. // multi-slice (a single-slice frame yields nothing to read early). `reportSliceOffsets`
if !enable_async && std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref() == Ok("1") { // requires `enableEncodeAsync = 0`, so async (Windows) sessions never arm.
if !enable_async && subframe {
init.set_enableSubFrameWrite(1); init.set_enableSubFrameWrite(1);
init.set_reportSliceOffsets(1); init.set_reportSliceOffsets(1);
} }
@@ -158,16 +195,13 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"), Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
} }
// Multi-slice frames (latency plan §7 LN1 groundwork — EXPERIMENTAL, default off = the preset's // Multi-slice frames (latency plan §7 LN1): `c.slices` splits every frame into N slices
// single slice): `PUNKTFUNK_NVENC_SLICES=N` (2..=32) splits every frame into N slices
// (sliceMode 3 = "N slices per frame"), the unit sub-frame readback ships early and loss // (sliceMode 3 = "N slices per frame"), the unit sub-frame readback ships early and loss
// concealment can discard independently. Costs ~1-2 % bitrate in slice headers. H.264/HEVC // concealment can discard independently. Costs ~1-2 % bitrate in slice headers. H.264/HEVC
// only — AV1 partitions via tiles, not slices. // only — AV1 partitions via tiles, not slices (the resolver already returns 1 there).
if let Some(n) = std::env::var("PUNKTFUNK_NVENC_SLICES") // Default 4 on Linux direct-NVENC (Phase 3), env-only elsewhere; ≤ 1 keeps the preset's
.ok() // single slice.
.and_then(|s| s.parse::<u32>().ok()) if let Some(n) = Some(c.slices).filter(|n| *n >= 2) {
.filter(|n| (2..=32).contains(n))
{
match c.codec { match c.codec {
Codec::H264 => { Codec::H264 => {
cfg.encodeCodecConfig.h264Config.sliceMode = 3; cfg.encodeCodecConfig.h264Config.sliceMode = 3;
+10 -1
View File
@@ -37,7 +37,8 @@
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{ use super::nvenc_core::{
apply_low_latency_config, build_init_params, codec_guid, LowLatencyConfig, NvStatusExt, RFI_DPB, apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
LowLatencyConfig, NvStatusExt, RFI_DPB,
}; };
use super::nvenc_status; use super::nvenc_status;
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
@@ -744,6 +745,10 @@ impl NvencD3d11Encoder {
av1_input_depth_minus8: if ten_bit_in { 2 } else { 0 }, av1_input_depth_minus8: if ten_bit_in { 2 } else { 0 },
hdr: self.hdr, hdr: self.hdr,
rfi_supported: self.rfi_supported, rfi_supported: self.rfi_supported,
// Env-only on Windows (default single slice): the Phase-3 default-on is a
// Linux-direct-NVENC decision — the Windows async path stays untouched, and a
// Windows operator opting in must choose slices+sync over async retrieve.
slices: resolve_slices(self.codec, 1),
}, },
); );
Ok(cfg) Ok(cfg)
@@ -791,6 +796,9 @@ impl NvencD3d11Encoder {
&mut cfg, &mut cfg,
split_mode, split_mode,
enable_async, enable_async,
// Windows: env opt-in only ("1"), never a default — and build_init_params
// additionally refuses it on an async session.
resolve_subframe(false),
); );
match (api().initialize_encoder)(enc, &mut init).nv_ok() { match (api().initialize_encoder)(enc, &mut init).nv_ok() {
@@ -1568,6 +1576,7 @@ impl Encoder for NvencD3d11Encoder {
&mut cfg, &mut cfg,
self.split_mode, self.split_mode,
self.session_async, self.session_async,
resolve_subframe(false),
), ),
..Default::default() ..Default::default()
}; };
+260 -8
View File
@@ -478,10 +478,14 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
b b
}); });
// HDR signalling (10-bit sessions are the HDR path on Windows — same coupling as NVENC): // Colour signalling, written UNCONDITIONALLY (mirrors nvenc_core.rs): the input is already
// BT.2020/PQ colour description + the source's mastering/CLL grade at every IDR. // CSC'd to a specific matrix — BT.709 limited for SDR (the capture-side VideoConverter),
// BT.2020 PQ for HDR (HdrP010Converter) — so the stream must say so. An SDR stream without a
// colour description leaves the choice to the decoder's "unspecified" default, and
// Moonlight/third-party/Android-vendor decoders default to 601 at sub-HD → mis-rendered
// colours. (10-bit sessions are the HDR path on Windows — same coupling as NVENC.)
let hdr = cfg.ten_bit && cfg.codec != Codec::H264; let hdr = cfg.ten_bit && cfg.codec != Codec::H264;
let vsi = hdr.then(|| { let vsi = {
// SAFETY: all-zero is valid; header stamped below. // SAFETY: all-zero is valid; header stamped below.
let mut b: Box<vpl::mfxExtVideoSignalInfo> = Box::new(unsafe { std::mem::zeroed() }); let mut b: Box<vpl::mfxExtVideoSignalInfo> = Box::new(unsafe { std::mem::zeroed() });
b.Header.BufferId = vpl::MFX_EXTBUFF_VIDEO_SIGNAL_INFO as u32; b.Header.BufferId = vpl::MFX_EXTBUFF_VIDEO_SIGNAL_INFO as u32;
@@ -489,11 +493,17 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
b.VideoFormat = 5; // unspecified b.VideoFormat = 5; // unspecified
b.VideoFullRange = 0; b.VideoFullRange = 0;
b.ColourDescriptionPresent = 1; b.ColourDescriptionPresent = 1;
b.ColourPrimaries = 9; // BT.2020 if hdr {
b.TransferCharacteristics = 16; // SMPTE ST 2084 (PQ) b.ColourPrimaries = 9; // BT.2020
b.MatrixCoefficients = 9; // BT.2020 non-constant b.TransferCharacteristics = 16; // SMPTE ST 2084 (PQ)
b b.MatrixCoefficients = 9; // BT.2020 non-constant
}); } else {
b.ColourPrimaries = 1; // BT.709
b.TransferCharacteristics = 1; // BT.709
b.MatrixCoefficients = 1; // BT.709
}
Some(b)
};
let mastering = cfg.hdr_meta.filter(|_| hdr).map(|m| { let mastering = cfg.hdr_meta.filter(|_| hdr).map(|m| {
// SAFETY: all-zero is valid; header stamped below. // SAFETY: all-zero is valid; header stamped below.
let mut b: Box<vpl::mfxExtMasteringDisplayColourVolume> = let mut b: Box<vpl::mfxExtMasteringDisplayColourVolume> =
@@ -1994,4 +2004,246 @@ mod tests {
"the bitrate retarget emitted a keyframe (StartNewSequence leak)" "the bitrate retarget emitted a keyframe (StartNewSequence leak)"
); );
} }
/// FULL-CHAIN colour check at the field capture size: a known P010 colour-bar source at
/// 1920x1080 — whose height is NOT 16-aligned, so the ingest `CopySubresourceRegion` copies
/// into a 1920x1088 runtime pool surface whose chroma plane sits at a DIFFERENT row offset
/// than the source's (the seam no 640x480 test exercises) — encoded to Main10 HEVC and
/// dumped to `%TEMP%\pf_qsv_1080_bars.h265` for off-box decode verification against the
/// same codes. On-box this asserts stream shape; the pixel verdict needs a decoder.
#[test]
fn qsv_live_p010_1080_colorbars_dump() {
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
use windows::Win32::Graphics::Direct3D11::{
D3D11CreateDevice, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
D3D11_SDK_VERSION, D3D11_SUBRESOURCE_DATA, D3D11_USAGE_DEFAULT,
};
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_P010, DXGI_SAMPLE_DESC};
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
// (Y, Cb, Cr) 10-bit limited codes for the 8 sRGB bars white/yellow/cyan/green/magenta/
// red/blue/black at 80-nit SDR white under PQ/BT.2020 — the same math as pf-capture's
// `p010_reference` (and the bars_pq2020 client fixture). Stored MSB-aligned (`<<6`).
const BARS: [(u16, u16, u16); 8] = [
(490, 512, 512),
(478, 423, 518),
(464, 525, 473),
(450, 432, 476),
(350, 584, 585),
(325, 448, 598),
(226, 650, 535),
(64, 512, 512),
];
const W: u32 = 1920;
const H: u32 = 1080;
init_tracing();
let Ok((_l, impls)) = intel_loader() else {
eprintln!("skipping: no VPL loader");
return;
};
let Some(imp) = impls.iter().find(|i| i.luid_valid) else {
eprintln!("skipping: no Intel VPL implementation on this box");
return;
};
if !probe_can_encode_10bit(Codec::H265) {
eprintln!("skipping: this GPU declines 10-bit HEVC");
return;
}
// P010 initial data: plane 0 = H rows of W u16 luma; plane 1 = H/2 rows of W u16
// (interleaved Cb,Cr pairs), same pitch. Bars are vertical: bar index = x / (W/8).
let bar_w = (W / 8) as usize;
let mut init = vec![0u16; (W as usize) * (H as usize + H as usize / 2)];
for y in 0..H as usize {
for x in 0..W as usize {
init[y * W as usize + x] = BARS[(x / bar_w).min(7)].0 << 6;
}
}
let chroma_base = (W as usize) * (H as usize);
for cy in 0..(H as usize / 2) {
for cx in 0..(W as usize / 2) {
let (_, cb, cr) = BARS[((cx * 2) / bar_w).min(7)];
init[chroma_base + cy * W as usize + cx * 2] = cb << 6;
init[chroma_base + cy * W as usize + cx * 2 + 1] = cr << 6;
}
}
// SAFETY: self-contained harness on one thread/device (same contract as `drive_live`);
// the initial-data pointer outlives the synchronous CreateTexture2D that reads it.
let (device, tex) = unsafe {
let luid = windows::Win32::Foundation::LUID {
LowPart: u32::from_le_bytes(imp.luid[..4].try_into().unwrap()),
HighPart: i32::from_le_bytes(imp.luid[4..].try_into().unwrap()),
};
let factory: IDXGIFactory4 = CreateDXGIFactory1().expect("dxgi factory");
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).expect("intel adapter");
let mut device = None;
D3D11CreateDevice(
&adapter,
D3D_DRIVER_TYPE_UNKNOWN,
windows::Win32::Foundation::HMODULE::default(),
Default::default(),
None,
D3D11_SDK_VERSION,
Some(&mut device),
None,
None,
)
.expect("d3d11 device on intel adapter");
let device: ID3D11Device = device.expect("device");
let desc = D3D11_TEXTURE2D_DESC {
Width: W,
Height: H,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_P010,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
CPUAccessFlags: 0,
MiscFlags: 0,
};
let data = D3D11_SUBRESOURCE_DATA {
pSysMem: init.as_ptr() as *const std::ffi::c_void,
SysMemPitch: W * 2,
SysMemSlicePitch: 0,
};
let mut t: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, Some(&data), Some(&mut t))
.expect("bar texture");
(device.clone(), t.expect("texture"))
};
let mut enc = QsvEncoder::open(
Codec::H265,
PixelFormat::P010,
W,
H,
30,
10_000_000,
10,
ChromaFormat::Yuv420,
)
.expect("open");
enc.set_hdr_meta(Some(test_hdr_meta()));
let mut stream = Vec::new();
let mut aus = 0usize;
let mut keyframes = 0usize;
for i in 0..12u32 {
let frame = CapturedFrame {
width: W,
height: H,
pts_ns: i as u64 * 33_333_333,
format: PixelFormat::P010,
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
texture: tex.clone(),
device: device.clone(),
pyro: None,
}),
cursor: None,
};
enc.submit_indexed(&frame, i).expect("submit");
if let Some(au) = enc.poll().expect("poll") {
aus += 1;
keyframes += au.keyframe as usize;
stream.extend_from_slice(&au.data);
}
}
enc.flush().expect("flush");
while let Some(au) = enc.poll().expect("drain") {
aus += 1;
keyframes += au.keyframe as usize;
stream.extend_from_slice(&au.data);
}
assert!(aus >= 10, "expected ≥10 AUs, got {aus}");
assert!(keyframes >= 1, "expected an IDR in the dump");
let path = std::env::temp_dir().join("pf_qsv_1080_bars.h265");
std::fs::write(&path, &stream).expect("write dump");
println!(
"wrote {} AUs ({} bytes, {keyframes} keyframes) to {}",
aus,
stream.len(),
path.display()
);
}
/// The PRODUCTION host chain minus the IDD ring: the REAL `HdrP010Converter` renders the 8
/// sRGB bars into a ring-profile P010 texture (`BIND_RENDER_TARGET` only — RTV-written, not
/// CPU-uploaded) on the VPL implementation's own adapter, and THAT texture goes through the
/// unaligned-height ingest copy into a Main10 encode. Dumped to
/// `%TEMP%\pf_qsv_conv_1080_bars.h265`; expected decode codes = the bars_pq2020 fixture set
/// (see `hdr_p010_convert_bars_on_luid`).
#[test]
fn qsv_live_hdr_converter_e2e_1080_dump() {
const W: u32 = 1920;
const H: u32 = 1080;
init_tracing();
let Ok((_l, impls)) = intel_loader() else {
eprintln!("skipping: no VPL loader");
return;
};
let Some(imp) = impls.iter().find(|i| i.luid_valid) else {
eprintln!("skipping: no Intel VPL implementation on this box");
return;
};
if !probe_can_encode_10bit(Codec::H265) {
eprintln!("skipping: this GPU declines 10-bit HEVC");
return;
}
let (device, tex) = pf_capture::dxgi::hdr_p010_convert_bars_on_luid(imp.luid, W, H)
.expect("converter bars");
let mut enc = QsvEncoder::open(
Codec::H265,
PixelFormat::P010,
W,
H,
30,
10_000_000,
10,
ChromaFormat::Yuv420,
)
.expect("open");
enc.set_hdr_meta(Some(test_hdr_meta()));
let mut stream = Vec::new();
let mut aus = 0usize;
for i in 0..12u32 {
let frame = CapturedFrame {
width: W,
height: H,
pts_ns: i as u64 * 33_333_333,
format: PixelFormat::P010,
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
texture: tex.clone(),
device: device.clone(),
pyro: None,
}),
cursor: None,
};
enc.submit_indexed(&frame, i).expect("submit");
if let Some(au) = enc.poll().expect("poll") {
aus += 1;
stream.extend_from_slice(&au.data);
}
}
enc.flush().expect("flush");
while let Some(au) = enc.poll().expect("drain") {
aus += 1;
stream.extend_from_slice(&au.data);
}
assert!(aus >= 10, "expected ≥10 AUs, got {aus}");
let path = std::env::temp_dir().join("pf_qsv_conv_1080_bars.h265");
std::fs::write(&path, &stream).expect("write dump");
println!(
"wrote {aus} AUs ({} bytes) to {}",
stream.len(),
path.display()
);
}
} }
+86 -4
View File
@@ -129,6 +129,9 @@ pub fn open_video(
cuda: bool, cuda: bool,
bit_depth: u8, bit_depth: u8,
chroma: ChromaFormat, chroma: ChromaFormat,
// The session may hand this encoder cursor bitmaps to composite (cursor-as-metadata
// captures). Backends whose fast path can't blend (Vulkan EFC RGB-direct) key off it.
cursor_blend: bool,
) -> Result<Box<dyn Encoder>> { ) -> Result<Box<dyn Encoder>> {
let (inner, backend) = open_video_backend( let (inner, backend) = open_video_backend(
codec, codec,
@@ -140,6 +143,7 @@ pub fn open_video(
cuda, cuda,
bit_depth, bit_depth,
chroma, chroma,
cursor_blend,
)?; )?;
// Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label // Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label
// is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by // is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by
@@ -203,6 +207,12 @@ impl Encoder for TrackedEncoder {
fn invalidate_ref_frames(&mut self, first_frame: i64, last_frame: i64) -> bool { fn invalidate_ref_frames(&mut self, first_frame: i64, last_frame: i64) -> bool {
self.inner.invalidate_ref_frames(first_frame, last_frame) self.inner.invalidate_ref_frames(first_frame, last_frame)
} }
// Forwarded for the same reason as `set_wire_chunking` below — the unforwarded default
// (`false` = "backend can't pipeline, stop asking") silently killed the §7 LN3 contention
// escalation for every session, since the host loop only ever holds the wrapped box.
fn set_pipelined(&mut self, on: bool) -> bool {
self.inner.set_pipelined(on)
}
// The classic TrackedEncoder trap: a defaulted trait method that isn't forwarded // The classic TrackedEncoder trap: a defaulted trait method that isn't forwarded
// silently no-ops through the wrapper (bit the direct-NVENC work, then THIS — the // silently no-ops through the wrapper (bit the direct-NVENC work, then THIS — the
// §4.4 chunking probe run hit the default while the plan said Some(1408)). // §4.4 chunking probe run hit the default while the plan said Some(1408)).
@@ -217,6 +227,14 @@ impl Encoder for TrackedEncoder {
fn poll(&mut self) -> Result<Option<EncodedFrame>> { fn poll(&mut self) -> Result<Option<EncodedFrame>> {
self.inner.poll() self.inner.poll()
} }
// Both chunked-poll methods forwarded (the same trap class): the defaults would report
// "not chunked" and wrap whole AUs, silently discarding the sub-frame overlap.
fn supports_chunked_poll(&self) -> bool {
self.inner.supports_chunked_poll()
}
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
self.inner.poll_chunk()
}
fn reset(&mut self) -> bool { fn reset(&mut self) -> bool {
self.inner.reset() self.inner.reset()
} }
@@ -250,7 +268,9 @@ fn open_video_backend(
cuda: bool, cuda: bool,
bit_depth: u8, bit_depth: u8,
chroma: ChromaFormat, chroma: ChromaFormat,
cursor_blend: bool,
) -> Result<(Box<dyn Encoder>, &'static str)> { ) -> Result<(Box<dyn Encoder>, &'static str)> {
let _ = cursor_blend; // consumed only by the Linux vulkan-encode arm below
validate_dimensions(codec, width, height)?; validate_dimensions(codec, width, height)?;
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`) // Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero. // and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
@@ -308,8 +328,15 @@ fn open_video_backend(
&& vulkan_encode_enabled() && vulkan_encode_enabled()
&& !(bit_depth == 10 && format.is_hdr_rgb10()) && !(bit_depth == 10 && format.is_hdr_rgb10())
{ {
match vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps) match vulkan_video::VulkanVideoEncoder::open(
{ codec,
format,
width,
height,
fps,
bitrate_bps,
cursor_blend,
) {
Ok(e) => { Ok(e) => {
tracing::info!( tracing::info!(
codec = ?codec, codec = ?codec,
@@ -318,12 +345,32 @@ fn open_video_backend(
); );
return Ok((Box::new(e) as Box<dyn Encoder>, "vulkan")); return Ok((Box::new(e) as Box<dyn Encoder>, "vulkan"));
} }
// Native NV12 (PUNKTFUNK_PIPEWIRE_NV12 capture) has no VAAPI fallback:
// libav's dmabuf lane would import the two-plane buffer as packed RGB
// (silent garbage) and its CPU lane bails per frame — die crisply instead.
Err(e) if format == PixelFormat::Nv12 => {
return Err(e.context(
"Vulkan Video open failed on a native-NV12 capture \
no VAAPI fallback exists; set PUNKTFUNK_PIPEWIRE_NV12=0 to \
restore the packed-RGB negotiation",
));
}
Err(e) => tracing::warn!( Err(e) => tracing::warn!(
error = %format!("{e:#}"), error = %format!("{e:#}"),
"Vulkan Video encode open failed — falling back to libav VAAPI" "Vulkan Video encode open failed — falling back to libav VAAPI"
), ),
} }
} }
// Same rule when the Vulkan backend was never eligible (H264 session,
// PUNKTFUNK_VULKAN_ENCODE=0, or a build without the feature).
if format == PixelFormat::Nv12 {
anyhow::bail!(
"native NV12 capture requires the Vulkan Video encoder (HEVC/AV1 \
session, --features vulkan-encode, PUNKTFUNK_VULKAN_ENCODE not 0) this \
session resolved to libav VAAPI; set PUNKTFUNK_PIPEWIRE_NV12=0 to restore \
the packed-RGB negotiation"
);
}
vaapi::VaapiEncoder::open( vaapi::VaapiEncoder::open(
codec, codec,
format, format,
@@ -362,8 +409,16 @@ fn open_video_backend(
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}" "the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
); );
} }
vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps) vulkan_video::VulkanVideoEncoder::open(
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan")) codec,
format,
width,
height,
fps,
bitrate_bps,
cursor_blend,
)
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
} }
#[cfg(not(feature = "vulkan-encode"))] #[cfg(not(feature = "vulkan-encode"))]
{ {
@@ -786,6 +841,33 @@ fn vulkan_encode_enabled() -> bool {
.unwrap_or(true) .unwrap_or(true)
} }
/// Whether THIS session's encoder can ingest a producer-native NV12 capture: only the raw
/// Vulkan Video backend does (libav VAAPI would misread the two-plane buffer as packed RGB —
/// [`open_video`] refuses the combination), so the session's codec must be one it encodes and
/// the backend must be eligible to open. The host facade threads the verdict into the capture
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
/// escapes at the capture gate).
#[cfg(target_os = "linux")]
pub fn linux_native_nv12_ok(codec: Codec) -> bool {
#[cfg(feature = "vulkan-encode")]
{
matches!(codec, Codec::H265 | Codec::Av1)
&& vulkan_encode_enabled()
// NVENC/PyroWave prefs never open the Vulkan Video backend; every other pref
// (auto/vaapi/amd/intel/vulkan) tries it first on AMD/Intel — see [`open_video`].
&& !matches!(
pf_host_config::config().encoder_pref.as_str(),
"nvenc" | "nvidia" | "cuda" | "pyrowave"
)
}
#[cfg(not(feature = "vulkan-encode"))]
{
let _ = codec;
false
}
}
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA /// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT /// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be /// create a CUDA context (that would allocate GPU state on every host that merely *might* be
+31 -10
View File
@@ -105,13 +105,15 @@ pub fn drm_fourcc(format: PixelFormat) -> Option<u32> {
Bgra => drm_fourcc_code(b"AR24"), // DRM_FORMAT_ARGB8888 Bgra => drm_fourcc_code(b"AR24"), // DRM_FORMAT_ARGB8888
Rgbx => drm_fourcc_code(b"XB24"), // DRM_FORMAT_XBGR8888 Rgbx => drm_fourcc_code(b"XB24"), // DRM_FORMAT_XBGR8888
Rgba => drm_fourcc_code(b"AB24"), // DRM_FORMAT_ABGR8888 Rgba => drm_fourcc_code(b"AB24"), // DRM_FORMAT_ABGR8888
// Linux native NV12 capture (gamescope PipeWire): one LINEAR dmabuf with contiguous Y then
// interleaved UV, exposed under DRM_FORMAT_NV12.
Nv12 => drm_fourcc_code(b"NV12"),
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10, PQ/BT.2020). // The GNOME 50+ HDR screencast formats (packed 2:10:10:10, PQ/BT.2020).
X2Rgb10 => drm_fourcc_code(b"XR30"), // DRM_FORMAT_XRGB2101010 X2Rgb10 => drm_fourcc_code(b"XR30"), // DRM_FORMAT_XRGB2101010
X2Bgr10 => drm_fourcc_code(b"XB30"), // DRM_FORMAT_XBGR2101010 X2Bgr10 => drm_fourcc_code(b"XB30"), // DRM_FORMAT_XBGR2101010
// 24-bit packed RGB/BGR have no straightforward dmabuf import here; use the CPU path. // 24-bit packed RGB/BGR have no straightforward dmabuf import here; use the CPU path.
// Rgb10a2/Nv12/P010 are the Windows HDR / video-processor formats — never produced on // Rgb10a2/P010 are Windows formats; Yuv444 is OUR convert output, never a capture source.
// Linux; Yuv444 is OUR convert's OUTPUT, never a capture source format. Rgb | Bgr | Rgb10a2 | P010 | Yuv444 => return None,
Rgb | Bgr | Rgb10a2 | Nv12 | P010 | Yuv444 => return None,
}) })
} }
@@ -144,6 +146,12 @@ pub struct OutputFormat {
/// (never BGRA-passthrough / P010). `false` on every non-PyroWave session and on Linux (the /// (never BGRA-passthrough / P010). `false` on every non-PyroWave session and on Linux (the
/// wavelet encoder ingests dmabufs / CPU RGB there, not a D3D11 texture). /// wavelet encoder ingests dmabufs / CPU RGB there, not a D3D11 texture).
pub pyrowave: bool, pub pyrowave: bool,
/// THIS session's encoder can ingest a producer-native NV12 capture (Linux raw Vulkan Video
/// backend on an H265/AV1 session — see `pf_encode::linux_native_nv12_ok`). The Linux capture
/// negotiation only offers gamescope the NV12 pod when this is set: libav VAAPI (the H264
/// codec's backend, and the fallback family) would misread the two-plane buffer as packed
/// RGB. Always `false` on Windows (the IDD-push capturer owns its own formats).
pub nv12_native: bool,
} }
impl OutputFormat { impl OutputFormat {
@@ -161,6 +169,11 @@ impl OutputFormat {
chroma_444: false, chroma_444: false,
// GameStream never negotiates PyroWave (native punktfunk/1 only). // GameStream never negotiates PyroWave (native punktfunk/1 only).
pyrowave: false, pyrowave: false,
// Conservative: the GameStream + spike paths don't resolve the codec here, and a
// Moonlight client may negotiate H264 (whose VAAPI backend can't ingest NV12) — so
// they never prefer the producer-native NV12 pod. The punktfunk/1 plane opts in via
// `SessionPlan::output_format()`, which knows the codec.
nv12_native: false,
} }
} }
} }
@@ -201,18 +214,26 @@ pub struct CapturedFrame {
pub cursor: Option<CursorOverlay>, pub cursor: Option<CursorOverlay>,
} }
/// A captured frame still living in a single-plane packed-RGB dmabuf (the VAAPI zero-copy path). /// A captured frame still living in a DMA-BUF. Packed RGB uses one plane. Native Linux NV12
/// (gamescope PipeWire) travels in ONE fd: Y starts at `offset`, and the interleaved UV plane
/// lives at `plane1`'s offset/stride when the producer reported them — else at the contiguous
/// fallback `offset + stride * frame_height` with the shared `stride`.
///
/// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be /// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be
/// imported into a VA surface there without the compositor's buffer being closed underneath it. /// imported there without the compositor's buffer being closed underneath it. Content stability
/// (Content stability across the brief import window relies on the compositor's buffer pool depth, /// across the brief import window relies on the compositor's buffer pool depth, like any zero-copy
/// same as any zero-copy capture — the VAAPI importer copies into its own NV12 surface promptly.) /// capture.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub struct DmabufFrame { pub struct DmabufFrame {
pub fd: std::os::fd::OwnedFd, pub fd: std::os::fd::OwnedFd,
/// DRM FourCC of the packed-RGB plane (e.g. `XR24` for BGRx). /// DRM FourCC (`XR24` for BGRx, `NV12` for native 4:2:0).
pub fourcc: u32, pub fourcc: u32,
/// DRM format modifier the compositor allocated (0 = LINEAR). /// DRM format modifier the compositor allocated (0 = LINEAR).
pub modifier: u64, pub modifier: u64,
/// Second-plane `(offset, stride)` within the SAME fd, when the producer reported one (the
/// PipeWire buffer's plane-1 chunk — NV12's interleaved UV). `None` falls back to the
/// contiguous-plane contract above. Always `None` for single-plane packed RGB.
pub plane1: Option<(u32, u32)>,
pub offset: u32, pub offset: u32,
pub stride: u32, pub stride: u32,
} }
@@ -225,8 +246,8 @@ pub enum FramePayload {
/// The dmabuf has already been imported + copied into this owned device buffer. /// The dmabuf has already been imported + copied into this owned device buffer.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
Cuda(pf_zerocopy::DeviceBuffer), Cuda(pf_zerocopy::DeviceBuffer),
/// A raw packed-RGB dmabuf — the AMD/Intel (VAAPI) zero-copy path. The encoder imports it into /// A raw DMA-BUF: packed RGB for the existing GPU CSC paths, or native NV12 from a producer
/// a VA surface and does RGB→NV12 on the GPU video engine (no host CSC, no upload). /// such as gamescope. The encoder imports it without a host copy.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
Dmabuf(DmabufFrame), Dmabuf(DmabufFrame),
/// A GPU-resident D3D11 texture (Windows zero-copy path for NVENC). Owns the copied frame. /// A GPU-resident D3D11 texture (Windows zero-copy path for NVENC). Owns the copied frame.
+17
View File
@@ -63,6 +63,13 @@ pub struct HostConfig {
/// deliver full chroma, and the GPU/driver passed the encode probe — otherwise 4:2:0. /// deliver full chroma, and the GPU/driver passed the encode probe — otherwise 4:2:0.
/// `PUNKTFUNK_444=0`/`false`/`off`/`no` disables. Independent of `ten_bit` (chroma vs depth). /// `PUNKTFUNK_444=0`/`false`/`off`/`no` disables. Independent of `ten_bit` (chroma vs depth).
pub four_four_four: bool, pub four_four_four: bool,
/// `PUNKTFUNK_CHACHA20` — host policy gate for the negotiated ChaCha20-Poly1305 session
/// cipher (design/chacha20-session-cipher.md). **Default ON** (pure rollout safety — perf-only,
/// both AEADs are full-strength): the host merely *allows* it — a session only seals with
/// ChaCha when the client advertised `VIDEO_CAP_CHACHA20` (set by soft-AES armv7 clients,
/// e.g. webOS TVs, whose GCM decrypt caps at ~100 Mbps); everyone else stays AES-128-GCM.
/// `PUNKTFUNK_CHACHA20=0`/`false`/`off`/`no` disables.
pub chacha20: bool,
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation. /// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
pub perf: bool, pub perf: bool,
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic). /// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
@@ -147,6 +154,16 @@ impl HostConfig {
) )
}) })
.unwrap_or(true), .unwrap_or(true),
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
// per-session switch; see the field doc).
chacha20: val("PUNKTFUNK_CHACHA20")
.map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(true),
perf: flag("PUNKTFUNK_PERF"), perf: flag("PUNKTFUNK_PERF"),
video_source: val("PUNKTFUNK_VIDEO_SOURCE"), video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
compositor: val("PUNKTFUNK_COMPOSITOR"), compositor: val("PUNKTFUNK_COMPOSITOR"),
+5
View File
@@ -333,6 +333,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// bottom-right corner (the reported bug). The menu/library is keyboard+gamepad-driven // bottom-right corner (the reported bug). The menu/library is keyboard+gamepad-driven
// and consumes no mouse, so nothing wanted these synthetic events anyway. // and consumes no mouse, so nothing wanted these synthetic events anyway.
sdl3::hint::set("SDL_TOUCH_MOUSE_EVENTS", "0"); sdl3::hint::set("SDL_TOUCH_MOUSE_EVENTS", "0");
// The Wayland `app_id` (and X11 WM_CLASS) — compositors match it against
// io.unom.Punktfunk.desktop for the window/taskbar icon. Without it SDL uses a generic
// identity and the session window gets the default-Wayland icon (the Linux analog of
// the AppUserModelID adoption above).
sdl3::hint::set("SDL_APP_ID", "io.unom.Punktfunk");
let sdl = sdl3::init().context("SDL init")?; let sdl = sdl3::init().context("SDL init")?;
let video = sdl.video().context("SDL video")?; let video = sdl.video().context("SDL video")?;
let events = sdl.event().context("SDL events")?; let events = sdl.event().context("SDL events")?;
+9 -1
View File
@@ -30,9 +30,17 @@ impl Presenter {
// switch modes before anything touches this frame. Only where the surface // switch modes before anything touches this frame. Only where the surface
// offers HDR10 — otherwise PQ stays on the SDR swapchain and the CSC shader // offers HDR10 — otherwise PQ stays on the SDR swapchain and the CSC shader
// tonemaps (mode 1). // tonemaps (mode 1).
//
// CPU frames NEVER take the HDR10 surface: software decode uploads swscale RGBA with
// no CSC/tonemap pass, so on a mode-0 swapchain that sRGB-encoded content would be
// composed as PQ — the field-reported psychedelic cyan/magenta picture (reproduced
// 2026-07-21: Fedora-class client, no hw HEVC decode, GNOME/Mesa offering HDR10 even
// on an SDR desktop). On the SDR swapchain the same frames are merely untonemapped
// (washed out) — wrong in the known, benign way until the CPU lane grows a real
// PQ→sRGB pass.
let frame_pq = match &input { let frame_pq = match &input {
FrameInput::Redraw => None, FrameInput::Redraw => None,
FrameInput::Cpu(f) => Some(f.color.is_pq()), FrameInput::Cpu(_) => Some(false),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
FrameInput::Dmabuf(d) => Some(d.color.is_pq()), FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
FrameInput::VkFrame(v) => Some(v.color.is_pq()), FrameInput::VkFrame(v) => Some(v.color.is_pq()),
+9
View File
@@ -617,6 +617,15 @@ pub(super) fn pick_formats(
surface: vk::SurfaceKHR, surface: vk::SurfaceKHR,
colorspace_ext: bool, colorspace_ext: bool,
) -> Result<(vk::SurfaceFormatKHR, Option<vk::SurfaceFormatKHR>)> { ) -> Result<(vk::SurfaceFormatKHR, Option<vk::SurfaceFormatKHR>)> {
// `PUNKTFUNK_HDR10=0` (explicit-off grammar) refuses the HDR10/ST.2084 swapchain outright,
// pinning PQ streams to the shader tonemap on an SDR surface. Two reasons this exists:
// desktop compositors newly offer HDR10 even on SDR desktops (GNOME 48 / Plasma 6 with
// Mesa ≥ 25.1 — a lane that otherwise engages silently), and it is the A/B lever that
// splits "HDR10 passthrough composes wrong" from "the decoded planes are wrong" in the
// field without rebuilding anything.
let colorspace_ext = colorspace_ext
&& !std::env::var("PUNKTFUNK_HDR10")
.is_ok_and(|v| matches!(v.as_str(), "0" | "false" | "off" | "no"));
let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?; let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?;
let mut sdr = None; let mut sdr = None;
for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] { for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] {
+28
View File
@@ -254,6 +254,34 @@ pub fn detect() -> Result<Compositor> {
} }
} }
/// Attach-only probes: while any scope is held, backend `create` paths must not stop, relaunch,
/// or take over box sessions — they may only attach to an already-live output, and fail fast
/// otherwise. The capture-loss rebuild holds one for its first seconds: right after a capture
/// loss the active-session detection can be STALE (a Game→Desktop switch observed live: the
/// probe's gamescope re-acquire restarted `gamescope-session.target` and yanked the user out of
/// the KDE session they had just switched to). A counter, so overlapping scopes compose.
static REBUILD_PROBES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
/// RAII scope marking pipeline builds as attach-only probes (see [`rebuild_probe_active`]).
pub struct RebuildProbeScope(());
pub fn rebuild_probe_scope() -> RebuildProbeScope {
REBUILD_PROBES.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
RebuildProbeScope(())
}
impl Drop for RebuildProbeScope {
fn drop(&mut self) {
REBUILD_PROBES.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
}
}
/// Is any [`rebuild_probe_scope`] active? Destructive session operations (stop/relaunch/
/// takeover-restart) must be skipped while true.
pub fn rebuild_probe_active() -> bool {
REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0
}
/// Open the virtual-display driver for `compositor`. /// Open the virtual-display driver for `compositor`.
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> { pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -325,6 +325,29 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
if steamos_session_present() { if steamos_session_present() {
return create_managed_session_steamos(mode); return create_managed_session_steamos(mode);
} }
// Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box
// sessions — right after a capture loss the caller's session detection can be stale, and a
// destructive rebuild here would fight the session the user just switched to.
if crate::rebuild_probe_active() {
let guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
let same_mode = guard.as_ref().is_some_and(|s| {
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
});
if same_mode {
if let Some(node_id) = find_gamescope_node() {
point_injector_at_eis();
tracing::info!(
node_id,
"gamescope session: attach-only probe reusing live node"
);
return Ok(managed_output(node_id, mode));
}
}
return Err(anyhow!(
"gamescope session has no attachable live node — attach-only rebuild probe refuses \
to stop/relaunch box sessions (re-detection follows the live session)"
));
}
// Steam is single-instance: if the box autologged into gaming mode on a physical display (the // Steam is single-instance: if the box autologged into gaming mode on a physical display (the
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and // Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by // renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
@@ -607,12 +630,17 @@ fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode) -> Result<()> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?; std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
} }
// UnsetEnvironment: the same headless-must-not-attach armor `launch_session` gives its
// transient unit — the manager env can carry a stale desktop DISPLAY/WAYLAND_DISPLAY (from a
// portal settle), and gamescope would abort trying to attach to it instead of becoming the
// display server. Unit-scoped belt-and-suspenders on top of the observe_session_instance scrub.
let body = format!( let body = format!(
"[Service]\n\ "[Service]\n\
Environment=PATH={shim}:/usr/bin:/bin:/usr/local/bin\n\ Environment=PATH={shim}:/usr/bin:/bin:/usr/local/bin\n\
Environment=PF_W={w}\n\ Environment=PF_W={w}\n\
Environment=PF_H={h}\n\ Environment=PF_H={h}\n\
Environment=PF_HZ={hz}\n", Environment=PF_HZ={hz}\n\
UnsetEnvironment=DISPLAY WAYLAND_DISPLAY\n",
shim = shim_dir.display(), shim = shim_dir.display(),
w = mode.width, w = mode.width,
h = mode.height, h = mode.height,
@@ -650,6 +678,16 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
} }
*guard = None; // tracked session lost its node — fall through to a clean restart *guard = None; // tracked session lost its node — fall through to a clean restart
} }
// Attach-only rebuild probe: the reuse path above may attach, but a restart of the session
// target is out of bounds — observed live on a Deck: a stale post-capture-loss detection made
// this restart steal the seat back from the KDE session the user had just switched to.
if crate::rebuild_probe_active() {
return Err(anyhow!(
"gamescope has no live node and this is an attach-only rebuild probe — refusing to \
restart {STEAMOS_SESSION_TARGET} (the box may be mid-switch to another session; \
re-detection follows it)"
));
}
let shim_dir = write_headless_shim()?; let shim_dir = write_headless_shim()?;
write_steamos_dropin(&shim_dir, mode)?; write_steamos_dropin(&shim_dir, mode)?;
systemctl_user(&["daemon-reload"]); systemctl_user(&["daemon-reload"]);
@@ -57,6 +57,13 @@ pub fn observe_session_instance(active: &ActiveSession) {
if let Some(old) = compositor_for_kind(prev.0) { if let Some(old) = compositor_for_kind(prev.0) {
registry::invalidate_backend(old.id()); registry::invalidate_backend(old.id());
} }
// The dead desktop's socket vars may still sit in the systemd --user manager env
// ([`settle_desktop_portal`]'s import-environment) — scrub them NOW, or the next
// `gamescope-session.target` start inherits a stale WAYLAND_DISPLAY and gamescope
// runs NESTED against the dead desktop socket instead of becoming the display
// server ("Failed to connect to wayland socket: wayland-0" — kept a Deck's Game
// Mode from starting at all, observed live 2026-07-21).
scrub_desktop_manager_env();
} }
let epoch = bump_session_epoch(); let epoch = bump_session_epoch();
tracing::info!( tracing::info!(
@@ -70,6 +77,23 @@ pub fn observe_session_instance(active: &ActiveSession) {
*last = Some(cur); *last = Some(cur);
} }
/// Counterpart to [`settle_desktop_portal`]'s `import-environment`: drop the desktop session's
/// socket vars from the systemd `--user` manager env once that desktop instance is GONE. They
/// persist in the manager otherwise, and every later user unit inherits them — including
/// `gamescope-session.target`, whose gamescope then aborts trying to attach to the dead desktop
/// socket. Best-effort; the D-Bus activation env has no unset op, but gamescope-session is
/// systemd-started, so the manager scrub is the one that matters. (A desktop restart re-imports
/// via the next [`settle_desktop_portal`], so scrubbing on a bounce is harmless.)
#[cfg(target_os = "linux")]
fn scrub_desktop_manager_env() {
let _ = std::process::Command::new("systemctl")
.args(["--user", "unset-environment", "WAYLAND_DISPLAY", "DISPLAY"])
.status();
}
#[cfg(not(target_os = "linux"))]
fn scrub_desktop_manager_env() {}
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die /// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are /// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]). /// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
+5
View File
@@ -33,6 +33,11 @@ reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the w
# NOT interoperable.) See vendor/fec-rs/LICENSE (BSD-2-Clause). # NOT interoperable.) See vendor/fec-rs/LICENSE (BSD-2-Clause).
fec-rs = { path = "vendor/fec-rs" } fec-rs = { path = "vendor/fec-rs" }
aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream
# ChaCha20-Poly1305 session crypto, negotiated by clients without hardware AES (the soft-AES
# armv7 targets — webOS TVs — where GCM caps decrypt at ~100 Mbps; ARX runs 4-7x faster there).
# Same RustCrypto `aead 0.5` generation as aes-gcm: identical trait/nonce/tag shapes, pure Rust,
# cross-compiles like aes-gcm (no cmake). See design/chacha20-session-cipher.md.
chacha20poly1305 = "0.10"
zerocopy = { version = "0.8", features = ["derive"] } zerocopy = { version = "0.8", features = ["derive"] }
bytes = "1" bytes = "1"
socket2 = { version = "0.6", features = [ socket2 = { version = "0.6", features = [
+47 -37
View File
@@ -1,7 +1,8 @@
//! Tier-1 microbenchmarks for the punktfunk/1 hot path — GPU-free, so they run in normal CI. //! Tier-1 microbenchmarks for the punktfunk/1 hot path — GPU-free, so they run in normal CI.
//! //!
//! Two layers: //! Two layers:
//! - `crypto/*` — the isolated AES-128-GCM primitives on one ~MTU shard. //! - `crypto/*` — the isolated AEAD primitives (AES-128-GCM + the negotiated
//! ChaCha20-Poly1305) on one ~MTU shard.
//! - `pipeline/*`— a whole frame through the real per-frame path end to end over the in-process //! - `pipeline/*`— a whole frame through the real per-frame path end to end over the in-process
//! loopback transport: FEC encode → AES-GCM seal → packetize → (loopback) → reassemble → //! loopback transport: FEC encode → AES-GCM seal → packetize → (loopback) → reassemble →
//! FEC decode → open. This is what a throughput/latency regression in the core would show up in. //! FEC decode → open. This is what a throughput/latency regression in the core would show up in.
@@ -11,11 +12,11 @@
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role}; use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
use punktfunk_core::crypto::SessionCrypto; use punktfunk_core::crypto::{SessionCrypto, SessionKey};
use punktfunk_core::session::Session; use punktfunk_core::session::Session;
use punktfunk_core::transport::loopback_pair; use punktfunk_core::transport::loopback_pair;
const TAG_LEN: usize = 16; // AES-GCM authentication tag const TAG_LEN: usize = 16; // AEAD authentication tag (GCM and Poly1305 share the size)
const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard
fn cfg(role: Role, scheme: FecScheme) -> Config { fn cfg(role: Role, scheme: FecScheme) -> Config {
@@ -38,48 +39,57 @@ fn cfg(role: Role, scheme: FecScheme) -> Config {
shard_payload: SHARD, shard_payload: SHARD,
max_frame_bytes: 8 * 1024 * 1024, max_frame_bytes: 8 * 1024 * 1024,
encrypt: true, // bench the real path — crypto is always on for punktfunk/1 encrypt: true, // bench the real path — crypto is always on for punktfunk/1
key: [7u8; 16], key: SessionKey::Aes128Gcm([7u8; 16]),
salt: [1, 2, 3, 4], salt: [1, 2, 3, 4],
loopback_drop_period: 0, // throughput run: no induced loss (loss-harness covers recovery) loopback_drop_period: 0, // throughput run: no induced loss (loss-harness covers recovery)
} }
} }
fn bench_crypto(c: &mut Criterion) { fn bench_crypto(c: &mut Criterion) {
let host = SessionCrypto::new(&[7u8; 16], [1, 2, 3, 4], Role::Host);
let client = SessionCrypto::new(&[7u8; 16], [1, 2, 3, 4], Role::Client);
let payload = vec![0xABu8; SHARD];
let sealed = host.seal(0, &payload).unwrap();
let mut g = c.benchmark_group("crypto"); let mut g = c.benchmark_group("crypto");
g.throughput(Throughput::Bytes(SHARD as u64)); g.throughput(Throughput::Bytes(SHARD as u64));
g.bench_function("seal", |b| { // Both negotiated session AEADs. On the x86 / Apple Silicon this runs on, both must be
let mut seq = 0u64; // line-rate-trivial — the chacha20 series is the host-side sealing-cost check for the
b.iter(|| { // negotiated soft-AES-armv7 path (design/chacha20-session-cipher.md §7). The AES series
let ct = host.seal(seq, black_box(&payload)).unwrap(); // keeps its unsuffixed names so the CI regression compare retains its history.
seq += 1; for (suffix, key) in [
black_box(ct) ("", SessionKey::Aes128Gcm([7u8; 16])),
}) ("_chacha20", SessionKey::ChaCha20Poly1305([7u8; 32])),
}); ] {
g.bench_function("seal_in_place", |b| { let host = SessionCrypto::new(&key, [1, 2, 3, 4], Role::Host);
let mut seq = 0u64; let client = SessionCrypto::new(&key, [1, 2, 3, 4], Role::Client);
let mut buf = vec![0xABu8; SHARD + TAG_LEN]; let payload = vec![0xABu8; SHARD];
b.iter(|| { let sealed = host.seal(0, &payload).unwrap();
host.seal_in_place(seq, black_box(&mut buf)).unwrap();
seq += 1; g.bench_function(format!("seal{suffix}"), |b| {
}) let mut seq = 0u64;
}); b.iter(|| {
g.bench_function("open", |b| { let ct = host.seal(seq, black_box(&payload)).unwrap();
b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap())) seq += 1;
}); black_box(ct)
g.bench_function("open_in_place", |b| { })
// In-place open consumes the buffer, so each iteration restores the ciphertext first — });
// one memcpy, mirroring what the recv ring does when the next datagram lands in the slot. g.bench_function(format!("seal_in_place{suffix}"), |b| {
let mut buf = sealed.clone(); let mut seq = 0u64;
b.iter(|| { let mut buf = vec![0xABu8; SHARD + TAG_LEN];
buf.copy_from_slice(black_box(&sealed)); b.iter(|| {
black_box(client.open_in_place(0, &mut buf).unwrap()); host.seal_in_place(seq, black_box(&mut buf)).unwrap();
}) seq += 1;
}); })
});
g.bench_function(format!("open{suffix}"), |b| {
b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap()))
});
g.bench_function(format!("open_in_place{suffix}"), |b| {
// In-place open consumes the buffer, so each iteration restores the ciphertext first —
// one memcpy, mirroring what the recv ring does when the next datagram lands in the slot.
let mut buf = sealed.clone();
b.iter(|| {
buf.copy_from_slice(black_box(&sealed));
black_box(client.open_in_place(0, &mut buf).unwrap());
})
});
}
g.finish(); g.finish();
} }
+5 -1
View File
@@ -11,6 +11,7 @@
//! - Panics never cross the boundary: every entry point is wrapped in `catch_unwind`. //! - Panics never cross the boundary: every entry point is wrapped in `catch_unwind`.
use crate::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role}; use crate::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
use crate::crypto::SessionKey;
use crate::error::PunktfunkStatus; use crate::error::PunktfunkStatus;
use crate::input::InputEvent; use crate::input::InputEvent;
use crate::reanchor::{GateVerdict, ReanchorGate}; use crate::reanchor::{GateVerdict, ReanchorGate};
@@ -94,7 +95,10 @@ impl PunktfunkConfig {
shard_payload: self.shard_payload as usize, shard_payload: self.shard_payload as usize,
max_frame_bytes, max_frame_bytes,
encrypt: self.encrypt != 0, encrypt: self.encrypt != 0,
key: self.key, // The C ABI keeps its fixed 16-byte key and always selects AES-128-GCM — no
// ABI_VERSION bump. Raw-`Config` C embedders can't negotiate ChaCha; the Swift/
// Kotlin clients are aarch64 with AES CE and never want it.
key: SessionKey::Aes128Gcm(self.key),
salt: self.salt, salt: self.salt,
loopback_drop_period: self.loopback_drop_period, loopback_drop_period: self.loopback_drop_period,
}; };
+1
View File
@@ -393,6 +393,7 @@ impl NativeClient {
launch, launch,
pin, pin,
identity, identity,
connect_timeout: timeout,
frames: frame_chan_w, frames: frame_chan_w,
audio_tx, audio_tx,
rumble_tx, rumble_tx,
@@ -32,21 +32,65 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
identity.as_ref().map(|(c, k)| (c.as_str(), k.as_str())), identity.as_ref().map(|(c, k)| (c.as_str(), k.as_str())),
); );
let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?; let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
let conn = ep // Dial with retry across the connect budget, not a single attempt: one quinn dial gives
.connect(remote, "punktfunk") // up after the transport's idle window (~8 s of silence), which is shorter than a
.map_err(|_| PunktfunkError::InvalidArg("connect"))? // suspend-to-RAM resume — the Steam Deck flow fires Wake-on-LAN and connects
.await // immediately, so the host is still waking while the first Initials go out, and a
.map_err(|e| { // single-shot dial died just before the host came up. Short attempts keep the Initial
// A pin mismatch surfaces as a TLS failure; report it as a crypto error so // cadence dense (quinn's per-attempt retransmits back off toward multi-second gaps), so
// the embedder can distinguish "wrong host identity" from plain IO trouble. // the connect lands within ~a second of the host's network returning. Only SILENCE is
let fp_mismatch = // retried: a host that answers and rejects us (pin mismatch, ALPN/version, typed close)
pin.is_some() && observed.lock().unwrap().map(|fp| Some(fp) != pin) == Some(true); // must surface immediately, and the embedder's shutdown flag (budget expiry in
if fp_mismatch { // `connect`, or a user cancel) stops the loop between attempts.
PunktfunkError::Crypto const DIAL_ATTEMPT: std::time::Duration = std::time::Duration::from_secs(3);
} else { // Redial headroom: leave room for the control handshake (Hello/Welcome/clock sync)
PunktfunkError::Io(std::io::Error::other(e.to_string())) // after a late dial success, so it still completes inside the embedder's budget.
const CONTROL_HEADROOM: std::time::Duration = std::time::Duration::from_secs(2);
let start = tokio::time::Instant::now();
let deadline = start + args.connect_timeout;
let redial_until = start + args.connect_timeout.saturating_sub(CONTROL_HEADROOM);
let conn = loop {
let connecting = ep
.connect(remote, "punktfunk")
.map_err(|_| PunktfunkError::InvalidArg("connect"))?;
// Cap the attempt to the remaining budget so a success never lands after the
// embedder's `ready_rx` wait has already given up and flagged a teardown.
let now = tokio::time::Instant::now();
let attempt = DIAL_ATTEMPT.min(deadline.saturating_duration_since(now));
let gave_up = || {
tokio::time::Instant::now() >= redial_until
|| shutdown.load(std::sync::atomic::Ordering::SeqCst)
};
match tokio::time::timeout(attempt, connecting).await {
Ok(Ok(conn)) => break conn,
Ok(Err(e)) => {
// A pin mismatch surfaces as a TLS failure; report it as a crypto error so
// the embedder can distinguish "wrong host identity" from plain IO trouble.
let fp_mismatch = pin.is_some()
&& observed.lock().unwrap().map(|fp| Some(fp) != pin) == Some(true);
if fp_mismatch {
return Err(PunktfunkError::Crypto);
}
// The transport's own idle expiry — the host never answered — is the one
// retryable outcome; everything else is a real answer or a local failure.
let host_silent = matches!(e, quinn::ConnectionError::TimedOut);
if !host_silent {
return Err(PunktfunkError::Io(std::io::Error::other(e.to_string())));
}
if gave_up() {
return Err(PunktfunkError::Timeout);
}
} }
})?; // Attempt window elapsed with the host still silent; dropping `connecting`
// abandoned that dial — go again unless the budget is spent.
Err(_) => {
if gave_up() {
return Err(PunktfunkError::Timeout);
}
}
}
tracing::debug!(%remote, "host silent — re-dialing (wake/resume tolerant connect)");
};
let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]); let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]);
// The rest of the handshake runs in an inner future so a failure can consult // The rest of the handshake runs in an inner future so a failure can consult
// `conn.close_reason()`: a host that turned us away with a typed application close // `conn.close_reason()`: a host that turned us away with a typed application close
@@ -83,10 +127,13 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
// bit only asks the host for observability datagrams (never changes the encode). // bit only asks the host for observability datagrams (never changes the encode).
// PROBE_SEQ likewise: the shared reassembler keeps probe filler in its own window // PROBE_SEQ likewise: the shared reassembler keeps probe filler in its own window
// (every embedder inherits it), so the host may burst speed tests without consuming // (every embedder inherits it), so the host may burst speed tests without consuming
// video frame indexes. // video frame indexes. STREAMED_AU the same way: the shared reassembler accepts
// sentinel-headed streamed blocks (retro-validated at the final block), so the host
// may overlap a multi-slice encode's tail with packetize/FEC/pacing.
video_caps: video_caps video_caps: video_caps
| crate::quic::VIDEO_CAP_HOST_TIMING | crate::quic::VIDEO_CAP_HOST_TIMING
| crate::quic::VIDEO_CAP_PROBE_SEQ, | crate::quic::VIDEO_CAP_PROBE_SEQ
| crate::quic::VIDEO_CAP_STREAMED_AU,
// Requested surround channel count; the host echoes the resolved value in Welcome. // Requested surround channel count; the host echoes the resolved value in Welcome.
audio_channels, audio_channels,
// The codecs this client can decode + its soft preference (0 = auto). The host // The codecs this client can decode + its soft preference (0 = auto). The host
@@ -25,6 +25,10 @@ pub(crate) struct WorkerArgs {
pub(crate) launch: Option<String>, pub(crate) launch: Option<String>,
pub(crate) pin: Option<[u8; 32]>, pub(crate) pin: Option<[u8; 32]>,
pub(crate) identity: Option<(String, String)>, pub(crate) identity: Option<(String, String)>,
/// The embedder's connect budget (the same value `connect` bounds `ready_rx` with): the
/// dial loop re-dials a silent host within it, so a host still resuming from Wake-on-LAN
/// is caught the moment its network comes back instead of failing on the first attempt.
pub(crate) connect_timeout: std::time::Duration,
pub(crate) frames: Arc<FrameChannel>, pub(crate) frames: Arc<FrameChannel>,
pub(crate) audio_tx: SyncSender<AudioPacket>, pub(crate) audio_tx: SyncSender<AudioPacket>,
pub(crate) rumble_tx: SyncSender<RumbleUpdate>, pub(crate) rumble_tx: SyncSender<RumbleUpdate>,
+15 -6
View File
@@ -1,5 +1,6 @@
//! Session configuration and protocol/FEC parameters. //! Session configuration and protocol/FEC parameters.
use crate::crypto::SessionKey;
use crate::error::{PunktfunkError, Result}; use crate::error::{PunktfunkError, Result};
use crate::packet::{CRYPTO_OVERHEAD, HEADER_LEN, MAX_DATAGRAM_BYTES}; use crate::packet::{CRYPTO_OVERHEAD, HEADER_LEN, MAX_DATAGRAM_BYTES};
use zeroize::Zeroize; use zeroize::Zeroize;
@@ -355,9 +356,11 @@ pub struct Config {
/// hostile/corrupt headers; see [`Session`](crate::session::Session)). /// hostile/corrupt headers; see [`Session`](crate::session::Session)).
pub max_frame_bytes: usize, pub max_frame_bytes: usize,
pub encrypt: bool, pub encrypt: bool,
/// AES-128 session key established during pairing. MUST be unique per session when /// The negotiated session AEAD + its key, established during pairing/handshake —
/// AES-128-GCM for every peer by default, ChaCha20-Poly1305 when the client negotiated it
/// (soft-AES armv7 targets; see [`SessionKey`]). MUST be unique per session when
/// `encrypt` is set (see the nonce-uniqueness contract in [`crate::crypto`]). /// `encrypt` is set (see the nonce-uniqueness contract in [`crate::crypto`]).
pub key: [u8; 16], pub key: SessionKey,
/// Per-session nonce salt, established alongside `key` during pairing. MUST be /// Per-session nonce salt, established alongside `key` during pairing. MUST be
/// unique per (key, session). /// unique per (key, session).
pub salt: [u8; 4], pub salt: [u8; 4],
@@ -382,7 +385,8 @@ impl std::fmt::Debug for Config {
.field("shard_payload", &self.shard_payload) .field("shard_payload", &self.shard_payload)
.field("max_frame_bytes", &self.max_frame_bytes) .field("max_frame_bytes", &self.max_frame_bytes)
.field("encrypt", &self.encrypt) .field("encrypt", &self.encrypt)
.field("key", &"<redacted>") // SessionKey's own Debug redacts the material but keeps the cipher choice visible.
.field("key", &self.key)
.field("salt", &"<redacted>") .field("salt", &"<redacted>")
.field("loopback_drop_period", &self.loopback_drop_period) .field("loopback_drop_period", &self.loopback_drop_period)
.finish() .finish()
@@ -426,7 +430,7 @@ impl Config {
"max_frame_bytes too large for this shard/block configuration (block count overflows u16)", "max_frame_bytes too large for this shard/block configuration (block count overflows u16)",
)); ));
} }
if self.encrypt && self.key == [0u8; 16] { if self.encrypt && self.key.is_zero() {
return Err(PunktfunkError::InvalidArg( return Err(PunktfunkError::InvalidArg(
"encrypt requires a non-zero session key (see crypto nonce-uniqueness contract)", "encrypt requires a non-zero session key (see crypto nonce-uniqueness contract)",
)); ));
@@ -449,7 +453,7 @@ impl Config {
shard_payload: 1024, shard_payload: 1024,
max_frame_bytes: 64 * 1024 * 1024, max_frame_bytes: 64 * 1024 * 1024,
encrypt: false, encrypt: false,
key: [0u8; 16], key: SessionKey::Aes128Gcm([0u8; 16]),
salt: [0u8; 4], salt: [0u8; 4],
loopback_drop_period: 0, loopback_drop_period: 0,
} }
@@ -465,7 +469,12 @@ mod tests {
let mut c = Config::p1_defaults(Role::Host); let mut c = Config::p1_defaults(Role::Host);
c.encrypt = true; // key is still all-zero c.encrypt = true; // key is still all-zero
assert!(c.validate().is_err()); assert!(c.validate().is_err());
c.key = [1u8; 16]; c.key = SessionKey::Aes128Gcm([1u8; 16]);
assert!(c.validate().is_ok());
// The rejection follows whichever cipher variant is active.
c.key = SessionKey::ChaCha20Poly1305([0u8; 32]);
assert!(c.validate().is_err());
c.key = SessionKey::ChaCha20Poly1305([1u8; 32]);
assert!(c.validate().is_ok()); assert!(c.validate().is_ok());
} }
+266 -106
View File
@@ -1,9 +1,10 @@
//! AES-128-GCM session sealing, matching GameStream's video crypto in P1. //! Session sealing with the negotiated AEAD — AES-128-GCM (matching GameStream's video
//! crypto in P1) by default, ChaCha20-Poly1305 (RFC 8439) for clients without hardware AES.
//! //!
//! ## Nonce uniqueness (the GCM safety requirement) //! ## Nonce uniqueness (the AEAD safety requirement)
//! //!
//! The 96-bit nonce is `salt (4 bytes) || sequence (8 bytes, big-endian)`. Reusing a //! The 96-bit nonce is `salt (4 bytes) || sequence (8 bytes, big-endian)`. Reusing a
//! `(key, nonce)` pair under AES-GCM is catastrophic, so two precautions apply: //! `(key, nonce)` pair is catastrophic under either AEAD, so two precautions apply:
//! //!
//! 1. **Per-direction salts.** Host and client share one `key` and `salt`, and each //! 1. **Per-direction salts.** Host and client share one `key` and `salt`, and each
//! counts its sequence from 0. To stop the host's video stream and the client's input //! counts its sequence from 0. To stop the host's video stream and the client's input
@@ -17,17 +18,96 @@
//! The sequence number is also passed as AEAD associated data, so tampering with the //! The sequence number is also passed as AEAD associated data, so tampering with the
//! on-wire sequence is detected (the tag check fails) rather than silently shifting the //! on-wire sequence is detected (the tag check fails) rather than silently shifting the
//! nonce. Note: this layer does not provide anti-replay — see `Session`. //! nonce. Note: this layer does not provide anti-replay — see `Session`.
//!
//! ## Why two ciphers
//!
//! Both AEADs are full-strength; the choice (negotiated via `Welcome::cipher`) is purely a
//! performance one. On targets without hardware AES — the soft-AES armv7 clients (webOS TVs) —
//! GCM's fixsliced AES + software GHASH costs ~50100 cycles/byte and caps decrypt at
//! ~100 Mbps, while ChaCha20-Poly1305's ARX construction runs ~1017 cycles/byte in portable
//! software (design/chacha20-session-cipher.md). Same 96-bit nonce, 16-byte tag, and AAD
//! shape, so the entire nonce discipline above carries over verbatim.
use crate::config::Role; use crate::config::Role;
use crate::error::{PunktfunkError, Result}; use crate::error::{PunktfunkError, Result};
use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload}; use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload};
use aes_gcm::{Aes128Gcm, Key, Nonce}; use aes_gcm::{Aes128Gcm, Key, Nonce};
use chacha20poly1305::ChaCha20Poly1305;
use zeroize::Zeroize;
/// 16-byte AEAD authentication tag appended by GCM. /// 16-byte AEAD authentication tag appended by either session cipher.
pub const TAG_LEN: usize = 16; pub const TAG_LEN: usize = 16;
// The wire (CRYPTO_OVERHEAD) and every in-place split assume both negotiated AEADs append
// exactly TAG_LEN bytes — a different-tag cipher can never slip in behind this constant.
const _: () = assert!(std::mem::size_of::<aes_gcm::Tag>() == TAG_LEN);
const _: () = assert!(std::mem::size_of::<chacha20poly1305::Tag>() == TAG_LEN);
/// The negotiated session AEAD together with its key material — merged so the invalid state
/// (a ChaCha cipher with an AES-sized key, or vice versa) is unrepresentable. AES-128-GCM is
/// the default every peer speaks; ChaCha20-Poly1305 is granted to clients that advertised
/// [`VIDEO_CAP_CHACHA20`](crate::quic::VIDEO_CAP_CHACHA20) (the soft-AES armv7 targets —
/// see the module docs). 256 bits for ChaCha is what RFC 8439 requires.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SessionKey {
Aes128Gcm([u8; 16]),
ChaCha20Poly1305([u8; 32]),
}
impl SessionKey {
/// Canonical lowercase cipher name for session-start logs.
pub fn cipher_name(&self) -> &'static str {
match self {
SessionKey::Aes128Gcm(_) => "aes-128-gcm",
SessionKey::ChaCha20Poly1305(_) => "chacha20-poly1305",
}
}
/// True when the key material is all zeros — the pairing-layer footgun `Config::validate`
/// rejects when encryption is on (see the nonce-uniqueness contract in the module docs).
pub fn is_zero(&self) -> bool {
match self {
SessionKey::Aes128Gcm(k) => k == &[0u8; 16],
SessionKey::ChaCha20Poly1305(k) => k == &[0u8; 32],
}
}
}
/// Key material never appears in logs, whichever variant is active — only the cipher choice
/// (`Config`'s hand-written `Debug` relies on this).
impl std::fmt::Debug for SessionKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SessionKey::Aes128Gcm(_) => f.write_str("Aes128Gcm(<redacted>)"),
SessionKey::ChaCha20Poly1305(_) => f.write_str("ChaCha20Poly1305(<redacted>)"),
}
}
}
/// Same zeroize-on-drop discipline the raw key array had (`Config`'s `Drop`).
impl Zeroize for SessionKey {
fn zeroize(&mut self) {
match self {
SessionKey::Aes128Gcm(k) => k.zeroize(),
SessionKey::ChaCha20Poly1305(k) => k.zeroize(),
}
}
}
/// The two negotiated AEADs behind one seal/open surface. Both are the same RustCrypto
/// `aead 0.5` generation (identical trait shapes, nonce/tag types), so each call below is a
/// two-arm match right next to the cipher work itself.
// AES's precomputed round keys (~0.7 KB) dwarf ChaCha's 32-byte state, but there is exactly
// one long-lived `SessionCrypto` per session — boxing the variant would trade that one-off
// slack for a pointer chase on every per-datagram seal/open.
#[allow(clippy::large_enum_variant)]
enum Cipher {
Aes128Gcm(Aes128Gcm),
ChaCha20Poly1305(ChaCha20Poly1305),
}
pub struct SessionCrypto { pub struct SessionCrypto {
cipher: Aes128Gcm, cipher: Cipher,
/// Salt for nonces we seal with (our direction). /// Salt for nonces we seal with (our direction).
send_salt: [u8; 4], send_salt: [u8; 4],
/// Salt for nonces we open with (the peer's direction). /// Salt for nonces we open with (the peer's direction).
@@ -35,11 +115,18 @@ pub struct SessionCrypto {
} }
impl SessionCrypto { impl SessionCrypto {
pub fn new(key: &[u8; 16], salt: [u8; 4], role: Role) -> Self { pub fn new(key: &SessionKey, salt: [u8; 4], role: Role) -> Self {
let key = Key::<Aes128Gcm>::from_slice(key); let cipher = match key {
SessionKey::Aes128Gcm(k) => {
Cipher::Aes128Gcm(Aes128Gcm::new(Key::<Aes128Gcm>::from_slice(k)))
}
SessionKey::ChaCha20Poly1305(k) => Cipher::ChaCha20Poly1305(ChaCha20Poly1305::new(
Key::<ChaCha20Poly1305>::from_slice(k),
)),
};
let own = direction(role); let own = direction(role);
SessionCrypto { SessionCrypto {
cipher: Aes128Gcm::new(key), cipher,
send_salt: dir_salt(salt, own), send_salt: dir_salt(salt, own),
recv_salt: dir_salt(salt, own ^ 1), recv_salt: dir_salt(salt, own ^ 1),
} }
@@ -49,15 +136,16 @@ impl SessionCrypto {
/// authenticated as associated data. /// authenticated as associated data.
pub fn seal(&self, seq: u64, plaintext: &[u8]) -> Result<Vec<u8>> { pub fn seal(&self, seq: u64, plaintext: &[u8]) -> Result<Vec<u8>> {
let nonce = nonce(self.send_salt, seq); let nonce = nonce(self.send_salt, seq);
self.cipher let aad = seq.to_be_bytes();
.encrypt( let payload = Payload {
Nonce::from_slice(&nonce), msg: plaintext,
Payload { aad: &aad,
msg: plaintext, };
aad: &seq.to_be_bytes(), match &self.cipher {
}, Cipher::Aes128Gcm(c) => c.encrypt(Nonce::from_slice(&nonce), payload),
) Cipher::ChaCha20Poly1305(c) => c.encrypt(Nonce::from_slice(&nonce), payload),
.map_err(|_| PunktfunkError::Crypto) }
.map_err(|_| PunktfunkError::Crypto)
} }
/// Seal in place, no per-packet allocation: `buf` is laid out as `[plaintext .. ][TAG_LEN]` (the /// Seal in place, no per-packet allocation: `buf` is laid out as `[plaintext .. ][TAG_LEN]` (the
@@ -69,10 +157,16 @@ impl SessionCrypto {
let nonce = nonce(self.send_salt, seq); let nonce = nonce(self.send_salt, seq);
let split = buf.len() - TAG_LEN; let split = buf.len() - TAG_LEN;
let (plaintext, tag_slot) = buf.split_at_mut(split); let (plaintext, tag_slot) = buf.split_at_mut(split);
let tag = self let aad = seq.to_be_bytes();
.cipher let tag = match &self.cipher {
.encrypt_in_place_detached(Nonce::from_slice(&nonce), &seq.to_be_bytes(), plaintext) Cipher::Aes128Gcm(c) => {
.map_err(|_| PunktfunkError::Crypto)?; c.encrypt_in_place_detached(Nonce::from_slice(&nonce), &aad, plaintext)
}
Cipher::ChaCha20Poly1305(c) => {
c.encrypt_in_place_detached(Nonce::from_slice(&nonce), &aad, plaintext)
}
}
.map_err(|_| PunktfunkError::Crypto)?;
tag_slot.copy_from_slice(&tag); tag_slot.copy_from_slice(&tag);
Ok(()) Ok(())
} }
@@ -80,20 +174,21 @@ impl SessionCrypto {
/// Open `ciphertext || tag` for sequence `seq` (also bound as associated data). /// Open `ciphertext || tag` for sequence `seq` (also bound as associated data).
pub fn open(&self, seq: u64, ciphertext: &[u8]) -> Result<Vec<u8>> { pub fn open(&self, seq: u64, ciphertext: &[u8]) -> Result<Vec<u8>> {
let nonce = nonce(self.recv_salt, seq); let nonce = nonce(self.recv_salt, seq);
self.cipher let aad = seq.to_be_bytes();
.decrypt( let payload = Payload {
Nonce::from_slice(&nonce), msg: ciphertext,
Payload { aad: &aad,
msg: ciphertext, };
aad: &seq.to_be_bytes(), match &self.cipher {
}, Cipher::Aes128Gcm(c) => c.decrypt(Nonce::from_slice(&nonce), payload),
) Cipher::ChaCha20Poly1305(c) => c.decrypt(Nonce::from_slice(&nonce), payload),
.map_err(|_| PunktfunkError::Crypto) }
.map_err(|_| PunktfunkError::Crypto)
} }
/// Open in place, no per-packet allocation: `buf` holds `[ciphertext .. ][tag]` on entry and /// Open in place, no per-packet allocation: `buf` holds `[ciphertext .. ][tag]` on entry and
/// the plaintext in its first `buf.len() - TAG_LEN` bytes on success (returned as the length) /// the plaintext in its first `buf.len() - TAG_LEN` bytes on success (returned as the length)
/// — byte-identical to `open`, just written in place. GCM verifies the tag *before* /// — byte-identical to `open`, just written in place. Both AEADs verify the tag *before*
/// decrypting, so on failure `buf` still holds the ciphertext (the caller drops the packet /// decrypting, so on failure `buf` still holds the ciphertext (the caller drops the packet
/// either way). The hot-path receiver (`Session::poll_frame`) uses this to avoid the `Vec` /// either way). The hot-path receiver (`Session::poll_frame`) uses this to avoid the `Vec`
/// that `open`'s convenience API allocates for every datagram at line rate — the receive /// that `open`'s convenience API allocates for every datagram at line rate — the receive
@@ -105,14 +200,22 @@ impl SessionCrypto {
let nonce = nonce(self.recv_salt, seq); let nonce = nonce(self.recv_salt, seq);
let split = buf.len() - TAG_LEN; let split = buf.len() - TAG_LEN;
let (ciphertext, tag) = buf.split_at_mut(split); let (ciphertext, tag) = buf.split_at_mut(split);
self.cipher let aad = seq.to_be_bytes();
.decrypt_in_place_detached( match &self.cipher {
Cipher::Aes128Gcm(c) => c.decrypt_in_place_detached(
Nonce::from_slice(&nonce), Nonce::from_slice(&nonce),
&seq.to_be_bytes(), &aad,
ciphertext, ciphertext,
aes_gcm::Tag::from_slice(tag), aes_gcm::Tag::from_slice(tag),
) ),
.map_err(|_| PunktfunkError::Crypto)?; Cipher::ChaCha20Poly1305(c) => c.decrypt_in_place_detached(
Nonce::from_slice(&nonce),
&aad,
ciphertext,
chacha20poly1305::Tag::from_slice(tag),
),
}
.map_err(|_| PunktfunkError::Crypto)?;
Ok(split) Ok(split)
} }
} }
@@ -145,6 +248,13 @@ pub fn random_key() -> [u8; 16] {
k k
} }
/// Generate a fresh random ChaCha20-Poly1305 session key (RFC 8439's 256-bit size).
pub fn random_key32() -> [u8; 32] {
let mut k = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::rng(), &mut k);
k
}
/// Generate a fresh random per-session nonce salt. /// Generate a fresh random per-session nonce salt.
pub fn random_salt() -> [u8; 4] { pub fn random_salt() -> [u8; 4] {
let mut s = [0u8; 4]; let mut s = [0u8; 4];
@@ -156,93 +266,143 @@ pub fn random_salt() -> [u8; 4] {
mod tests { mod tests {
use super::*; use super::*;
/// One fresh key per negotiated cipher — every sealing test below must hold for both.
fn both_keys() -> [SessionKey; 2] {
[
SessionKey::Aes128Gcm(random_key()),
SessionKey::ChaCha20Poly1305(random_key32()),
]
}
#[test] #[test]
fn seal_open_roundtrip_cross_direction() { fn seal_open_roundtrip_cross_direction() {
let key = random_key(); for key in both_keys() {
let salt = random_salt(); let salt = random_salt();
let host = SessionCrypto::new(&key, salt, Role::Host); let host = SessionCrypto::new(&key, salt, Role::Host);
let client = SessionCrypto::new(&key, salt, Role::Client); let client = SessionCrypto::new(&key, salt, Role::Client);
let msg = b"the quick brown fox"; let msg = b"the quick brown fox";
let sealed = host.seal(42, msg).unwrap(); // host -> client (video direction) let sealed = host.seal(42, msg).unwrap(); // host -> client (video direction)
assert_ne!(&sealed[..msg.len()], &msg[..]); // actually encrypted assert_ne!(&sealed[..msg.len()], &msg[..]); // actually encrypted
assert_eq!(sealed.len(), msg.len() + TAG_LEN); assert_eq!(sealed.len(), msg.len() + TAG_LEN);
assert_eq!(client.open(42, &sealed).unwrap(), msg); assert_eq!(client.open(42, &sealed).unwrap(), msg);
// Wrong sequence (nonce + AAD) → authentication failure. // Wrong sequence (nonce + AAD) → authentication failure.
assert!(client.open(43, &sealed).is_err()); assert!(client.open(43, &sealed).is_err());
// Direction separation: the host opens with the peer (client) salt, so it cannot // Direction separation: the host opens with the peer (client) salt, so it cannot
// open its own outbound packet → distinct nonce spaces per direction. // open its own outbound packet → distinct nonce spaces per direction.
assert!(host.open(42, &sealed).is_err()); assert!(host.open(42, &sealed).is_err());
}
} }
#[test] #[test]
fn directions_use_distinct_nonce_spaces() { fn directions_use_distinct_nonce_spaces() {
let key = random_key(); for key in both_keys() {
let salt = [0u8; 4]; // even an all-zero base salt must separate the directions let salt = [0u8; 4]; // even an all-zero base salt must separate the directions
let host = SessionCrypto::new(&key, salt, Role::Host); let host = SessionCrypto::new(&key, salt, Role::Host);
let client = SessionCrypto::new(&key, salt, Role::Client); let client = SessionCrypto::new(&key, salt, Role::Client);
// Same seq, same key, opposite directions → different ciphertext (no reuse). // Same seq, same key, opposite directions → different ciphertext (no reuse).
assert_ne!( assert_ne!(
host.seal(0, b"abc").unwrap(), host.seal(0, b"abc").unwrap(),
client.seal(0, b"abc").unwrap() client.seal(0, b"abc").unwrap()
); );
}
} }
#[test] #[test]
fn open_in_place_matches_open_and_rejects_tampering() { fn open_in_place_matches_open_and_rejects_tampering() {
let key = random_key(); for key in both_keys() {
let salt = random_salt(); let salt = random_salt();
let host = SessionCrypto::new(&key, salt, Role::Host); let host = SessionCrypto::new(&key, salt, Role::Host);
let client = SessionCrypto::new(&key, salt, Role::Client); let client = SessionCrypto::new(&key, salt, Role::Client);
for msg in [ for msg in [
&b""[..], &b""[..],
b"x", b"x",
b"the quick brown fox jumps over 13 lazy dogs!!", b"the quick brown fox jumps over 13 lazy dogs!!",
] { ] {
let sealed = host.seal(9, msg).unwrap(); let sealed = host.seal(9, msg).unwrap();
let mut buf = sealed.clone(); let mut buf = sealed.clone();
let n = client.open_in_place(9, &mut buf).unwrap(); let n = client.open_in_place(9, &mut buf).unwrap();
assert_eq!( assert_eq!(
&buf[..n], &buf[..n],
msg, msg,
"in-place open must be byte-identical to open" "in-place open must be byte-identical to open"
); );
// Wrong sequence (nonce + AAD) → authentication failure, like `open`. // Wrong sequence (nonce + AAD) → authentication failure, like `open`.
let mut buf = sealed.clone(); let mut buf = sealed.clone();
assert!(client.open_in_place(8, &mut buf).is_err()); assert!(client.open_in_place(8, &mut buf).is_err());
// A flipped ciphertext/tag bit → authentication failure. // A flipped ciphertext/tag bit → authentication failure.
let mut buf = sealed.clone(); let mut buf = sealed.clone();
let last = buf.len() - 1; let last = buf.len() - 1;
buf[last] ^= 1; buf[last] ^= 1;
assert!(client.open_in_place(9, &mut buf).is_err()); assert!(client.open_in_place(9, &mut buf).is_err());
}
// Shorter than a tag can't be a sealed packet at all.
let mut runt = vec![0u8; TAG_LEN - 1];
assert!(client.open_in_place(0, &mut runt).is_err());
} }
// Shorter than a tag can't be a sealed packet at all.
let mut runt = vec![0u8; TAG_LEN - 1];
assert!(client.open_in_place(0, &mut runt).is_err());
} }
#[test] #[test]
fn seal_in_place_matches_seal_and_opens() { fn seal_in_place_matches_seal_and_opens() {
let key = random_key(); for key in both_keys() {
let salt = random_salt(); let salt = random_salt();
let host = SessionCrypto::new(&key, salt, Role::Host); let host = SessionCrypto::new(&key, salt, Role::Host);
let client = SessionCrypto::new(&key, salt, Role::Client); let client = SessionCrypto::new(&key, salt, Role::Client);
for msg in [ for msg in [
&b""[..], &b""[..],
b"x", b"x",
b"the quick brown fox jumps over 13 lazy dogs!!", b"the quick brown fox jumps over 13 lazy dogs!!",
] { ] {
let reference = host.seal(7, msg).unwrap(); // ciphertext || tag let reference = host.seal(7, msg).unwrap(); // ciphertext || tag
// In-place: [plaintext .. ][TAG_LEN scratch]. // In-place: [plaintext .. ][TAG_LEN scratch].
let mut buf = msg.to_vec(); let mut buf = msg.to_vec();
buf.resize(msg.len() + TAG_LEN, 0); buf.resize(msg.len() + TAG_LEN, 0);
host.seal_in_place(7, &mut buf).unwrap(); host.seal_in_place(7, &mut buf).unwrap();
assert_eq!( assert_eq!(
buf, reference, buf, reference,
"in-place seal must be byte-identical to seal" "in-place seal must be byte-identical to seal"
); );
assert_eq!(client.open(7, &buf).unwrap(), msg); assert_eq!(client.open(7, &buf).unwrap(), msg);
}
} }
} }
#[test]
fn ciphers_are_not_interchangeable() {
// A packet sealed under one AEAD must not open under the other — negotiation skew has
// to fail loudly (a tag mismatch), never decode garbage. The ChaCha key repeats the AES
// key bytes so even overlapping key material can't accidentally interoperate.
let salt = random_salt();
let aes = SessionKey::Aes128Gcm([7u8; 16]);
let chacha = SessionKey::ChaCha20Poly1305([7u8; 32]);
let sealed = SessionCrypto::new(&aes, salt, Role::Host)
.seal(1, b"cross-cipher")
.unwrap();
assert!(SessionCrypto::new(&chacha, salt, Role::Client)
.open(1, &sealed)
.is_err());
let sealed = SessionCrypto::new(&chacha, salt, Role::Host)
.seal(1, b"cross-cipher")
.unwrap();
assert!(SessionCrypto::new(&aes, salt, Role::Client)
.open(1, &sealed)
.is_err());
}
#[test]
fn session_key_zero_check_and_debug_redaction() {
assert!(SessionKey::Aes128Gcm([0u8; 16]).is_zero());
assert!(SessionKey::ChaCha20Poly1305([0u8; 32]).is_zero());
assert!(!SessionKey::Aes128Gcm([1u8; 16]).is_zero());
assert!(!SessionKey::ChaCha20Poly1305([1u8; 32]).is_zero());
// Key bytes must never reach a log, whichever variant — only the cipher choice.
for key in both_keys() {
let dbg = format!("{key:?}");
assert!(dbg.contains("<redacted>"), "{dbg}");
}
let mut k = SessionKey::ChaCha20Poly1305([9u8; 32]);
k.zeroize();
assert!(k.is_zero());
}
} }
@@ -47,11 +47,50 @@ pub struct Packetizer {
/// where every packet of the block is dropped wholesale, the frame never completes, and the /// where every packet of the block is dropped wholesale, the frame never completes, and the
/// resulting loss pushes adaptive FEC *higher*. See the `recovery_for` clamp in `packetize_each`. /// resulting loss pushes adaptive FEC *higher*. See the `recovery_for` clamp in `packetize_each`.
max_total_shards: usize, max_total_shards: usize,
/// The peer's per-frame block ceiling, mirroring [`ReassemblerLimits::from_config`]'s
/// `max_blocks` — the streamed path's bound on how many sentinel blocks it may emit (a
/// streamed AU's size isn't known up front, so this is the only pre-emission guard against
/// producing a frame the receiver must reject).
max_blocks: usize,
}
/// One in-progress **streamed** access unit (design/nvenc-subframe-slice-output.md Phase 2):
/// the caller feeds encoder chunks in as they exist ([`Packetizer::push_streamed`]) and every
/// completed `max_data_per_block × shard_payload` block leaves under SENTINEL headers
/// (`frame_bytes = 0`, `block_count = 0` — "not final yet") before the AU's total size is
/// known; [`Packetizer::finish_streamed`] seals the tail block with the real totals and
/// `FLAG_EOF`. Only ever sent to a peer that advertised
/// [`crate::quic::VIDEO_CAP_STREAMED_AU`].
pub struct StreamedAu {
frame_index: u32,
pts_ns: u64,
user_flags: u32,
/// Bytes not yet sealed into a block. Kept ≤ one block by `push_streamed` (it flushes only
/// while STRICTLY more than a block is buffered, so the final block always has ≥ 1 byte and
/// a sentinel block is never retroactively the frame's last).
pending: Vec<u8>,
/// Sentinel blocks already emitted.
blocks_out: u16,
/// Total AU bytes accumulated so far (`pending` included).
total_bytes: u64,
/// The frame's first packet (block 0, shard 0 — carries `FLAG_SOF`) has been emitted.
opened: bool,
}
impl StreamedAu {
/// The wire frame index this AU is sealed with (the caller's RFI bookkeeping domain).
pub fn frame_index(&self) -> u32 {
self.frame_index
}
} }
impl Packetizer { impl Packetizer {
pub fn new(config: &Config) -> Self { pub fn new(config: &Config) -> Self {
let max_data = config.fec.max_data_per_block as usize; let max_data = config.fec.max_data_per_block as usize;
let total_data_max = config
.max_frame_bytes
.div_ceil(config.shard_payload.max(1))
.max(1);
Packetizer { Packetizer {
next_frame_index: 0, next_frame_index: 0,
next_probe_index: 0, next_probe_index: 0,
@@ -64,6 +103,7 @@ impl Packetizer {
// Mirrors `ReassemblerLimits::from_config` — keep the two in step. // Mirrors `ReassemblerLimits::from_config` — keep the two in step.
max_total_shards: (max_data + config.fec.recovery_for(max_data)) max_total_shards: (max_data + config.fec.recovery_for(max_data))
.min(config.fec.scheme.max_total_shards()), .min(config.fec.scheme.max_total_shards()),
max_blocks: total_data_max.div_ceil(max_data).max(1),
} }
} }
@@ -277,4 +317,202 @@ impl Packetizer {
self.next_seq = next_seq; self.next_seq = next_seq;
Ok(()) Ok(())
} }
/// Open a **streamed** access unit (see [`StreamedAu`]). `frame_index` follows the same
/// contract as [`packetize_each`](Self::packetize_each): `Some(i)` = the caller owns the
/// video numbering; `None` draws from the internal counter.
pub fn begin_streamed(
&mut self,
pts_ns: u64,
user_flags: u32,
frame_index: Option<u32>,
) -> StreamedAu {
let frame_index = frame_index.unwrap_or_else(|| {
let i = self.next_frame_index;
self.next_frame_index = i.wrapping_add(1);
i
});
StreamedAu {
frame_index,
pts_ns,
user_flags,
pending: Vec::new(),
blocks_out: 0,
total_bytes: 0,
opened: false,
}
}
/// Feed one encoder chunk into a streamed AU, emitting every block that COMPLETES under
/// sentinel headers (`frame_bytes = 0`, `block_count = 0`, exactly `max_data_per_block`
/// data shards — the receiver's offset formula needs no total). Flushes only while
/// STRICTLY more than one block is buffered, so the frame's last block — whose header must
/// carry the real totals — is never emitted here. Packets reach `emit` in wire order (the
/// caller's nonce order), data then parity per block.
pub fn push_streamed(
&mut self,
au: &mut StreamedAu,
chunk: &[u8],
coder: &dyn ErasureCoder,
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()> {
au.total_bytes += chunk.len() as u64;
au.pending.extend_from_slice(chunk);
let block_bytes = self.fec.max_data_per_block as usize * self.shard_payload;
while au.pending.len() > block_bytes {
// Room for this sentinel block AND the final block after it, within the peer's
// per-frame block ceiling and the u16 wire field.
if au.blocks_out as usize + 2 > self.max_blocks.min(u16::MAX as usize) {
return Err(PunktfunkError::Unsupported(
"streamed AU exceeds the negotiated max_frame_bytes",
));
}
let sof = !au.opened;
let (bi, pts, uf) = (au.blocks_out, au.pts_ns, au.user_flags);
let fi = au.frame_index;
self.emit_streamed_block(
fi,
pts,
uf,
bi,
&au.pending[..block_bytes],
0,
0,
sof,
false,
coder,
&mut emit,
)?;
au.pending.drain(..block_bytes);
au.blocks_out += 1;
au.opened = true;
}
Ok(())
}
/// Close a streamed AU: seal the final block — its headers carry the REAL
/// `frame_bytes`/`block_count`, which retro-validate the whole frame at the receiver — with
/// `FLAG_EOF` on the last emitted packet. An empty AU degenerates to today's single
/// zero-padded-shard frame (`block_count = 1`, never a sentinel).
pub fn finish_streamed(
&mut self,
au: StreamedAu,
coder: &dyn ErasureCoder,
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()> {
let frame_bytes = u32::try_from(au.total_bytes)
.map_err(|_| PunktfunkError::Unsupported("streamed AU exceeds u32 bytes"))?;
let block_count = au.blocks_out + 1;
self.emit_streamed_block(
au.frame_index,
au.pts_ns,
au.user_flags,
au.blocks_out,
&au.pending,
frame_bytes,
block_count,
!au.opened,
true,
coder,
&mut emit,
)
}
/// Seal ONE streamed block (data shards + its parity, in wire order). Sentinel blocks pass
/// `frame_bytes = 0` / `block_count = 0`; the final block passes the real totals. `sof`
/// marks the frame's very first packet, `eof` its very last.
#[allow(clippy::too_many_arguments)]
fn emit_streamed_block(
&mut self,
frame_index: u32,
pts_ns: u64,
user_flags: u32,
block_index: u16,
bytes: &[u8],
frame_bytes: u32,
block_count: u16,
sof: bool,
eof: bool,
coder: &dyn ErasureCoder,
emit: &mut impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()> {
let payload = self.shard_payload;
if payload > u16::MAX as usize {
return Err(PunktfunkError::InvalidArg("shard_payload exceeds u16"));
}
// At least one (zero-padded) data shard even for an empty final block (empty AU).
let k = bytes.len().div_ceil(payload).max(1);
let m = self
.fec
.recovery_for(k)
.min(self.max_total_shards.saturating_sub(k));
if k + m > u16::MAX as usize {
return Err(PunktfunkError::Unsupported("block shard count exceeds u16"));
}
// Stage the one possibly-partial (or empty-frame) shard in the zero-padded scratch.
let full_shards = bytes.len() / payload;
self.tail.clear();
self.tail.resize(payload, 0);
let rem = bytes.len() % payload;
if rem > 0 {
self.tail[..rem].copy_from_slice(&bytes[full_shards * payload..]);
}
let tail = &self.tail;
let shard_at = |s: usize| -> &[u8] {
if s < full_shards {
&bytes[s * payload..(s + 1) * payload]
} else {
tail.as_slice()
}
};
let data_shards: Vec<&[u8]> = (0..k).map(shard_at).collect();
if self.recovery.is_empty() {
self.recovery.push(Vec::new());
}
coder.encode_into(&data_shards, m, &mut self.recovery[0])?;
let mut next_seq = self.next_seq;
let mut emit_one = |next_seq: &mut u32, shard_index: usize, body: &[u8], flags: u8| {
let seq = *next_seq;
*next_seq = next_seq.wrapping_add(1);
let hdr = PacketHeader {
pts_ns,
frame_index,
stream_seq: seq,
frame_bytes,
user_flags,
block_index,
block_count,
data_shards: k as u16,
recovery_shards: m as u16,
shard_index: shard_index as u16,
shard_bytes: payload as u16,
magic: PUNKTFUNK_MAGIC,
version: self.version,
fec_scheme: coder.scheme() as u8,
flags,
};
emit(&hdr, body)
};
for (shard_index, body) in data_shards.iter().enumerate() {
let mut flags = FLAG_PIC;
if sof && shard_index == 0 {
flags |= FLAG_SOF;
}
if eof && m == 0 && shard_index + 1 == k {
flags |= FLAG_EOF;
}
emit_one(&mut next_seq, shard_index, body, flags)?;
}
for r in 0..m {
let mut flags = FLAG_PIC;
if eof && r + 1 == m {
flags |= FLAG_EOF;
}
let body: &[u8] = &self.recovery[0][r];
emit_one(&mut next_seq, k + r, body, flags)?;
}
self.next_seq = next_seq;
Ok(())
}
} }
+136 -26
View File
@@ -70,7 +70,12 @@ struct BlockState {
} }
struct FrameBuf { struct FrameBuf {
/// Exact AU size. 0 = unknown: the frame was opened by a streamed-AU SENTINEL packet
/// ([`crate::quic::VIDEO_CAP_STREAMED_AU`]) and the final block's real totals haven't
/// arrived yet — the frame can't complete before they do (and retro-validate).
frame_bytes: usize, frame_bytes: usize,
/// Block count; 0 = unknown (sentinel-opened, totals not yet pinned). A legacy-opened
/// frame always has ≥ 1 here, so 0 doubles as the "unpinned streamed" marker.
block_count: usize, block_count: usize,
pts_ns: u64, pts_ns: u64,
user_flags: u32, user_flags: u32,
@@ -277,33 +282,58 @@ impl Reassembler {
|| total == 0 || total == 0
|| total > lim.max_total_shards || total > lim.max_total_shards
|| shard_index >= total || shard_index >= total
|| block_count == 0
|| block_count > lim.max_blocks
|| hdr.block_index as usize >= block_count
|| frame_bytes > lim.max_frame_bytes || frame_bytes > lim.max_frame_bytes
{ {
drop(stats); drop(stats);
return Ok(None); return Ok(None);
} }
// Derived-geometry firewall: every sender (our Packetizer, any version) slices a frame // Streamed-AU sentinel ([`crate::quic::VIDEO_CAP_STREAMED_AU`]): `block_count == 0` — a
// into consecutive blocks of exactly `max_data_per_block` data shards with only the LAST // value no legacy sender ever emits — marks a NON-FINAL block of an AU whose total size
// block smaller, and stamps the exact `frame_bytes` in every header. That makes every // doesn't exist yet (the host is still encoding its tail). The exact derived-geometry
// data shard's final AU offset computable on arrival — // check below can't run without a total, so a sentinel is bounded by the negotiated
// offset = (block_index × max_data_per_block + shard_index) × shard_bytes // limits instead — and by construction: full-K exactly (the offset formula needs it),
// — which is what lets shards land straight in the frame buffer below. Enforce the // never the last block the limits allow (the real final block must still fit after it),
// invariant so a header lying about its geometry is dropped instead of scribbling into // and no total to lie about. The frame-wide exact check runs retroactively the moment
// another shard's range. // the final block's real totals arrive (the pinning arm below).
let total_data = frame_bytes.div_ceil(shard_bytes).max(1); let sentinel = block_count == 0;
let expect_blocks = total_data.div_ceil(lim.max_data_shards).max(1);
let block_idx = hdr.block_index as usize; let block_idx = hdr.block_index as usize;
let expect_data_shards = if block_idx + 1 == expect_blocks { // For a sentinel-opened frame the buffer must hold ANY final geometry the totals may
total_data - (expect_blocks - 1) * lim.max_data_shards // later pin — the maximum the negotiated limits allow (the design's "allocate at
// max_frame_bytes"; the existing in-flight budget bounds the amplification).
let total_data_max = lim.max_frame_bytes.div_ceil(shard_bytes).max(1);
let total_data = frame_bytes.div_ceil(shard_bytes).max(1);
if sentinel {
if frame_bytes != 0
|| data_shards != lim.max_data_shards
|| block_idx + 1 >= lim.max_blocks
{
drop(stats);
return Ok(None);
}
} else { } else {
lim.max_data_shards if block_count > lim.max_blocks || block_idx >= block_count {
}; drop(stats);
if block_count != expect_blocks || data_shards != expect_data_shards { return Ok(None);
drop(stats); }
return Ok(None); // Derived-geometry firewall: every sender (our Packetizer, any version) slices a
// frame into consecutive blocks of exactly `max_data_per_block` data shards with
// only the LAST block smaller, and stamps the exact `frame_bytes` in every
// non-sentinel header. That makes every data shard's final AU offset computable on
// arrival —
// offset = (block_index × max_data_per_block + shard_index) × shard_bytes
// — which is what lets shards land straight in the frame buffer below. Enforce the
// invariant so a header lying about its geometry is dropped instead of scribbling
// into another shard's range.
let expect_blocks = total_data.div_ceil(lim.max_data_shards).max(1);
let expect_data_shards = if block_idx + 1 == expect_blocks {
total_data - (expect_blocks - 1) * lim.max_data_shards
} else {
lim.max_data_shards
};
if block_count != expect_blocks || data_shards != expect_data_shards {
drop(stats);
return Ok(None);
}
} }
let body = &pkt[HEADER_LEN..HEADER_LEN + shard_bytes]; let body = &pkt[HEADER_LEN..HEADER_LEN + shard_bytes];
@@ -350,8 +380,13 @@ impl Reassembler {
} }
// First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later // First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later
// packets must agree with its geometry. // packets must agree with its geometry. A sentinel-opened (streamed) frame allocates at
let buf_len = total_data * shard_bytes; // the limits' maximum — its real size doesn't exist yet.
let buf_len = if sentinel {
total_data_max * shard_bytes
} else {
total_data * shard_bytes
};
let frame = match win.frames.entry(hdr.frame_index) { let frame = match win.frames.entry(hdr.frame_index) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => { std::collections::hash_map::Entry::Vacant(e) => {
@@ -374,7 +409,66 @@ impl Reassembler {
}) })
} }
}; };
if frame.block_count != block_count || frame.frame_bytes != frame_bytes { if sentinel {
// Sentinel packets carry no totals to cross-check: while the frame is unpinned
// (`block_count == 0`) they match by construction, and once the totals are pinned —
// whichever packet arrived first, sentinel or final (reorder is normal) — a
// sentinel block must still be NON-final under them. Its full-K shape was already
// enforced by the firewall, which is exactly what the pinned geometry demands of
// every non-final block, and its shard offsets are within the pinned range by
// `block_idx + 1 < block_count`.
if frame.block_count != 0 && block_idx + 1 >= frame.block_count {
drop(stats);
return Ok(None);
}
} else if frame.block_count == 0 {
// A streamed frame meets its FINAL block's real totals: retro-validate every block
// the sentinels created against the exact geometry these totals derive (the same
// invariant the firewall enforces per-packet for legacy frames), then PIN them. A
// header that lies — totals under which an already-received sentinel block is
// out of range or not full-K — kills the WHOLE frame: its landed shards can't be
// trusted to be at the offsets this geometry means, so delivering any of it would
// hand the decoder spliced bytes.
let expect_blocks = total_data.div_ceil(lim.max_data_shards).max(1);
let final_k = total_data - (expect_blocks - 1) * lim.max_data_shards;
let lied = frame.blocks.iter().any(|(&bi, b)| {
let bi = bi as usize;
bi >= expect_blocks
|| (bi + 1 < expect_blocks && b.data_shards != lim.max_data_shards)
|| (bi + 1 == expect_blocks && b.data_shards != final_k)
});
if lied {
let mut f = win
.frames
.remove(&hdr.frame_index)
.expect("frame entry exists");
*in_flight_bytes -= f.buf.len();
// Remember the index (with its late-shard memory, exactly like an aged-out
// frame) so stragglers can't resurrect it, reclaim the parity buffers, and
// count the loss — the client's recovery request is the right outcome for a
// frame that was destroyed by a lying header.
win.completed.insert(
hdr.frame_index,
reconstructed_shards(&f.blocks, lim.max_data_shards),
);
for block in f.blocks.values_mut() {
for slot in block.recovery.iter_mut() {
if let Some(rb) = slot.take() {
if recovery_pool.len() < RECOVERY_POOL_MAX {
recovery_pool.push(rb);
}
}
}
}
if !is_probe {
StatsCounters::add(&stats.frames_dropped, 1);
}
drop(stats);
return Ok(None);
}
frame.frame_bytes = frame_bytes;
frame.block_count = block_count;
} else if frame.block_count != block_count || frame.frame_bytes != frame_bytes {
drop(stats); drop(stats);
return Ok(None); return Ok(None);
} }
@@ -382,6 +476,7 @@ impl Reassembler {
buf, buf,
blocks, blocks,
blocks_ok, blocks_ok,
block_count: frame_block_count,
.. ..
} = frame; } = frame;
@@ -402,6 +497,14 @@ impl Reassembler {
drop(stats); drop(stats);
return Ok(None); return Ok(None);
} }
// Defense-in-depth (2026-07 security review): the geometry invariants above guarantee
// every packet of a block agrees on K, so this can't fire today — but `have_data`
// indexing and the recovery-slot math below assume it, and an explicit check keeps a
// future firewall refactor from turning that assumption into an OOB panic.
if block.data_shards != data_shards {
drop(stats);
return Ok(None);
}
if block.done { if block.done {
// A data shard the parity reconstruct already restored (`!have_data`) was late, not // A data shard the parity reconstruct already restored (`!have_data`) was late, not
// lost — net it out of the `fec_recovered_shards` it was counted into (see the // lost — net it out of the `fec_recovered_shards` it was counted into (see the
@@ -485,8 +588,12 @@ impl Reassembler {
} }
} }
// Whole frame ready? // Whole frame ready? Judged against the FRAME's pinned block count, not this packet's
if *blocks_ok == block_count { // header — a streamed frame can complete on a reordered sentinel packet (header says 0)
// after the final block already pinned the real totals, and can never complete before
// they're pinned (`0` never equals a non-zero `blocks_ok`).
let block_count = *frame_block_count;
if block_count != 0 && *blocks_ok == block_count {
let mut done = win.frames.remove(&hdr.frame_index).unwrap(); let mut done = win.frames.remove(&hdr.frame_index).unwrap();
win.completed.insert( win.completed.insert(
hdr.frame_index, hdr.frame_index,
@@ -595,7 +702,10 @@ impl ReassemblyWindow {
// where shards are missing (the codec's block walk skips zero windows). // where shards are missing (the codec's block walk skips zero windows).
// Newest-wins if several age out in one prune. Still counted dropped below. // Newest-wins if several age out in one prune. Still counted dropped below.
if let Some(sink) = partial_sink.as_deref_mut() { if let Some(sink) = partial_sink.as_deref_mut() {
if f.user_flags & USER_FLAG_CHUNK_ALIGNED != 0 { // `frame_bytes > 0` also excludes an UNPINNED streamed frame (its total is
// still the 0 sentinel value): truncating its max-sized buffer to 0 would
// deliver an empty "partial" — worse than the plain drop it gets instead.
if f.user_flags & USER_FLAG_CHUNK_ALIGNED != 0 && f.frame_bytes > 0 {
let mut buf = std::mem::take(&mut f.buf); let mut buf = std::mem::take(&mut f.buf);
buf.truncate(f.frame_bytes); buf.truncate(f.frame_bytes);
let newer = sink let newer = sink
+402 -2
View File
@@ -1,6 +1,7 @@
use super::reassemble::LOSS_WINDOW_NS; use super::reassemble::LOSS_WINDOW_NS;
use super::*; use super::*;
use crate::config::{Config, FecScheme}; use crate::config::{Config, FecScheme};
use crate::crypto::SessionKey;
use crate::fec::coder_for; use crate::fec::coder_for;
use crate::stats::StatsCounters; use crate::stats::StatsCounters;
use zerocopy::{FromBytes, IntoBytes}; use zerocopy::{FromBytes, IntoBytes};
@@ -182,7 +183,7 @@ fn explicit_frame_index_is_stamped_and_internal_counter_untouched() {
shard_payload: 16, shard_payload: 16,
max_frame_bytes: 4096, max_frame_bytes: 4096,
encrypt: false, encrypt: false,
key: [0u8; 16], key: SessionKey::Aes128Gcm([0u8; 16]),
salt: [0u8; 4], salt: [0u8; 4],
loopback_drop_period: 0, loopback_drop_period: 0,
}; };
@@ -292,7 +293,7 @@ fn e2e_config(scheme: FecScheme, fec_percent: u8) -> Config {
shard_payload: 16, shard_payload: 16,
max_frame_bytes: 4096, max_frame_bytes: 4096,
encrypt: false, encrypt: false,
key: [0u8; 16], key: SessionKey::Aes128Gcm([0u8; 16]),
salt: [0u8; 4], salt: [0u8; 4],
loopback_drop_period: 0, loopback_drop_period: 0,
} }
@@ -640,3 +641,402 @@ fn adaptive_fec_ramp_keeps_maximal_blocks_within_the_peers_ceiling() {
src src
); );
} }
// ---------------------------------------------------------------------------
// Streamed access units (VIDEO_CAP_STREAMED_AU — nvenc-subframe-slice-output.md Phase 2)
// ---------------------------------------------------------------------------
/// Packetize one streamed AU from `chunks` via begin/push/finish, returning the emitted wire
/// packets (header ++ shard) and the concatenated source bytes.
fn streamed_packets(
scheme: FecScheme,
fec_percent: u8,
chunks: &[&[u8]],
) -> (Vec<Vec<u8>>, Vec<u8>) {
let cfg = e2e_config(scheme, fec_percent);
let coder = coder_for(scheme);
let mut pk = Packetizer::new(&cfg);
let mut au = pk.begin_streamed(12345, 0, Some(0));
let mut pkts: Vec<Vec<u8>> = Vec::new();
let mut src = Vec::new();
for c in chunks {
src.extend_from_slice(c);
pk.push_streamed(&mut au, c, coder.as_ref(), |h: &PacketHeader, b: &[u8]| {
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
p.extend_from_slice(h.as_bytes());
p.extend_from_slice(b);
pkts.push(p);
Ok(())
})
.unwrap();
}
pk.finish_streamed(au, coder.as_ref(), |h: &PacketHeader, b: &[u8]| {
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
p.extend_from_slice(h.as_bytes());
p.extend_from_slice(b);
pkts.push(p);
Ok(())
})
.unwrap();
(pkts, src)
}
/// Deliver a streamed AU's packets (optionally with kills within the FEC budget, reversed
/// order, and a duplicate) and assert byte-identical completion. Reversed order is the
/// critical case: the FINAL block's real-total headers arrive FIRST, the frame opens
/// legacy-shaped, and the sentinels must still be accepted against the pinned totals.
fn streamed_roundtrip(scheme: FecScheme, kill: &[usize], reverse: bool) {
let chunks: Vec<Vec<u8>> = (0..3)
.map(|c| (0..50).map(|i| (c * 57 + i * 131 + 7) as u8).collect())
.collect();
let chunk_refs: Vec<&[u8]> = chunks.iter().map(|c| c.as_slice()).collect();
let (pkts, src) = streamed_packets(scheme, 50, &chunk_refs);
// 150 B / 16 B shards / 4-shard blocks → sentinel blocks 0,1 (4 data + 2 rec each) +
// final block (2 data + 1 rec) = 15 packets.
assert_eq!(
pkts.len(),
15,
"expected geometry changed — update the kills"
);
let mut delivery: Vec<Vec<u8>> = pkts
.iter()
.enumerate()
.filter(|(i, _)| !kill.contains(i))
.map(|(_, p)| p.clone())
.collect();
if reverse {
delivery.reverse();
}
if let Some(dup) = delivery.first().cloned() {
delivery.push(dup);
}
let cfg = e2e_config(scheme, 50);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let coder = coder_for(scheme);
let stats = StatsCounters::default();
let mut got = None;
for p in &delivery {
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
assert!(got.is_none(), "frame must complete exactly once");
got = Some(f);
}
}
let f = got.expect("streamed frame must complete within the FEC budget");
assert_eq!(
f.data, src,
"reassembled streamed AU must be byte-identical"
);
assert_eq!(f.pts_ns, 12345);
assert!(f.complete);
}
#[test]
fn streamed_roundtrip_clean_and_reversed() {
streamed_roundtrip(FecScheme::Gf16, &[], false);
streamed_roundtrip(FecScheme::Gf16, &[], true);
streamed_roundtrip(FecScheme::Gf8, &[], true);
}
/// Loss within each block's FEC budget: one data shard from a sentinel block, one from the
/// final block — in both delivery orders.
#[test]
fn streamed_roundtrip_survives_loss_and_reorder() {
// Wire order: blk0 = 0..4 data + 4..6 rec, blk1 = 6..10 data + 10..12 rec,
// final = 12..14 data + 14 rec.
streamed_roundtrip(FecScheme::Gf16, &[1, 12], false);
streamed_roundtrip(FecScheme::Gf16, &[1, 12], true);
}
/// The wire shape of a streamed AU: sentinel headers (block_count = 0, frame_bytes = 0,
/// full-K) on every non-final block, real totals + EOF on the final block, SOF on the very
/// first packet only.
#[test]
fn streamed_headers_sentinel_then_final() {
let chunks: Vec<Vec<u8>> = (0..3).map(|_| vec![0xA5u8; 50]).collect();
let chunk_refs: Vec<&[u8]> = chunks.iter().map(|c| c.as_slice()).collect();
let (pkts, src) = streamed_packets(FecScheme::Gf16, 50, &chunk_refs);
let mut saw_final = false;
for (i, p) in pkts.iter().enumerate() {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
assert_eq!(
h.flags & FLAG_SOF != 0,
i == 0,
"SOF exactly on the first packet"
);
assert_eq!(
h.flags & FLAG_EOF != 0,
i + 1 == pkts.len(),
"EOF exactly on the last packet"
);
if h.block_index < 2 {
assert_eq!(
h.block_count, 0,
"non-final block must ride sentinel headers"
);
assert_eq!(h.frame_bytes, 0);
assert_eq!(h.data_shards, 4, "sentinel blocks are exactly full-K");
} else {
saw_final = true;
assert_eq!(h.block_count, 3, "final block carries the real block count");
assert_eq!(h.frame_bytes as usize, src.len(), "and the real AU size");
}
}
assert!(saw_final);
}
/// A streamed AU smaller than one block emits NO sentinels — its single (final) block is
/// byte-identical in shape to a legacy frame, so small frames pay zero streaming overhead
/// and any receiver accepts them.
#[test]
fn streamed_small_frame_degenerates_to_legacy() {
let (pkts, src) = streamed_packets(FecScheme::Gf16, 50, &[&[0x5Au8; 40]]);
for p in &pkts {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
assert_eq!(
h.block_count, 1,
"single-block streamed AU must be legacy-shaped"
);
assert_eq!(h.frame_bytes as usize, src.len());
}
}
/// Sentinel firewall: a sentinel header that is not exactly full-K, or claims a non-zero
/// total, or sits where the final block could no longer follow, is dropped before any
/// allocation happens.
#[test]
fn streamed_sentinel_firewall_bounds() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
let sentinel = |f: fn(&mut PacketHeader)| {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8; // limits().max_data_shards — the only legal sentinel K
h.recovery_shards = 0;
f(&mut h);
h
};
// Not full-K.
let h = sentinel(|h| h.data_shards = 7);
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
// Claims a total.
let h = sentinel(|h| h.frame_bytes = 64);
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
// Sits on the last block the limits allow (no room for the final block after it).
let h = sentinel(|h| h.block_index = 3); // limits().max_blocks == 4
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(stats.snapshot().packets_dropped, 3);
// A conformant sentinel IS accepted (proves the rejections above weren't vacuous).
let h = sentinel(|_| {});
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(
stats.snapshot().packets_dropped,
3,
"conformant sentinel accepted"
);
}
/// Retro-validation: final-block totals under which an already-received sentinel block is
/// out of range (or mis-sized) kill the WHOLE frame — no spliced delivery — and the killed
/// index cannot be resurrected by stragglers.
#[test]
fn streamed_lying_final_totals_kill_the_frame_wholesale() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
// Two sentinel blocks (indexes 0 and 1) open the frame and land shards.
for bi in 0..2u16 {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
h.block_index = bi;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
}
// A "final" header claiming the whole AU is ONE 16-byte shard: geometry-valid on its own
// (expect_blocks = 1, K = 1), but it disowns both sentinel blocks → the frame dies.
let mut lying = base_header();
lying.block_count = 1;
lying.frame_bytes = 16;
lying.data_shards = 1;
lying.recovery_shards = 0;
assert!(r
.push(&packet(lying), coder.as_ref(), &stats)
.unwrap()
.is_none());
let snap = stats.snapshot();
assert_eq!(
snap.frames_dropped, 1,
"the lying frame must be counted lost"
);
// A straggler sentinel for the killed index must not resurrect it.
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
let before = stats.snapshot().packets_dropped;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(
stats.snapshot().packets_dropped,
before + 1,
"straggler for a killed frame must be dropped, not re-open it"
);
}
/// A sentinel first-packet commits a MAX-sized frame buffer, so the in-flight budget must
/// bite after IN_FLIGHT_BUF_FACTOR frames — the amplification bound for one-datagram opens.
#[test]
fn streamed_open_amplification_is_budget_bounded() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
// limits(): max_frame_bytes 4096 → each sentinel open commits 4096 B; budget = 4×4096.
for fi in 0..5u32 {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
h.frame_index = fi;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
}
assert_eq!(
stats.snapshot().packets_dropped,
1,
"the fifth max-sized open must be refused by the in-flight budget"
);
}
/// The final-first order's single buffer-safety guard (2026-07 security review finding 3): a
/// frame opened by its FINAL block allocates an EXACT-sized buffer; a sentinel aimed at (or
/// past) the pinned final slot must be dropped — without the guard its full-K write would land
/// outside that buffer. And the reject must not corrupt the frame: it still completes.
#[test]
fn streamed_out_of_range_sentinel_after_final_first_is_dropped() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
// Final block opens the frame: block_count = 1, frame_bytes = 32 → K = 2. Send shard 0
// only, so the frame stays in flight with the totals pinned.
let mut fin = base_header();
fin.block_count = 1;
fin.frame_bytes = 32;
fin.data_shards = 2;
fin.recovery_shards = 0;
fin.shard_index = 0;
assert!(r
.push(&packet(fin), coder.as_ref(), &stats)
.unwrap()
.is_none());
// Sentinels at the final slot (0) and past it (1): both non-final-impossible under the
// pinned block_count = 1 → dropped, never written into the 32-byte buffer.
for bi in 0..2u16 {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
h.block_index = bi;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
}
assert_eq!(stats.snapshot().packets_dropped, 2);
// The frame is unharmed: its real second shard completes it at the exact pinned length.
let mut fin2 = fin;
fin2.shard_index = 1;
let got = r
.push(&packet(fin2), coder.as_ref(), &stats)
.unwrap()
.expect("frame must still complete after the rejected sentinels");
assert_eq!(got.data.len(), 32);
assert!(got.complete);
}
/// A second "final" header with DIFFERENT totals must be rejected once a streamed frame is
/// pinned (re-pinning would re-interpret already-landed shards), and the frame must still
/// complete under the first totals.
#[test]
fn streamed_second_final_with_different_totals_is_rejected() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
let sentinel_shard = |shard_index: u16| {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
h.block_index = 0;
h.shard_index = shard_index;
h
};
let final_shard = |frame_bytes: u32, data_shards: u16, shard_index: u16| {
let mut h = base_header();
h.block_count = 2;
h.frame_bytes = frame_bytes;
h.data_shards = data_shards;
h.recovery_shards = 0;
h.block_index = 1;
h.shard_index = shard_index;
h
};
// Sentinel opens block 0, then the real final pins totals: 10 shards = 160 bytes.
assert!(r
.push(&packet(sentinel_shard(0)), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert!(r
.push(&packet(final_shard(160, 2, 0)), coder.as_ref(), &stats)
.unwrap()
.is_none());
// A second final claiming 144 bytes (K = 1): geometry-valid alone, but it contradicts the
// pinned totals → dropped.
let before = stats.snapshot().packets_dropped;
assert!(r
.push(&packet(final_shard(144, 1, 0)), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(stats.snapshot().packets_dropped, before + 1);
// The frame still completes under the FIRST totals: the rest of block 0 + the final tail.
let mut got = None;
for s in 1..8u16 {
assert!(got.is_none());
got = r
.push(&packet(sentinel_shard(s)), coder.as_ref(), &stats)
.unwrap();
}
assert!(got.is_none(), "block 1 still owes a shard");
let got = r
.push(&packet(final_shard(160, 2, 1)), coder.as_ref(), &stats)
.unwrap()
.expect("frame completes under the first pinned totals");
assert_eq!(got.data.len(), 160);
}
+25
View File
@@ -32,6 +32,29 @@ pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08;
/// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window /// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window
/// reassembler would silently drop as stale. /// reassembler would silently drop as stale.
pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10; pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10;
/// [`Hello::video_caps`] bit: the client's reassembler accepts **streamed access units**
/// (design/nvenc-subframe-slice-output.md Phase 2): the host may ship an AU's early FEC blocks
/// before the AU's total size exists — while the tail of the frame is still encoding — so the
/// AU's last packet leaves the host sooner (latency plan §7 LN1). Non-final blocks ride
/// SENTINEL headers (`block_count == 0` — a value no legacy sender emits — with
/// `frame_bytes == 0` and exactly `max_data_per_block` data shards, so the shard-offset
/// formula needs no total); the FINAL block's headers carry the real
/// `frame_bytes`/`block_count` (+ `FLAG_EOF`), which retro-validate the whole frame's geometry
/// — a mismatch drops the frame wholesale. The host streams ONLY to clients advertising this
/// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
/// the fallback is zero-risk.
pub const VIDEO_CAP_STREAMED_AU: u8 = 0x20;
/// [`Hello::video_caps`] bit: the client can open **ChaCha20-Poly1305**-sealed session datagrams
/// AND requests them — set by clients without hardware AES (the soft-AES armv7 targets, e.g.
/// webOS TVs), where GCM's software AES + GHASH caps decrypt at ~100 Mbps while ChaCha's ARX
/// construction runs 47× faster in portable code (design/chacha20-session-cipher.md).
/// Support-plus-request in one bit mirrors [`VIDEO_CAP_444`]'s "capable AND turned on"
/// precedent. The host grants it only when its `PUNKTFUNK_CHACHA20` kill-switch (default on)
/// allows, answering with [`Welcome::cipher`] `= 1` + the 32-byte [`Welcome::key_chacha`];
/// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a
/// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
/// control channel, so there is no downgrade surface.
pub const VIDEO_CAP_CHACHA20: u8 = 0x40;
/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`] /// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder /// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
@@ -213,6 +236,8 @@ mod tests {
audio_channels: 2, audio_channels: 2,
codec: CODEC_HEVC, codec: CODEC_HEVC,
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD, host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
cipher: 0,
key_chacha: None,
}; };
let got = Welcome::decode(&w.encode()).unwrap(); let got = Welcome::decode(&w.encode()).unwrap();
assert_eq!(got.host_caps & HOST_CAP_CLIPBOARD, HOST_CAP_CLIPBOARD); assert_eq!(got.host_caps & HOST_CAP_CLIPBOARD, HOST_CAP_CLIPBOARD);
+152 -3
View File
@@ -4,6 +4,7 @@ use super::*;
use crate::config::{ use crate::config::{
CompositorPref, Config, FecConfig, FecScheme, GamepadPref, Mode, ProtocolPhase, Role, CompositorPref, Config, FecConfig, FecScheme, GamepadPref, Mode, ProtocolPhase, Role,
}; };
use crate::crypto::SessionKey;
use crate::error::{PunktfunkError, Result}; use crate::error::{PunktfunkError, Result};
/// `client → host`: open the session, requesting a display mode (the host creates its /// `client → host`: open the session, requesting a display mode (the host creates its
@@ -107,6 +108,13 @@ pub const HELLO_NAME_MAX: usize = 64;
/// (`steam:<appid>` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field. /// (`steam:<appid>` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field.
pub const HELLO_LAUNCH_MAX: usize = 128; pub const HELLO_LAUNCH_MAX: usize = 128;
/// [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the
/// only one pre-cipher builds know).
pub const CIPHER_AES_128_GCM: u8 = 0;
/// [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via
/// [`VIDEO_CAP_CHACHA20`] for clients without hardware AES.
pub const CIPHER_CHACHA20_POLY1305: u8 = 1;
/// `host → client`: the complete session offer. /// `host → client`: the complete session offer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Welcome { pub struct Welcome {
@@ -173,6 +181,22 @@ pub struct Welcome {
/// per-transition events otherwise). Appended after `codec` as a single trailing byte; an /// 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). /// older host that omits it decodes to `0` (no capabilities — legacy events only).
pub host_caps: u8, pub host_caps: u8,
/// The session AEAD the data plane seals with — [`CIPHER_AES_128_GCM`] (`0`, the default
/// every peer speaks) or [`CIPHER_CHACHA20_POLY1305`] (`1`). The host sets `1` ONLY toward
/// a client that advertised [`VIDEO_CAP_CHACHA20`] (the soft-AES armv7 targets). Appended
/// after `host_caps` at offset 68 and — unlike the earlier trailing fields — emitted only
/// when non-zero, so an AES session's Welcome stays **byte-identical** to the pre-cipher
/// wire form; an older host omits it (→ `0`, AES). Decode is fail-closed: an unknown id is
/// an `Err`, never a silent AES fallback — the host only picks a cipher this client
/// advertised, so an unknown id reaching us is a bug, and falling back would yield an
/// undecryptable session with a confusing failure signature.
pub cipher: u8,
/// The 256-bit ChaCha20-Poly1305 session key (RFC 8439 requires the full 32 bytes; wire
/// cost is once per handshake) — present iff `cipher == 1`, at offsets 69..101. The legacy
/// 16-byte `key` keeps its offset and stays independently random, so nothing downstream
/// ever observes an all-zero key. Decode rejects `cipher == 1` with fewer than 32 key
/// bytes following.
pub key_chacha: Option<[u8; 32]>,
} }
/// `client → host`: data plane is bound, begin streaming. /// `client → host`: data plane is bound, begin streaming.
@@ -366,6 +390,21 @@ impl Welcome {
b.push(self.codec); b.push(self.codec);
// Host input caps at offset 67 — older clients stop before this → 0 (legacy input only). // Host input caps at offset 67 — older clients stop before this → 0 (legacy input only).
b.push(self.host_caps); b.push(self.host_caps);
// Session cipher at offset 68 + the 32-byte ChaCha key at 69..101 — emitted ONLY when a
// non-default cipher was negotiated, so an AES session's Welcome stays byte-identical
// to the pre-cipher wire form. The host only sets cipher toward a client that
// advertised VIDEO_CAP_CHACHA20, so an old client never sees these bytes at all.
debug_assert_eq!(
self.cipher == CIPHER_CHACHA20_POLY1305,
self.key_chacha.is_some(),
"key_chacha present iff cipher == 1"
);
if self.cipher != CIPHER_AES_128_GCM {
b.push(self.cipher);
if let Some(k) = &self.key_chacha {
b.extend_from_slice(k);
}
}
b b
} }
@@ -374,8 +413,10 @@ impl Welcome {
// scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45] // scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45]
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59] // salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63] // bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
// chroma_format[64] audio_channels[65] codec[66] (everything from compositor on is an // chroma_format[64] audio_channels[65] codec[66] host_caps[67] cipher[68]
// optional trailing byte; an older host stops earlier). // key_chacha[69..101] (everything from compositor on is an optional trailing byte; an
// older host stops earlier; cipher/key_chacha are present only when ChaCha was
// negotiated).
if b.len() < 53 || &b[0..4] != MAGIC { if b.len() < 53 || &b[0..4] != MAGIC {
return Err(PunktfunkError::InvalidArg("bad Welcome")); return Err(PunktfunkError::InvalidArg("bad Welcome"));
} }
@@ -385,6 +426,24 @@ impl Welcome {
key.copy_from_slice(&b[29..45]); key.copy_from_slice(&b[29..45]);
let mut salt = [0u8; 4]; let mut salt = [0u8; 4];
salt.copy_from_slice(&b[45..49]); salt.copy_from_slice(&b[45..49]);
// Session cipher at 68 — absent on an older host → AES-128-GCM. Fail-closed on
// anything else: `cipher == 1` with fewer than 32 key bytes must be an error (a silent
// AES fallback would yield an undecryptable session with a confusing failure
// signature), and an unknown id (≥ 2) reaching us is a bug — a host only picks a
// cipher this client advertised — never a legitimate negotiation.
let cipher = b.get(68).copied().unwrap_or(CIPHER_AES_128_GCM);
let key_chacha = match cipher {
CIPHER_AES_128_GCM => None,
CIPHER_CHACHA20_POLY1305 => {
let bytes = b
.get(69..101)
.ok_or(PunktfunkError::InvalidArg("bad Welcome"))?;
let mut k = [0u8; 32];
k.copy_from_slice(bytes);
Some(k)
}
_ => return Err(PunktfunkError::InvalidArg("bad Welcome")),
};
Ok(Welcome { Ok(Welcome {
abi_version: u32at(4), abi_version: u32at(4),
udp_port: u16at(8), udp_port: u16at(8),
@@ -452,6 +511,8 @@ impl Welcome {
// Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state // Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state
// snapshots; the client keeps sending legacy per-transition events). // snapshots; the client keeps sending legacy per-transition events).
host_caps: b.get(67).copied().unwrap_or(0), host_caps: b.get(67).copied().unwrap_or(0),
cipher,
key_chacha,
}) })
} }
@@ -462,7 +523,12 @@ impl Welcome {
c.fec = self.fec; c.fec = self.fec;
c.shard_payload = self.shard_payload as usize; c.shard_payload = self.shard_payload as usize;
c.encrypt = self.encrypt; c.encrypt = self.encrypt;
c.key = self.key; // The negotiated AEAD: the ChaCha key when cipher == 1 (guaranteed present by decode —
// the `(1, None)` shape is unreachable off the wire), the legacy AES key otherwise.
c.key = match (self.cipher, self.key_chacha) {
(CIPHER_CHACHA20_POLY1305, Some(k)) => SessionKey::ChaCha20Poly1305(k),
_ => SessionKey::Aes128Gcm(self.key),
};
c.salt = self.salt; c.salt = self.salt;
// Client-side reassembler ceiling: p1_defaults' 64 MiB hostile-header memory bound is // Client-side reassembler ceiling: p1_defaults' 64 MiB hostile-header memory bound is
// ~10x larger than any real access unit. Derive it from the negotiated rate instead: // ~10x larger than any real access unit. Derive it from the negotiated rate instead:
@@ -531,6 +597,8 @@ mod tests {
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, host_caps: HOST_CAP_GAMEPAD_STATE,
cipher: 0,
key_chacha: None,
}; };
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w); assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
@@ -564,6 +632,81 @@ mod tests {
assert!(derived > (8 << 20) && derived < (64 << 20)); assert!(derived > (8 << 20) && derived < (64 << 20));
} }
#[test]
fn welcome_cipher_negotiation_wire_and_back_compat() {
use crate::crypto::SessionKey;
let base = Welcome {
abi_version: 2,
udp_port: 7000,
mode: Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
},
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 20,
max_data_per_block: 4096,
},
shard_payload: 1200,
encrypt: true,
key: [7u8; 16],
salt: [9, 8, 7, 6],
frames: 0,
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 50_000,
bit_depth: 8,
color: ColorInfo::SDR_BT709,
chroma_format: CHROMA_IDC_420,
audio_channels: 2,
codec: CODEC_HEVC,
host_caps: 0,
cipher: CIPHER_AES_128_GCM,
key_chacha: None,
};
// An AES session's Welcome is byte-identical to the pre-cipher wire form (68 bytes) —
// the old-client × new-host interop guarantee.
let enc = base.encode();
assert_eq!(enc.len(), 68);
assert_eq!(Welcome::decode(&enc).unwrap(), base);
// ChaCha roundtrip: cipher byte at 68, the 32-byte key at 69..101.
let k32: [u8; 32] = core::array::from_fn(|i| i as u8 + 1);
let cha = Welcome {
cipher: CIPHER_CHACHA20_POLY1305,
key_chacha: Some(k32),
..base
};
let cenc = cha.encode();
assert_eq!(cenc.len(), 68 + 1 + 32);
assert_eq!(Welcome::decode(&cenc).unwrap(), cha);
// A truncated old-host Welcome (no cipher byte) decodes to the AES default.
let old_host = Welcome::decode(&cenc[..68]).unwrap();
assert_eq!(old_host.cipher, CIPHER_AES_128_GCM);
assert_eq!(old_host.key_chacha, None);
// cipher == 1 with a missing / short key → Err, fail-closed (a silent AES fallback
// would yield an undecryptable session with a confusing failure signature).
assert!(Welcome::decode(&cenc[..69]).is_err());
assert!(Welcome::decode(&cenc[..100]).is_err());
// An unknown cipher id (≥ 2) → Err: the host only picks a cipher we advertised, so an
// unknown id reaching us is a bug, never a legitimate negotiation.
let mut bad = cenc.clone();
bad[68] = 2;
assert!(Welcome::decode(&bad).is_err());
// session_config maps both variants onto the data-plane key, and both validate.
let aes_cfg = base.session_config(Role::Client);
assert_eq!(aes_cfg.key, SessionKey::Aes128Gcm([7u8; 16]));
aes_cfg.validate().expect("AES config validates");
let cha_cfg = cha.session_config(Role::Client);
assert_eq!(cha_cfg.key, SessionKey::ChaCha20Poly1305(k32));
cha_cfg.validate().expect("ChaCha config validates");
}
#[test] #[test]
fn codec_negotiation_and_back_compat() { fn codec_negotiation_and_back_compat() {
// resolve_codec precedence (HEVC > AV1 > H.264), no preference (0). // resolve_codec precedence (HEVC > AV1 > H.264), no preference (0).
@@ -656,6 +799,8 @@ mod tests {
audio_channels: 2, audio_channels: 2,
codec: CODEC_PYROWAVE, codec: CODEC_PYROWAVE,
host_caps: 0, host_caps: 0,
cipher: 0,
key_chacha: None,
} }
.encode(), .encode(),
) )
@@ -726,6 +871,8 @@ mod tests {
audio_channels: 2, audio_channels: 2,
codec: CODEC_H264, codec: CODEC_H264,
host_caps: 0, host_caps: 0,
cipher: 0,
key_chacha: None,
} }
.encode(), .encode(),
) )
@@ -831,6 +978,8 @@ mod tests {
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, host_caps: HOST_CAP_GAMEPAD_STATE,
cipher: 0,
key_chacha: None,
}; };
let wenc = w.encode(); let wenc = w.encode();
assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps
+77 -9
View File
@@ -13,7 +13,9 @@ use crate::crypto::SessionCrypto;
use crate::error::{PunktfunkError, Result}; use crate::error::{PunktfunkError, Result};
use crate::fec::{coder_for, ErasureCoder}; use crate::fec::{coder_for, ErasureCoder};
use crate::input::InputEvent; use crate::input::InputEvent;
use crate::packet::{Packetizer, Reassembler, ReassemblerLimits, MAX_DATAGRAM_BYTES}; use crate::packet::{
PacketHeader, Packetizer, Reassembler, ReassemblerLimits, StreamedAu, MAX_DATAGRAM_BYTES,
};
use crate::stats::{Stats, StatsCounters}; use crate::stats::{Stats, StatsCounters};
use crate::transport::Transport; use crate::transport::Transport;
use zerocopy::IntoBytes; use zerocopy::IntoBytes;
@@ -274,6 +276,68 @@ impl Session {
pts_ns: u64, pts_ns: u64,
user_flags: u32, user_flags: u32,
frame_index: Option<u32>, frame_index: Option<u32>,
) -> Result<Vec<Vec<u8>>> {
self.seal_run(true, |p, coder, emit| {
p.packetize_each(data, pts_ns, user_flags, frame_index, coder, emit)
})
}
/// Host: open a **streamed** access unit ([`crate::quic::VIDEO_CAP_STREAMED_AU`] — only
/// toward a client that advertised it; anyone else must get the whole-AU
/// [`seal_frame_at`](Self::seal_frame_at) path). The AU's bytes are fed in with
/// [`seal_streamed_chunk`](Self::seal_streamed_chunk) as the encoder produces them and
/// closed with [`seal_streamed_finish`](Self::seal_streamed_finish); the three calls'
/// returned wire batches are ONE frame, and the nonce order is emission order across the
/// calls — send each batch as it is returned, before sealing the next.
pub fn begin_streamed_frame_at(
&mut self,
pts_ns: u64,
user_flags: u32,
frame_index: u32,
) -> Result<StreamedAu> {
if self.config.role != Role::Host {
return Err(PunktfunkError::InvalidArg(
"seal_frame called on a client session",
));
}
Ok(self
.packetizer
.begin_streamed(pts_ns, user_flags, Some(frame_index)))
}
/// Feed one encoder chunk into a streamed AU, sealing every FEC block it completes under
/// sentinel headers (see [`begin_streamed_frame_at`](Self::begin_streamed_frame_at)). The
/// returned batch is often EMPTY (the chunk is buffered until a block fills) — that's
/// normal, not an error.
pub fn seal_streamed_chunk(
&mut self,
au: &mut StreamedAu,
chunk: &[u8],
) -> Result<Vec<Vec<u8>>> {
self.seal_run(false, |p, coder, emit| {
p.push_streamed(au, chunk, coder, emit)
})
}
/// Close a streamed AU: seal the final block with the real totals (+ `FLAG_EOF`), which
/// retro-validate the frame at the receiver. Counts the frame as submitted.
pub fn seal_streamed_finish(&mut self, au: StreamedAu) -> Result<Vec<Vec<u8>>> {
self.seal_run(true, |p, coder, emit| p.finish_streamed(au, coder, emit))
}
/// The shared packetize → pooled-wire → seal machinery behind [`seal_frame`](Self::seal_frame)
/// and the streamed sealers: `run` drives the packetizer against an emit sink that writes
/// each packet's plaintext at its final wire offset; the seal pass (two-lane for large runs)
/// then encrypts in place. `count_frame` gates the per-frame stats — a streamed AU counts
/// once, at its finish call.
fn seal_run(
&mut self,
count_frame: bool,
run: impl FnOnce(
&mut Packetizer,
&dyn ErasureCoder,
&mut dyn FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()>,
) -> Result<Vec<Vec<u8>>> { ) -> Result<Vec<Vec<u8>>> {
if self.config.role != Role::Host { if self.config.role != Role::Host {
return Err(PunktfunkError::InvalidArg( return Err(PunktfunkError::InvalidArg(
@@ -320,10 +384,10 @@ impl Session {
// sealing itself is a separate pass so it can split across lanes. // sealing itself is a separate pass so it can split across lanes.
let seq_base = *next_seq; let seq_base = *next_seq;
let encrypting = crypto.is_some(); let encrypting = crypto.is_some();
let result = packetizer.packetize_each(data, pts_ns, user_flags, frame_index, coder_ref, { let result = {
let wires = &mut wires; let wires = &mut wires;
let used = &mut used; let used = &mut used;
move |hdr, body| { let mut emit = move |hdr: &PacketHeader, body: &[u8]| -> Result<()> {
if *used == wires.len() { if *used == wires.len() {
wires.push(Vec::new()); wires.push(Vec::new());
} }
@@ -342,8 +406,9 @@ impl Session {
wire.extend_from_slice(body); wire.extend_from_slice(body);
} }
Ok(()) Ok(())
} };
}); run(packetizer, coder_ref, &mut emit)
};
result?; result?;
// A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same // A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same
// as the previous `resize_with(packets.len(), ..)` did. (Before the seal phase, so a // as the previous `resize_with(packets.len(), ..)` did. (Before the seal phase, so a
@@ -421,10 +486,12 @@ impl Session {
if let Some(p) = self.seal_perf.as_mut() { if let Some(p) = self.seal_perf.as_mut() {
p.fec_ns += fec_ns.load(std::sync::atomic::Ordering::Relaxed); p.fec_ns += fec_ns.load(std::sync::atomic::Ordering::Relaxed);
p.seal_ns += seal_ns; p.seal_ns += seal_ns;
p.frames += 1; p.frames += count_frame as u64;
p.packets += used as u64; p.packets += used as u64;
} }
StatsCounters::add(&self.stats.frames_submitted, 1); if count_frame {
StatsCounters::add(&self.stats.frames_submitted, 1);
}
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum(); let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64); StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
StatsCounters::add(&self.stats.bytes_sent, bytes); StatsCounters::add(&self.stats.bytes_sent, bytes);
@@ -714,6 +781,7 @@ impl Session {
mod wire_equivalence_tests { mod wire_equivalence_tests {
use super::*; use super::*;
use crate::config::{FecConfig, FecScheme, ProtocolPhase}; use crate::config::{FecConfig, FecScheme, ProtocolPhase};
use crate::crypto::SessionKey;
use crate::transport::loopback_pair; use crate::transport::loopback_pair;
fn host_cfg(scheme: FecScheme, fec_percent: u8, encrypt: bool) -> Config { fn host_cfg(scheme: FecScheme, fec_percent: u8, encrypt: bool) -> Config {
@@ -731,7 +799,7 @@ mod wire_equivalence_tests {
shard_payload: 64, shard_payload: 64,
max_frame_bytes: 8 * 1024 * 1024, max_frame_bytes: 8 * 1024 * 1024,
encrypt, encrypt,
key: [7u8; 16], key: SessionKey::Aes128Gcm([7u8; 16]),
salt: [3, 1, 4, 1], salt: [3, 1, 4, 1],
loopback_drop_period: 0, loopback_drop_period: 0,
} }
@@ -863,7 +931,7 @@ mod wire_equivalence_tests {
shard_payload: 1024, shard_payload: 1024,
max_frame_bytes: 8 * 1024 * 1024, max_frame_bytes: 8 * 1024 * 1024,
encrypt: false, encrypt: false,
key: [0u8; 16], key: SessionKey::Aes128Gcm([0u8; 16]),
salt: [0u8; 4], salt: [0u8; 4],
loopback_drop_period: 0, loopback_drop_period: 0,
}; };
+26 -1
View File
@@ -5,6 +5,7 @@
use proptest::prelude::*; use proptest::prelude::*;
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role}; use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
use punktfunk_core::crypto::SessionKey;
use punktfunk_core::fec::coder_for; use punktfunk_core::fec::coder_for;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::session::Session; use punktfunk_core::session::Session;
@@ -25,7 +26,7 @@ fn config(role: Role, scheme: FecScheme, encrypt: bool, drop_period: u32) -> Con
shard_payload: 1024, shard_payload: 1024,
max_frame_bytes: 8 * 1024 * 1024, max_frame_bytes: 8 * 1024 * 1024,
encrypt, encrypt,
key: [7u8; 16], key: SessionKey::Aes128Gcm([7u8; 16]),
salt: [1, 2, 3, 4], salt: [1, 2, 3, 4],
loopback_drop_period: drop_period, loopback_drop_period: drop_period,
} }
@@ -101,6 +102,30 @@ fn encrypted_stream_recovers_under_loss() {
assert_eq!(stats.frames_completed, frames.len() as u64); assert_eq!(stats.frames_completed, frames.len() as u64);
} }
/// The negotiated ChaCha20-Poly1305 session cipher through the same lossy full-stream path:
/// loss/replay behavior is cipher-independent (the replay window keys off the authenticated
/// seq), so recovery must be byte-identical to the AES run above.
#[test]
fn chacha20_encrypted_stream_recovers_under_loss() {
let frames = sample_frames();
let mk = |role| {
let mut c = config(role, FecScheme::Gf16, true, 8);
c.key = SessionKey::ChaCha20Poly1305([7u8; 32]);
c
};
let (host_tp, client_tp) = loopback_pair(8, 0);
let mut host = Session::new(mk(Role::Host), Box::new(host_tp)).unwrap();
let mut client = Session::new(mk(Role::Client), Box::new(client_tp)).unwrap();
for (i, frame) in frames.iter().enumerate() {
host.submit_frame(frame, i as u64 * 1_000_000, 0).unwrap();
let got = client
.poll_frame()
.expect("frame should recover despite loss");
assert_eq!(&got.data, frame, "frame {i} mismatched after recovery");
}
assert!(client.stats().fec_recovered_shards > 0);
}
#[test] #[test]
fn lossless_stream_is_exact() { fn lossless_stream_is_exact() {
let frames = sample_frames(); let frames = sample_frames();
+7
View File
@@ -110,6 +110,13 @@ serde_json = "1"
# utoipa into axum 0.8 extractors; utoipa-axum collects `#[utoipa::path]` routes into the # utoipa into axum 0.8 extractors; utoipa-axum collects `#[utoipa::path]` routes into the
# spec; utoipa-scalar serves the interactive docs. Codegen-friendly: the spec is emitted # spec; utoipa-scalar serves the interactive docs. Codegen-friendly: the spec is emitted
# verbatim by the `openapi` subcommand. Control plane only — never the per-frame path. # verbatim by the `openapi` subcommand. Control plane only — never the per-frame path.
# Plugin-store index signatures: ed25519 verification of a catalog document before any field of
# it is read (store/index.rs). Already in the tree via rustls — the workspace is ring-only, so this
# is the one signature primitive available without pulling aws-lc-sys (which fails on Windows CI).
ring = "0.17"
# Semver comparisons for the plugin store: `minHost` gating and the revocation list's version
# ranges (`<0.3.2`). Already in the lockfile transitively.
semver = "1"
utoipa = { version = "5", features = ["axum_extras"] } utoipa = { version = "5", features = ["axum_extras"] }
utoipa-axum = "0.2" utoipa-axum = "0.2"
utoipa-scalar = { version = "0.3", features = ["axum"] } utoipa-scalar = { version = "0.3", features = ["axum"] }
+9 -3
View File
@@ -26,7 +26,10 @@ pub use pf_capture::{dxgi, synthetic_nv12};
/// capture→encode cycle). Resolved here (the host facade) and threaded in, so the edge stays one-way /// capture→encode cycle). Resolved here (the host facade) and threaded in, so the edge stays one-way
/// (plan §2.4 / §W6). /// (plan §2.4 / §W6).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn zero_copy_policy(pyrowave_session: bool) -> pf_capture::ZeroCopyPolicy { fn zero_copy_policy(
pyrowave_session: bool,
native_nv12_session: bool,
) -> pf_capture::ZeroCopyPolicy {
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi(); let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
// The raw-dmabuf passthrough serves a PyroWave session on ANY vendor (the wavelet encoder's // The raw-dmabuf passthrough serves a PyroWave session on ANY vendor (the wavelet encoder's
// own Vulkan device imports the dmabuf) — per-session from the negotiated codec, plus the // own Vulkan device imports the dmabuf) — per-session from the negotiated codec, plus the
@@ -56,6 +59,7 @@ fn zero_copy_policy(pyrowave_session: bool) -> pf_capture::ZeroCopyPolicy {
backend_is_gpu: crate::encode::resolved_backend_is_gpu(), backend_is_gpu: crate::encode::resolved_backend_is_gpu(),
pyrowave_session, pyrowave_session,
pyrowave_modifiers, pyrowave_modifiers,
native_nv12_session,
} }
} }
@@ -70,7 +74,9 @@ pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei; let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei;
// Monitor mirrors never carry the native PyroWave plane (GameStream protocol) — per-session // Monitor mirrors never carry the native PyroWave plane (GameStream protocol) — per-session
// passthrough is virtual-output-only; the global encoder-pref lever still applies inside. // passthrough is virtual-output-only; the global encoder-pref lever still applies inside.
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false)) // Native NV12 stays off too: the mirror path doesn't resolve the codec here, and the desktop
// compositors it mirrors (GNOME/KWin) don't produce NV12 anyway.
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false, false))
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
@@ -101,7 +107,7 @@ pub fn capture_virtual_output(
vout.keepalive, vout.keepalive,
want.gpu, want.gpu,
want.chroma_444, want.chroma_444,
zero_copy_policy(want.pyrowave), zero_copy_policy(want.pyrowave, want.nv12_native),
) )
} }
+7
View File
@@ -170,6 +170,12 @@ pub enum EventKind {
/// lease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set. /// lease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set.
id: String, id: String,
}, },
#[serde(rename = "store.changed")]
/// The set of installed plugins, or what the store knows about them, changed — an install or
/// uninstall finished, or a catalog refresh brought in new rows. A consumer re-reads
/// `GET /api/v1/store/catalog` / `…/installed`. Deliberately payload-free: the store's answer
/// is a join over several sources of truth, so "go look again" is the only honest signal.
StoreChanged,
#[serde(rename = "host.started")] #[serde(rename = "host.started")]
HostStarted { HostStarted {
version: String, version: String,
@@ -197,6 +203,7 @@ impl EventKind {
EventKind::DisplayReleased { .. } => "display.released", EventKind::DisplayReleased { .. } => "display.released",
EventKind::LibraryChanged { .. } => "library.changed", EventKind::LibraryChanged { .. } => "library.changed",
EventKind::PluginsChanged { .. } => "plugins.changed", EventKind::PluginsChanged { .. } => "plugins.changed",
EventKind::StoreChanged => "store.changed",
EventKind::HostStarted { .. } => "host.started", EventKind::HostStarted { .. } => "host.started",
EventKind::HostStopping => "host.stopping", EventKind::HostStopping => "host.stopping",
} }
@@ -86,6 +86,12 @@ pub fn start(
crate::events::emit(crate::events::EventKind::ClientConnected { crate::events::emit(crate::events::EventKind::ClientConnected {
client: event_client.clone(), client: event_client.clone(),
}); });
// GPU clock pin (Linux, opt-in `PUNKTFUNK_PIN_CLOCKS`): hold the box-wide vendor clock
// floor while this compat-plane stream runs, refcounted with every other live session
// across both planes. Released when the closure exits (stream stopped) — so idle clocks
// aren't pinned between Moonlight sessions. No-op off Linux / when the flag is unset.
#[cfg(target_os = "linux")]
let _clock_pin = crate::gpuclocks::session_pin();
let result = run( let result = run(
cfg, cfg,
app.as_ref(), app.as_ref(),
@@ -658,6 +664,9 @@ fn stream_body(
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the // GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only. // Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
encode::ChromaFormat::Yuv420, encode::ChromaFormat::Yuv420,
// Desktop monitor capture negotiates cursor-as-metadata where available — the encoder
// may be handed cursor bitmaps to composite.
true,
) )
.context("open video encoder for stream")?; .context("open video encoder for stream")?;
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only). // FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
@@ -811,6 +820,7 @@ fn stream_body(
frame.is_cuda(), frame.is_cuda(),
gs_bit_depth(frame.format), gs_bit_depth(frame.format),
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0 encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
true, // metadata-cursor capture — see the first open
) )
.context("reopen encoder after rebuild")?; .context("reopen encoder after rebuild")?;
supports_rfi = enc.caps().supports_rfi; supports_rfi = enc.caps().supports_rfi;
+98 -22
View File
@@ -2,11 +2,15 @@
//! plan Tier 1B): the driver's adaptive P-state ramps clocks down between bursty encode frames, //! plan Tier 1B): the driver's adaptive P-state ramps clocks down between bursty encode frames,
//! so every frame re-pays a spin-up. This is NOT theoretical — measured on the 780M (VCN 4), //! so every frame re-pays a spin-up. This is NOT theoretical — measured on the 780M (VCN 4),
//! a 1440p HEVC encode takes ~4.4 ms/frame with the clocks hot (120 fps pacing) but ~8 ms/frame //! a 1440p HEVC encode takes ~4.4 ms/frame with the clocks hot (120 fps pacing) but ~8 ms/frame
//! at a 60 fps duty cycle: the sag doubles per-frame encode latency. //! at a 60 fps duty cycle: the sag doubles per-frame encode latency. So the pin is worth holding
//! only while a client is actively streaming: it is armed on the first live client and released
//! when the last one disconnects (refcounted across both streaming planes — see [`session_pin`]),
//! leaving the driver's idle power management alone the rest of the time.
//! //!
//! **AMD** (`PUNKTFUNK_PIN_CLOCKS=1`, root-gated by sysfs ownership): write `high` into each //! **AMD** (`PUNKTFUNK_PIN_CLOCKS=1`, root-gated by sysfs ownership): write `high` into each
//! amdgpu card's `power_dpm_force_performance_level` for the host lifetime, restoring the prior //! amdgpu card's `power_dpm_force_performance_level` while a client is streaming, restoring the
//! value on exit. Non-root gets EACCES → logged once with the privilege recipe. Deliberately //! prior value when the last client disconnects. Non-root gets EACCES → logged once with the
//! privilege recipe. Deliberately
//! opt-in: it defeats power management box-wide and is wrong on battery (Steam Deck!). //! opt-in: it defeats power management box-wide and is wrong on battery (Steam Deck!).
//! //!
//! **NVIDIA** — two independent halves, both no-ops off NVIDIA: //! **NVIDIA** — two independent halves, both no-ops off NVIDIA:
@@ -28,14 +32,16 @@
//! while leaving boost headroom — NVIDIA's own latency guidance is "raise the floor, don't pin //! while leaving boost headroom — NVIDIA's own latency guidance is "raise the floor, don't pin
//! the max" (locking above base just gets throttled; a max pin only burns idle watts). Non-root //! the max" (locking above base just gets throttled; a max pin only burns idle watts). Non-root
//! callers get `NVML_ERROR_NO_PERMISSION` — logged once with the privilege recipe, then the //! callers get `NVML_ERROR_NO_PERMISSION` — logged once with the privilege recipe, then the
//! host runs unpinned. The pin is undone on drop (host exit); after a crash it persists until //! host runs unpinned. The pin is undone on drop (when the last client disconnects); after a
//! driver reload/reboot, which the reset-before-pin on the next start self-heals. Deliberately //! crash it persists until driver reload/reboot, which the reset-before-pin on the next arm
//! self-heals. Deliberately
//! NOT default-on: it defeats idle downclocking for the whole box and is wrong on //! NOT default-on: it defeats idle downclocking for the whole box and is wrong on
//! battery-powered hosts. //! battery-powered hosts.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use std::os::raw::{c_char, c_int, c_uint, c_void}; use std::os::raw::{c_char, c_int, c_uint, c_void};
use std::sync::{Mutex, OnceLock};
/// `nvmlDevice_t` — an opaque driver handle. /// `nvmlDevice_t` — an opaque driver handle.
type NvmlDevice = *mut c_void; type NvmlDevice = *mut c_void;
@@ -133,8 +139,9 @@ struct AmdPin {
restore: String, restore: String,
} }
/// Host-lifetime guard: holds the armed clock pins (NVML floor and/or amdgpu perf level) and /// Holds the armed clock pins (NVML floor and/or amdgpu perf level) and undoes them on drop. Owned
/// undoes them on drop. /// by the [`session_pin`] refcount: constructed when the first live client arms the pin, dropped
/// (clocks restored) when the last one disconnects.
pub struct ClockGuard { pub struct ClockGuard {
nvml: Option<NvmlPin>, nvml: Option<NvmlPin>,
amd: Vec<AmdPin>, amd: Vec<AmdPin>,
@@ -142,9 +149,10 @@ pub struct ClockGuard {
// SAFETY: `ClockGuard` holds opaque NVML device handles + resolved fn pointers from the loaded // SAFETY: `ClockGuard` holds opaque NVML device handles + resolved fn pointers from the loaded
// driver library (plus plain sysfs paths/strings). NVML is documented thread-safe, the handles are // driver library (plus plain sysfs paths/strings). NVML is documented thread-safe, the handles are
// plain driver tokens with no thread affinity, and the guard is only ever *moved* (held in `main`, // plain driver tokens with no thread affinity, and the guard is only ever *moved* (into the
// dropped once at exit) and used through `&mut`/ownership — never shared. Transfer across threads // `pin_refcount` mutex when armed, taken back out and dropped when the last client disconnects) and
// is therefore sound. // used through exclusive ownership behind that mutex — never shared. Transfer across threads is
// therefore sound.
unsafe impl Send for ClockGuard {} unsafe impl Send for ClockGuard {}
impl Drop for ClockGuard { impl Drop for ClockGuard {
@@ -182,22 +190,89 @@ impl Drop for ClockGuard {
} }
/// Startup hook for the host subcommands (`serve` / `punktfunk1-host`): install the NVIDIA P2-cap /// Startup hook for the host subcommands (`serve` / `punktfunk1-host`): install the NVIDIA P2-cap
/// application profile and, when `PUNKTFUNK_PIN_CLOCKS` is set, arm the vendor clock pin (NVML /// application profile — the process-scoped, no-root half of the NVIDIA lever, which the driver
/// core-clock floor / amdgpu `high` performance level). Returns the guard keeping the pins for /// only acts on once the host holds a live CUDA/NVENC context (i.e. during a session).
/// the host lifetime. `None` when nothing was armed. ///
pub fn on_host_start() -> Option<ClockGuard> { /// The vendor clock *pin* is deliberately NOT armed here anymore: held for the whole host lifetime
/// it kept the box's clocks hot even with no client connected. It is now refcounted per live client
/// via [`session_pin`] (armed on both streaming planes), so idle clocks are left to the driver's
/// power management until someone actually streams.
pub fn on_host_start() {
if nvidia_present() { if nvidia_present() {
ensure_cuda_perf_profile(); ensure_cuda_perf_profile();
} }
}
/// The box-wide clock-pin refcount, shared across BOTH streaming planes (native + GameStream): the
/// vendor pin is a single global GPU setting, so N concurrent sessions share ONE pin — armed when
/// the first client goes live, released when the last one leaves.
struct PinRefcount {
/// Number of live [`SessionClockPin`] handles.
live: usize,
/// The armed pins, present iff `live > 0` and something was actually pinnable.
guard: Option<ClockGuard>,
}
fn pin_refcount() -> &'static Mutex<PinRefcount> {
static STATE: OnceLock<Mutex<PinRefcount>> = OnceLock::new();
STATE.get_or_init(|| {
Mutex::new(PinRefcount {
live: 0,
guard: None,
})
})
}
/// RAII handle that keeps the box-wide clock pin armed while it is alive. Obtain one per live client
/// session on either plane via [`session_pin`]; when the last outstanding handle drops, the pin is
/// released and the driver's idle downclocking resumes. A no-op handle when `PUNKTFUNK_PIN_CLOCKS`
/// is unset (the opt-in gate) — the refcount only ticks for sessions that asked for pinning.
pub struct SessionClockPin {
/// Whether this handle actually incremented the refcount (false = opt-in gate off → no-op).
counted: bool,
}
/// Arm the box-wide clock pin for one live client session, refcounted across every plane. Returns
/// an RAII handle; the pin is released when the last handle drops. A no-op (returns immediately,
/// touches no GPU state) unless `PUNKTFUNK_PIN_CLOCKS` is set.
pub fn session_pin() -> SessionClockPin {
if !flag_truthy("PUNKTFUNK_PIN_CLOCKS") { if !flag_truthy("PUNKTFUNK_PIN_CLOCKS") {
return None; return SessionClockPin { counted: false };
} }
let nvml = if nvidia_present() { pin_nvidia() } else { None }; let mut state = pin_refcount().lock().unwrap();
let amd = pin_amdgpu(); state.live += 1;
if nvml.is_none() && amd.is_empty() { if state.live == 1 {
return None; // 0 → 1: the first live client — arm the vendor pin (reset-before-pin inside `pin_nvidia`
// heals a stale pin from a crashed previous run).
let nvml = if nvidia_present() { pin_nvidia() } else { None };
let amd = pin_amdgpu();
state.guard = if nvml.is_none() && amd.is_empty() {
None // nothing pinnable (no perms / no supported GPU) — the session streams unpinned
} else {
Some(ClockGuard { nvml, amd })
};
}
SessionClockPin { counted: true }
}
impl Drop for SessionClockPin {
fn drop(&mut self) {
if !self.counted {
return;
}
// Take the guard out under the lock but drop it *outside* — releasing the pin does NVML +
// sysfs I/O we don't want to hold the refcount lock across.
let release = {
let mut state = pin_refcount().lock().unwrap();
state.live = state.live.saturating_sub(1);
if state.live == 0 {
state.guard.take()
} else {
None
}
};
drop(release);
} }
Some(ClockGuard { nvml, amd })
} }
/// Force every amdgpu card's DPM performance level to `high` for the session — the encode-latency /// Force every amdgpu card's DPM performance level to `high` for the session — the encode-latency
@@ -238,7 +313,7 @@ fn pin_amdgpu() -> Vec<AmdPin> {
card = %name, card = %name,
was = %prev, was = %prev,
"amdgpu performance level pinned to high (encode clock sag removed) — \ "amdgpu performance level pinned to high (encode clock sag removed) — \
restored on host exit" restored when the last client disconnects"
); );
pins.push(AmdPin { pins.push(AmdPin {
path, path,
@@ -327,7 +402,8 @@ fn pin_nvidia() -> Option<NvmlPin> {
} }
tracing::info!( tracing::info!(
devices = pinned.len(), devices = pinned.len(),
"NVIDIA core-clock floor armed (min=TDP/base, max=boost) — released on host exit" "NVIDIA core-clock floor armed (min=TDP/base, max=boost) — released when the last \
client disconnects"
); );
Some(NvmlPin { nvml, pinned }) Some(NvmlPin { nvml, pinned })
} }
+40 -8
View File
@@ -74,6 +74,9 @@ mod session_plan;
mod session_status; mod session_status;
mod spike; mod spike;
mod stats_recorder; mod stats_recorder;
// The plugin store: signed catalogs, tiered trust, and install/uninstall jobs that run through the
// same runner CLI the `plugins` subcommand uses (design/plugin-store.md).
mod store;
mod stream_marker; mod stream_marker;
// `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior // `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior
// run; it lives in the `pf-win-display` leaf crate (plan §W6). // run; it lives in the `pf-win-display` leaf crate (plan §W6).
@@ -243,14 +246,17 @@ fn real_main() -> Result<()> {
crate::capture::dxgi::install_gpu_pref_hook(); crate::capture::dxgi::install_gpu_pref_hook();
} }
// NVIDIA clock hygiene (Linux, host subcommands only): install the P2-cap driver profile and, // NVIDIA clock hygiene (Linux, host subcommands only): install the P2-cap driver profile. The
// under PUNKTFUNK_PIN_CLOCKS, hold the NVML core-clock floor for the host lifetime (reset on // vendor clock *pin* (PUNKTFUNK_PIN_CLOCKS) is no longer held for the host lifetime — it is
// exit via the guard's Drop). No-op off NVIDIA / on the tool subcommands. // armed per live client via `gpuclocks::session_pin()` on both streaming planes, so idle clocks
// are left alone while nobody is connected. No-op off NVIDIA / on the tool subcommands.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let _nv_clocks = match args.first().map(String::as_str) { if matches!(
Some("serve") | Some("punktfunk1-host") => gpuclocks::on_host_start(), args.first().map(String::as_str),
_ => None, Some("serve") | Some("punktfunk1-host")
}; ) {
gpuclocks::on_host_start();
}
match args.first().map(String::as_str) { match args.first().map(String::as_str) {
// The host: the native punktfunk/1 plane + management API by default (secure), and — with // The host: the native punktfunk/1 plane + management API by default (secure), and — with
@@ -319,7 +325,33 @@ fn real_main() -> Result<()> {
// `PUNKTFUNK_HDR_SHADER_P010` colour math without green-screening a live HDR stream. Prints // `PUNKTFUNK_HDR_SHADER_P010` colour math without green-screening a live HDR stream. Prints
// PASS/FAIL + max Y/Cb/Cr error. // PASS/FAIL + max Y/Cb/Cr error.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
Some("hdr-p010-selftest") => crate::capture::dxgi::hdr_p010_selftest(), Some("hdr-p010-selftest") => {
// Optional args: a `WxH` size (default 64x64 — pass the real capture size: heights
// like 1080 are NOT 16-aligned and exercise a different driver path) and a GPU
// vendor (`intel`|`nvidia`|`amd` — dual-GPU boxes otherwise test the default
// adapter, which may not be the one that encodes).
let mut size = (64u32, 64u32);
let mut vendor = None;
for a in args.iter().skip(2) {
match a.as_str() {
"intel" => vendor = Some(0x8086),
"nvidia" => vendor = Some(0x10de),
"amd" => vendor = Some(0x1002),
s => {
let parsed = s
.split_once('x')
.and_then(|(w, h)| Some((w.parse().ok()?, h.parse().ok()?)));
match parsed {
Some(wh) => size = wh,
None => anyhow::bail!(
"hdr-p010-selftest: unrecognized arg {s:?} (want WxH or intel|nvidia|amd)"
),
}
}
}
}
crate::capture::dxgi::hdr_p010_selftest_at(size.0, size.1, vendor)
}
// Linux HDR readiness probe (GNOME 50+ portal path): prints whether a monitor is currently // Linux HDR readiness probe (GNOME 50+ portal path): prints whether a monitor is currently
// in BT.2100 (HDR) colour mode, whether the NVENC/VAAPI backend probes Main10 for // in BT.2100 (HDR) colour mode, whether the NVENC/VAAPI backend probes Main10 for
// HEVC/AV1, and the GameStream HDR capability the two combine into — the "why isn't my // HEVC/AV1, and the GameStream HDR capability the two combine into — the "why isn't my
+13 -1
View File
@@ -42,6 +42,7 @@ mod plugins;
mod session; mod session;
mod shared; mod shared;
mod stats; mod stats;
mod store;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
@@ -240,7 +241,17 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(hooks::get_hooks, hooks::set_hooks)) .routes(routes!(hooks::get_hooks, hooks::set_hooks))
.routes(routes!(plugins::list_plugins)) .routes(routes!(plugins::list_plugins))
.routes(routes!(plugins::register_plugin, plugins::delete_plugin)) .routes(routes!(plugins::register_plugin, plugins::delete_plugin))
.routes(routes!(plugins::get_ui_credential)), .routes(routes!(plugins::get_ui_credential))
.routes(routes!(store::get_catalog))
.routes(routes!(store::refresh_catalog))
.routes(routes!(store::list_installed))
.routes(routes!(store::install_plugin))
.routes(routes!(store::uninstall_plugin))
.routes(routes!(store::list_jobs))
.routes(routes!(store::get_job))
.routes(routes!(store::list_sources))
.routes(routes!(store::put_source, store::delete_source))
.routes(routes!(store::get_runtime, store::set_runtime)),
) )
.split_for_parts() .split_for_parts()
} }
@@ -279,6 +290,7 @@ pub fn openapi_json() -> String {
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"), (name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"), (name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
(name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"), (name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"),
(name = "store", description = "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"),
) )
)] )]
struct ApiDoc; struct ApiDoc;
+7
View File
@@ -131,8 +131,15 @@ pub(crate) async fn require_auth(
/// or eject the operator's. /// or eject the operator's.
/// - **UI proxy credentials** — a plugin has no business reading another plugin's per-boot UI /// - **UI proxy credentials** — a plugin has no business reading another plugin's per-boot UI
/// secret; only the console proxy (admin token) needs it. /// secret; only the console proxy (admin token) needs it.
/// - **the plugin store** — installing a plugin is running new code with operator privileges, and a
/// plugin that can do that is a persistence/escalation primitive: it could install a helper that
/// isn't constrained the way it is, or switch the runner's own service state. Denied wholesale
/// (reads included — the catalog is not sensitive, but there is no reason a plugin needs it, and
/// a whole-prefix deny can't be defeated by a route added later).
pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool { pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
let denied = path == "/api/v1/hooks" let denied = path == "/api/v1/hooks"
|| path == "/api/v1/store"
|| path.starts_with("/api/v1/store/")
|| path == "/api/v1/pair" || path == "/api/v1/pair"
|| path.starts_with("/api/v1/pair/") || path.starts_with("/api/v1/pair/")
|| path == "/api/v1/native/pair" || path == "/api/v1/native/pair"
+19
View File
@@ -202,6 +202,15 @@ impl PluginRegistry {
}) })
} }
/// The ids of live registrations, without pruning or announcing anything.
fn live_ids(&self) -> Vec<String> {
let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
map.iter()
.filter(|(_, s)| s.is_live())
.map(|(id, _)| id.clone())
.collect()
}
/// Remove `id`. Returns `true` if a **live** entry existed (a clean deregister); removing an /// Remove `id`. Returns `true` if a **live** entry existed (a clean deregister); removing an
/// already-expired or unknown id returns `false` (nothing to announce). /// already-expired or unknown id returns `false` (nothing to announce).
fn remove(&self, id: &str) -> bool { fn remove(&self, id: &str) -> bool {
@@ -222,6 +231,16 @@ pub(crate) fn registry() -> &'static PluginRegistry {
REG.get_or_init(PluginRegistry::new) REG.get_or_init(PluginRegistry::new)
} }
/// Which plugin ids are registered right now — the plugin store's "running" column
/// ([`super::store::list_installed`]).
///
/// Deliberately **not** [`list_plugins`]: that one prunes expired leases and emits
/// `plugins.changed` for each, which is right for the nav's poll but would turn the store's own
/// polling into an event fire-hose. This is a pure read.
pub(crate) fn live_plugin_ids() -> Vec<String> {
registry().live_ids()
}
// ---------------------------------------------------------------- validation // ---------------------------------------------------------------- validation
/// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK /// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK
+779
View File
@@ -0,0 +1,779 @@
//! Plugin **store** API: browse catalogs, install/uninstall, manage sources and the runner
//! (design `plugin-store.md` §4.2). The domain logic lives in [`crate::store`]; this is its HTTP
//! surface.
//!
//! Auth: **admin bearer + loopback**, like every mutation — and explicitly *denied to the plugin
//! token* ([`super::auth::plugin_may_access`]). A plugin that can install plugins is a persistence
//! and escalation primitive: it could install a helper that isn't constrained the way it is. That
//! deny is a carve-out in the exclusion-based allowlist, so it is spelled out there and pinned by a
//! test.
//!
//! (A console *session* can already reach arbitrary command execution via `PUT /hooks`, so the
//! store adds convenience for a session holder rather than a new privilege class. It is still worth
//! keeping the plugin lane away from it — the lanes exist precisely so a plugin defect isn't an
//! operator compromise.)
//!
//! Everything here does blocking work — filesystem scans, network fetches, process spawns — so
//! handlers hop to `spawn_blocking` rather than stalling the runtime.
use super::shared::*;
use crate::store::{self, index, jobs, manifest, sources};
/// Run blocking store work off the async runtime, mapping a joined-thread panic to a 500.
async fn blocking<T, F>(f: F) -> Result<T, Response>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(f).await.map_err(|e| {
tracing::error!("plugin-store worker panicked: {e}");
api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"the plugin store worker failed",
)
})
}
// ---------------------------------------------------------------- wire shapes
/// Facts about this host, so the console can grey out rows it can't install.
#[derive(Serialize, ToSchema)]
pub(crate) struct HostFacts {
pub version: String,
/// `linux` / `windows` / `macos`.
pub platform: String,
}
/// A configured catalog source and how its last refresh went.
#[derive(Serialize, ToSchema)]
pub(crate) struct SourceView {
pub name: String,
pub url: String,
/// The built-in `unom` source: not editable, not removable, and the only source whose entries
/// may carry the "verified" tier.
pub builtin: bool,
/// Whether we check a signature on this source's index. An unsigned source still works; the
/// console marks it.
pub signed: bool,
/// The catalog we're serving is older than the last refresh attempt (offline, or the last
/// fetch failed) — entries still install, because the pin travelled with the entry.
pub stale: bool,
/// Unix seconds of the data we hold, when we hold any.
#[serde(skip_serializing_if = "Option::is_none")]
pub fetched_at: Option<u64>,
/// Why the last refresh failed, if it did.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub entry_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
}
/// One row on the shelf.
#[derive(Serialize, ToSchema)]
pub(crate) struct CatalogEntry {
pub id: String,
pub pkg: String,
pub title: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
pub author: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
/// The one installable version this entry pins.
pub version: String,
/// Which source listed it.
pub source: String,
/// `verified` (built-in source) or `external` (an operator-added source). Never `unverified`:
/// unverified installs come from a raw spec and are never listed (D7).
pub tier: String,
/// When unom reviewed this exact tarball (built-in source only).
#[serde(skip_serializing_if = "Option::is_none")]
pub reviewed_at: Option<String>,
pub platforms: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_host: Option<String>,
/// Can this host install it?
pub compatible: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub incompatible_reason: Option<String>,
/// The version installed right now, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub installed_version: Option<String>,
/// Installed, but at a different version than the catalog pins.
pub update_available: bool,
/// A revocation covering the catalogued version — do not offer this without shouting.
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub(crate) struct CatalogResponse {
pub host: HostFacts,
pub sources: Vec<SourceView>,
pub plugins: Vec<CatalogEntry>,
/// True while a package operation is in flight — the console disables install buttons.
pub busy: bool,
}
/// An installed plugin package, joined with its provenance and whether it's actually running.
#[derive(Serialize, ToSchema)]
pub(crate) struct InstalledView {
pub pkg: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
/// `verified` / `external` / `unverified` / `cli` — remembered from install time, so an
/// unverified plugin stays visibly unverified long after the dialog is forgotten.
pub tier: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
/// The catalog entry this maps to, when it is on a shelf we know.
#[serde(skip_serializing_if = "Option::is_none")]
pub entry_id: Option<String>,
/// The plugin id it registers under — the key into `GET /plugins`.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub installed_at: Option<String>,
/// Is it registered in the live lease registry right now?
pub running: bool,
/// The catalog's version, when it's newer than what's installed.
#[serde(skip_serializing_if = "Option::is_none")]
pub update_available: Option<String>,
/// A revocation covering the *installed* version. Reported, never auto-removed: silently
/// deleting running code is its own hazard, so the operator decides.
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked: Option<String>,
}
/// `POST /store/install` — either a catalogued entry, or a raw spec the operator owns.
#[derive(Deserialize, ToSchema)]
pub(crate) struct InstallRequest {
/// Catalog source name (with [`Self::id`]).
#[serde(default)]
pub source: Option<String>,
/// Catalog entry id (with [`Self::source`]).
#[serde(default)]
pub id: Option<String>,
/// A raw package spec (`@scope/name`, `@scope/name@1.2.3`, an https tarball or git+https URL).
/// Nothing reviewed it and nothing pins it.
#[serde(default)]
pub spec: Option<String>,
/// Required with [`Self::spec`]: the operator's explicit acknowledgement that this installs
/// unreviewed code with operator privileges. The console collects it behind a typed
/// confirmation; the API refuses without it so no other caller can skip the decision.
#[serde(default)]
pub accept_unverified: bool,
}
#[derive(Deserialize, ToSchema)]
pub(crate) struct UninstallRequest {
pub pkg: String,
}
/// 202 body: where to watch the work.
#[derive(Serialize, ToSchema)]
pub(crate) struct JobRef {
pub job: String,
}
#[derive(Deserialize, ToSchema)]
pub(crate) struct SourceInput {
pub url: String,
/// `ed25519:<base64>`. Omitted ⇒ an unsigned source (accepted, flagged everywhere).
#[serde(default)]
pub public_key: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub(crate) struct RuntimeView {
/// Is the runner payload/unit present at all?
pub installed: bool,
pub enabled: bool,
pub running: bool,
/// systemd unit or scheduled-task name.
pub unit: String,
/// Windows: the account the task runs as.
#[serde(skip_serializing_if = "Option::is_none")]
pub principal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Deserialize, ToSchema)]
pub(crate) struct RuntimeRequest {
pub enabled: bool,
}
// ---------------------------------------------------------------- assembly
fn runtime_view() -> RuntimeView {
let st = crate::plugins::runtime_status();
RuntimeView {
installed: st.installed,
enabled: st.enabled,
running: st.running,
unit: st.unit.to_string(),
principal: st.principal,
detail: (!st.detail.is_empty()).then_some(st.detail),
}
}
/// Build the merged catalog view: every source's shelf, annotated with what this host has and can
/// run. `force` refreshes past the TTL.
fn build_catalog(force: bool) -> CatalogResponse {
let states = store::catalogs(force);
let installed = store::installed_packages(&store::plugins_dir());
let mut plugins = Vec::new();
let mut source_views = Vec::new();
for st in &states {
let count = st.index.as_ref().map(|i| i.plugins.len()).unwrap_or(0);
source_views.push(SourceView {
name: st.source.name.clone(),
url: st.source.url.clone(),
builtin: st.source.is_official(),
signed: st.source.is_signed(),
stale: st.stale,
fetched_at: st.fetched_at,
error: st.error.clone(),
entry_count: count as u32,
public_key: st.source.public_key.clone(),
});
let Some(idx) = &st.index else { continue };
let verified = st.source.is_official();
for e in &idx.plugins {
let installed_version = installed
.iter()
.find(|p| p.pkg == e.pkg)
.and_then(|p| p.version.clone());
let reason = e.incompatible_reason();
plugins.push(CatalogEntry {
id: e.id.clone(),
pkg: e.pkg.clone(),
title: e.title.clone(),
description: e.description.clone(),
icon: e.icon.clone(),
author: e.author.clone(),
homepage: e.homepage.clone(),
license: e.license.clone(),
version: e.version.clone(),
source: st.source.name.clone(),
// The badge is the built-in source's alone: a third-party curator can pin and
// publish, but cannot confer unom's review (D6).
tier: if verified { "verified" } else { "external" }.to_string(),
reviewed_at: verified
.then(|| e.verification.as_ref().map(|v| v.reviewed_at.clone()))
.flatten(),
platforms: e.platforms.clone(),
min_host: e.min_host.clone(),
compatible: reason.is_none(),
incompatible_reason: reason,
update_available: installed_version.as_deref().is_some_and(|v| v != e.version),
installed_version,
blocked: store::advisory_for(&e.pkg, Some(&e.version)).map(|a| a.reason),
});
}
}
// Stable, useful order: alphabetical by title, then by source so duplicates across shelves
// don't jitter between polls.
plugins.sort_by(|a, b| {
a.title
.to_lowercase()
.cmp(&b.title.to_lowercase())
.then_with(|| a.source.cmp(&b.source))
});
CatalogResponse {
host: HostFacts {
version: index::host_version().to_string(),
platform: index::HOST_PLATFORM.to_string(),
},
sources: source_views,
plugins,
busy: jobs::busy(),
}
}
fn build_installed(live: &[String]) -> Vec<InstalledView> {
let dir = store::plugins_dir();
let records = manifest::load(&dir);
let catalogs = store::cached_catalogs();
let mut out: Vec<InstalledView> = store::installed_packages(&dir)
.into_iter()
.map(|p| {
let rec = records.get(&p.pkg);
// The catalog row for this package, if any shelf carries it — the source of "an update
// is available" and of a friendly title for CLI-installed packages.
let entry = catalogs.iter().find_map(|s| {
s.index
.as_ref()?
.plugins
.iter()
.find(|e| e.pkg == p.pkg)
.map(|e| (e, s.source.name.clone()))
});
let plugin_id = entry
.as_ref()
.map(|(e, _)| e.id.clone())
.or_else(|| store::plugin_id_for_pkg(&p.pkg));
InstalledView {
// Absence of a manifest record is meaningful: the CLI put it there (§4.5).
tier: rec
.map(|r| r.tier)
.unwrap_or(manifest::Tier::Cli)
.as_str()
.to_string(),
source: rec
.and_then(|r| r.source.clone())
.or_else(|| entry.as_ref().map(|(_, s)| s.clone())),
entry_id: rec
.and_then(|r| r.entry_id.clone())
.or_else(|| entry.as_ref().map(|(e, _)| e.id.clone())),
title: entry.as_ref().map(|(e, _)| e.title.clone()),
installed_at: rec.and_then(|r| r.installed_at.clone()),
running: plugin_id
.as_deref()
.is_some_and(|id| live.iter().any(|l| l == id)),
update_available: entry.as_ref().and_then(|(e, _)| {
(p.version.as_deref() != Some(e.version.as_str())).then(|| e.version.clone())
}),
blocked: store::advisory_for(&p.pkg, p.version.as_deref()).map(|a| a.reason),
plugin_id,
version: p.version,
pkg: p.pkg,
}
})
.collect();
out.sort_by(|a, b| a.pkg.cmp(&b.pkg));
out
}
// ---------------------------------------------------------------- handlers
/// Browse the plugin catalog
///
/// The merged shelf across every configured source, annotated with what this host already has and
/// what it can run. Sources past their freshness window are refreshed first; a source that can't be
/// reached keeps serving its last good copy, marked `stale` (a LAN-only host still has a working
/// store — an entry's pin travelled with the entry).
#[utoipa::path(
get,
path = "/store/catalog",
tag = "store",
operation_id = "getPluginCatalog",
responses(
(status = OK, description = "The merged catalog", body = CatalogResponse),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn get_catalog() -> Response {
match blocking(|| build_catalog(false)).await {
Ok(c) => Json(c).into_response(),
Err(e) => e,
}
}
/// Refresh every catalog now
///
/// Bypasses the freshness window and re-fetches all sources, then returns the merged catalog.
#[utoipa::path(
post,
path = "/store/refresh",
tag = "store",
operation_id = "refreshPluginCatalog",
responses(
(status = OK, description = "The freshly-fetched catalog", body = CatalogResponse),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn refresh_catalog() -> Response {
match blocking(|| build_catalog(true)).await {
Ok(c) => {
crate::events::emit(crate::events::EventKind::StoreChanged);
Json(c).into_response()
}
Err(e) => e,
}
}
/// List installed plugins
///
/// What's actually in the plugins directory, joined with how it got there (the provenance manifest)
/// and whether it is registered right now. A package with no provenance record was installed with
/// the CLI and reports `tier: "cli"` — absence is the answer, not a gap.
#[utoipa::path(
get,
path = "/store/installed",
tag = "store",
operation_id = "listInstalledPlugins",
responses(
(status = OK, description = "Installed plugin packages", body = [InstalledView]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn list_installed() -> Response {
// The live set comes from the in-memory lease registry — cheap, and it must be read on this
// thread rather than inside the blocking closure to keep the borrow simple.
let live: Vec<String> = super::plugins::live_plugin_ids();
match blocking(move || build_installed(&live)).await {
Ok(v) => Json(v).into_response(),
Err(e) => e,
}
}
/// Install a plugin
///
/// Either `{source, id}` — a catalogued entry, installed at its pinned version after its integrity
/// is re-checked against the registry — or `{spec, accept_unverified: true}`, which installs an
/// unreviewed package the operator takes responsibility for. Returns `202` with a job id; watch it
/// at `GET /store/jobs/{id}`.
///
/// One package operation runs at a time (`409` otherwise): `bun` operations share a lockfile and a
/// `node_modules` tree.
#[utoipa::path(
post,
path = "/store/install",
tag = "store",
operation_id = "installPlugin",
request_body = InstallRequest,
responses(
(status = ACCEPTED, description = "Install job started", body = JobRef),
(status = BAD_REQUEST, description = "Unknown entry, bad spec, or missing acknowledgement", body = ApiError),
(status = CONFLICT, description = "Another package operation is in flight", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn install_plugin(ApiJson(req): ApiJson<InstallRequest>) -> Response {
let plan =
match (
req.source.as_deref(),
req.id.as_deref(),
req.spec.as_deref(),
) {
(Some(source), Some(id), None) => {
let found = match blocking({
let (source, id) = (source.to_string(), id.to_string());
move || store::find_entry(&source, &id)
})
.await
{
Ok(f) => f,
Err(e) => return e,
};
let Some((entry, verified)) = found else {
return api_error(
StatusCode::BAD_REQUEST,
"no such plugin in that source's catalog",
);
};
if let Some(reason) = entry.incompatible_reason() {
return api_error(
StatusCode::BAD_REQUEST,
&format!("this plugin cannot run on this host: {reason}"),
);
}
match jobs::Plan::from_entry(&entry, source, verified) {
Ok(p) => p,
Err(e) => return api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
}
}
(None, None, Some(spec)) => {
if !req.accept_unverified {
return api_error(
StatusCode::BAD_REQUEST,
"installing from a raw package spec runs unreviewed code with operator \
privileges set `accept_unverified` to confirm",
);
}
match jobs::Plan::from_spec(spec) {
Ok(p) => p,
Err(e) => return api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
}
}
_ => return api_error(
StatusCode::BAD_REQUEST,
"provide either {source, id} for a catalogued plugin or {spec} for a raw package",
),
};
match jobs::spawn_install(plan) {
Ok(job) => (StatusCode::ACCEPTED, Json(JobRef { job })).into_response(),
Err(e) => api_error(StatusCode::CONFLICT, &format!("{e:#}")),
}
}
/// Uninstall a plugin
///
/// Removes the package and forgets its provenance, then restarts the runner. Only names the runner
/// would actually supervise are accepted, so this can't be used to rip a shared dependency out of
/// the tree.
#[utoipa::path(
post,
path = "/store/uninstall",
tag = "store",
operation_id = "uninstallPlugin",
request_body = UninstallRequest,
responses(
(status = ACCEPTED, description = "Uninstall job started", body = JobRef),
(status = BAD_REQUEST, description = "Not a plugin package name", body = ApiError),
(status = CONFLICT, description = "Another package operation is in flight", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn uninstall_plugin(ApiJson(req): ApiJson<UninstallRequest>) -> Response {
if let Err(e) = store::valid_installed_pkg(&req.pkg) {
return api_error(StatusCode::BAD_REQUEST, &format!("{e:#}"));
}
// The name shape is not enough. `@punktfunk/plugin-kit` — the framework every kit-built plugin
// *depends on* — matches `@scope/plugin-*` exactly, so a syntactic guard waves it through and
// the store offers to uninstall a library out from under the plugins using it (accepted on
// Windows on-glass before this check existed). Only a package the operator actually installed
// may be removed, which is precisely what `installed_packages` reports: the plugins dir's
// top-level dependencies, transitive ones excluded.
let pkg = req.pkg.clone();
let known = match blocking(move || {
store::installed_packages(&store::plugins_dir())
.iter()
.any(|p| p.pkg == pkg)
})
.await
{
Ok(k) => k,
Err(e) => return e,
};
if !known {
return api_error(
StatusCode::BAD_REQUEST,
"that package is not an installed plugin — it may be a dependency of one, or already \
removed",
);
}
match jobs::spawn_uninstall(req.pkg) {
Ok(job) => (StatusCode::ACCEPTED, Json(JobRef { job })).into_response(),
Err(e) => api_error(StatusCode::CONFLICT, &format!("{e:#}")),
}
}
/// List recent package jobs
#[utoipa::path(
get,
path = "/store/jobs",
tag = "store",
operation_id = "listPluginJobs",
responses(
(status = OK, description = "Recent install/uninstall jobs, oldest first", body = [Job]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn list_jobs() -> Json<Vec<jobs::Job>> {
Json(jobs::list())
}
/// Follow one package job
///
/// Poll this while `state` is `running`; `log` carries the tail of the package manager's output.
#[utoipa::path(
get,
path = "/store/jobs/{id}",
tag = "store",
operation_id = "getPluginJob",
params(("id" = String, Path, description = "The job id returned by install/uninstall")),
responses(
(status = OK, description = "The job", body = Job),
(status = NOT_FOUND, description = "No such job (they are kept for a bounded history)", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn get_job(Path(id): Path<String>) -> Response {
match jobs::get(&id) {
Some(j) => Json(j).into_response(),
None => api_error(StatusCode::NOT_FOUND, "no such job"),
}
}
/// List catalog sources
#[utoipa::path(
get,
path = "/store/sources",
tag = "store",
operation_id = "listPluginSources",
responses(
(status = OK, description = "Configured sources, built-in first", body = [SourceView]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn list_sources() -> Response {
match blocking(|| {
store::cached_catalogs()
.into_iter()
.map(|st| SourceView {
name: st.source.name.clone(),
url: st.source.url.clone(),
builtin: st.source.is_official(),
signed: st.source.is_signed(),
stale: st.stale,
fetched_at: st.fetched_at,
error: st.error,
entry_count: st.index.map(|i| i.plugins.len()).unwrap_or(0) as u32,
public_key: st.source.public_key,
})
.collect::<Vec<_>>()
})
.await
{
Ok(v) => Json(v).into_response(),
Err(e) => e,
}
}
/// Add or update a catalog source
///
/// Adding a source is a trust decision: its entries become installable on this host. They are
/// attributed to it in the console and never carry the "verified" badge, which belongs to the
/// built-in source alone.
#[utoipa::path(
put,
path = "/store/sources/{name}",
tag = "store",
operation_id = "putPluginSource",
params(("name" = String, Path, description = "Source slug (`[a-z][a-z0-9-]*`)")),
request_body = SourceInput,
responses(
(status = NO_CONTENT, description = "Source saved"),
(status = BAD_REQUEST, description = "Invalid name, url or key — or the reserved built-in name", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn put_source(
Path(name): Path<String>,
ApiJson(input): ApiJson<SourceInput>,
) -> Response {
let source = sources::Source {
name: name.clone(),
url: input.url,
public_key: input.public_key.filter(|k| !k.trim().is_empty()),
};
let saved = blocking(move || {
let r = sources::put(source);
if r.is_ok() {
// A redefined source must not keep serving what the old definition cached.
store::drop_source_cache(&name);
}
r
})
.await;
match saved {
Err(e) => e,
Ok(Err(e)) => api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
Ok(Ok(())) => {
crate::events::emit(crate::events::EventKind::StoreChanged);
StatusCode::NO_CONTENT.into_response()
}
}
}
/// Remove a catalog source
#[utoipa::path(
delete,
path = "/store/sources/{name}",
tag = "store",
operation_id = "deletePluginSource",
params(("name" = String, Path, description = "Source slug")),
responses(
(status = NO_CONTENT, description = "Removed (or already absent)"),
(status = FORBIDDEN, description = "The built-in source cannot be removed, or the plugin token is not authorized", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn delete_source(Path(name): Path<String>) -> Response {
if name == sources::OFFICIAL_NAME {
return api_error(
StatusCode::FORBIDDEN,
"the built-in source cannot be removed",
);
}
let removed = blocking(move || {
let r = sources::remove(&name);
if matches!(r, Ok(true)) {
store::drop_source_cache(&name);
}
r
})
.await;
match removed {
Err(e) => e,
Ok(Err(e)) => api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
Ok(Ok(_)) => {
crate::events::emit(crate::events::EventKind::StoreChanged);
StatusCode::NO_CONTENT.into_response()
}
}
}
/// Plugin runner state
///
/// Installed plugins only run while the runner is on, and the runner discovers units at startup —
/// so this is both the "is anything running" answer and the explanation for a freshly installed
/// plugin that hasn't appeared yet.
#[utoipa::path(
get,
path = "/store/runtime",
tag = "store",
operation_id = "getPluginRuntime",
responses(
(status = OK, description = "Runner state", body = RuntimeView),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn get_runtime() -> Response {
match blocking(runtime_view).await {
Ok(v) => Json(v).into_response(),
Err(e) => e,
}
}
/// Turn the plugin runner on or off
#[utoipa::path(
post,
path = "/store/runtime",
tag = "store",
operation_id = "setPluginRuntime",
request_body = RuntimeRequest,
responses(
(status = OK, description = "The resulting runner state", body = RuntimeView),
(status = BAD_REQUEST, description = "The runner could not be switched", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn set_runtime(ApiJson(req): ApiJson<RuntimeRequest>) -> Response {
let switched =
blocking(move || crate::plugins::set_runtime_enabled(req.enabled).map(|()| runtime_view()))
.await;
match switched {
Err(e) => e,
Ok(Err(e)) => api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
Ok(Ok(v)) => {
crate::events::emit(crate::events::EventKind::StoreChanged);
Json(v).into_response()
}
}
}
// Re-exported so `routes!` can name the response body types.
pub(crate) use crate::store::jobs::Job;
+46
View File
@@ -434,6 +434,46 @@ fn plugin_allowlist_excludes_escalation_routes() {
&Method::GET, &Method::GET,
"/api/v1/plugins/x/ui-credential" "/api/v1/plugins/x/ui-credential"
)); ));
// The plugin STORE, wholesale. Installing a plugin runs new code with operator privileges, so a
// plugin able to do it could install a helper that isn't constrained the way it is — and
// `POST /store/runtime` would let it switch its own supervisor. Denied by whole-prefix so a
// route added here later is denied by default rather than by remembering to list it.
for path in [
"/api/v1/store/catalog",
"/api/v1/store/installed",
"/api/v1/store/sources",
"/api/v1/store/jobs",
"/api/v1/store/jobs/job-1",
"/api/v1/store/runtime",
"/api/v1/store/some-route-that-does-not-exist-yet",
] {
assert!(
!auth::plugin_may_access(&Method::GET, path),
"plugin token must not reach {path}"
);
}
for path in [
"/api/v1/store/install",
"/api/v1/store/uninstall",
"/api/v1/store/refresh",
"/api/v1/store/runtime",
] {
assert!(
!auth::plugin_may_access(&Method::POST, path),
"plugin token must not reach {path}"
);
}
assert!(!auth::plugin_may_access(
&Method::PUT,
"/api/v1/store/sources/evil"
));
assert!(!auth::plugin_may_access(
&Method::DELETE,
"/api/v1/store/sources/unom"
));
// …but a route that merely starts with the same letters is unaffected.
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/status"));
} }
/// The plugin bearer lane end-to-end: scoped 403s on the carve-outs, 200s on the plugin surface, /// The plugin bearer lane end-to-end: scoped 403s on the carve-outs, 200s on the plugin surface,
@@ -484,6 +524,12 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() {
(Method::GET, "/api/v1/native/pending"), (Method::GET, "/api/v1/native/pending"),
(Method::DELETE, "/api/v1/clients/aabbcc"), (Method::DELETE, "/api/v1/clients/aabbcc"),
(Method::GET, "/api/v1/plugins/x/ui-credential"), (Method::GET, "/api/v1/plugins/x/ui-credential"),
// The plugin store: a plugin must not be able to install plugins or switch its own runner.
(Method::GET, "/api/v1/store/catalog"),
(Method::POST, "/api/v1/store/install"),
(Method::POST, "/api/v1/store/uninstall"),
(Method::POST, "/api/v1/store/runtime"),
(Method::PUT, "/api/v1/store/sources/evil"),
] { ] {
let (status, body) = send(&app, plugin_req(method.clone(), path)).await; let (status, body) = send(&app, plugin_req(method.clone(), path)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}"); assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}");
+41 -6
View File
@@ -439,7 +439,11 @@ pub(crate) async fn serve(
) )
.await .await
{ {
Ok(()) => tracing::info!(%peer, "session complete"), Ok(Served::Session) => tracing::info!(%peer, "session complete"),
Ok(Served::ProbeClose) => tracing::debug!(
%peer,
"closed before the control handshake (reachability probe)"
),
Err(e) => { Err(e) => {
tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error") tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error")
} }
@@ -629,6 +633,15 @@ type AudioCapSlot = Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCaptu
/// connection (the host stops waiting at once). /// connection (the host stops waiting at once).
const PENDING_APPROVAL_WAIT: std::time::Duration = std::time::Duration::from_secs(180); const PENDING_APPROVAL_WAIT: std::time::Duration = std::time::Duration::from_secs(180);
/// How a served connection ended. A peer that completes the QUIC handshake and closes cleanly
/// (code 0) without ever opening the control stream is a reachability probe (the clients'
/// hosts-page "online" pips / `--reachable`) or an abandoned connect — routine, and logged
/// quietly: as a WARN it buried the real failures in a wake-on-LAN triage log.
enum Served {
Session,
ProbeClose,
}
/// One client session: handshake → input/audio planes → data plane until done/disconnect. /// One client session: handshake → input/audio planes → data plane until done/disconnect.
/// Everything torn down on return (RAII: virtual output, encoder, threads via channel close). /// Everything torn down on return (RAII: virtual output, encoder, threads via channel close).
/// A connection whose first message is a PairRequest runs the pairing ceremony instead. /// A connection whose first message is a PairRequest runs the pairing ceremony instead.
@@ -651,14 +664,23 @@ async fn serve_session(
// parked knock can't hold a streaming slot. `sem` is the pool it re-acquires from. // parked knock can't hold a streaming slot. `sem` is the pool it re-acquires from.
mut permit: tokio::sync::OwnedSemaphorePermit, mut permit: tokio::sync::OwnedSemaphorePermit,
sem: Arc<tokio::sync::Semaphore>, sem: Arc<tokio::sync::Semaphore>,
) -> Result<()> { ) -> Result<Served> {
let peer = conn.remote_address(); let peer = conn.remote_address();
// First message decides what this connection is: a pairing ceremony or a session. // First message decides what this connection is: a pairing ceremony or a session.
let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, conn.accept_bi()) let (mut send, mut recv) = match tokio::time::timeout(HANDSHAKE_TIMEOUT, conn.accept_bi())
.await .await
.map_err(|_| anyhow!("control stream timeout"))? .map_err(|_| anyhow!("control stream timeout"))?
.context("accept control stream")?; {
// A clean close before any control stream: a reachability probe / abandoned connect,
// not a failed session (see [`Served::ProbeClose`]).
Err(quinn::ConnectionError::ApplicationClosed(ref ac))
if ac.error_code == quinn::VarInt::from_u32(0) =>
{
return Ok(Served::ProbeClose);
}
r => r.context("accept control stream")?,
};
let first = tokio::time::timeout(HANDSHAKE_TIMEOUT, io::read_msg(&mut recv)) let first = tokio::time::timeout(HANDSHAKE_TIMEOUT, io::read_msg(&mut recv))
.await .await
.map_err(|_| anyhow!("first message timeout"))??; .map_err(|_| anyhow!("first message timeout"))??;
@@ -709,7 +731,9 @@ async fn serve_session(
} }
*last = Some(std::time::Instant::now()); *last = Some(std::time::Instant::now());
} }
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin).await; return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin)
.await
.map(|()| Served::Session);
} }
// Pairing gate for a session Hello (a PairRequest was handled above). Lifted OUT of the // Pairing gate for a session Hello (a PairRequest was handled above). Lifted OUT of the
@@ -1175,6 +1199,13 @@ async fn serve_session(
launch: hello.launch.clone(), launch: hello.launch.clone(),
plane: crate::events::Plane::Native, plane: crate::events::Plane::Native,
}); });
// GPU clock pin (Linux, opt-in `PUNKTFUNK_PIN_CLOCKS`): hold the box-wide vendor clock floor for
// as long as THIS session streams, refcounted with every other live session across both planes.
// RAII like the marker above — armed on the first live client, released when the last one
// disconnects, so idle clocks aren't pinned while nobody is connected. No-op off Linux / when
// the flag is unset.
#[cfg(target_os = "linux")]
let _clock_pin = crate::gpuclocks::session_pin();
// The session's launch, threaded into the data plane. Windows carries the store-qualified id // The session's launch, threaded into the data plane. Windows carries the store-qualified id
// (spawned into the interactive user session once capture is live); other hosts resolve the id // (spawned into the interactive user session once capture is live); other hosts resolve the id
// to its shell command HERE against the host's own library — a client can only ever pick an // to its shell command HERE against the host's own library — a client can only ever pick an
@@ -1238,6 +1269,9 @@ async fn serve_session(
// so mid-session bursts don't consume video frame indexes. An older client (bit clear) gets // so mid-session bursts don't consume video frame indexes. An older client (bit clear) gets
// mid-session probes declined instead — see `run_probe_burst`. // mid-session probes declined instead — see `run_probe_burst`.
let probe_seq = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ != 0; let probe_seq = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ != 0;
// Streamed-AU capability: the client's reassembler accepts sentinel-headed streamed blocks,
// so a chunked encoder session may ship an AU's early FEC blocks while its tail encodes.
let streamed_au = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_STREAMED_AU != 0;
let stats_dp = stats; // data-plane handle to the shared stats recorder let stats_dp = stats; // data-plane handle to the shared stats recorder
// Short label for web-console stats captures: the client's cert-fingerprint prefix, else its // Short label for web-console stats captures: the client's cert-fingerprint prefix, else its
// peer IP (no fingerprint = anonymous TOFU/--open client). // peer IP (no fingerprint = anonymous TOFU/--open client).
@@ -1330,6 +1364,7 @@ async fn serve_session(
conn: conn_stream, conn: conn_stream,
timing_conn, timing_conn,
probe_seq, probe_seq,
streamed_au,
stats: stats_dp, stats: stats_dp,
client_label, client_label,
launch: launch_for_dp, launch: launch_for_dp,
@@ -1388,7 +1423,7 @@ async fn serve_session(
// host-managed gamescope path on a box that autologs into gaming mode (Bazzite default), put the // host-managed gamescope path on a box that autologs into gaming mode (Bazzite default), put the
// TV's gaming session back so it's the default when no one is streaming. // TV's gaming session back so it's the default when no one is streaming.
crate::vdisplay::restore_managed_session(); crate::vdisplay::restore_managed_session();
result result.map(|()| Served::Session)
} }
/// 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
@@ -354,6 +354,28 @@ pub(super) async fn negotiate(
// just follow. // just follow.
let mut salt = [0u8; 4]; let mut salt = [0u8; 4];
rand::thread_rng().fill_bytes(&mut salt); rand::thread_rng().fill_bytes(&mut salt);
// Session AEAD: ChaCha20-Poly1305 when the client asked for it (VIDEO_CAP_CHACHA20 — the
// soft-AES armv7 targets, whose GCM decrypt caps at ~100 Mbps) and the operator
// kill-switch allows (PUNKTFUNK_CHACHA20, default on — pure rollout safety; perf-only,
// both AEADs are full-strength). The fresh-per-session discipline above applies to this
// key identically; the legacy 16-byte `key` stays independently random so nothing
// downstream ever observes an all-zero key.
let client_wants_chacha = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_CHACHA20 != 0;
let chacha = client_wants_chacha && pf_host_config::config().chacha20;
let key_chacha = chacha.then(|| {
let mut k = [0u8; 32];
rand::thread_rng().fill_bytes(&mut k);
k
});
tracing::info!(
cipher = if chacha {
"chacha20-poly1305"
} else {
"aes-128-gcm"
},
client_wants_chacha,
"session cipher"
);
let welcome = Welcome { let welcome = Welcome {
abi_version: punktfunk_core::WIRE_VERSION, abi_version: punktfunk_core::WIRE_VERSION,
udp_port, udp_port,
@@ -423,6 +445,16 @@ pub(super) async fn negotiate(
} else { } else {
0 0
}, },
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
// pre-cipher wire form. The host's own data plane picks the cipher up via
// `welcome.session_config` — no other host change.
cipher: if chacha {
punktfunk_core::quic::CIPHER_CHACHA20_POLY1305
} else {
punktfunk_core::quic::CIPHER_AES_128_GCM
},
key_chacha,
}; };
io::write_msg(send, &welcome.encode()).await?; io::write_msg(send, &welcome.encode()).await?;
bringup.mark("welcome"); bringup.mark("welcome");
+437 -90
View File
@@ -252,6 +252,19 @@ fn paced_submit(
let wires = session let wires = session
.seal_frame_at(data, pts_ns, flags, frame_index) .seal_frame_at(data, pts_ns, flags, frame_index)
.map_err(|e| anyhow!("seal_frame: {e:?}"))?; .map_err(|e| anyhow!("seal_frame: {e:?}"))?;
pace_sealed(session, wires, deadline, burst_cap, pace_rate_bps)
}
/// The pace-and-send half of [`paced_submit`], for wires that are ALREADY sealed — shared with
/// the streamed-AU path, whose seal happens per encoder chunk ([`handle_chunk`]) under the same
/// microburst policy and frame deadline.
fn pace_sealed(
session: &mut Session,
wires: Vec<Vec<u8>>,
deadline: std::time::Instant,
burst_cap: Option<usize>,
pace_rate_bps: u64,
) -> Result<PaceStat> {
let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect(); let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
// FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors. // FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors.
crate::send_pacing::inject_video_drop(&mut refs); crate::send_pacing::inject_video_drop(&mut refs);
@@ -335,6 +348,116 @@ struct FrameMsg {
was_measured: bool, was_measured: bool,
} }
/// What the encode thread hands the send thread: a whole AU (the legacy path — every session
/// shape except a chunked encoder toward a streamed-capable client), or one slice-boundary
/// chunk of a streamed AU (§7 LN1 Phase 2 — the send thread seals/paces each chunk's completed
/// FEC blocks while the encoder still produces the AU's tail).
enum SendMsg {
Frame(FrameMsg),
Chunk(ChunkMsg),
}
/// One encoder chunk of a streamed AU. AU-level fields (`capture_ns`/`flags`/`frame_index`/
/// `deadline`) are identical on every chunk of one AU (the send thread opens the streamed seal
/// at `first`); the perf split fields are meaningful on `last` (whole-AU figures, exactly like
/// [`FrameMsg`]'s).
struct ChunkMsg {
data: Vec<u8>,
first: bool,
last: bool,
capture_ns: u64,
flags: u32,
frame_index: u32,
deadline: std::time::Instant,
encode_us: u32,
queue_us: u32,
cap_us: u32,
submit_us: u32,
wait_us: u32,
repeat: bool,
was_measured: bool,
}
/// A streamed AU the send thread has open: the core's incremental sealer plus the pace
/// aggregation across its per-chunk flushes (the accounting the whole-AU path reads off one
/// [`paced_submit`] call).
struct StreamedOpen {
au: punktfunk_core::packet::StreamedAu,
spread_us: u32,
paced: bool,
}
/// Feed one [`ChunkMsg`] through the streamed sealer: open at `first`, seal + pace every FEC
/// block the chunk completes, close (+ final block, real totals) at `last`. Returns
/// `Some((accounting, aggregated PaceStat))` when the AU finished — the caller runs the same
/// per-AU accounting as the whole-frame path — and `None` mid-AU.
fn handle_chunk(
session: &mut Session,
open: &mut Option<StreamedOpen>,
c: ChunkMsg,
burst_cap: Option<usize>,
pace_rate_bps: u64,
) -> Result<Option<(FrameMsg, PaceStat)>> {
if c.first {
if open.take().is_some() {
// The encode loop abandoned a mid-flight AU (an encoder stall/rebuild forfeits the
// in-flight frame). Its sentinel packets are already on the wire — the client ages
// that frame out and the rebuild's IDR re-anchors; just don't leak the open state.
tracing::warn!(
"streamed AU abandoned mid-flight (encoder rebuild) — client ages it out"
);
}
*open = Some(StreamedOpen {
au: session
.begin_streamed_frame_at(c.capture_ns, c.flags, c.frame_index)
.map_err(|e| anyhow!("begin_streamed_frame: {e:?}"))?,
spread_us: 0,
paced: false,
});
}
let Some(s) = open.as_mut() else {
return Err(anyhow!(
"streamed chunk without an open AU (encode-loop bug)"
));
};
let wires = session
.seal_streamed_chunk(&mut s.au, &c.data)
.map_err(|e| anyhow!("seal_streamed_chunk: {e:?}"))?;
if !wires.is_empty() {
let stat = pace_sealed(session, wires, c.deadline, burst_cap, pace_rate_bps)?;
s.spread_us = s.spread_us.saturating_add(stat.spread_us);
s.paced |= stat.paced;
}
if !c.last {
return Ok(None);
}
let s = open.take().expect("checked above");
let tail = session
.seal_streamed_finish(s.au)
.map_err(|e| anyhow!("seal_streamed_finish: {e:?}"))?;
let stat = pace_sealed(session, tail, c.deadline, burst_cap, pace_rate_bps)?;
Ok(Some((
FrameMsg {
data: Vec::new(), // already on the wire — accounting only
capture_ns: c.capture_ns,
flags: c.flags,
frame_index: c.frame_index,
deadline: c.deadline,
encode_us: c.encode_us,
queue_us: c.queue_us,
cap_us: c.cap_us,
submit_us: c.submit_us,
wait_us: c.wait_us,
repeat: c.repeat,
was_measured: c.was_measured,
},
PaceStat {
spread_us: s.spread_us.saturating_add(stat.spread_us),
paced: s.paced || stat.paced,
},
)))
}
/// The dedicated send thread: it owns the whole [`Session`] (so no socket clone or shared stats are /// The dedicated send thread: it owns the whole [`Session`] (so no socket clone or shared stats are
/// needed) and does FEC+seal + microburst-paced send OFF the capture/encode thread, plus the /// needed) and does FEC+seal + microburst-paced send OFF the capture/encode thread, plus the
/// speed-test probe bursts (which also need the Session). Decoupling the paced send from encoding /// speed-test probe bursts (which also need the Session). Decoupling the paced send from encoding
@@ -380,7 +503,7 @@ pub(super) fn reconfig_allowed(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn send_loop( fn send_loop(
mut session: Session, mut session: Session,
frame_rx: std::sync::mpsc::Receiver<FrameMsg>, frame_rx: std::sync::mpsc::Receiver<SendMsg>,
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>, probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>, probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
@@ -425,96 +548,119 @@ fn send_loop(
let mut last_frames_dropped = 0u64; let mut last_frames_dropped = 0u64;
let mut last_packets_dropped = 0u64; let mut last_packets_dropped = 0u64;
let mut last_fec_recovered = 0u64; let mut last_fec_recovered = 0u64;
// The streamed AU currently open (VIDEO_CAP_STREAMED_AU chunked sends) — `Some` strictly
// between a `ChunkMsg::first` and its `last`.
let mut streamed: Option<StreamedOpen> = None;
loop { loop {
if stop.load(Ordering::SeqCst) { if stop.load(Ordering::SeqCst) {
break; break;
} }
// Probes run here (they need the Session); a burst pauses video — the encode thread blocks // Probes run here (they need the Session); a burst pauses video — the encode thread blocks
// on the full frame channel meanwhile, which is exactly the intended pause. // on the full frame channel meanwhile, which is exactly the intended pause. Never mid-AU:
service_probes(&mut session, &stop, &probe_rx, &probe_result_tx, probe_seq); // a streamed frame's chunks are already leaving the socket, so a burst spliced between
// them would push the AU's tail past its deadline (the exact latency the mode removes).
if streamed.is_none() {
service_probes(&mut session, &stop, &probe_rx, &probe_result_tx, probe_seq);
}
// Adaptive FEC: pick up any new recovery target the control task set from client LossReports. // Adaptive FEC: pick up any new recovery target the control task set from client LossReports.
apply_fec_target(&mut session, &fec_target); apply_fec_target(&mut session, &fec_target);
// Short timeout so we keep re-checking `stop` + probes when no frames are flowing. // Short timeout so we keep re-checking `stop` + probes when no frames are flowing.
match frame_rx.recv_timeout(std::time::Duration::from_millis(50)) { match frame_rx.recv_timeout(std::time::Duration::from_millis(50)) {
Ok(msg) => match paced_submit( Ok(send_msg) => {
&mut session,
&msg.data,
msg.capture_ns,
msg.flags,
msg.frame_index,
msg.deadline,
burst_cap,
// Live ABR-tracked encoder bitrate → pace rate; 0 (not yet known) = uncapped. // Live ABR-tracked encoder bitrate → pace rate; 0 (not yet known) = uncapped.
(stats.bitrate_kbps.load(Ordering::Relaxed) as f64 * 1000.0 * pace_factor) as u64, let pace_rate = (stats.bitrate_kbps.load(Ordering::Relaxed) as f64
) { * 1000.0
Ok(stat) => { * pace_factor) as u64;
// First VIDEO packets are on the wire — complete the bring-up trace (P0.1; // `Ok(Some(..))` = an AU fully left the socket (a whole frame, or a streamed
// once-only, no-op on every later frame). Speed-test filler isn't video. // AU's last chunk) — run the per-AU accounting; `Ok(None)` = mid-AU chunk.
if msg.flags & FLAG_PROBE as u32 == 0 { let outcome = match send_msg {
stats.bringup.finish("first_packet"); SendMsg::Frame(msg) => paced_submit(
&mut session,
&msg.data,
msg.capture_ns,
msg.flags,
msg.frame_index,
msg.deadline,
burst_cap,
pace_rate,
)
.map(|stat| Some((msg, stat))),
SendMsg::Chunk(c) => {
handle_chunk(&mut session, &mut streamed, c, burst_cap, pace_rate)
} }
// Host timing (0xCF): stamped now — the AU's packets have fully left the };
// socket — against the same capture anchor the wire pts carries, so the match outcome {
// client's per-frame math tiles exactly (network = its host+network this). // Mid-AU chunk: sealed + on the wire; the per-AU accounting runs at `last`.
// Best-effort like every side-plane datagram; skipped for speed-test filler Ok(None) => {}
// (FLAG_PROBE isn't video and its pts is the burst clock). Ok(Some((msg, stat))) => {
if let Some(tc) = &timing_conn { // First VIDEO packets are on the wire — complete the bring-up trace (P0.1;
// once-only, no-op on every later frame). Speed-test filler isn't video.
if msg.flags & FLAG_PROBE as u32 == 0 { if msg.flags & FLAG_PROBE as u32 == 0 {
let host_us = (now_ns().saturating_sub(msg.capture_ns) / 1000) stats.bringup.finish("first_packet");
.min(u32::MAX as u64)
as u32;
let t = punktfunk_core::quic::HostTiming {
pts_ns: msg.capture_ns,
host_us,
// T0.1 stage split: queue + encode ride the FrameMsg (always
// measured), pace is this send's spread. The client derives
// seal/FEC + channel-wait as the residual against host_us.
stages: Some(punktfunk_core::quic::HostStages {
queue_us: msg.queue_us,
encode_us: msg.encode_us,
pace_us: stat.spread_us,
}),
};
let _ = tc.send_datagram(
punktfunk_core::quic::encode_host_timing_datagram(&t).into(),
);
} }
} // Host timing (0xCF): stamped now — the AU's packets have fully left the
if perf || stats.rec.is_armed() { // socket — against the same capture anchor the wire pts carries, so the
// `encode_us`/`pace_us`/fps are valid for every frame (always measured), // client's per-frame math tiles exactly (network = its host+network this).
// including the Windows relay + tail-drain frames. The cap/submit/wait splits // Best-effort like every side-plane datagram; skipped for speed-test filler
// are only real when the frame was measured at capture time — a frame captured // (FLAG_PROBE isn't video and its pts is the burst clock).
// before this capture armed carries zeroed splits, so skip those (an empty if let Some(tc) = &timing_conn {
// window → `percentile()` returns 0) rather than pull the percentiles down. if msg.flags & FLAG_PROBE as u32 == 0 {
encode_us.push(msg.encode_us); let host_us = (now_ns().saturating_sub(msg.capture_ns) / 1000)
pace_us.push(stat.spread_us); .min(u32::MAX as u64)
if msg.was_measured { as u32;
cap_v.push(msg.cap_us); let t = punktfunk_core::quic::HostTiming {
submit_v.push(msg.submit_us); pts_ns: msg.capture_ns,
wait_v.push(msg.wait_us); host_us,
// Queue age is only meaningful for fresh frames (repeats/tail carry 0 // T0.1 stage split: queue + encode ride the FrameMsg (always
// by construction — including those would drag the percentiles down). // measured), pace is this send's spread. The client derives
if !msg.repeat { // seal/FEC + channel-wait as the residual against host_us.
queue_v.push(msg.queue_us); stages: Some(punktfunk_core::quic::HostStages {
queue_us: msg.queue_us,
encode_us: msg.encode_us,
pace_us: stat.spread_us,
}),
};
let _ = tc.send_datagram(
punktfunk_core::quic::encode_host_timing_datagram(&t).into(),
);
} }
} }
if msg.repeat { if perf || stats.rec.is_armed() {
repeat_frames += 1; // `encode_us`/`pace_us`/fps are valid for every frame (always measured),
} else { // including the Windows relay + tail-drain frames. The cap/submit/wait splits
new_frames += 1; // are only real when the frame was measured at capture time — a frame captured
} // before this capture armed carries zeroed splits, so skip those (an empty
if stat.paced { // window → `percentile()` returns 0) rather than pull the percentiles down.
paced_frames += 1; encode_us.push(msg.encode_us);
} else { pace_us.push(stat.spread_us);
immediate_frames += 1; if msg.was_measured {
cap_v.push(msg.cap_us);
submit_v.push(msg.submit_us);
wait_v.push(msg.wait_us);
// Queue age is only meaningful for fresh frames (repeats/tail carry 0
// by construction — including those would drag the percentiles down).
if !msg.repeat {
queue_v.push(msg.queue_us);
}
}
if msg.repeat {
repeat_frames += 1;
} else {
new_frames += 1;
}
if stat.paced {
paced_frames += 1;
} else {
immediate_frames += 1;
}
} }
} }
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "send failed — stopping stream");
break;
}
} }
Err(e) => { }
tracing::error!(error = %format!("{e:#}"), "send failed — stopping stream");
break;
}
},
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, // encode thread done Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, // encode thread done
} }
@@ -798,6 +944,11 @@ pub(super) struct SessionContext {
/// stale — mid-session probes are DECLINED for it (a zeroed [`ProbeResult`]) rather than /// stale — mid-session probes are DECLINED for it (a zeroed [`ProbeResult`]) rather than
/// consuming video frame indexes its gap detectors can't see (the phantom-gap freeze). /// consuming video frame indexes its gap detectors can't see (the phantom-gap freeze).
pub(super) probe_seq: bool, pub(super) probe_seq: bool,
/// The client advertised [`punktfunk_core::quic::VIDEO_CAP_STREAMED_AU`]: when the session's
/// encoder runs chunked poll (multi-slice sub-frame readback, §7 LN1), the host streams each
/// AU's FEC blocks under sentinel headers as the slices complete instead of waiting for the
/// whole AU. `false` = older client — chunks (if any) are drained whole-AU, zero wire change.
pub(super) streamed_au: bool,
/// Shared streaming-stats recorder. The capture loop reads `is_armed()` per frame to decide /// Shared streaming-stats recorder. The capture loop reads `is_armed()` per frame to decide
/// whether to measure the per-stage split; the send thread builds + pushes the aggregated /// whether to measure the per-stage split; the send thread builds + pushes the aggregated
/// `StatsSample` at its 2 s boundary. /// `StatsSample` at its 2 s boundary.
@@ -832,7 +983,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site // path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site
// (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `bit_depth` is the // (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `bit_depth` is the
// only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`. // only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`.
let mut plan = crate::session_plan::SessionPlan::resolve(ctx.bit_depth, ctx.chroma, ctx.codec); let mut plan = crate::session_plan::SessionPlan::resolve(
ctx.bit_depth,
ctx.chroma,
ctx.codec,
ctx.compositor != pf_vdisplay::Compositor::Gamescope,
);
// PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens // PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens
// packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames. // packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames.
if ctx.codec == crate::encode::Codec::PyroWave { if ctx.codec == crate::encode::Codec::PyroWave {
@@ -866,6 +1022,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
conn, conn,
timing_conn, timing_conn,
probe_seq, probe_seq,
streamed_au,
stats, stats,
client_label, client_label,
launch, launch,
@@ -873,6 +1030,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
bringup, bringup,
resize_ms, resize_ms,
} = ctx; } = ctx;
// Streamed-AU wire mode: the client's cap AND the host escape hatch (`PUNKTFUNK_STREAMED_AU=0`
// reverts to whole-AU sends without touching the encoder's slicing knobs). The third gate —
// whether the ENCODER actually chunks — is dynamic (`supports_chunked_poll`, per AU).
let streamed_wire = streamed_au && std::env::var("PUNKTFUNK_STREAMED_AU").as_deref() != Ok("0");
if streamed_wire {
tracing::info!(
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — chunked encoder output \
will stream per-slice"
);
}
tracing::info!( tracing::info!(
compositor = compositor.id(), compositor = compositor.id(),
?mode, ?mode,
@@ -944,6 +1111,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
plan, plan,
&quit, &quit,
&stop, &stop,
8,
Some(bringup.as_ref()), Some(bringup.as_ref()),
)?; )?;
// Setup done — the IDD-push setup lock releases as the guard leaves this arm's scope, // Setup done — the IDD-push setup lock releases as the guard leaves this arm's scope,
@@ -1032,7 +1200,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// encode of frame N+1 overlaps the paced transmit of frame N instead of waiting behind its tail. // encode of frame N+1 overlaps the paced transmit of frame N instead of waiting behind its tail.
// The bounded channel applies backpressure (the encode thread blocks if the send falls behind, // The bounded channel applies backpressure (the encode thread blocks if the send falls behind,
// so frames slow down rather than a dropped frame freezing the infinite-GOP stream). // so frames slow down rather than a dropped frame freezing the infinite-GOP stream).
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<FrameMsg>(3); let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<SendMsg>(3);
// Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive // Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive
// bitrate change (bitrate_rx below) updates it so the console shows the actual target. // bitrate change (bitrate_rx below) updates it so the console shows the actual target.
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps)); let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps));
@@ -1259,6 +1427,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
plan, plan,
&quit, &quit,
&stop, &stop,
8,
None, None,
)?; )?;
Ok((new_vd, pipe)) Ok((new_vd, pipe))
@@ -1355,6 +1524,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
bit_depth, bit_depth,
plan, plan,
&quit, &quit,
// No first-frame shortening here: this direct call has no retry wrapper to
// absorb an early bail, and the resize source is a live compositor (the
// takeover race doesn't apply) — keep the patient default.
None,
Some(resize_trace.as_ref()), Some(resize_trace.as_ref()),
) { ) {
Ok(next_pipe) => { Ok(next_pipe) => {
@@ -1470,6 +1643,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
frame.is_cuda(), frame.is_cuda(),
bit_depth, bit_depth,
plan.chroma, plan.chroma,
plan.cursor_blend,
) { ) {
Ok(mut new_enc) => { Ok(mut new_enc) => {
tracing::info!( tracing::info!(
@@ -1710,7 +1884,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// connected, frozen on the last frame, and the stream resumes when the new output // connected, frozen on the last frame, and the stream resumes when the new output
// appears — no reconnect. // appears — no reconnect.
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40); const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET; // Attach-only holdoff: for the first seconds after a capture loss the session
// detection can be STALE (the new session isn't up yet), and a rebuild acting on
// a stale "Gaming" answer restarts gamescope-session.target — which on SteamOS
// steals the seat back from the session the user just switched to (observed
// live). While the holdoff lasts, builds run under a vdisplay rebuild-probe
// scope: attach to live outputs only, never stop/relaunch/take over sessions.
const PROBE_HOLDOFF: std::time::Duration = std::time::Duration::from_secs(4);
let loss_at = std::time::Instant::now();
let rebuild_deadline = loss_at + REBUILD_BUDGET;
let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop { let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop {
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids // Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
// retargeting (then we stick to the pinned backend and just rebuild it). // retargeting (then we stick to the pinned backend and just rebuild it).
@@ -1744,6 +1926,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
} }
} }
} }
let _probe = (loss_at.elapsed() < PROBE_HOLDOFF)
.then(crate::vdisplay::rebuild_probe_scope);
match build_pipeline_with_retry( match build_pipeline_with_retry(
&mut vd, &mut vd,
cur_mode, cur_mode,
@@ -1752,6 +1936,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
plan, plan,
&quit, &quit,
&stop, &stop,
// 1, not 8: this loop re-detects the active session per iteration — short
// inner cycles are what let it FOLLOW a session switch instead of burning
// retries against a compositor that no longer exists. One attempt per
// cycle also keeps every probe on the SHORT first-frame window (attempt 1
// = 2.5 s): a patient 10 s attempt here just waits on a stale backend
// (observed live: it made a Game→Desktop switch 20 s instead of ~9 —
// the winning KWin rebuild took 0.7 s once detection caught up). The
// slow-new-session case is the OUTER loop's job (40 s budget, fresh
// 2.5 s probes until the new compositor delivers).
1,
None, None,
) { ) {
Ok(p) => break p, Ok(p) => break p,
@@ -1764,6 +1958,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
} }
tracing::warn!(error = %format!("{e2:#}"), tracing::warn!(error = %format!("{e2:#}"),
"capture lost — new session not up yet, retrying"); "capture lost — new session not up yet, retrying");
// Probe failures are instant (attach-only bail) — pace the loop so
// re-detection runs at ~2 Hz instead of spinning.
std::thread::sleep(std::time::Duration::from_millis(500));
} }
} }
}; };
@@ -1932,6 +2129,116 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// carry it to the shared stall recovery below instead of killing the session outright. // carry it to the shared stall recovery below instead of killing the session outright.
let mut poll_err: Option<anyhow::Error> = None; let mut poll_err: Option<anyhow::Error> = None;
while inflight.len() >= depth { while inflight.len() >= depth {
// Streamed chunked drain (§7 LN1 Phase 2): toward a STREAMED_AU client with the
// encoder's chunked poll live, forward each slice chunk to the send thread the
// moment it's readable, so packetize/FEC/pacing overlap the encode tail. Re-queried
// per AU (never cached): a pipelined-retrieve escalation or a session rebuild turns
// the mode off and the next AU falls back to the whole-AU path below.
if streamed_wire && enc.supports_chunked_poll() {
let t_wait = std::time::Instant::now();
let mut first_chunk_us = 0u32;
let mut au_flags = 0u32;
let mut au_done = false;
loop {
let c = match enc.poll_chunk() {
Ok(Some(c)) => c,
Ok(None) => break, // defensive: nothing in flight
Err(e) => {
poll_err = Some(e);
break;
}
};
// Every chunk proves the encoder is alive.
last_au_at = std::time::Instant::now();
encoder_resets = 0;
if c.first {
first_chunk_us = t_wait.elapsed().as_micros() as u32;
au_flags = if c.keyframe {
(FLAG_PIC | FLAG_SOF) as u32
} else {
FLAG_PIC as u32
};
let caps = enc.caps();
if caps.intra_refresh_recovery
&& caps.intra_refresh_period > 0
&& mark_recovery_boundary(
&mut ir_wave_pos,
c.keyframe,
caps.intra_refresh_period,
)
{
au_flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_POINT;
}
if c.recovery_anchor {
au_flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR;
}
if c.chunk_aligned {
au_flags |= punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED;
}
if let Some(m) = last_hdr_meta {
if c.keyframe || resend_meta {
let _ = conn.send_datagram(
punktfunk_core::quic::encode_hdr_meta_datagram(&m).into(),
);
resend_meta = false;
}
}
bringup.mark("first_au");
}
let last = c.last;
let (cap_ns, sub_ns, deadline) = *inflight.front().expect("inflight non-empty");
let wait_total_us = t_wait.elapsed().as_micros() as u32;
let encode_us = (now_ns().saturating_sub(sub_ns) / 1000) as u32;
let msg = ChunkMsg {
data: c.data,
first: c.first,
last,
capture_ns: cap_ns,
flags: au_flags,
frame_index: au_seq,
deadline,
encode_us,
queue_us,
cap_us,
submit_us,
wait_us: if measure { wait_total_us } else { 0 },
repeat,
was_measured: measure,
};
if frame_tx.send(SendMsg::Chunk(msg)).is_err() {
send_gone = true;
break;
}
if last {
inflight.pop_front();
au_seq = au_seq.wrapping_add(1);
sent += 1;
au_done = true;
if perf {
st_wait.push(wait_total_us);
// The overlap measurement the Phase-3 gate needs (sampled): how
// early the first slice reached the send thread vs. the whole
// encode — the win is roughly their difference per AU.
if sent % 120 == 0 {
tracing::info!(
first_slice_us = first_chunk_us,
encode_us,
"streamed AU (sampled): first slice handed to send at \
first_slice_us; encode finished at encode_us"
);
}
}
break;
}
}
if send_gone || poll_err.is_some() {
break;
}
if au_done {
continue; // drain the next in-flight frame, if depth allows
}
break; // defensive Ok(None): leave the frame in flight, re-poll next tick
}
let t_wait = std::time::Instant::now(); let t_wait = std::time::Instant::now();
let polled = enc.poll(); let polled = enc.poll();
let wait_us = if measure { let wait_us = if measure {
@@ -2012,7 +2319,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// Hand to the send thread; this blocks (backpressure) if it's behind. An Err means it // Hand to the send thread; this blocks (backpressure) if it's behind. An Err means it
// exited (send failure / stop) — end the encode loop too. // exited (send failure / stop) — end the encode loop too.
bringup.mark("first_au"); // P0.1 (first-crossing only; free afterwards) bringup.mark("first_au"); // P0.1 (first-crossing only; free afterwards)
if frame_tx.send(msg).is_err() { if frame_tx.send(SendMsg::Frame(msg)).is_err() {
send_gone = true; send_gone = true;
break; break;
} }
@@ -2157,7 +2464,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
repeat: false, repeat: false,
was_measured: false, was_measured: false,
}; };
if frame_tx.send(msg).is_err() { if frame_tx.send(SendMsg::Frame(msg)).is_err() {
break; break;
} }
au_seq = au_seq.wrapping_add(1); au_seq = au_seq.wrapping_add(1);
@@ -2279,6 +2586,7 @@ fn try_inplace_resize(
new_frame.is_cuda(), new_frame.is_cuda(),
bit_depth, bit_depth,
plan.chroma, plan.chroma,
plan.cursor_blend,
) { ) {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
@@ -2347,7 +2655,12 @@ pub(super) fn prepare_display(
// Same plan resolution as `virtual_stream` (pure in these inputs + host config), including // Same plan resolution as `virtual_stream` (pure in these inputs + host config), including
// PyroWave's datagram-aligned wire mode — `Session::shard_payload()` echoes the negotiated // PyroWave's datagram-aligned wire mode — `Session::shard_payload()` echoes the negotiated
// Welcome value passed here. // Welcome value passed here.
let mut plan = crate::session_plan::SessionPlan::resolve(bit_depth, chroma, codec); let mut plan = crate::session_plan::SessionPlan::resolve(
bit_depth,
chroma,
codec,
compositor != pf_vdisplay::Compositor::Gamescope,
);
if codec == crate::encode::Codec::PyroWave { if codec == crate::encode::Codec::PyroWave {
plan.wire_chunk = Some(shard_payload as usize); plan.wire_chunk = Some(shard_payload as usize);
} }
@@ -2371,6 +2684,7 @@ pub(super) fn prepare_display(
plan, plan,
quit, quit,
stop, stop,
8,
Some(trace), Some(trace),
)?; )?;
Ok(PreparedDisplay { vd, pipeline }) Ok(PreparedDisplay { vd, pipeline })
@@ -2395,15 +2709,22 @@ fn build_pipeline_with_retry(
plan: crate::session_plan::SessionPlan, plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>, quit: &Arc<AtomicBool>,
stop: &Arc<AtomicBool>, stop: &Arc<AtomicBool>,
// Retry budget: 8 everywhere EXCEPT the capture-loss rebuild (2). That path wraps this call
// in its own outer loop that RE-DETECTS the active session between calls — during a
// Gaming↔Desktop switch the old compositor is simply gone, so burning 8 attempts (~13 s)
// against its dead socket only delays following the box to the session that replaced it
// (observed live: a Desktop→Gaming switch spent 13 of its 27 s retrying gone-KWin).
max_attempts: u32,
// Transition trace (P0.1): `Some` for the traced builds (bring-up, resize); each stage stamps // Transition trace (P0.1): `Some` for the traced builds (bring-up, resize); each stage stamps
// once (first crossing) so the retry loop can pass it through unconditionally. // once (first crossing) so the retry loop can pass it through unconditionally.
trace: Option<&crate::bringup::Trace>, trace: Option<&crate::bringup::Trace>,
) -> Result<Pipeline> { ) -> Result<Pipeline> {
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed // ~10s first-frame wait per attempt (attempt 1: see FIRST_ATTEMPT_FRAME_BUDGET below). 8
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take // gives a ~80s budget for the SLOW case: a host-managed gamescope session cold-starting Steam
// 30-60s to produce its first frame, and a first-connect timeout would tear down the warm // Big Picture (the SteamOS/Bazzite takeover) can take 30-60s to produce its first frame, and
// session (forcing another cold start on reconnect). A genuinely permanent failure still fails // a first-connect timeout would tear down the warm session (forcing another cold start on
// fast via `is_permanent_build_error`; only transient "no frame yet" retries consume the budget. // reconnect). A genuinely permanent failure still fails fast via `is_permanent_build_error`;
// only transient "no frame yet" retries consume the budget.
// IDD-push only: HOLD one monitor lease across all build attempts. A failed attempt's capturer // IDD-push only: HOLD one monitor lease across all build attempts. A failed attempt's capturer
// drop releases ITS lease, but this held lease keeps the shared monitor Active (refs >= 1), so the // drop releases ITS lease, but this held lease keeps the shared monitor Active (refs >= 1), so the
// next attempt's `vd.create` JOINS it (refcount++) instead of finding it Lingering and tripping the // next attempt's `vd.create` JOINS it (refcount++) instead of finding it Lingering and tripping the
@@ -2421,9 +2742,16 @@ fn build_pipeline_with_retry(
} else { } else {
None None
}; };
const MAX_ATTEMPTS: u32 = 8; // Attempt 1 waits only briefly for the first frame: a PipeWire stream connected while
// gamescope re-initializes its headless takeover negotiates a format and reaches `Streaming`
// but never receives a buffer — a FRESH connect then delivers within ~0.5 s (observed on
// SteamOS: every gamescope bring-up burned the full 10 s on attempt 1, then attempt 2 got
// frames instantly → 17 s bring-ups). Healthy compositors deliver the first frame well inside
// this window (KWin ~0.3 s), and the genuinely-slow cold start above still gets the patient
// 10 s window on every later attempt.
const FIRST_ATTEMPT_FRAME_BUDGET: std::time::Duration = std::time::Duration::from_millis(2500);
let mut backoff = std::time::Duration::from_millis(500); let mut backoff = std::time::Duration::from_millis(500);
for attempt in 1..=MAX_ATTEMPTS { for attempt in 1..=max_attempts {
// The client is gone (connection closed → `stop`): every further attempt only churns the // The client is gone (connection closed → `stop`): every further attempt only churns the
// box for a session no one is watching — on a Bazzite takeover that means SIGKILLing and // box for a session no one is watching — on a Bazzite takeover that means SIGKILLing and
// relaunching the box's Steam session once per attempt for minutes (the .181 storm // relaunching the box's Steam session once per attempt for minutes (the .181 storm
@@ -2435,7 +2763,17 @@ fn build_pipeline_with_retry(
attempt - 1 attempt - 1
); );
} }
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit, trace) { let first_frame_budget = (attempt == 1).then_some(FIRST_ATTEMPT_FRAME_BUDGET);
match build_pipeline(
vd,
mode,
bitrate_kbps,
bit_depth,
plan,
quit,
first_frame_budget,
trace,
) {
Ok(pipe) => { Ok(pipe) => {
if attempt > 1 { if attempt > 1 {
tracing::info!(attempt, "pipeline up after retry"); tracing::info!(attempt, "pipeline up after retry");
@@ -2445,7 +2783,7 @@ fn build_pipeline_with_retry(
Err(e) => { Err(e) => {
let chain = format!("{e:#}"); let chain = format!("{e:#}");
let permanent = is_permanent_build_error(&chain); let permanent = is_permanent_build_error(&chain);
if permanent || attempt == MAX_ATTEMPTS { if permanent || attempt == max_attempts {
let why = if permanent { let why = if permanent {
"permanent" "permanent"
} else { } else {
@@ -2457,7 +2795,7 @@ fn build_pipeline_with_retry(
} }
tracing::warn!( tracing::warn!(
attempt, attempt,
max = MAX_ATTEMPTS, max = max_attempts,
backoff_ms = backoff.as_millis() as u64, backoff_ms = backoff.as_millis() as u64,
error = %chain, error = %chain,
"pipeline build failed — retrying" "pipeline build failed — retrying"
@@ -2518,6 +2856,10 @@ fn build_pipeline(
bit_depth: u8, bit_depth: u8,
plan: crate::session_plan::SessionPlan, plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>, quit: &Arc<AtomicBool>,
// First-frame wait override (`None` = the backend's default 10 s): the retry loop shortens
// its FIRST attempt so a stream stuck in the gamescope takeover race fails over to the
// reconnect that fixes it (see FIRST_ATTEMPT_FRAME_BUDGET in `build_pipeline_with_retry`).
first_frame_budget: Option<std::time::Duration>,
// Transition trace (P0.1): stamps the build's stages (display acquire, capture attach, first // Transition trace (P0.1): stamps the build's stages (display acquire, capture attach, first
// frame, encoder open) into the bring-up/resize timeline. `None` on untraced rebuilds. // frame, encoder open) into the bring-up/resize timeline. `None` on untraced rebuilds.
trace: Option<&crate::bringup::Trace>, trace: Option<&crate::bringup::Trace>,
@@ -2573,7 +2915,11 @@ fn build_pipeline(
t.mark("capture_attached"); t.mark("capture_attached");
} }
capturer.set_active(true); capturer.set_active(true);
let frame = match capturer.next_frame().context("first frame") { let first = match first_frame_budget {
Some(budget) => capturer.next_frame_within(budget),
None => capturer.next_frame(),
};
let frame = match first.context("first frame") {
Ok(f) => f, Ok(f) => f,
Err(e) => { Err(e) => {
// A reused kept display was dead — invalidate it so the next attempt creates fresh (A2). // A reused kept display was dead — invalidate it so the next attempt creates fresh (A2).
@@ -2599,6 +2945,7 @@ fn build_pipeline(
frame.is_cuda(), frame.is_cuda(),
bit_depth, bit_depth,
plan.chroma, plan.chroma,
plan.cursor_blend,
) )
.context("open video encoder")?; .context("open video encoder")?;
if let Some(t) = trace { if let Some(t) = trace {
+156 -38
View File
@@ -116,7 +116,11 @@ fn forward_to_runner(args: &[String]) -> Result<()> {
/// Resolve how to invoke the runner CLI: the program plus any leading args (the bundled bun needs /// Resolve how to invoke the runner CLI: the program plus any leading args (the bundled bun needs
/// the runner script path passed to it). /// the runner script path passed to it).
fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> { ///
/// Also the plugin store's executor seam ([`crate::store::jobs`]): a console-triggered install runs
/// the *same* package ops through the *same* runner as the CLI, so there is exactly one
/// implementation of "install a plugin" on the box (design D4).
pub(crate) fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
// The installer lays the payload out as {app}\punktfunk-host.exe, {app}\bun\bun.exe and // The installer lays the payload out as {app}\punktfunk-host.exe, {app}\bun\bun.exe and
@@ -177,17 +181,163 @@ fn disable() -> Result<()> {
Ok(()) Ok(())
} }
#[cfg(target_os = "linux")]
fn status() -> Result<()> { fn status() -> Result<()> {
let active = systemctl_output(&["is-active", UNIT]).unwrap_or_else(|| "unknown".into()); let st = runtime_status();
let enabled = systemctl_output(&["is-enabled", UNIT]).unwrap_or_else(|| "unknown".into()); println!(
println!("runner: {UNIT}\nenabled: {enabled}\nactive: {active}"); "runner: {}\nstate: {}\nenabled: {}",
if active != "active" { st.unit,
if !st.installed {
"not installed"
} else if st.running {
"running"
} else {
"stopped"
},
st.enabled
);
if let Some(principal) = &st.principal {
println!("runs as: {principal}");
}
if st.installed && !st.running {
println!("\nStart it with: punktfunk-host plugins enable"); println!("\nStart it with: punktfunk-host plugins enable");
} else if !st.installed {
println!("\n{}", st.detail);
} }
Ok(()) Ok(())
} }
// ---- runtime state, shared by the CLI and the plugin store's mgmt API --------------------------
/// Whether the plugin runner is present, switched on, and up.
///
/// The store's console surface needs this as data (to offer "enable the runner" before the first
/// install, and to explain why a freshly installed plugin isn't running yet), so it lives here
/// rather than being formatted straight to stdout like the CLI once did.
#[derive(Debug, Clone)]
pub(crate) struct RuntimeStatus {
/// Is the runner payload / service unit on this box at all?
pub installed: bool,
/// Is it configured to start (systemd `enabled`, or a non-`Disabled` scheduled task)?
pub enabled: bool,
/// Is it up right now?
pub running: bool,
/// The unit / task name, so operator-facing copy can name the thing to look at.
pub unit: &'static str,
/// Windows: the account the task runs as (the SYSTEM→LocalService migration is visible here).
pub principal: Option<String>,
/// One line of human-readable context, mostly for the "not installed" case.
pub detail: String,
}
#[cfg(target_os = "linux")]
pub(crate) fn runtime_status() -> RuntimeStatus {
let enabled_raw = systemctl_output(&["is-enabled", UNIT]);
let active = systemctl_output(&["is-active", UNIT]).unwrap_or_default();
// `is-enabled` answers `not-found` when the unit file isn't installed at all; the runner
// payload being present is the other half of "can we install plugins".
let unit_known = enabled_raw.as_deref().is_some_and(|s| s != "not-found");
let installed = unit_known || runner_command().is_ok();
RuntimeStatus {
installed,
enabled: enabled_raw.as_deref() == Some("enabled"),
running: active == "active",
unit: UNIT,
principal: None,
detail: if installed {
String::new()
} else {
"the plugin runner package isn't installed (Debian/Ubuntu: `sudo apt install \
punktfunk-scripting`)"
.into()
},
}
}
#[cfg(target_os = "windows")]
pub(crate) fn runtime_status() -> RuntimeStatus {
let out = powershell_output(&format!(
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
if ($null -eq $t) {{ 'missing' }} else {{ \"$($t.State)|$($t.Principal.UserId)\" }}"
));
match out.as_deref().map(str::trim) {
Some("missing") | None => RuntimeStatus {
installed: false,
enabled: false,
running: false,
unit: TASK,
principal: None,
detail: "reinstall punktfunk with the scripting component to get the plugin runner"
.into(),
},
Some(raw) => {
let (state, principal) = raw.split_once('|').unwrap_or((raw, ""));
RuntimeStatus {
installed: true,
enabled: !state.eq_ignore_ascii_case("Disabled"),
running: state.eq_ignore_ascii_case("Running"),
unit: TASK,
principal: (!principal.is_empty()).then(|| principal.to_string()),
detail: String::new(),
}
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub(crate) fn runtime_status() -> RuntimeStatus {
RuntimeStatus {
installed: false,
enabled: false,
running: false,
unit: "punktfunk-scripting",
principal: None,
detail: "the plugin runner is only available on Linux and Windows hosts".into(),
}
}
/// Switch the runner on or off — the [`enable`]/[`disable`] the CLI runs, exposed for the store's
/// `POST /store/runtime`. On Windows this is reached from the SYSTEM service, which already clears
/// the elevation bar the CLI has to check for.
pub(crate) fn set_runtime_enabled(enabled: bool) -> Result<()> {
if enabled {
enable()
} else {
disable()
}
}
/// Restart the runner so it rediscovers installed units. Returns `false` (not an error) when it
/// isn't running — there is nothing to restart, and the store reports that as "installed, but the
/// runner is off" rather than as a failure.
///
/// Unit discovery happens once at runner startup ([`sdk/src/runner.ts`]), so this restart *is* the
/// activation step for a newly installed plugin.
pub(crate) fn restart_runtime() -> Result<bool> {
let st = runtime_status();
if !st.installed || !st.running {
return Ok(false);
}
#[cfg(target_os = "linux")]
{
run_systemctl(&["restart", UNIT])?;
Ok(true)
}
#[cfg(target_os = "windows")]
{
// Stop then start: `Restart-ScheduledTask` does not exist, and a Start on an already-
// running task is a no-op rather than a restart.
powershell(&format!(
"Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop"
))?;
Ok(true)
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
Ok(false)
}
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn run_systemctl(args: &[&str]) -> Result<()> { fn run_systemctl(args: &[&str]) -> Result<()> {
let status = Command::new("systemctl") let status = Command::new("systemctl")
@@ -451,33 +601,6 @@ fn icacls_path() -> String {
.unwrap_or_else(|_| "icacls".to_string()) .unwrap_or_else(|_| "icacls".to_string())
} }
#[cfg(target_os = "windows")]
fn status() -> Result<()> {
let out = powershell_output(&format!(
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
if ($null -eq $t) {{ 'missing' }} else {{ \
\"$($t.State)|$($t.Principal.UserId)\" }}"
));
match out.as_deref().map(str::trim) {
Some("missing") | None => {
println!(
"runner: {TASK}\nstate: not installed\n\nReinstall punktfunk with the \
scripting component to get the plugin runner."
);
}
Some(state) => {
// "State|Principal" — the principal line makes the SYSTEM→LocalService migration
// verifiable at a glance (`plugins enable` converges a legacy SYSTEM task).
let (state, principal) = state.split_once('|').unwrap_or((state, "?"));
println!("runner: {TASK}\nstate: {state}\nruns as: {principal}");
if state.eq_ignore_ascii_case("Disabled") {
println!("\nEnable it with: punktfunk-host plugins enable");
}
}
}
Ok(())
}
/// Resolve powershell by full System32 path rather than PATH — CreateProcess searches the launching /// Resolve powershell by full System32 path rather than PATH — CreateProcess searches the launching
/// EXE's own directory first, so a planted `powershell.exe` beside the host binary would otherwise /// EXE's own directory first, so a planted `powershell.exe` beside the host binary would otherwise
/// run with our privileges (security-review 2026-07-17; matches service.rs / pf_vdisplay). /// run with our privileges (security-review 2026-07-17; matches service.rs / pf_vdisplay).
@@ -603,8 +726,3 @@ fn enable() -> Result<()> {
fn disable() -> Result<()> { fn disable() -> Result<()> {
bail!("the plugin runner is only available on Linux and Windows hosts") bail!("the plugin runner is only available on Linux and Windows hosts")
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
fn status() -> Result<()> {
bail!("the plugin runner is only available on Linux and Windows hosts")
}
+14
View File
@@ -103,6 +103,11 @@ pub struct SessionPlan {
/// PyroWave session — applied to EVERY encoder this plan opens (initial + all rebuilds) so /// PyroWave session — applied to EVERY encoder this plan opens (initial + all rebuilds) so
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs. /// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
pub wire_chunk: Option<usize>, pub wire_chunk: Option<usize>,
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
/// captures — every non-gamescope compositor; gamescope embeds the pointer itself).
/// Encoders whose fast path cannot blend (the Vulkan EFC RGB-direct source) stay on their
/// blending path when this is set, so the pointer never silently vanishes from the stream.
pub cursor_blend: bool,
} }
impl SessionPlan { impl SessionPlan {
@@ -112,6 +117,7 @@ impl SessionPlan {
bit_depth: u8, bit_depth: u8,
chroma: crate::encode::ChromaFormat, chroma: crate::encode::ChromaFormat,
codec: crate::encode::Codec, codec: crate::encode::Codec,
cursor_blend: bool,
) -> Self { ) -> Self {
SessionPlan { SessionPlan {
capture: CaptureBackend::resolve(), capture: CaptureBackend::resolve(),
@@ -122,6 +128,7 @@ impl SessionPlan {
chroma, chroma,
codec, codec,
wire_chunk: None, wire_chunk: None,
cursor_blend,
} }
} }
@@ -172,6 +179,13 @@ impl SessionPlan {
// Vulkan device; on Linux the capture facade flips the zero-copy policy to the // Vulkan device; on Linux the capture facade flips the zero-copy policy to the
// raw-dmabuf passthrough (see above). // raw-dmabuf passthrough (see above).
pyrowave: self.codec == crate::encode::Codec::PyroWave, pyrowave: self.codec == crate::encode::Codec::PyroWave,
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
// backend — resolved HERE from the plan's codec so the capturer never reaches back
// into encode (the same one-way edge as `gpu` above).
#[cfg(target_os = "linux")]
nv12_native: crate::encode::linux_native_nv12_ok(self.codec),
#[cfg(not(target_os = "linux"))]
nv12_native: false,
} }
} }
} }
+1
View File
@@ -147,6 +147,7 @@ pub fn run(opts: Options) -> Result<()> {
first.is_cuda(), first.is_cuda(),
8, // spike synthetic harness: 8-bit 8, // spike synthetic harness: 8-bit
encode::ChromaFormat::Yuv420, // ...and 4:2:0 encode::ChromaFormat::Yuv420, // ...and 4:2:0
false, // synthetic frames carry no cursor
) )
.context("open encoder")?; .context("open encoder")?;
+619
View File
@@ -0,0 +1,619 @@
//! The **plugin store**: discovering and installing plugins from signed catalogs
//! (design `plugin-store.md`).
//!
//! Installing a plugin means running somebody's code with operator privileges, so the store is
//! built around one idea: *the index is the verification gate*. A catalog entry pins one exact
//! version and that version's tarball hash, and nothing here can express "track the latest
//! release". Upstream can publish whatever they like; this host will keep offering the reviewed
//! version until a newly reviewed entry lands in a signed index.
//!
//! That yields three tiers, which the whole surface is organized around:
//!
//! | tier | where it came from | surfaced? |
//! |------|--------------------|-----------|
//! | **verified** | the built-in `unom` source's index — unom reviewed this exact tarball | yes, with a badge |
//! | **external** | an operator-added source's index — pinned and integrity-checked, curated by somebody else | yes, attributed, no badge |
//! | **unverified** | a raw package spec typed into the console's danger dialog | never listed; install only |
//!
//! This module is the domain half (catalog state, installed-package facts, trust decisions); the
//! HTTP surface lives in [`crate::mgmt::store`]. Everything here is **blocking** — callers on the
//! async side hand it to `spawn_blocking`.
pub(crate) mod catalog;
pub(crate) mod index;
pub(crate) mod jobs;
pub(crate) mod manifest;
pub(crate) mod sources;
use anyhow::{bail, Context, Result};
use index::{Advisory, Entry, Index};
use sources::Source;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
/// How long a fetched catalog is considered fresh. Catalogs change when somebody reviews a
/// release — hours, not seconds — and a streaming host should not be chatting to the internet on a
/// timer for a page nobody has open.
const CATALOG_TTL_SECS: u64 = 6 * 60 * 60;
/// Where plugin packages live: `<config_dir>/plugins` (the same dir the SDK and runner use).
pub(crate) fn plugins_dir() -> PathBuf {
pf_paths::config_dir().join("plugins")
}
// ---------------------------------------------------------------- installed packages
/// A plugin package present in the plugins dir.
#[derive(Debug, Clone)]
pub(crate) struct InstalledPkg {
pub pkg: String,
pub version: Option<String>,
}
/// The packages the operator actually asked for: the `dependencies` of the plugins dir's own
/// `package.json`, which `bun add` maintains. `None` only when there is no readable `package.json`
/// at all.
///
/// This is what separates a plugin from a plugin's *library*. `@punktfunk/plugin-kit` is the
/// framework every kit-built plugin depends on — it matches the `plugin-*` naming convention
/// exactly, lands in `node_modules` as a transitive dependency, and is emphatically not something
/// the operator installed or can meaningfully uninstall. The convention alone cannot tell the two
/// apart; the top-level dependency list can.
///
/// A `package.json` with **no** `dependencies` key returns an empty list, not `None`: `bun remove`
/// drops the key entirely when the last plugin goes, and orphaned transitive packages can outlive
/// it in `node_modules`. Falling back to the naming convention there resurrects a plugin's library
/// as an installed plugin the moment you uninstall the last real one (seen on-glass). If bun is
/// managing this tree at all, its answer is the answer — including when the answer is "nothing".
fn top_level_deps(dir: &Path) -> Option<Vec<String>> {
let bytes = std::fs::read(dir.join("package.json")).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
Some(match v.get("dependencies").and_then(|d| d.as_object()) {
Some(deps) => deps.keys().cloned().collect(),
None => Vec::new(),
})
}
/// Enumerate installed plugin packages under `<dir>/node_modules`.
///
/// Mirrors the runner's own discovery so the store never claims something is installed that the
/// runner wouldn't supervise: the unscoped `punktfunk-plugin-*` convention, and **any** scope's
/// `plugin-*` (`@punktfunk/plugin-rom-manager`, `@retro-hub/plugin-x`). Scoped-any is what makes a
/// third-party catalog entry work at all — a scoped name is required for the registry mapping
/// (D8), so discovery must not be limited to the first-party scope.
///
/// Then narrowed to [`top_level_deps`] when a dependency list exists, so a plugin's *dependencies*
/// (notably `@punktfunk/plugin-kit`) aren't reported as installed plugins. A tree with no readable
/// `package.json` — hand-assembled, or an older layout — falls back to the convention alone rather
/// than reporting nothing.
pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
let modules = dir.join("node_modules");
let top_level = top_level_deps(dir);
let mut out = Vec::new();
let version_of = |pkg_dir: &Path| -> Option<String> {
let bytes = std::fs::read(pkg_dir.join("package.json")).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
v.get("version")?.as_str().map(str::to_string)
};
let Ok(entries) = std::fs::read_dir(&modules) else {
return out; // nothing installed yet
};
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
for name in names {
if name.starts_with("punktfunk-plugin-") {
let dir = modules.join(&name);
out.push(InstalledPkg {
version: version_of(&dir),
pkg: name,
});
} else if name.starts_with('@') {
let scope_dir = modules.join(&name);
let Ok(scoped) = std::fs::read_dir(&scope_dir) else {
continue;
};
let mut inner: Vec<String> = scoped
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
inner.sort();
for s in inner {
if s.starts_with("plugin-") {
let dir = scope_dir.join(&s);
out.push(InstalledPkg {
pkg: format!("{name}/{s}"),
version: version_of(&dir),
});
}
}
}
}
if let Some(top) = top_level {
out.retain(|p| top.iter().any(|d| d == &p.pkg));
}
out
}
/// Point a package scope at its registry in the plugins dir's `bunfig.toml`.
///
/// The runner CLI can do this too (`--registry @scope=URL`), but the store must **not** depend on
/// that: `runner_command()` resolves whatever scripting package is installed on the box, which can
/// predate the host binary (the packaged runner and the host ship separately). An older runner
/// treats an unknown flag's *value* as a package name and the install dies with
/// "unrecognised dependency format" — found on-glass. Writing the mapping here keeps a
/// catalog-driven install working against every runner that has ever shipped.
///
/// Idempotent and non-destructive, matching `sdk/src/plugins.ts::ensureBunfig`: a scope already
/// mapped to this URL is left alone, one mapped elsewhere is rewritten, unrelated content survives.
pub(crate) fn ensure_bunfig_scope(dir: &Path, scope: &str, url: &str) -> Result<()> {
// The scope and URL both come from a signature-verified, field-validated index entry
// (`@`-prefixed, `[a-z0-9._-]`, https), so neither can smuggle a quote or newline into the TOML.
if !index::valid_scoped_pkg(&format!("{scope}/x")) || !url.starts_with("https://") {
bail!("refusing to map scope `{scope}` to `{url}`");
}
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
let path = dir.join("bunfig.toml");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let wanted = format!("\"{scope}\" = \"{url}\"");
let is_mapping_for_scope = |line: &str| {
let t = line.trim_start();
t.starts_with(&format!("\"{scope}\"")) || t.starts_with(&format!("{scope} "))
};
if existing.lines().any(|l| l.trim() == wanted) {
return Ok(()); // already correct
}
let updated = if existing.lines().any(is_mapping_for_scope) {
existing
.lines()
.map(|l| {
if is_mapping_for_scope(l) {
wanted.clone()
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
+ "\n"
} else if let Some(pos) = existing
.lines()
.position(|l| l.trim() == "[install.scopes]")
{
let mut lines: Vec<String> = existing.lines().map(str::to_string).collect();
lines.insert(pos + 1, wanted);
lines.join("\n") + "\n"
} else if existing.trim().is_empty() {
format!("[install.scopes]\n{wanted}\n")
} else {
format!(
"{}{}\n[install.scopes]\n{wanted}\n",
existing,
if existing.ends_with('\n') { "" } else { "\n" }
)
};
std::fs::write(&path, updated).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
/// Is `pkg` a name the runner would supervise? Guards the uninstall route so a stray
/// `POST /store/uninstall {"pkg": "effect"}` can't rip a shared dependency out of the tree.
pub(crate) fn valid_installed_pkg(pkg: &str) -> Result<()> {
let plausible = pkg.starts_with("punktfunk-plugin-")
|| (index::valid_scoped_pkg(pkg)
&& pkg
.split_once('/')
.is_some_and(|(_, name)| name.starts_with("plugin-")));
if !plausible {
bail!("`{pkg}` is not a plugin package (`@scope/plugin-*` or `punktfunk-plugin-*`)");
}
Ok(())
}
/// The plugin id a package registers under (`@punktfunk/plugin-rom-manager` → `rom-manager`), used
/// to line an installed package up with the live lease registry. Best-effort: the authoritative id
/// for a catalogued plugin is its index entry.
pub(crate) fn plugin_id_for_pkg(pkg: &str) -> Option<String> {
let last = pkg.rsplit('/').next()?;
let id = last
.strip_prefix("punktfunk-plugin-")
.or_else(|| last.strip_prefix("plugin-"))?;
index::valid_plugin_id(id).then(|| id.to_string())
}
// ---------------------------------------------------------------- catalog state
/// What we hold for one source: the last good index plus why it might be stale.
#[derive(Clone)]
pub(crate) struct SourceState {
pub source: Source,
pub index: Option<Index>,
/// Unix seconds of the fetch that produced [`Self::index`].
pub fetched_at: Option<u64>,
/// True when the last refresh attempt failed and we're serving an older copy.
pub stale: bool,
/// Why the last attempt failed, for the console to show against the source.
pub error: Option<String>,
pub etag: Option<String>,
}
impl SourceState {
fn empty(source: Source) -> Self {
Self {
source,
index: None,
fetched_at: None,
stale: false,
error: None,
etag: None,
}
}
fn is_fresh(&self) -> bool {
self.index.is_some()
&& self
.fetched_at
.is_some_and(|t| catalog::unix_now().saturating_sub(t) < CATALOG_TTL_SECS)
}
}
fn state() -> &'static RwLock<Vec<SourceState>> {
static STATE: std::sync::OnceLock<RwLock<Vec<SourceState>>> = std::sync::OnceLock::new();
STATE.get_or_init(|| RwLock::new(Vec::new()))
}
/// Reconcile the in-memory state list with the configured sources (added/removed/edited), seeding
/// anything new from the on-disk cache so a cold host has a browsable store before its first fetch.
fn sync_sources() {
let configured = sources::load();
let dir = catalog::cache_dir();
let mut st = state().write().unwrap_or_else(|e| e.into_inner());
st.retain(|s| configured.iter().any(|c| c.name == s.source.name));
for c in configured {
match st.iter_mut().find(|s| s.source.name == c.name) {
// The URL or key may have been edited under a name we already hold — drop what we
// cached for the old definition rather than attributing it to the new one.
Some(existing) => {
if existing.source.url != c.url || existing.source.public_key != c.public_key {
*existing = SourceState::empty(c);
} else {
existing.source = c;
}
}
None => {
let mut fresh = SourceState::empty(c.clone());
if let Some((index, meta)) = catalog::read_cache(&dir, &c.name) {
fresh.index = Some(index);
fresh.fetched_at = Some(meta.fetched_at);
fresh.etag = meta.etag;
fresh.stale = true; // from disk — unverified freshness until a fetch says otherwise
}
st.push(fresh);
}
}
}
}
/// Every source's catalog, refreshing those past their TTL (or all of them when `force`).
///
/// **Blocking** — network I/O. The freshness decision is ours (our fetch clock), never the
/// document's self-reported `generated` field.
pub(crate) fn catalogs(force: bool) -> Vec<SourceState> {
sync_sources();
let dir = catalog::cache_dir();
let todo: Vec<Source> = {
let st = state().read().unwrap_or_else(|e| e.into_inner());
st.iter()
.filter(|s| force || !s.is_fresh())
.map(|s| s.source.clone())
.collect()
};
for source in todo {
let etag = {
let st = state().read().unwrap_or_else(|e| e.into_inner());
st.iter()
.find(|s| s.source.name == source.name)
.and_then(|s| s.etag.clone())
};
let outcome = catalog::fetch(&source, etag.as_deref());
let now = catalog::unix_now();
let mut st = state().write().unwrap_or_else(|e| e.into_inner());
let Some(slot) = st.iter_mut().find(|s| s.source.name == source.name) else {
continue; // removed while we were fetching
};
match outcome {
catalog::Fetched::Fresh { index, etag } => {
let count = index.plugins.len();
catalog::write_cache(
&dir,
&source.name,
&index,
&catalog::CacheMeta {
etag: etag.clone(),
fetched_at: now,
},
);
slot.index = Some(*index);
slot.fetched_at = Some(now);
slot.etag = etag;
slot.stale = false;
slot.error = None;
tracing::info!(source = %source.name, entries = count, "plugin catalog refreshed");
}
catalog::Fetched::NotModified => {
slot.fetched_at = Some(now);
slot.stale = false;
slot.error = None;
}
catalog::Fetched::Failed(why) => {
// Fail soft: keep the last good shelf, say plainly that it's stale.
tracing::warn!(source = %source.name, "plugin catalog refresh failed: {why}");
slot.stale = true;
slot.error = Some(why);
}
}
}
state()
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
.cloned()
.collect()
}
/// The catalogs we already hold, without touching the network. For paths that must not block on a
/// remote host (an install resolving its own entry, an advisory lookup).
pub(crate) fn cached_catalogs() -> Vec<SourceState> {
sync_sources();
state()
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
.cloned()
.collect()
}
/// Find a catalog entry by `(source, id)`, plus whether that source is the built-in one — which is
/// the single place "verified" is decided (D6).
pub(crate) fn find_entry(source_name: &str, id: &str) -> Option<(Entry, bool)> {
cached_catalogs().into_iter().find_map(|s| {
if s.source.name != source_name {
return None;
}
let entry = s.index?.plugins.into_iter().find(|e| e.id == id)?;
Some((entry, s.source.is_official()))
})
}
/// The first advisory covering `pkg@version`, across every source we hold. Revocations are
/// deliberately not scoped to the source a plugin came from: a package known-bad anywhere is
/// known-bad here.
pub(crate) fn advisory_for(pkg: &str, version: Option<&str>) -> Option<Advisory> {
let version = version?;
cached_catalogs().into_iter().find_map(|s| {
s.index?
.security
.into_iter()
.find(|a| a.matches(pkg, version))
})
}
/// Forget a removed source's cached shelf, so re-adding the name later can't serve stale rows.
pub(crate) fn drop_source_cache(name: &str) {
catalog::drop_cache(&catalog::cache_dir(), name);
let mut st = state().write().unwrap_or_else(|e| e.into_inner());
st.retain(|s| s.source.name != name);
}
#[cfg(test)]
mod tests {
use super::*;
fn touch_pkg(root: &Path, pkg: &str, version: &str) {
let dir = root.join("node_modules").join(pkg);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("package.json"),
format!(r#"{{"name":"{pkg}","version":"{version}"}}"#),
)
.unwrap();
}
#[test]
fn scans_both_conventions_and_any_scope() {
let dir = tempfile::tempdir().unwrap();
touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.0");
// A third-party scoped plugin MUST be found: catalog entries are required to be scoped
// (D8), so limiting discovery to @punktfunk would make every external source unusable.
touch_pkg(dir.path(), "@retro-hub/plugin-x", "1.0.0");
touch_pkg(dir.path(), "punktfunk-plugin-legacy", "0.1.0");
// Not plugins: a plain dependency and a scoped non-plugin.
touch_pkg(dir.path(), "effect", "4.0.0");
touch_pkg(dir.path(), "@punktfunk/host", "0.1.2");
let found = installed_packages(dir.path());
let names: Vec<&str> = found.iter().map(|p| p.pkg.as_str()).collect();
assert!(
names.contains(&"@punktfunk/plugin-rom-manager"),
"{names:?}"
);
assert!(names.contains(&"@retro-hub/plugin-x"), "{names:?}");
assert!(names.contains(&"punktfunk-plugin-legacy"), "{names:?}");
assert!(!names.contains(&"effect"), "{names:?}");
assert!(!names.contains(&"@punktfunk/host"), "{names:?}");
assert_eq!(
found
.iter()
.find(|p| p.pkg == "@punktfunk/plugin-rom-manager")
.unwrap()
.version
.as_deref(),
Some("0.3.0")
);
}
#[test]
fn scan_of_a_missing_dir_is_empty_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert!(installed_packages(dir.path()).is_empty());
}
/// A plugin's LIBRARY is not an installed plugin.
///
/// Regression from a live install: `@punktfunk/plugin-kit` is the framework every kit-built
/// plugin depends on. It matches the `plugin-*` convention exactly and lands in `node_modules`
/// transitively, so a convention-only scan reported the framework as an installed plugin the
/// operator could uninstall. The plugins dir's own `dependencies` is the authority.
#[test]
fn transitive_plugin_named_dependencies_are_not_installed_plugins() {
let dir = tempfile::tempdir().unwrap();
touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.1");
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3"); // a dependency of the above
touch_pkg(dir.path(), "@punktfunk/host", "0.1.2");
std::fs::write(
dir.path().join("package.json"),
r#"{"dependencies":{"@punktfunk/plugin-rom-manager":"^0.3.1"}}"#,
)
.unwrap();
let found = installed_packages(dir.path());
assert_eq!(
found.iter().map(|p| p.pkg.as_str()).collect::<Vec<_>>(),
vec!["@punktfunk/plugin-rom-manager"],
"only the top-level install counts"
);
}
#[test]
fn a_tree_with_no_package_json_falls_back_to_the_convention() {
// Hand-assembled or older layouts must still be discovered, not silently reported empty.
let dir = tempfile::tempdir().unwrap();
touch_pkg(dir.path(), "punktfunk-plugin-legacy", "0.1.0");
assert_eq!(installed_packages(dir.path()).len(), 1);
}
/// Uninstalling the last plugin must not resurrect its library as an installed plugin.
///
/// `bun remove` drops the `dependencies` key entirely once it empties, while orphaned
/// transitive packages can linger in `node_modules`. Treating "package.json exists but has no
/// dependencies" as "no authority, fall back to the naming convention" made
/// `@punktfunk/plugin-kit` pop back into the installed list right after the operator removed
/// the only real plugin — seen on-glass.
#[test]
fn an_emptied_dependency_list_means_nothing_is_installed() {
let dir = tempfile::tempdir().unwrap();
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3"); // orphan left behind
std::fs::write(dir.path().join("package.json"), r#"{"name":"plugins"}"#).unwrap();
assert!(installed_packages(dir.path()).is_empty());
std::fs::write(
dir.path().join("package.json"),
r#"{"name":"plugins","dependencies":{}}"#,
)
.unwrap();
assert!(installed_packages(dir.path()).is_empty());
}
#[test]
fn uninstall_target_must_be_a_plugin_package() {
assert!(valid_installed_pkg("@punktfunk/plugin-rom-manager").is_ok());
assert!(valid_installed_pkg("@retro-hub/plugin-x").is_ok());
assert!(valid_installed_pkg("punktfunk-plugin-legacy").is_ok());
// A shared dependency is not removable through the store.
assert!(valid_installed_pkg("effect").is_err());
assert!(valid_installed_pkg("@punktfunk/host").is_err());
assert!(valid_installed_pkg("../../etc").is_err());
assert!(valid_installed_pkg("").is_err());
}
#[test]
fn bunfig_scope_mapping_is_idempotent_and_preserves_other_scopes() {
let dir = tempfile::tempdir().unwrap();
let read = || std::fs::read_to_string(dir.path().join("bunfig.toml")).unwrap();
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://retro.example/npm/").unwrap();
assert!(read().contains("[install.scopes]"));
assert!(read().contains("\"@retro-hub\" = \"https://retro.example/npm/\""));
// Idempotent — no duplicate line.
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://retro.example/npm/").unwrap();
assert_eq!(read().matches("@retro-hub").count(), 1);
// A second scope joins the same table.
ensure_bunfig_scope(
dir.path(),
"@punktfunk",
"https://git.unom.io/api/packages/unom/npm/",
)
.unwrap();
assert!(read().contains("@punktfunk"));
assert!(read().contains("@retro-hub"));
// A changed registry rewrites in place rather than duplicating.
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://new.example/npm/").unwrap();
assert_eq!(read().matches("@retro-hub").count(), 1);
assert!(read().contains("https://new.example/npm/"));
assert!(!read().contains("retro.example"));
assert!(read().contains("@punktfunk"), "unrelated scope survives");
}
#[test]
fn bunfig_scope_mapping_refuses_junk() {
let dir = tempfile::tempdir().unwrap();
// Only https, only a well-formed scope — both already guaranteed by index validation, held
// here as the second line of defence for the one place we format TOML by hand.
assert!(ensure_bunfig_scope(dir.path(), "@x", "http://insecure/").is_err());
assert!(ensure_bunfig_scope(dir.path(), "no-at-sign", "https://e/").is_err());
assert!(ensure_bunfig_scope(dir.path(), "@bad\"quote", "https://e/").is_err());
assert!(!dir.path().join("bunfig.toml").exists());
}
/// The name-shape guard is necessary but NOT sufficient — see `mgmt::store::uninstall_plugin`.
///
/// `@punktfunk/plugin-kit` is a plugin's *framework*, and it satisfies every syntactic rule
/// here. Windows on-glass accepted an uninstall of it. The real gate is membership in
/// [`installed_packages`], which excludes transitive dependencies; this test pins the fact that
/// the shape check alone lets it through, so nobody "simplifies" the handler back.
#[test]
fn name_shape_alone_does_not_protect_a_plugins_framework() {
assert!(
valid_installed_pkg("@punktfunk/plugin-kit").is_ok(),
"shape check passes plugin-kit — the handler must additionally require that the \
package is a top-level install"
);
let dir = tempfile::tempdir().unwrap();
touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.1");
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3");
std::fs::write(
dir.path().join("package.json"),
r#"{"dependencies":{"@punktfunk/plugin-rom-manager":"0.3.1"}}"#,
)
.unwrap();
let installed = installed_packages(dir.path());
assert!(installed
.iter()
.any(|p| p.pkg == "@punktfunk/plugin-rom-manager"));
assert!(
!installed.iter().any(|p| p.pkg == "@punktfunk/plugin-kit"),
"the framework is not an installed plugin, so uninstall must refuse it"
);
}
#[test]
fn plugin_id_derivation() {
assert_eq!(
plugin_id_for_pkg("@punktfunk/plugin-rom-manager").as_deref(),
Some("rom-manager")
);
assert_eq!(
plugin_id_for_pkg("punktfunk-plugin-playnite").as_deref(),
Some("playnite")
);
assert_eq!(plugin_id_for_pkg("@a/plugin-x").as_deref(), Some("x"));
assert_eq!(plugin_id_for_pkg("effect"), None);
}
}
+290
View File
@@ -0,0 +1,290 @@
//! Catalog **fetch + cache**: pulling a source's signed index over HTTPS and keeping the last good
//! copy on disk (design `plugin-store.md` §4.1).
//!
//! Two properties matter more than freshness here:
//!
//! - **Fail closed on signatures, fail soft on the network.** A source with a pinned key whose
//! document doesn't verify is an *error* — we keep serving the last good copy and say so, and we
//! never fall back to "well, unsigned then". But a box that simply can't reach the internet
//! (LAN-only, common for a streaming host) keeps a working store: the cached shelf still browses,
//! and installs off it still pin and integrity-check, because the pin travelled with the entry.
//! - **Bounded everything.** Size cap, timeout, redirect cap, https-only, no credentials ever
//! attached. A source URL is operator config, not request-time input, so there is no lever here
//! for a browser to aim the host at an arbitrary address.
use super::index::{Index, MAX_INDEX_BYTES};
use super::sources::Source;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Wall-clock budget for one index (or signature) fetch.
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
/// What a refresh attempt produced.
pub(crate) enum Fetched {
/// A new, verified, parsed document.
Fresh {
index: Box<Index>,
etag: Option<String>,
},
/// The source says our cached copy is still current (HTTP 304).
NotModified,
/// Nothing usable arrived. The caller keeps whatever it had and marks the source stale.
Failed(String),
}
/// Fetch, verify and parse a source's index.
///
/// Blocking (`ureq`) — callers run this on a blocking thread, never on the async runtime.
pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
if !source.url.starts_with("https://") {
return Fetched::Failed("source url must be https".into());
}
let agent = ureq::AgentBuilder::new()
.timeout(FETCH_TIMEOUT)
// A signed document doesn't need many hops to reach us; a redirect chain is a good way to
// waste a host's time.
.redirects(3)
.user_agent(&format!("punktfunk-host/{}", super::index::host_version()))
.build();
let mut req = agent.get(&source.url);
if let Some(tag) = etag {
req = req.set("If-None-Match", tag);
}
let resp = match req.call() {
// `ureq` only turns status >= 400 into `Err(Status)`, so a conditional request's 304
// arrives here as **Ok with an empty body** — not as an error. Reading it as an error arm
// (the intuitive reading) means every refresh after the first one verifies a signature
// over zero bytes and "fails"; the catalog then sits permanently stale, serving cache and
// never picking up a new entry. Found on-glass; pinned by `ureq_returns_304_as_ok`.
Ok(r) if r.status() == 304 => return Fetched::NotModified,
Ok(r) => r,
Err(ureq::Error::Status(code, _)) => {
return Fetched::Failed(format!("index fetch returned HTTP {code}"))
}
Err(e) => return Fetched::Failed(format!("index fetch failed: {e}")),
};
let new_etag = resp.header("etag").map(str::to_string);
let body = match read_capped(resp) {
Ok(b) => b,
Err(e) => return Fetched::Failed(e),
};
// Signature FIRST — nothing below may look at a field before this passes (design §6.3).
let keys = source.keys();
if !keys.is_empty() {
let sig = match agent.get(&source.sig_url()).call() {
Ok(r) => match read_capped(r) {
Ok(b) => b,
Err(e) => return Fetched::Failed(format!("signature: {e}")),
},
Err(e) => {
return Fetched::Failed(format!(
"this source is signed but its signature could not be fetched: {e}"
))
}
};
let sig_text = match String::from_utf8(sig) {
Ok(s) => s,
Err(_) => return Fetched::Failed("signature file is not text".into()),
};
if let Err(e) = super::index::verify_signature(&body, &sig_text, &keys) {
return Fetched::Failed(format!("signature check failed: {e}"));
}
}
match Index::parse(&body) {
Ok(index) => Fetched::Fresh {
index: Box::new(index),
etag: new_etag,
},
Err(e) => Fetched::Failed(format!("{e:#}")),
}
}
/// Read a response body, refusing anything past the cap without buffering it.
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
resp.into_reader()
.take((MAX_INDEX_BYTES + 1) as u64)
.read_to_end(&mut buf)
.map_err(|e| format!("reading the response body failed: {e}"))?;
if buf.len() > MAX_INDEX_BYTES {
return Err(format!("response exceeds the {MAX_INDEX_BYTES}-byte cap"));
}
Ok(buf)
}
// ---------------------------------------------------------------- disk cache
/// `<config_dir>/store-cache` — the last good copy of every source's index, so a host that boots
/// without a network still has a browsable store.
pub(crate) fn cache_dir() -> PathBuf {
pf_paths::config_dir().join("store-cache")
}
fn body_path(dir: &Path, source: &str) -> PathBuf {
dir.join(format!("{source}.json"))
}
fn meta_path(dir: &Path, source: &str) -> PathBuf {
dir.join(format!("{source}.meta.json"))
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
pub(crate) struct CacheMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
/// Unix seconds of the fetch that produced the cached body.
#[serde(default)]
pub fetched_at: u64,
}
/// The cached index for a source, if one is on disk and still parses. Re-validated on read: a
/// cache file is just as untrusted as the wire (it lives in a directory an admin can write).
///
/// NOTE the signature is **not** re-checked here — it was checked when the bytes were accepted,
/// and the cache directory is inside the host's own private config tree.
pub(crate) fn read_cache(dir: &Path, source: &str) -> Option<(Index, CacheMeta)> {
let body = std::fs::read(body_path(dir, source)).ok()?;
let index = Index::parse(&body).ok()?;
let meta = std::fs::read(meta_path(dir, source))
.ok()
.and_then(|b| serde_json::from_slice::<CacheMeta>(&b).ok())
.unwrap_or_default();
Some((index, meta))
}
/// Persist a freshly verified index as the new last-good copy.
pub(crate) fn write_cache(dir: &Path, source: &str, index: &Index, meta: &CacheMeta) {
if let Err(e) = pf_paths::create_private_dir(dir) {
tracing::warn!("could not create the store cache dir: {e}");
return;
}
let write = |path: PathBuf, bytes: Vec<u8>| {
let tmp = path.with_extension("tmp");
if std::fs::write(&tmp, bytes).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
};
match serde_json::to_vec_pretty(index) {
Ok(b) => write(body_path(dir, source), b),
Err(e) => tracing::warn!("could not serialize the catalog cache: {e}"),
}
if let Ok(b) = serde_json::to_vec_pretty(meta) {
write(meta_path(dir, source), b);
}
}
/// Drop a removed source's cache files so a later re-add can't be served a stale shelf.
pub(crate) fn drop_cache(dir: &Path, source: &str) {
let _ = std::fs::remove_file(body_path(dir, source));
let _ = std::fs::remove_file(meta_path(dir, source));
}
pub(crate) fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_index() -> Index {
Index::parse(
br#"{"schema":1,"name":"t","plugins":[{"id":"a","pkg":"@p/plugin-a",
"registry":"https://r.example/","title":"A","version":"1.0.0",
"integrity":"sha512-AAAA"}]}"#,
)
.unwrap()
}
#[test]
fn cache_round_trips() {
let dir = tempfile::tempdir().unwrap();
let idx = sample_index();
let meta = CacheMeta {
etag: Some("\"abc\"".into()),
fetched_at: 1_700_000_000,
};
write_cache(dir.path(), "unom", &idx, &meta);
let (back, back_meta) = read_cache(dir.path(), "unom").expect("cache should be readable");
assert_eq!(back.plugins.len(), 1);
assert_eq!(back.plugins[0].id, "a");
assert_eq!(back_meta.etag.as_deref(), Some("\"abc\""));
assert_eq!(back_meta.fetched_at, 1_700_000_000);
}
#[test]
fn missing_or_corrupt_cache_reads_as_none() {
let dir = tempfile::tempdir().unwrap();
assert!(read_cache(dir.path(), "nope").is_none());
std::fs::write(dir.path().join("bad.json"), b"{ not json").unwrap();
assert!(read_cache(dir.path(), "bad").is_none());
}
#[test]
fn cache_is_revalidated_on_read() {
// A tampered cache file (schema bumped by hand) must not be served.
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("x.json"), br#"{"schema":99,"plugins":[]}"#).unwrap();
assert!(read_cache(dir.path(), "x").is_none());
}
#[test]
fn drop_cache_removes_both_files() {
let dir = tempfile::tempdir().unwrap();
write_cache(dir.path(), "s", &sample_index(), &CacheMeta::default());
assert!(read_cache(dir.path(), "s").is_some());
drop_cache(dir.path(), "s");
assert!(read_cache(dir.path(), "s").is_none());
assert!(!meta_path(dir.path(), "s").exists());
}
/// Pins the HTTP-client assumption that [`fetch`]'s conditional-request handling rests on.
///
/// `ureq` converts only status >= 400 into `Err(Status)`. A 304 — which is exactly what a
/// successful `If-None-Match` produces, i.e. the *steady state* of a healthy catalog — comes
/// back as `Ok` with an empty body. Treating it as an error arm compiles, looks right, and
/// silently breaks every refresh after the first: the empty body fails the signature check and
/// the source sits stale forever. If a `ureq` upgrade ever changes this, fail here rather than
/// on someone's host.
#[test]
fn ureq_returns_304_as_ok() {
use std::io::Write as _;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
let _ = sock.write_all(b"HTTP/1.1 304 Not Modified\r\nETag: \"x\"\r\n\r\n");
let _ = sock.flush();
}
});
let resp = ureq::get(&format!("http://{addr}/index.json"))
.set("If-None-Match", "\"x\"")
.call();
let _ = server.join();
match resp {
Ok(r) => assert_eq!(r.status(), 304, "304 must arrive as Ok, and be checked for"),
Err(e) => panic!("ureq now reports 304 as an error ({e}) — fetch() must be updated"),
}
}
#[test]
fn non_https_source_never_reaches_the_network() {
let s = Source {
name: "x".into(),
url: "http://example.org/i.json".into(),
public_key: None,
};
assert!(matches!(fetch(&s, None), Fetched::Failed(_)));
}
}
+620
View File
@@ -0,0 +1,620 @@
//! The plugin-store **index**: the catalog document a source serves, and the ed25519 signature
//! that makes it trustworthy (design `plugin-store.md` §3.2).
//!
//! The index is the verification gate. Each entry pins **one exact version plus that version's
//! tarball integrity hash** — so "verified on every release" is a property of the data, not a
//! promise about process: upstream can publish a newer version, but nothing in this host will
//! offer it until a re-reviewed entry lands in a signed index. There is deliberately no way to
//! express "track latest" for a catalogued plugin.
//!
//! Two rules keep parsing safe:
//! 1. **Signature before parse.** [`verify_signature`] runs over the exact bytes; only then does
//! anything here look at a field. A source with a pinned key whose signature fails is an
//! error, never a fallback to "unsigned".
//! 2. **Validate every field, drop what fails.** A malformed *entry* is dropped with a warning
//! (one bad row shouldn't blind the operator to the rest of the shelf); a malformed
//! *document* — unknown schema, non-JSON, oversized — is rejected whole.
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
/// The only index schema this host understands. A future breaking change bumps this and old
/// hosts report the source as unreadable rather than guessing at unknown semantics.
pub(crate) const SCHEMA: u32 = 1;
/// Hard cap on a fetched index body. Generous for a text catalog (the seed index is ~2 KB) and
/// small enough that a hostile or broken source can't exhaust memory.
pub(crate) const MAX_INDEX_BYTES: usize = 5 * 1024 * 1024;
/// Caps on how much of a (validly signed) index we'll keep. A curated shelf is small; these exist
/// so a compromised signing key can't turn the console into an unbounded list.
const MAX_PLUGINS: usize = 500;
const MAX_ADVISORIES: usize = 200;
// ---------------------------------------------------------------- the document
/// A source's catalog document, as served (and signed).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct Index {
/// Document schema — must equal [`SCHEMA`].
pub schema: u32,
/// Human-readable name of the catalog ("unom official"). Display only.
#[serde(default)]
pub name: String,
/// RFC-3339 build timestamp. Display only — freshness is decided by our own fetch clock,
/// never by a field the source controls.
#[serde(default)]
pub generated: String,
/// The shelf.
#[serde(default)]
pub plugins: Vec<Entry>,
/// Revocations/advisories, checked against **installed** packages as well as catalog rows.
#[serde(default)]
pub security: Vec<Advisory>,
}
/// One catalogued plugin: exactly one installable version, pinned by integrity hash.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Entry {
/// The plugin's `definePlugin` id (`[a-z][a-z0-9-]*`) — also how a running plugin appears in
/// the lease registry, so the console can tell "catalogued" from "running".
pub id: String,
/// npm package name. **Must be scoped** (`@scope/name`): the scope is what maps the package
/// to [`Entry::registry`] in bun's `bunfig.toml`, so an unscoped name would be ambiguous
/// about where it installs from (design D8).
pub pkg: String,
/// The npm registry this package installs from. `https://` only.
pub registry: String,
pub title: String,
#[serde(default)]
pub description: String,
/// Lucide icon name for the console card (`[a-z0-9-]{1,48}`), matched against the console's
/// curated set; anything unknown falls back to a generic puzzle piece.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default)]
pub author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
/// **The** installable version — exact, never a range. One version per entry: the index is a
/// curated shelf, not a version archive (older releases live in the index repo's git history).
pub version: String,
/// npm integrity of that version's tarball (`sha512-<base64>`). Checked against the registry's
/// own advertised integrity before any install runs — a republish under the same version with
/// different content cannot pass.
pub integrity: String,
/// Present when a human reviewed this exact tarball. Only meaningful in the built-in source's
/// index; a third-party source can write it, which is why it never grants the "Verified" badge
/// (design D6).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification: Option<Verification>,
/// Minimum host version this plugin supports (semver). Incompatible rows still list, greyed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_host: Option<String>,
/// Host platforms this plugin works on (`linux`/`windows`/`macos`). Empty ⇒ all.
#[serde(default)]
pub platforms: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Verification {
/// ISO date the review landed. Display only.
pub reviewed_at: String,
}
/// A revocation: "this package at these versions is known-bad". Checked against what's *installed*,
/// not just what's listed — the whole point is to reach plugins already on the box.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct Advisory {
pub pkg: String,
/// A semver requirement (`<0.3.2`, `>=1.0.0, <1.2.0`, `*`). Unparseable ⇒ the advisory is
/// dropped rather than applied to everything.
pub versions: String,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
// ---------------------------------------------------------------- signature
/// An ed25519 public key pinned by a source record, spelled `ed25519:<base64 of the 32 raw bytes>`.
#[derive(Debug, Clone)]
pub(crate) struct PublicKey(Vec<u8>);
impl PublicKey {
pub(crate) fn parse(s: &str) -> Result<Self> {
use base64::Engine as _;
let b64 = s
.strip_prefix("ed25519:")
.context("public key must be spelled `ed25519:<base64>`")?;
let raw = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.context("public key is not valid base64")?;
if raw.len() != 32 {
bail!("ed25519 public key must be 32 bytes, got {}", raw.len());
}
Ok(Self(raw))
}
}
/// Verify a detached ed25519 signature over the **exact** index bytes against any of the pinned
/// keys (two slots, so a key rotation is "sign with the new one, ship a host that trusts both,
/// retire the old" rather than a flag day).
///
/// `sig_text` is the `.sig` file's contents: base64, whitespace-tolerant.
pub(crate) fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Result<()> {
use base64::Engine as _;
if keys.is_empty() {
bail!("no public key pinned for this source");
}
let sig = base64::engine::general_purpose::STANDARD
.decode(sig_text.trim())
.context("signature file is not valid base64")?;
for key in keys {
let pk = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &key.0);
if pk.verify(bytes, &sig).is_ok() {
return Ok(());
}
}
bail!("index signature does not verify against any pinned key")
}
// ---------------------------------------------------------------- parse + validate
impl Index {
/// Parse and validate an index document. Rejects the whole document on a structural problem;
/// drops individual entries/advisories that fail validation (with a warning) so one bad row
/// can't hide the rest of the catalog.
pub(crate) fn parse(bytes: &[u8]) -> Result<Index> {
if bytes.len() > MAX_INDEX_BYTES {
bail!("index is larger than the {MAX_INDEX_BYTES}-byte cap");
}
let mut idx: Index = serde_json::from_slice(bytes).context("index is not valid JSON")?;
if idx.schema != SCHEMA {
bail!(
"unsupported index schema {} (this host understands {SCHEMA})",
idx.schema
);
}
idx.name = sanitize(&idx.name, 64);
idx.generated = sanitize(&idx.generated, 40);
let mut seen: Vec<String> = Vec::new();
idx.plugins.truncate(MAX_PLUGINS);
idx.plugins.retain_mut(|e| match e.validate() {
Err(why) => {
tracing::warn!(pkg = %e.pkg, "dropping catalog entry: {why}");
false
}
Ok(()) => {
// Duplicate ids would make "install this one" ambiguous; first wins.
if seen.iter().any(|s| s == &e.id) {
tracing::warn!(id = %e.id, "dropping duplicate catalog entry");
false
} else {
seen.push(e.id.clone());
true
}
}
});
idx.security.truncate(MAX_ADVISORIES);
idx.security.retain_mut(|a| match a.validate() {
Err(why) => {
tracing::warn!(pkg = %a.pkg, "dropping advisory: {why}");
false
}
Ok(()) => true,
});
Ok(idx)
}
}
impl Entry {
fn validate(&mut self) -> Result<()> {
if !valid_plugin_id(&self.id) {
bail!("id must be kebab-case `[a-z][a-z0-9-]*`, ≤64");
}
if !valid_scoped_pkg(&self.pkg) {
bail!("pkg must be a scoped npm name (`@scope/name`)");
}
if !is_https(&self.registry) {
bail!("registry must be an https:// URL");
}
self.title = sanitize(&self.title, 64);
if self.title.is_empty() {
bail!("title must not be empty");
}
self.description = sanitize(&self.description, 280);
self.author = sanitize(&self.author, 64);
self.version = sanitize(&self.version, 32);
if semver::Version::parse(&self.version).is_err() {
bail!("version must be exact semver (`1.2.3`), not a range");
}
if !valid_integrity(&self.integrity) {
bail!("integrity must look like `sha512-<base64>`");
}
if let Some(icon) = &self.icon {
if !valid_icon(icon) {
self.icon = None; // cosmetic — drop it rather than the whole entry
}
}
if let Some(h) = &self.homepage {
if !is_https(h) || h.len() > 200 {
self.homepage = None;
}
}
if let Some(l) = &self.license {
self.license = Some(sanitize(l, 64)).filter(|s| !s.is_empty());
}
if let Some(v) = &self.verification {
if v.reviewed_at.len() > 32 {
self.verification = None;
}
}
if let Some(m) = &self.min_host {
if semver::Version::parse(m).is_err() {
self.min_host = None; // unusable constraint ⇒ no constraint
}
}
self.platforms
.retain(|p| matches!(p.as_str(), "linux" | "windows" | "macos"));
self.platforms.truncate(4);
Ok(())
}
/// Is this entry installable on the running host? Returns the operator-facing reason when not.
pub(crate) fn incompatible_reason(&self) -> Option<String> {
if !self.platforms.is_empty() && !self.platforms.iter().any(|p| p == HOST_PLATFORM) {
return Some(format!("requires {}", self.platforms.join(" or ")));
}
if let Some(min) = &self.min_host {
let (Ok(min), Ok(host)) = (
semver::Version::parse(min),
semver::Version::parse(host_version()),
) else {
return None;
};
if host < min {
return Some(format!("needs punktfunk {min} or newer"));
}
}
None
}
}
impl Advisory {
fn validate(&mut self) -> Result<()> {
if self.pkg.trim().is_empty() || self.pkg.len() > 214 {
bail!("advisory pkg is empty or too long");
}
semver::VersionReq::parse(&self.versions)
.context("advisory `versions` is not a semver requirement")?;
self.reason = sanitize(&self.reason, 280);
if let Some(u) = &self.url {
if !is_https(u) || u.len() > 200 {
self.url = None;
}
}
Ok(())
}
/// Does this advisory cover `pkg@version`?
pub(crate) fn matches(&self, pkg: &str, version: &str) -> bool {
if self.pkg != pkg {
return false;
}
let (Ok(req), Ok(v)) = (
semver::VersionReq::parse(&self.versions),
semver::Version::parse(version),
) else {
return false;
};
req.matches(&v)
}
}
// ---------------------------------------------------------------- small helpers
/// This host's platform token, as used in [`Entry::platforms`].
pub(crate) const HOST_PLATFORM: &str = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"linux"
};
/// The running host's version — the left-hand side of every `minHost` comparison.
pub(crate) fn host_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
/// Strip control characters (a catalog string ends up in a log line and in the console UI) and
/// clamp to `max` characters.
fn sanitize(s: &str, max: usize) -> String {
s.chars()
.filter(|c| !c.is_control())
.take(max)
.collect::<String>()
.trim()
.to_string()
}
pub(crate) fn valid_plugin_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.as_bytes()[0].is_ascii_lowercase()
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// `@scope/name` with npm's safe alphabet. Deliberately stricter than npm: no URL-ish characters
/// can survive into a `bun add` argument or a `bunfig.toml` scope key.
pub(crate) fn valid_scoped_pkg(pkg: &str) -> bool {
let Some(rest) = pkg.strip_prefix('@') else {
return false;
};
if pkg.len() > 214 {
return false;
}
let Some((scope, name)) = rest.split_once('/') else {
return false;
};
let ok = |s: &str| {
!s.is_empty()
&& s.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.')
})
};
ok(scope) && ok(name)
}
/// The `@scope` half of a scoped package name — the key `bunfig.toml` maps to a registry.
pub(crate) fn scope_of(pkg: &str) -> Option<String> {
let rest = pkg.strip_prefix('@')?;
let (scope, _) = rest.split_once('/')?;
Some(format!("@{scope}"))
}
fn valid_icon(icon: &str) -> bool {
(1..=48).contains(&icon.len())
&& icon
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// npm's Subresource-Integrity spelling: `<alg>-<base64>`.
fn valid_integrity(s: &str) -> bool {
if s.len() > 200 {
return false;
}
let Some((alg, b64)) = s.split_once('-') else {
return false;
};
matches!(alg, "sha512" | "sha384" | "sha256" | "sha1")
&& !b64.is_empty()
&& b64
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
}
fn is_https(url: &str) -> bool {
url.starts_with("https://") && url.len() > "https://".len()
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(entry_json: &str) -> Vec<u8> {
format!(r#"{{"schema":1,"name":"t","plugins":[{entry_json}]}}"#).into_bytes()
}
const GOOD: &str = r#"{"id":"rom-manager","pkg":"@punktfunk/plugin-rom-manager",
"registry":"https://git.unom.io/api/packages/unom/npm/","title":"ROM Manager",
"description":"d","author":"unom","version":"0.2.1","integrity":"sha512-AAAA",
"verification":{"reviewedAt":"2026-07-19"},"platforms":["linux","windows"]}"#;
#[test]
fn parses_a_good_entry() {
let idx = Index::parse(&doc(GOOD)).unwrap();
assert_eq!(idx.plugins.len(), 1);
let e = &idx.plugins[0];
assert_eq!(e.pkg, "@punktfunk/plugin-rom-manager");
assert_eq!(e.version, "0.2.1");
assert!(e.verification.is_some());
}
#[test]
fn rejects_unknown_schema_and_bad_json() {
assert!(Index::parse(br#"{"schema":2,"plugins":[]}"#).is_err());
assert!(Index::parse(b"not json").is_err());
}
#[test]
fn drops_invalid_entries_but_keeps_the_rest() {
let bad_unscoped = GOOD.replace("@punktfunk/plugin-rom-manager", "punktfunk-plugin-x");
let body = format!(r#"{{"schema":1,"plugins":[{bad_unscoped},{GOOD}]}}"#);
let idx = Index::parse(body.as_bytes()).unwrap();
assert_eq!(idx.plugins.len(), 1, "unscoped pkg must be dropped (D8)");
assert_eq!(idx.plugins[0].id, "rom-manager");
}
#[test]
fn rejects_version_ranges_and_http_registries() {
assert!(Index::parse(&doc(
&GOOD.replace(r#""version":"0.2.1""#, r#""version":"^0.2.1""#)
))
.unwrap()
.plugins
.is_empty());
assert!(Index::parse(&doc(
&GOOD.replace("https://git.unom.io", "http://git.unom.io")
))
.unwrap()
.plugins
.is_empty());
}
#[test]
fn drops_duplicate_ids() {
let body = format!(r#"{{"schema":1,"plugins":[{GOOD},{GOOD}]}}"#);
assert_eq!(Index::parse(body.as_bytes()).unwrap().plugins.len(), 1);
}
#[test]
fn sanitizes_control_characters_in_display_fields() {
let e = GOOD.replace("ROM Manager", "RO\\u0007M");
let idx = Index::parse(&doc(&e)).unwrap();
assert_eq!(idx.plugins[0].title, "ROM");
}
#[test]
fn advisory_matching_is_semver_ranged() {
let mut a = Advisory {
pkg: "@x/y".into(),
versions: "<0.3.2".into(),
reason: "bad".into(),
url: None,
};
a.validate().unwrap();
assert!(a.matches("@x/y", "0.3.1"));
assert!(!a.matches("@x/y", "0.3.2"));
assert!(!a.matches("@other/z", "0.3.1"));
}
#[test]
fn advisory_with_unparseable_range_is_dropped() {
let body = r#"{"schema":1,"plugins":[],"security":[
{"pkg":"@x/y","versions":"not a range","reason":"r"}]}"#;
assert!(Index::parse(body.as_bytes()).unwrap().security.is_empty());
}
#[test]
fn scope_extraction() {
assert_eq!(scope_of("@punktfunk/plugin-x").unwrap(), "@punktfunk");
assert_eq!(scope_of("@a/b").unwrap(), "@a");
assert!(scope_of("punktfunk-plugin-x").is_none());
}
#[test]
fn integrity_shape() {
assert!(valid_integrity("sha512-abcABC123+/="));
assert!(!valid_integrity("md5-abc"));
assert!(!valid_integrity("sha512-"));
assert!(!valid_integrity("sha512"));
}
#[test]
fn signature_verifies_only_over_exact_bytes_and_pinned_keys() {
use ring::signature::KeyPair as _;
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let key = {
use base64::Engine as _;
PublicKey::parse(&format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
))
.unwrap()
};
let body = br#"{"schema":1,"plugins":[]}"#;
let sig = {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(kp.sign(body).as_ref())
};
assert!(verify_signature(body, &sig, std::slice::from_ref(&key)).is_ok());
// whitespace in the .sig file is tolerated
assert!(verify_signature(body, &format!("{sig}\n"), std::slice::from_ref(&key)).is_ok());
// one byte of tampering fails
assert!(verify_signature(
br#"{"schema":1,"plugins":[ ]}"#,
&sig,
std::slice::from_ref(&key)
)
.is_err());
// a different key fails
let other = ring::signature::Ed25519KeyPair::from_pkcs8(
ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
.unwrap()
.as_ref(),
)
.unwrap();
let other_key = {
use base64::Engine as _;
PublicKey::parse(&format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(other.public_key().as_ref())
))
.unwrap()
};
assert!(verify_signature(body, &sig, &[other_key]).is_err());
// no pinned key at all fails closed
assert!(verify_signature(body, &sig, &[]).is_err());
}
/// The real published index must parse here, field for field.
///
/// This fixture is a snapshot of `unom/punktfunk-plugin-index`'s `v1/index.json`. The index
/// repo's validator reimplements this module's rules in TypeScript (it has to — it gates PRs
/// before anything is signed), and two hand-written parsers of the same grammar drift. This
/// test is the seam that catches the drift from our side: if a document the publisher accepts
/// stops being one we accept, an entry silently disappears from every operator's store.
#[test]
fn the_published_seed_index_parses() {
let bytes = include_bytes!("testdata/seed-index.json");
let idx = Index::parse(bytes).expect("the published index must parse");
assert_eq!(idx.plugins.len(), 2, "no entry may be silently dropped");
let rom = idx
.plugins
.iter()
.find(|e| e.id == "rom-manager")
.expect("rom-manager entry");
assert_eq!(rom.pkg, "@punktfunk/plugin-rom-manager");
assert!(rom.integrity.starts_with("sha512-"));
assert!(
semver::Version::parse(&rom.version).is_ok(),
"exact version"
);
assert_eq!(
rom.verification.as_ref().map(|v| v.reviewed_at.as_str()),
Some("2026-07-20"),
"camelCase `reviewedAt` must decode"
);
assert_eq!(
rom.min_host.as_deref(),
Some("0.15.0"),
"camelCase `minHost`"
);
assert_eq!(scope_of(&rom.pkg).unwrap(), "@punktfunk");
// A windows-only entry is listed everywhere but only *installable* where it can run.
let playnite = idx
.plugins
.iter()
.find(|e| e.id == "playnite")
.expect("playnite entry");
assert_eq!(playnite.platforms, vec!["windows"]);
if HOST_PLATFORM == "windows" {
assert!(playnite.incompatible_reason().is_none());
} else {
assert!(playnite.incompatible_reason().is_some());
}
}
#[test]
fn public_key_parsing_rejects_junk() {
assert!(PublicKey::parse("nope").is_err());
assert!(PublicKey::parse("ed25519:!!!").is_err());
assert!(PublicKey::parse("ed25519:AAAA").is_err()); // wrong length
}
}
+692
View File
@@ -0,0 +1,692 @@
//! Install / uninstall **jobs** (design `plugin-store.md` §4.3).
//!
//! A package operation takes tens of seconds, so the API hands back a job id immediately (202) and
//! the console polls. One at a time: `bun add` and `bun remove` share a lockfile and a
//! `node_modules` tree, so a second concurrent op is a corruption bug waiting to happen — a
//! request that arrives while a job runs gets 409, not a queue.
//!
//! The pipeline, and why each step is there:
//!
//! ```text
//! resolve → what exactly are we installing (catalog entry ⇒ pkg@version+integrity, or a raw spec)
//! verify → the registry's advertised integrity for that version MUST equal the index pin
//! install → spawn the runner CLI (`bun add`), streaming its output into the job log
//! check → the version on disk is the version we pinned, else roll back
//! record → provenance manifest, so the tier stays visible forever
//! restart → the runner rediscovers units only at startup, so activation is a restart
//! ```
//!
//! **verify** is the step that makes "verified" mean something. A reviewed entry pins a tarball
//! hash; if the registry now advertises a different hash for that same version, someone republished
//! it after review and the install is refused before any code is fetched, let alone run. Tier-3
//! (raw spec) installs skip it — there is no pin to check against, and that asymmetry *is* the
//! tier.
use super::index::{scope_of, Entry};
use super::manifest::{self, Record, Tier};
use anyhow::{bail, Context, Result};
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Read};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// How long a package op may run before we kill it. `bun add` over a cold cache on a slow link is
/// the worst case; five minutes is far past it.
const JOB_TIMEOUT: Duration = Duration::from_secs(300);
/// How many finished jobs we keep for the console to read back.
const JOB_HISTORY: usize = 20;
/// Lines of runner output retained per job (the console shows a tail).
const LOG_LINES: usize = 200;
// ---------------------------------------------------------------- job records
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")]
pub(crate) enum State {
Running,
Done,
Failed,
}
/// A job as the console sees it. Field names are snake_case like the rest of the management API
/// (the *file* formats — index, sources, manifest — follow npm's camelCase instead).
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
pub(crate) struct Job {
pub id: String,
/// `install` or `uninstall`.
pub kind: String,
/// What the operator asked for — a package name, or the raw spec they typed.
pub target: String,
pub state: State,
/// Coarse step name, for a progress line the operator can read.
pub phase: String,
/// Tail of the runner's combined stdout/stderr.
pub log: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub started_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub finished_at: Option<u64>,
}
struct Jobs {
jobs: VecDeque<Job>,
counter: u64,
}
fn jobs() -> &'static Mutex<Jobs> {
static JOBS: std::sync::OnceLock<Mutex<Jobs>> = std::sync::OnceLock::new();
JOBS.get_or_init(|| {
Mutex::new(Jobs {
jobs: VecDeque::new(),
counter: 0,
})
})
}
fn lock() -> std::sync::MutexGuard<'static, Jobs> {
jobs().lock().unwrap_or_else(|e| e.into_inner())
}
/// Start tracking a job, refusing if one is already in flight (single-flight, §4.3).
fn begin(kind: &str, target: &str) -> Result<String> {
let mut g = lock();
if let Some(active) = g.jobs.iter().find(|j| j.state == State::Running) {
bail!(
"another plugin operation is already running ({} {})",
active.kind,
active.target
);
}
g.counter += 1;
let id = format!("job-{}-{}", super::catalog::unix_now(), g.counter);
let job = Job {
id: id.clone(),
kind: kind.to_string(),
target: target.to_string(),
state: State::Running,
phase: "queued".into(),
log: Vec::new(),
error: None,
started_at: super::catalog::unix_now(),
finished_at: None,
};
g.jobs.push_back(job);
while g.jobs.len() > JOB_HISTORY {
g.jobs.pop_front();
}
Ok(id)
}
fn update(id: &str, f: impl FnOnce(&mut Job)) {
let mut g = lock();
if let Some(job) = g.jobs.iter_mut().find(|j| j.id == id) {
f(job);
}
}
fn set_phase(id: &str, phase: &str) {
tracing::info!(job = %id, phase, "plugin store job");
update(id, |j| j.phase = phase.to_string());
}
fn log_line(id: &str, line: String) {
update(id, |j| {
if j.log.len() >= LOG_LINES {
j.log.remove(0);
}
j.log.push(line);
});
}
fn finish(id: &str, result: Result<()>) {
update(id, |j| {
j.finished_at = Some(super::catalog::unix_now());
match &result {
Ok(()) => {
j.state = State::Done;
j.phase = "done".into();
}
Err(e) => {
j.state = State::Failed;
j.error = Some(format!("{e:#}"));
}
}
});
match result {
Ok(()) => tracing::info!(job = %id, "plugin store job finished"),
Err(e) => tracing::warn!(job = %id, "plugin store job failed: {e:#}"),
}
}
/// One job by id.
pub(crate) fn get(id: &str) -> Option<Job> {
lock().jobs.iter().find(|j| j.id == id).cloned()
}
/// Recent jobs, newest last.
pub(crate) fn list() -> Vec<Job> {
lock().jobs.iter().cloned().collect()
}
/// Is a job in flight? (The console disables install buttons on this.)
pub(crate) fn busy() -> bool {
lock().jobs.iter().any(|j| j.state == State::Running)
}
// ---------------------------------------------------------------- install plans
/// A fully resolved install: everything the executor needs, with the trust decision already made.
pub(crate) struct Plan {
/// Package name without a version.
pub pkg: Option<String>,
/// The argument handed to `bun add` — `pkg@version` for a catalogued entry, the raw spec
/// otherwise.
pub spec: String,
pub version: Option<String>,
/// `(scope, registry_url)` to map in the plugins dir's `bunfig.toml`.
pub registry: Option<(String, String)>,
pub integrity: Option<String>,
pub tier: Tier,
pub source: Option<String>,
pub entry_id: Option<String>,
}
impl Plan {
/// From a catalog entry: exact version, pinned integrity, scope→registry mapping.
pub(crate) fn from_entry(entry: &Entry, source: &str, verified: bool) -> Result<Plan> {
let scope = scope_of(&entry.pkg).context("catalog entry package must be scoped")?;
Ok(Plan {
pkg: Some(entry.pkg.clone()),
spec: format!("{}@{}", entry.pkg, entry.version),
version: Some(entry.version.clone()),
registry: Some((scope, entry.registry.clone())),
integrity: Some(entry.integrity.clone()),
tier: if verified {
Tier::Verified
} else {
Tier::External
},
source: Some(source.to_string()),
entry_id: Some(entry.id.clone()),
})
}
/// From a raw package spec typed into the danger dialog. No pin, no review, no shelf.
pub(crate) fn from_spec(spec: &str) -> Result<Plan> {
let spec = validate_spec(spec)?;
Ok(Plan {
pkg: parse_spec_pkg(&spec),
spec,
version: None,
registry: None,
integrity: None,
tier: Tier::Unverified,
source: None,
entry_id: None,
})
}
}
/// Accept only specs we're willing to hand to `bun add`.
///
/// This is not shell quoting (we always exec with an argument vector, never a shell) — it is about
/// what the *package manager* will do with the string. Two real hazards: a leading `-` turns the
/// operand into a flag, and `file:` specs would let a console session install code from anywhere on
/// the host's filesystem, which is a different (and larger) capability than "install a package".
/// Local paths remain available through the CLI, which is where local development belongs.
fn validate_spec(spec: &str) -> Result<String> {
let s = spec.trim();
if s.is_empty() {
bail!("empty package spec");
}
if s.len() > 400 {
bail!("package spec is too long");
}
if s.starts_with('-') {
bail!("a package spec cannot start with '-'");
}
if s.chars().any(|c| c.is_whitespace() || c.is_control()) {
bail!("a package spec cannot contain whitespace or control characters");
}
let lower = s.to_ascii_lowercase();
for bad in ["file:", "link:", "portal:"] {
if lower.starts_with(bad) {
bail!("`{bad}` specs are not installable from the console — use the `punktfunk-host plugins add` CLI");
}
}
// URL-ish specs: only https and git+https. (http:// and git:// are unauthenticated transports.)
if lower.contains("://")
&& !(lower.starts_with("https://") || lower.starts_with("git+https://"))
{
bail!("only https:// and git+https:// URLs are accepted");
}
Ok(s.to_string())
}
/// Best-effort package name from an npm-style spec (`@scope/name`, `@scope/name@1.2.3`, `name@1`).
/// `None` for URL/git specs — those are identified after the fact by diffing `node_modules`.
fn parse_spec_pkg(spec: &str) -> Option<String> {
if spec.contains("://") {
return None;
}
if let Some(rest) = spec.strip_prefix('@') {
// Scoped: the version separator is the '@' AFTER the scope's slash.
let (scope_and_name, _) = match rest.split_once('/') {
Some((scope, tail)) => match tail.split_once('@') {
Some((name, ver)) => (format!("@{scope}/{name}"), Some(ver)),
None => (format!("@{scope}/{tail}"), None),
},
None => return None,
};
return Some(scope_and_name);
}
Some(
spec.split_once('@')
.map(|(n, _)| n)
.unwrap_or(spec)
.to_string(),
)
}
// ---------------------------------------------------------------- execution
/// Kick off an install. Returns the job id; the work runs on a detached thread.
pub(crate) fn spawn_install(plan: Plan) -> Result<String> {
let target = plan.pkg.clone().unwrap_or_else(|| plan.spec.clone());
let id = begin("install", &target)?;
let job_id = id.clone();
std::thread::Builder::new()
.name("pf-store-install".into())
.spawn(move || {
let result = run_install(&job_id, plan);
finish(&job_id, result);
crate::events::emit(crate::events::EventKind::StoreChanged);
})
.context("spawn the plugin-install worker")?;
Ok(id)
}
/// Kick off an uninstall.
pub(crate) fn spawn_uninstall(pkg: String) -> Result<String> {
if super::valid_installed_pkg(&pkg).is_err() {
bail!("not an installable plugin package name");
}
let id = begin("uninstall", &pkg)?;
let job_id = id.clone();
std::thread::Builder::new()
.name("pf-store-uninstall".into())
.spawn(move || {
let result = run_uninstall(&job_id, &pkg);
finish(&job_id, result);
crate::events::emit(crate::events::EventKind::StoreChanged);
})
.context("spawn the plugin-uninstall worker")?;
Ok(id)
}
fn run_install(id: &str, plan: Plan) -> Result<()> {
let dir = super::plugins_dir();
// ---- verify: the pin must still describe what the registry serves ------------------------
if let (Some(integrity), Some(pkg), Some(version), Some((_, registry))) = (
plan.integrity.as_deref(),
plan.pkg.as_deref(),
plan.version.as_deref(),
plan.registry.as_ref(),
) {
set_phase(id, "verifying");
let advertised = registry_integrity(registry, pkg, version)
.with_context(|| format!("check {pkg}@{version} against {registry}"))?;
if advertised != integrity {
bail!(
"integrity mismatch for {pkg}@{version}: the catalog pins {integrity} but the \
registry now serves {advertised}. This version was republished after it was \
reviewed refusing to install."
);
}
log_line(id, format!("integrity ok: {pkg}@{version}"));
}
// ---- install ------------------------------------------------------------------------------
set_phase(id, "installing");
let before = super::installed_packages(&dir);
// Map the entry's scope to its registry ourselves rather than through a runner flag: the
// installed scripting package can be older than this binary, and an older runner would read an
// unknown flag's value as a package name (see `ensure_bunfig_scope`).
if let Some((scope, url)) = &plan.registry {
super::ensure_bunfig_scope(&dir, scope, url)
.with_context(|| format!("map {scope} to {url}"))?;
}
let mut args = vec!["add".to_string(), plan.spec.clone()];
if plan.version.is_some() {
// Pin the dependency range too, so a later `bun install` in this tree can't drift off the
// reviewed version. Safely ignored by a runner too old to know it (an unknown `-`-prefixed
// flag is skipped, not misread) — the version we install is exact either way, so the worst
// case is a caret range recorded in package.json.
args.push("--exact".into());
}
if !plan.spec.starts_with("@punktfunk/") {
// The runner CLI's supply-chain gate refuses anything resolving off Punktfunk's own
// registry unless told otherwise. Here the operator already made that decision — either by
// adding the source, or in the danger dialog — so pass the flag rather than making the gate
// unable to express "yes, deliberately".
args.push("--allow-public-registry".into());
}
args.push("--plugins".into());
args.push(dir.to_string_lossy().into_owned());
run_runner(id, &args)?;
// ---- check: is what landed what we asked for? ---------------------------------------------
set_phase(id, "checking");
let after = super::installed_packages(&dir);
let added: Vec<_> = after
.iter()
.filter(|p| !before.iter().any(|b| b.pkg == p.pkg))
.collect();
// For a catalogued install we know the package name; for a URL/git spec we learn it here.
let pkg = plan
.pkg
.clone()
.or_else(|| added.first().map(|p| p.pkg.clone()))
.context(
"the install finished but no new plugin package appeared — is this package a \
punktfunk plugin? (it must be named `@scope/plugin-*` or `punktfunk-plugin-*`)",
)?;
let installed = after
.iter()
.find(|p| p.pkg == pkg)
.with_context(|| format!("{pkg} is not present after install"))?;
if let (Some(want), Some(got)) = (plan.version.as_deref(), installed.version.as_deref()) {
if want != got {
// Roll back rather than leave an unreviewed version installed under a verified badge.
log_line(id, format!("rolling back: expected {want}, found {got}"));
set_phase(id, "rolling back");
let _ = run_runner(
id,
&[
"remove".to_string(),
pkg.clone(),
"--plugins".to_string(),
dir.to_string_lossy().into_owned(),
],
);
bail!("installed {pkg}@{got} but the catalog pinned {want} — rolled back");
}
}
// ---- record + activate ---------------------------------------------------------------------
set_phase(id, "recording");
manifest::record(
&dir,
&pkg,
Record {
tier: plan.tier,
source: plan.source.clone(),
entry_id: plan.entry_id.clone(),
version: installed.version.clone().or(plan.version.clone()),
spec: (plan.tier == Tier::Unverified).then(|| plan.spec.clone()),
installed_at: Some(manifest::now_stamp()),
},
)
.context("record install provenance")?;
restart_runner(id);
Ok(())
}
fn run_uninstall(id: &str, pkg: &str) -> Result<()> {
let dir = super::plugins_dir();
set_phase(id, "removing");
run_runner(
id,
&[
"remove".to_string(),
pkg.to_string(),
"--plugins".to_string(),
dir.to_string_lossy().into_owned(),
],
)?;
set_phase(id, "recording");
manifest::forget(&dir, pkg).context("update install provenance")?;
restart_runner(id);
Ok(())
}
/// Restart the runner so it rediscovers units. Best-effort and non-fatal: the package IS installed
/// at this point, so a restart failure must not present as an install failure — it presents as
/// "installed, runner needs a nudge", which the console says out loud.
fn restart_runner(id: &str) {
set_phase(id, "restarting runner");
match crate::plugins::restart_runtime() {
Ok(true) => log_line(id, "plugin runner restarted".into()),
Ok(false) => log_line(
id,
"the plugin runner is not enabled — enable it to start this plugin".into(),
),
Err(e) => log_line(id, format!("could not restart the plugin runner: {e:#}")),
}
}
/// Run the bun runner CLI with `args`, streaming both streams into the job log.
fn run_runner(id: &str, args: &[String]) -> Result<()> {
let (program, prefix) = crate::plugins::runner_command()?;
tracing::info!(job = %id, program = %program.display(), ?args, "spawning the plugin runner");
let mut child = Command::new(&program)
.args(&prefix)
.args(args)
// No stdin: `bun add` must never sit waiting on a prompt inside a service.
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("run the plugin runner ({})", program.display()))?;
// Pump both streams concurrently: bun writes progress to one and errors to the other, and a
// full pipe on either would block the child.
let mut pumps = Vec::new();
if let Some(out) = child.stdout.take() {
let job = id.to_string();
pumps.push(std::thread::spawn(move || pump(&job, out)));
}
if let Some(err) = child.stderr.take() {
let job = id.to_string();
pumps.push(std::thread::spawn(move || pump(&job, err)));
}
let deadline = Instant::now() + JOB_TIMEOUT;
let status = loop {
match child.try_wait().context("wait for the plugin runner")? {
Some(status) => break status,
None if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
bail!(
"the plugin runner timed out after {}s",
JOB_TIMEOUT.as_secs()
);
}
None => std::thread::sleep(Duration::from_millis(150)),
}
};
for p in pumps {
let _ = p.join();
}
if !status.success() {
bail!(
"the plugin runner exited with status {} — see the job log",
status.code().unwrap_or(-1)
);
}
Ok(())
}
/// Copy one child stream into the job log, line by line.
fn pump(job: &str, stream: impl std::io::Read) {
for line in BufReader::new(stream).lines().map_while(Result::ok) {
let line = line.trim_end().to_string();
if !line.is_empty() {
log_line(job, line);
}
}
}
// ---------------------------------------------------------------- registry preflight
/// The integrity hash the registry itself advertises for `pkg@version`.
///
/// This is the check that gives the pin teeth. npm clients already refuse a tarball whose bytes
/// don't match the registry's advertised hash, so the remaining attack is a *republish* — same
/// version, new content, new hash. Comparing the registry's current hash against the reviewed one
/// catches exactly that, before anything is downloaded.
fn registry_integrity(registry: &str, pkg: &str, version: &str) -> Result<String> {
let base = registry.trim_end_matches('/');
// npm registry convention: the scope separator is percent-encoded in the packument path.
let url = format!("{base}/{}", pkg.replace('/', "%2f"));
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(20))
.redirects(3)
.user_agent(&format!("punktfunk-host/{}", super::index::host_version()))
.build();
let resp = agent
.get(&url)
.set("Accept", "application/json")
.call()
.map_err(|e| match e {
ureq::Error::Status(404, _) => {
anyhow::anyhow!("the registry does not know this package")
}
other => anyhow::anyhow!("registry request failed: {other}"),
})?;
let mut body = Vec::new();
resp.into_reader()
.take(16 * 1024 * 1024)
.read_to_end(&mut body)
.context("read the registry response")?;
let doc: serde_json::Value =
serde_json::from_slice(&body).context("registry returned invalid JSON")?;
let dist = doc
.get("versions")
.and_then(|v| v.get(version))
.and_then(|v| v.get("dist"))
.with_context(|| format!("the registry has no version {version} of this package"))?;
dist.get("integrity")
.and_then(|i| i.as_str())
.map(str::to_string)
.context("the registry did not advertise an integrity hash for this version")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spec_validation_refuses_the_dangerous_shapes() {
assert!(validate_spec("@scope/plugin-x").is_ok());
assert!(validate_spec("@scope/plugin-x@1.2.3").is_ok());
assert!(validate_spec("punktfunk-plugin-x").is_ok());
assert!(validate_spec("https://e.org/p.tgz").is_ok());
assert!(validate_spec("git+https://e.org/p.git").is_ok());
assert!(validate_spec("").is_err());
assert!(validate_spec(" ").is_err());
// a leading dash would be parsed by bun as a flag, not an operand
assert!(validate_spec("--production").is_err());
assert!(validate_spec("a b").is_err());
// local-path specs are CLI-only: installing from anywhere on the filesystem is a bigger
// capability than installing a package
assert!(validate_spec("file:/etc/passwd").is_err());
assert!(validate_spec("link:../evil").is_err());
// unauthenticated transports
assert!(validate_spec("http://e.org/p.tgz").is_err());
assert!(validate_spec("git://e.org/p.git").is_err());
}
#[test]
fn spec_package_name_parsing() {
assert_eq!(parse_spec_pkg("@a/b").as_deref(), Some("@a/b"));
assert_eq!(parse_spec_pkg("@a/b@1.2.3").as_deref(), Some("@a/b"));
assert_eq!(parse_spec_pkg("plain").as_deref(), Some("plain"));
assert_eq!(parse_spec_pkg("plain@2").as_deref(), Some("plain"));
// URL specs are identified by diffing node_modules after the install, not by parsing
assert_eq!(parse_spec_pkg("https://e.org/p.tgz"), None);
}
#[test]
fn plan_from_entry_pins_version_registry_and_integrity() {
let idx = super::super::index::Index::parse(
br#"{"schema":1,"plugins":[{"id":"rom-manager","pkg":"@punktfunk/plugin-rom-manager",
"registry":"https://git.unom.io/api/packages/unom/npm/","title":"ROM",
"version":"0.3.0","integrity":"sha512-AAAA"}]}"#,
)
.unwrap();
let plan = Plan::from_entry(&idx.plugins[0], "unom", true).unwrap();
assert_eq!(plan.spec, "@punktfunk/plugin-rom-manager@0.3.0");
assert_eq!(plan.version.as_deref(), Some("0.3.0"));
assert_eq!(plan.tier, Tier::Verified);
assert_eq!(
plan.registry.as_ref().unwrap().0,
"@punktfunk",
"the scope drives the bunfig registry mapping"
);
assert_eq!(plan.integrity.as_deref(), Some("sha512-AAAA"));
// The same entry from an operator-added source is External, never Verified (D6).
let ext = Plan::from_entry(&idx.plugins[0], "retro-hub", false).unwrap();
assert_eq!(ext.tier, Tier::External);
assert_eq!(ext.source.as_deref(), Some("retro-hub"));
}
#[test]
fn plan_from_spec_is_unverified_and_unpinned() {
let plan = Plan::from_spec(" @someone/punktfunk-plugin-x ").unwrap();
assert_eq!(plan.tier, Tier::Unverified);
assert_eq!(plan.spec, "@someone/punktfunk-plugin-x");
assert!(plan.version.is_none());
assert!(
plan.integrity.is_none(),
"nothing to check a raw spec against"
);
assert!(plan.registry.is_none());
}
/// `begin` is process-global and single-flight, so tests that create jobs must not overlap.
fn exclusive() -> std::sync::MutexGuard<'static, ()> {
static M: Mutex<()> = Mutex::new(());
M.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn single_flight_refuses_a_second_job() {
let _g = exclusive();
let first = begin("install", "@a/b").expect("first job starts");
assert!(begin("install", "@c/d").is_err(), "second must be refused");
assert!(busy());
finish(&first, Ok(()));
assert!(!busy());
let second = begin("install", "@c/d").expect("a finished job frees the slot");
finish(&second, Ok(()));
}
#[test]
fn job_log_is_bounded_and_tails() {
let _g = exclusive();
let id = begin("install", "@log/test").unwrap();
for i in 0..(LOG_LINES + 50) {
log_line(&id, format!("line {i}"));
}
let job = get(&id).unwrap();
assert_eq!(job.log.len(), LOG_LINES);
assert_eq!(job.log.last().unwrap(), &format!("line {}", LOG_LINES + 49));
finish(&id, Err(anyhow::anyhow!("boom")));
let job = get(&id).unwrap();
assert_eq!(job.state, State::Failed);
assert!(job.error.unwrap().contains("boom"));
assert!(job.finished_at.is_some());
}
}
+246
View File
@@ -0,0 +1,246 @@
//! The **provenance manifest**: how a plugin got onto this box, remembered for as long as it is
//! installed (design `plugin-store.md` §4.5).
//!
//! `node_modules` knows *what* is installed; it has no idea whether the operator picked a reviewed
//! entry off the official shelf or pasted a tarball URL into the danger dialog. That distinction is
//! exactly what the console has to keep showing — an unverified plugin must stay visibly
//! unverified in the Installed list and in the header above its own UI, long after the install
//! dialog is forgotten.
//!
//! `<plugins_dir>/install-manifest.json`, written by the host (which is the only thing that ever
//! performs a *store* install). **Absence is meaningful**: a package with no manifest entry was
//! installed by the CLI, and is reported as [`Tier::Cli`] rather than being silently promoted.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// How a plugin came to be installed. Ordered by how much review stands behind it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Tier {
/// From the built-in source's index: unom reviewed this exact tarball.
Verified,
/// From an operator-added source's index: pinned and integrity-checked, but curated by
/// somebody else. Attribution, never the badge (D6).
External,
/// Installed from a raw package spec through the danger dialog. Nobody reviewed it.
Unverified,
/// No manifest entry — `punktfunk-host plugins add` put it there.
Cli,
}
impl Tier {
pub(crate) fn as_str(self) -> &'static str {
match self {
Tier::Verified => "verified",
Tier::External => "external",
Tier::Unverified => "unverified",
Tier::Cli => "cli",
}
}
}
/// One remembered install.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Record {
pub tier: Tier,
/// The catalog source's slug, when the install came off a shelf.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
/// The catalog entry id, when there was one — lets the console re-associate an installed
/// package with its shelf row even if the package name is unusual.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_id: Option<String>,
/// The version we installed (as pinned). The *live* version always comes from disk; this is
/// what we asked for.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
/// The raw spec, for [`Tier::Unverified`] installs — the only record of what the operator
/// actually typed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec: Option<String>,
/// RFC-3339 install time (wall clock — display only).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed_at: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct ManifestFile {
schema: u32,
/// Keyed by npm package name. `BTreeMap` so the file has a stable, diffable order.
#[serde(default)]
plugins: BTreeMap<String, Record>,
}
impl Default for ManifestFile {
fn default() -> Self {
Self {
schema: 1,
plugins: BTreeMap::new(),
}
}
}
fn manifest_path(plugins_dir: &std::path::Path) -> std::path::PathBuf {
plugins_dir.join("install-manifest.json")
}
/// Read the manifest. A missing or corrupt file reads as empty — every package then reports as
/// CLI-installed, which is the honest answer when we have no provenance to show.
pub(crate) fn load(plugins_dir: &std::path::Path) -> BTreeMap<String, Record> {
let Ok(bytes) = std::fs::read(manifest_path(plugins_dir)) else {
return BTreeMap::new();
};
match serde_json::from_slice::<ManifestFile>(&bytes) {
Ok(f) if f.schema == 1 => f.plugins,
Ok(f) => {
tracing::warn!(
schema = f.schema,
"unknown install-manifest schema — ignoring"
);
BTreeMap::new()
}
Err(e) => {
tracing::warn!("install-manifest.json is unreadable ({e}) — treating as empty");
BTreeMap::new()
}
}
}
/// Record (or replace) one package's provenance.
pub(crate) fn record(plugins_dir: &std::path::Path, pkg: &str, rec: Record) -> Result<()> {
let mut plugins = load(plugins_dir);
plugins.insert(pkg.to_string(), rec);
write(plugins_dir, plugins)
}
/// Forget a package (on uninstall). Silent if it wasn't recorded.
pub(crate) fn forget(plugins_dir: &std::path::Path, pkg: &str) -> Result<()> {
let mut plugins = load(plugins_dir);
if plugins.remove(pkg).is_none() {
return Ok(());
}
write(plugins_dir, plugins)
}
fn write(plugins_dir: &std::path::Path, plugins: BTreeMap<String, Record>) -> Result<()> {
std::fs::create_dir_all(plugins_dir)
.with_context(|| format!("create {}", plugins_dir.display()))?;
let json = serde_json::to_string_pretty(&ManifestFile { schema: 1, plugins })
.context("serialize install-manifest.json")?;
let path = manifest_path(plugins_dir);
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, format!("{json}\n"))
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
Ok(())
}
/// Wall-clock RFC-3339-ish stamp for [`Record::installed_at`]. Display only — nothing compares
/// these, so a clock jump costs a cosmetic oddity, never a decision.
pub(crate) fn now_stamp() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// Civil-date arithmetic from a Unix timestamp (days since epoch → y/m/d), so this needs no
// date crate. UTC, second resolution.
let (days, rem) = ((secs / 86_400) as i64, secs % 86_400);
let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(tier: Tier) -> Record {
Record {
tier,
source: Some("unom".into()),
entry_id: Some("rom-manager".into()),
version: Some("0.2.1".into()),
spec: None,
installed_at: Some(now_stamp()),
}
}
#[test]
fn round_trips_and_absence_means_cli() {
let dir = tempfile::tempdir().unwrap();
assert!(load(dir.path()).is_empty(), "no file ⇒ nothing recorded");
record(
dir.path(),
"@punktfunk/plugin-rom-manager",
rec(Tier::Verified),
)
.unwrap();
let m = load(dir.path());
let r = m.get("@punktfunk/plugin-rom-manager").unwrap();
assert_eq!(r.tier, Tier::Verified);
assert_eq!(r.version.as_deref(), Some("0.2.1"));
// A package we never recorded has no entry — the caller reports it as CLI-installed.
assert!(!m.contains_key("punktfunk-plugin-other"));
}
#[test]
fn tier_survives_a_reinstall_at_a_new_tier() {
let dir = tempfile::tempdir().unwrap();
record(dir.path(), "@x/y", rec(Tier::Verified)).unwrap();
record(dir.path(), "@x/y", rec(Tier::Unverified)).unwrap();
assert_eq!(load(dir.path()).get("@x/y").unwrap().tier, Tier::Unverified);
}
#[test]
fn forget_removes_only_the_named_package() {
let dir = tempfile::tempdir().unwrap();
record(dir.path(), "@x/y", rec(Tier::Verified)).unwrap();
record(dir.path(), "@x/z", rec(Tier::External)).unwrap();
forget(dir.path(), "@x/y").unwrap();
let m = load(dir.path());
assert!(!m.contains_key("@x/y"));
assert!(m.contains_key("@x/z"));
forget(dir.path(), "@not/here").unwrap(); // no-op, no error
}
#[test]
fn corrupt_manifest_reads_as_empty_not_an_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("install-manifest.json"), b"{ not json").unwrap();
assert!(load(dir.path()).is_empty());
}
#[test]
fn tier_json_spelling_is_stable() {
// The console keys its badges off these strings.
assert_eq!(
serde_json::to_string(&Tier::Unverified).unwrap(),
"\"unverified\""
);
assert_eq!(Tier::Verified.as_str(), "verified");
}
#[test]
fn stamp_is_rfc3339_shaped() {
let s = now_stamp();
assert_eq!(s.len(), 20, "{s}");
assert!(s.ends_with('Z'));
assert_eq!(&s[4..5], "-");
assert_eq!(&s[10..11], "T");
// A known epoch value, to catch the civil-date arithmetic drifting.
assert!(s.as_str() > "2026-01-01T00:00:00Z", "{s}");
}
}
+296
View File
@@ -0,0 +1,296 @@
//! Catalog **sources** — where the store's shelves come from (design `plugin-store.md` §3.1).
//!
//! One source = one URL serving a signed index. The **official** source is compiled in (URL plus
//! two pinned key slots) and cannot be edited or removed; operator-added sources live in
//! `<config_dir>/plugin-sources.json`.
//!
//! Adding a source is itself a trust decision, made once: a source's entries become installable
//! on this host. That is why they carry attribution and warning styling in the console but never
//! the "Verified" badge — verification is unom reviewing a specific tarball, and it is not
//! transferable to a third-party curator (D6).
use super::index::PublicKey;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
/// The built-in source's slug. Reserved: an operator source may not take this name.
pub(crate) const OFFICIAL_NAME: &str = "unom";
/// The built-in source's index URL.
///
/// Served straight out of the index repo over Gitea's anonymous raw endpoint: real HTTPS with a
/// real certificate, no new vhost to stand up, and "merged to main" *is* "published". The document
/// is signed, so the transport is not what we're trusting — swapping this for a dedicated static
/// host later is a one-constant change with no protocol impact.
///
/// One consequence worth knowing: CI signs *after* the merge, so between a merge and the signature
/// commit the index is newer than its `.sig`. That window fails **closed** — the signature check
/// rejects the document and hosts keep serving their last good copy, marked stale.
pub(crate) const OFFICIAL_URL: &str =
"https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json";
/// Pinned signing keys for the built-in source. **Two slots** so a key rotation is "sign with the
/// new key, ship a host that trusts both, retire the old one" instead of a flag day where old
/// hosts lose the catalog. An empty slot is ignored.
pub(crate) const OFFICIAL_KEYS: [&str; 2] = [
"ed25519:V7KKMg8sq2A2TW7D/GFWaM0ruAvigpld9r93JdWcQHw=",
"", // rotation slot
];
/// How many operator sources we'll hold. A guard rail, not a design limit.
const MAX_SOURCES: usize = 32;
/// A catalog source as stored in `plugin-sources.json`. camelCase like the index document it
/// points at (the management API's own view of a source is snake_case — see `mgmt::store`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Source {
/// Slug (`[a-z][a-z0-9-]*`, ≤32) — the cache-file name and the console's attribution chip.
pub name: String,
/// `https://` URL of the index document. The `.sig` is fetched from `<url>.sig`.
pub url: String,
/// Pinned ed25519 key. A source without one is accepted but marked **unsigned**, and its
/// entries inherit that marker — we can still show you the shelf, we just can't promise the
/// shelf wasn't rewritten in transit.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
}
impl Source {
/// The compiled-in official source.
pub(crate) fn official() -> Source {
Source {
name: OFFICIAL_NAME.to_string(),
url: OFFICIAL_URL.to_string(),
// Held in [`OFFICIAL_KEYS`], not here: the built-in keys are code, not config, so a
// config file can never downgrade the official source to unsigned.
public_key: None,
}
}
pub(crate) fn is_official(&self) -> bool {
self.name == OFFICIAL_NAME
}
/// The keys this source's index must verify against. Empty ⇒ unsigned source (accepted, but
/// flagged); for the official source this is always the compiled-in pair.
pub(crate) fn keys(&self) -> Vec<PublicKey> {
if self.is_official() {
return OFFICIAL_KEYS
.iter()
.filter(|k| !k.is_empty())
.filter_map(|k| PublicKey::parse(k).ok())
.collect();
}
self.public_key
.as_deref()
.and_then(|k| PublicKey::parse(k).ok())
.into_iter()
.collect()
}
/// Does this source's index carry a signature we check? Drives the console's "unsigned" marker.
pub(crate) fn is_signed(&self) -> bool {
!self.keys().is_empty()
}
/// Where the detached signature lives. Kept a pure function of the index URL so a source
/// record can never point the signature fetch somewhere else than the document it signs.
pub(crate) fn sig_url(&self) -> String {
format!("{}.sig", self.url)
}
fn validate(&self) -> Result<()> {
if !valid_source_name(&self.name) {
bail!("source name must be kebab-case `[a-z][a-z0-9-]*`, ≤32 characters");
}
if !self.url.starts_with("https://") || self.url.len() > 500 {
bail!("source url must be an https:// URL (≤500 characters)");
}
if let Some(k) = &self.public_key {
PublicKey::parse(k).context("source publicKey")?;
}
Ok(())
}
}
pub(crate) fn valid_source_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 32
&& name.as_bytes()[0].is_ascii_lowercase()
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
// ---------------------------------------------------------------- persistence
#[derive(Serialize, Deserialize, Default)]
struct SourcesFile {
#[serde(default = "one")]
schema: u32,
#[serde(default)]
sources: Vec<Source>,
}
fn one() -> u32 {
1
}
fn sources_path() -> std::path::PathBuf {
pf_paths::config_dir().join("plugin-sources.json")
}
/// Every source this host reads, official first. The official source is prepended here rather
/// than stored, so it survives a hand-edited (or deleted) config file.
pub(crate) fn load() -> Vec<Source> {
let mut out = vec![Source::official()];
let path = sources_path();
let Ok(bytes) = std::fs::read(&path) else {
return out; // no operator sources yet — the common case
};
match serde_json::from_slice::<SourcesFile>(&bytes) {
Ok(file) => {
for s in file.sources.into_iter().take(MAX_SOURCES) {
// Defense in depth: a hand-edited file can't smuggle in a source that shadows the
// official one or carries an unusable URL/key.
if s.is_official() || s.validate().is_err() {
tracing::warn!(name = %s.name, "ignoring invalid entry in plugin-sources.json");
continue;
}
if !out.iter().any(|e| e.name == s.name) {
out.push(s);
}
}
}
Err(e) => tracing::warn!(
"plugin-sources.json is unreadable ({e}) — using the official source only"
),
}
out
}
/// Add or replace an operator source. Refuses the reserved official name and invalid records.
pub(crate) fn put(source: Source) -> Result<()> {
source.validate()?;
if source.is_official() {
bail!("`{OFFICIAL_NAME}` is the built-in source and cannot be redefined");
}
let mut list: Vec<Source> = load().into_iter().filter(|s| !s.is_official()).collect();
if list.len() >= MAX_SOURCES && !list.iter().any(|s| s.name == source.name) {
bail!("too many plugin sources (max {MAX_SOURCES})");
}
list.retain(|s| s.name != source.name);
list.push(source);
save(list)
}
/// Remove an operator source. Returns `false` if there was nothing to remove.
pub(crate) fn remove(name: &str) -> Result<bool> {
if name == OFFICIAL_NAME {
bail!("the built-in `{OFFICIAL_NAME}` source cannot be removed");
}
let list: Vec<Source> = load().into_iter().filter(|s| !s.is_official()).collect();
if !list.iter().any(|s| s.name == name) {
return Ok(false); // nothing to remove — don't rewrite the file
}
save(list.into_iter().filter(|s| s.name != name).collect())?;
Ok(true)
}
fn save(list: Vec<Source>) -> Result<()> {
let dir = pf_paths::config_dir();
pf_paths::create_private_dir(&dir).context("create the punktfunk config dir")?;
let file = SourcesFile {
schema: 1,
sources: list,
};
let json = serde_json::to_string_pretty(&file).context("serialize plugin-sources.json")?;
let path = sources_path();
// Write-then-rename: a crash mid-write must not leave a half-file that makes the host forget
// the operator's sources on next boot.
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, format!("{json}\n"))
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn official_source_is_signed_by_compiled_in_keys() {
let s = Source::official();
assert!(s.is_official());
assert!(s.is_signed(), "the built-in source must carry a pinned key");
assert_eq!(s.sig_url(), format!("{OFFICIAL_URL}.sig"));
// A config file cannot downgrade it: `keys()` ignores the record's own field entirely.
let mut forged = Source::official();
forged.public_key = Some("ed25519:AAAA".into());
assert_eq!(forged.keys().len(), 1);
}
#[test]
fn compiled_in_keys_parse() {
for k in OFFICIAL_KEYS.iter().filter(|k| !k.is_empty()) {
PublicKey::parse(k).expect("compiled-in official key must parse");
}
}
#[test]
fn unsigned_third_party_source_is_flagged_not_rejected() {
let s = Source {
name: "retro-hub".into(),
url: "https://example.org/index.json".into(),
public_key: None,
};
s.validate().unwrap();
assert!(!s.is_signed());
}
#[test]
fn validation_rejects_bad_names_urls_and_keys() {
let base = Source {
name: "ok".into(),
url: "https://e.org/i.json".into(),
public_key: None,
};
assert!(base.validate().is_ok());
assert!(Source {
name: "Bad".into(),
..base.clone()
}
.validate()
.is_err());
assert!(Source {
name: "9bad".into(),
..base.clone()
}
.validate()
.is_err());
assert!(Source {
url: "http://e.org/i.json".into(),
..base.clone()
}
.validate()
.is_err());
assert!(Source {
public_key: Some("ed25519:nope".into()),
..base.clone()
}
.validate()
.is_err());
}
#[test]
fn sig_url_is_derived_not_configurable() {
let s = Source {
name: "x".into(),
url: "https://e.org/v1/index.json".into(),
public_key: None,
};
assert_eq!(s.sig_url(), "https://e.org/v1/index.json.sig");
}
}
@@ -0,0 +1,49 @@
{
"schema": 1,
"name": "unom official",
"generated": "2026-07-20T18:00:00Z",
"plugins": [
{
"id": "rom-manager",
"pkg": "@punktfunk/plugin-rom-manager",
"registry": "https://git.unom.io/api/packages/unom/npm/",
"title": "ROM Manager",
"description": "Scans your ROM directories, maps them to emulators, fetches box art, and reconciles everything into the host game library as a provider. Includes a console-hosted web UI for mapping and cleanup.",
"icon": "gamepad-2",
"author": "unom",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-rom-manager",
"license": "MIT OR Apache-2.0",
"version": "0.3.1",
"integrity": "sha512-SGqMriqQPOQobXMYiT20w0rcTMNdYfZc8No3fPu57njMN+2eTVBVlQjyn1t3KoRFxfoRYGwuumN4X3aNmS0Tpw==",
"verification": {
"reviewedAt": "2026-07-20"
},
"minHost": "0.15.0",
"platforms": [
"linux",
"windows"
]
},
{
"id": "playnite",
"pkg": "@punktfunk/plugin-playnite",
"registry": "https://git.unom.io/api/packages/unom/npm/",
"title": "Playnite",
"description": "Bridges your Playnite library on Windows into the host game library, keeping installed games, metadata, and launch commands in sync as a library provider.",
"icon": "library",
"author": "unom",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-playnite",
"license": "MIT OR Apache-2.0",
"version": "0.1.1",
"integrity": "sha512-H40QEcUN7g0wHTy5CFCOTA4+j/FypbVzWSZ0OlW5Ej5+FnnT1fMUDVOx8KEH34mavTy70fd1zx3zDucu9hNQuQ==",
"verification": {
"reviewedAt": "2026-07-20"
},
"minHost": "0.15.0",
"platforms": [
"windows"
]
}
],
"security": []
}
+61 -9
View File
@@ -30,6 +30,7 @@ and nothing you configure here runs anywhere near the streaming path.
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count | | `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
| `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) | | `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) |
| `plugins.changed` | a plugin's registration changes (registered, restarted, deregistered, or its lease expired) | plugin id | | `plugins.changed` | a plugin's registration changes (registered, restarted, deregistered, or its lease expired) | plugin id |
| `store.changed` | an install or uninstall finished, or a plugin catalog was refreshed | none — re-read `GET /api/v1/store/catalog` / `…/installed` |
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled | | `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled |
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema` Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
@@ -167,12 +168,29 @@ The canonical "decide, don't just observe" pattern — approve pairing from your
## Recipe: full controller passthrough (VirtualHere) ## Recipe: full controller passthrough (VirtualHere)
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
triggers, USB rumble — instead of the emulated pad, share the physical device from the couch with triggers, USB rumble — instead of the emulated pad, hand the physical device from the couch to the
[VirtualHere](https://www.virtualhere.com/) (USB-over-IP) and bind it to the host only while a host over [VirtualHere](https://www.virtualhere.com/) (USB-over-IP), bound only while a client is
client is connected. The couch runs the VirtualHere **server** (sharing the pad); the host runs connected so the couch keeps its own controller the rest of the time.
the VirtualHere **client** and this automation drives its `-t` IPC.
Zero-code, bracketed on the stream with two hooks: **The two sides.** VirtualHere is a server/client pair, and you run both:
- **Server — on the couch** (where the pad is physically plugged in). Run the VirtualHere USB
Server there; it shares the pad on the LAN. Leave it running.
- **Client — on the host** (where the game and this automation run). Install the VirtualHere
Client (as a service, or the tray app). It auto-discovers the couch's shared pad on the same
LAN; across subnets, add it once with `<VH_CLIENT> -t "MANUAL HUB ADD,<couch-ip>:7575"`. The
client binary is `vhclientx86_64` on Linux, `vhui64.exe` on Windows, `vhclientosx` on macOS,
`vhclientarm64` on ARM Linux.
The client's `-t` flag is a one-shot IPC to the already-running client: `-t LIST` prints every
visible device with its address (`server.port`, e.g. `couch-deck.11`); `-t "USE,<addr>"` mounts it
onto the host; `-t "STOP USING,<addr>"` hands it back. The automation just brackets
`USE` / `STOP USING` around a session.
### Zero-code: two hooks
Bracket it on the stream with two [hooks](#hooks-hooksjson) — mount when video starts, release
when it stops. This is all most setups need:
```json ```json
{ {
@@ -183,8 +201,42 @@ Zero-code, bracketed on the stream with two hooks:
} }
``` ```
`couch-deck.11` is the device's VirtualHere address (`vhclientx86_64 -t LIST`); same-LAN setups `couch-deck.11` is the device's VirtualHere address from `vhclientx86_64 -t LIST`.
auto-discover it, otherwise `MANUAL HUB ADD,<couch-ip>:7575` once. For a version that resolves the
device by name, filters to one couch, and releases the pad on a clean shutdown, see the ### Scripted: resolve by name, release on shutdown
The hooks above hard-code the address, and can strand the pad on the host if it's stopped
mid-stream (the `stream.stopped` hook never fires). The
[`virtualhere-dualsense.ts`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk/examples/virtualhere-dualsense.ts) [`virtualhere-dualsense.ts`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk/examples/virtualhere-dualsense.ts)
SDK example. SDK example is the robust version: it resolves the device by **name substring** (survives the
address changing), can filter to **one couch** in a multi-client setup, and **releases the pad on
a clean shutdown** (`systemctl stop`, ^C) so the couch always gets its controller back.
It's a standalone [`@punktfunk/host` script](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#running-a-single-script-as-a-service)
— run it in its own directory. The one edit is its import: the in-repo copy imports from
`../src/index.js`; outside the repo it's the published package:
```sh
mkdir ~/punktfunk-scripts && cd ~/punktfunk-scripts
bun init -y
bun add @punktfunk/host # point the @punktfunk scope at the registry first — see the SDK README
# save the example as virtualhere-dualsense.ts, and change its first import to the package:
# - import { connect } from "../src/index.js";
# + import { connect } from "@punktfunk/host";
VH_DEVICE=DualSense bun virtualhere-dualsense.ts
```
`VH_DEVICE` is required — a VirtualHere address (`couch-deck.11`) or a device-name substring
(`DualSense`). Optional: `VH_CLIENT` overrides the client binary (default `vhclientx86_64`);
`VH_ONLY_CLIENT` binds only for one punktfunk client label. Running on the host box the SDK needs
no token or URL — it reads the host's loopback credentials itself (see
[Connection resolution](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#connection-resolution)).
Keep it running as a
[systemd user unit](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#running-a-single-script-as-a-service)
(its default `SIGTERM` triggers the script's own release step — so `systemctl stop` hands the pad
back), or drop it under the [scripting runner](/docs/plugins) with your other units.
> The example brackets on `client.connected` / `client.disconnected` — the pad returns to the
> couch the moment they disconnect. Switch to `stream.started` / `stream.stopped` if you'd rather
> pass it through only while video is actually flowing; both are noted in the file's header.
+1
View File
@@ -90,6 +90,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. | | `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. |
| `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. Windows host, plus the Linux **GNOME 50+ GameStream desktop mirror** (`PUNKTFUNK_VIDEO_SOURCE=portal`, mirrored monitor in HDR mode — check with `punktfunk-host hdr-probe`). Linux **virtual displays** (native protocol, GameStream default) stay 8-bit: Mutter's virtual-monitor screencast is SDR-only upstream. | | `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. Windows host, plus the Linux **GNOME 50+ GameStream desktop mirror** (`PUNKTFUNK_VIDEO_SOURCE=portal`, mirrored monitor in HDR mode — check with `punktfunk-host hdr-probe`). Linux **virtual displays** (native protocol, GameStream default) stay 8-bit: Mutter's virtual-monitor screencast is SDR-only upstream. |
| `PUNKTFUNK_444` | `1` · `0` *(default on)* | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **On by default** on the host; the client's own 4:4:4 setting (default off) is the real switch. Set `0` to force 4:2:0. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. | | `PUNKTFUNK_444` | `1` · `0` *(default on)* | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **On by default** on the host; the client's own 4:4:4 setting (default off) is the real switch. Set `0` to force 4:2:0. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. |
| `PUNKTFUNK_CHACHA20` | `1` · `0` *(default on)* | ChaCha20-Poly1305 session encryption for clients without hardware AES (old ARM TVs, e.g. webOS), lifting their ~100 Mbps software-AES decrypt ceiling. **On by default** on the host; a session uses it only when the client requests it — everyone else stays on AES-GCM. Purely a performance choice (both ciphers are full-strength); set `0` to force AES-GCM for all sessions. |
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. | | `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. | | `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. | | `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
+50 -2
View File
@@ -14,7 +14,49 @@ Two first-party plugins today:
| **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. | | **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
| **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. | | **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
## Installing a plugin ## Installing from the console
Open the [web console](/docs/web-console) → **Plugins**. The **Browse** tab lists the plugin
catalog; pick one, confirm, and the host installs it and restarts the runner for you. **Installed**
shows what you have (and switches the runner on and off), **Sources** is where the catalog comes
from.
That is the whole install. The rest of this page covers the CLI, which does the same thing, and the
trust model behind the badges — worth reading once, because a plugin runs with the same privileges
as the host.
### What "Verified" means
Every catalogued plugin pins **one exact version** and that version's package hash. A verified entry
means somebody at unom reviewed *that exact package* — not the project in general, and not whatever
it publishes next. When a plugin releases a new version, the store keeps offering the reviewed one
until the new release is reviewed too. Before anything is downloaded, the host re-checks the pinned
hash against the registry, so a package that was quietly republished under the same version number
is refused rather than installed.
Three things a plugin can be:
| Badge | Where it came from |
|---|---|
| **Verified** | The built-in catalog. unom reviewed this exact package. |
| **Unverified**, *from &lt;source&gt;* | A catalog **you** added. Still pinned and hash-checked, but curated by somebody else — unom has not looked at the code. |
| **Unverified** | Installed by hand from a package spec. Nobody reviewed it and nothing pins it; the console asks you to type the name to confirm, and the plugin stays marked this way for as long as it is installed. |
### Adding another catalog
**Sources** → *Add a catalog source*: a name and the URL of its index. Optionally paste the source's
`ed25519:…` public key — with a key set, the host refuses any index from that source that isn't
correctly signed, rather than falling back to an unsigned one.
Adding a source is a trust decision you make once: its plugins become installable on this host.
They are always attributed to it and never carry the Verified badge, which belongs to the built-in
catalog alone.
To publish a plugin to the built-in catalog, open a pull request against
[`punktfunk-plugin-index`](https://git.unom.io/unom/punktfunk-plugin-index) — its README covers the
format and what review looks for.
## Installing from the CLI
Two commands: install the plugin, then turn the runner on. The host CLI handles the rest — creating Two commands: install the plugin, then turn the runner on. The host CLI handles the rest — creating
the plugins directory, pointing it at the package registry, and starting the supervisor. the plugins directory, pointing it at the package registry, and starting the supervisor.
@@ -50,7 +92,13 @@ Open the [web console](/docs/web-console) and the plugin's page appears in the n
that's the whole install. that's the whole install.
The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need
`enable` once — plugins you add later are picked up automatically. `enable` once. The runner discovers plugins when it starts, so one installed later needs a restart
to come up (`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting`
task) — the console does that restart for you as part of installing.
A plugin installed from the CLI shows up in the console as **Installed via CLI**: the console knows
what is installed, but not who vouched for it. Install the same plugin from the store's Browse tab
and it carries its catalog badge instead.
### The rest of the commands ### The rest of the commands
+18 -2
View File
@@ -58,8 +58,10 @@ It is idempotent — safe to re-run. In one pass it:
1. creates the `pf2` Debian-trixie distrobox and installs the build toolchain, 1. creates the `pf2` Debian-trixie distrobox and installs the build toolchain,
2. builds `punktfunk-host` (and the web console), 2. builds `punktfunk-host` (and the web console),
3. writes config to `~/.config/punktfunk/` (a generated web-console login password), 3. writes config to `~/.config/punktfunk/` (a generated web-console login password),
4. raises the UDP socket buffers to 32 MB and adds you to the `input` group (needs `sudo`; skipped 4. raises the UDP socket buffers to 32 MB, installs the gamepad udev rule + the `vhci-hcd` autoload
with a warning if unavailable), and adds you to the `input` group (virtual gamepads / **native Steam Deck controller passthrough**),
and seeds the KDE RemoteDesktop grant for Desktop-mode input — this step **prompts for your `sudo`
password** (a stock Steam Deck requires one; without it gamepad passthrough and the UDP tuning are skipped),
5. installs + starts the `punktfunk-host` and `punktfunk-web` **systemd user services** (with linger, 5. installs + starts the `punktfunk-host` and `punktfunk-web` **systemd user services** (with linger,
so they run without a login session). so they run without a login session).
@@ -82,6 +84,14 @@ When it finishes it prints the web-console URL and how to pair.
> If you only ever use native clients, install with `--no-gamestream` for a host with no GameStream > If you only ever use native clients, install with `--no-gamestream` for a host with no GameStream
> surface at all. > surface at all.
> **First install — reboot once before streaming.** KWin only authorizes Desktop-mode screen capture
> on a fresh session, and the new `input` group (native Steam Deck controller passthrough) only takes
> effect on a new login — so after the **first** install, **reboot the Deck** (a re-run that changes
> nothing doesn't need it). Streaming **Game Mode** with a generic Xbox pad works right away; **Desktop
> capture and the native Steam Deck controller need the reboot.** If a client connects and every
> session ends with `KWin does not expose zkde_screencast_unstable_v1` or the pad shows up as an Xbox
> 360 controller, you haven't rebooted yet.
## 3. Pair a device ## 3. Pair a device
By default the host **requires PIN pairing** (secure). Two ways to pair: By default the host **requires PIN pairing** (secure). Two ways to pair:
@@ -128,6 +138,12 @@ bash ~/punktfunk/scripts/steamdeck/update.sh
thrash the managed session. Pick one mode per session. thrash the managed session. Pick one mode per session.
- **Keep the device awake.** On handhelds, Game Mode auto-suspends on idle, which drops the host off - **Keep the device awake.** On handhelds, Game Mode auto-suspends on idle, which drops the host off
the network mid stream — disable auto-suspend (Settings → Power) for a headless host. the network mid stream — disable auto-suspend (Settings → Power) for a headless host.
- **Native Steam Deck controller passthrough** presents the client's pad as a real Steam Deck
controller (paddles, trackpads, gyro) via a virtual USB device — that needs the `input` group and the
`vhci-hcd` module live, so it only works **after the first-install reboot** above; until then the pad
degrades to a generic Xbox 360 controller (still fully playable). If you're streaming *to* another
Steam Deck, also set Steam Input to **Off** for Punktfunk on that Deck — see
[Stream to a Steam Deck](/docs/steam-deck).
- **It survives OS updates**, but a major SteamOS bump can move library versions; if the host fails to - **It survives OS updates**, but a major SteamOS bump can move library versions; if the host fails to
start after an update, just re-run `update.sh` to rebuild against the new base. start after an update, just re-run `update.sh` to rebuild against the new base.
- Deeper reference (services, container, manual steps): [`scripts/steamdeck/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/scripts/steamdeck/README.md). - Deeper reference (services, container, manual steps): [`scripts/steamdeck/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/scripts/steamdeck/README.md).
+42 -1
View File
@@ -272,7 +272,7 @@
#define INBOUND_REQ_FLAG 2147483648 #define INBOUND_REQ_FLAG 2147483648
#endif #endif
// 16-byte AEAD authentication tag appended by GCM. // 16-byte AEAD authentication tag appended by either session cipher.
#define TAG_LEN 16 #define TAG_LEN 16
// Wire tag distinguishing an input datagram from a video packet. // Wire tag distinguishing an input datagram from a video packet.
@@ -450,6 +450,35 @@
#define VIDEO_CAP_PROBE_SEQ 16 #define VIDEO_CAP_PROBE_SEQ 16
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_caps`] bit: the client's reassembler accepts **streamed access units**
// (design/nvenc-subframe-slice-output.md Phase 2): the host may ship an AU's early FEC blocks
// before the AU's total size exists — while the tail of the frame is still encoding — so the
// AU's last packet leaves the host sooner (latency plan §7 LN1). Non-final blocks ride
// SENTINEL headers (`block_count == 0` — a value no legacy sender emits — with
// `frame_bytes == 0` and exactly `max_data_per_block` data shards, so the shard-offset
// formula needs no total); the FINAL block's headers carry the real
// `frame_bytes`/`block_count` (+ `FLAG_EOF`), which retro-validate the whole frame's geometry
// — a mismatch drops the frame wholesale. The host streams ONLY to clients advertising this
// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
// the fallback is zero-risk.
#define VIDEO_CAP_STREAMED_AU 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_caps`] bit: the client can open **ChaCha20-Poly1305**-sealed session datagrams
// AND requests them — set by clients without hardware AES (the soft-AES armv7 targets, e.g.
// webOS TVs), where GCM's software AES + GHASH caps decrypt at ~100 Mbps while ChaCha's ARX
// construction runs 47× faster in portable code (design/chacha20-session-cipher.md).
// Support-plus-request in one bit mirrors [`VIDEO_CAP_444`]'s "capable AND turned on"
// precedent. The host grants it only when its `PUNKTFUNK_CHACHA20` kill-switch (default on)
// allows, answering with [`Welcome::cipher`] `= 1` + the 32-byte [`Welcome::key_chacha`];
// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a
// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
// control channel, so there is no downgrade surface.
#define VIDEO_CAP_CHACHA20 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`] // [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder // (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
@@ -840,6 +869,18 @@
#define HELLO_LAUNCH_MAX 128 #define HELLO_LAUNCH_MAX 128
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the
// only one pre-cipher builds know).
#define CIPHER_AES_128_GCM 0
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via
// [`VIDEO_CAP_CHACHA20`] for clients without hardware AES.
#define CIPHER_CHACHA20_POLY1305 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PairRequest`]. // Type byte of [`PairRequest`].
#define MSG_PAIR_REQUEST 16 #define MSG_PAIR_REQUEST 16
+106 -11
View File
@@ -48,6 +48,9 @@ BIN="$TARGET_DIR/release/punktfunk-host"
CONFIG="$HOME/.config/punktfunk" CONFIG="$HOME/.config/punktfunk"
UNITS="$HOME/.config/systemd/user" UNITS="$HOME/.config/systemd/user"
XRD="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" XRD="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
# Set when this run does something that only a fresh login picks up (input-group add, first-time
# KWin .desktop grant). Drives the loud "reboot before streaming" note in the summary.
NEED_RELOGIN=0
# --- 0. preflight ---------------------------------------------------------- # --- 0. preflight ----------------------------------------------------------
log "Preflight" log "Preflight"
@@ -66,6 +69,33 @@ fi
DISTROBOX="$(command -v distrobox)" # baked into the web unit (may be /usr/bin or ~/.local/bin) DISTROBOX="$(command -v distrobox)" # baked into the web unit (may be /usr/bin or ~/.local/bin)
ok "distrobox: $DISTROBOX" ok "distrobox: $DISTROBOX"
# --- acquire sudo up front (before the ~15-min build) ----------------------
# Steps 4-5 (UDP buffers, gamepad udev rule, vhci-hcd, input group, linger) need root. Prompt NOW,
# not after the build — so you authorize once and walk away, and a non-interactive run fails LOUDLY
# here instead of silently skipping the tuning at the very end. A stock SteamOS 'deck' has no
# password, so sudo can't work until you set one.
SUDO_OK=0
if sudo -n true 2>/dev/null; then
SUDO_OK=1
elif [ -t 0 ]; then
warn "sudo is needed once (UDP buffers, gamepad udev rule, vhci-hcd, input group, linger):"
if sudo -v; then
SUDO_OK=1
# keep the sudo timestamp warm across the long build so steps 4-5 don't re-prompt / expire
( while sudo -n -v 2>/dev/null; do sleep 50; done ) &
_pf_sudo_keepalive=$!
trap '[ -n "${_pf_sudo_keepalive:-}" ] && kill "$_pf_sudo_keepalive" 2>/dev/null || true' EXIT
fi
fi
if [ "$SUDO_OK" != 1 ]; then
if [ -t 0 ]; then
warn "No sudo — a stock SteamOS 'deck' account has no password. Set one and re-run: passwd"
else
warn "No TTY for the sudo prompt (non-interactive run) — system tuning + linger will be SKIPPED."
warn "Run in Konsole or an interactive 'ssh -t' session (or pre-authorize sudo) to enable them."
fi
fi
# --- 1. build container + toolchain --------------------------------------- # --- 1. build container + toolchain ---------------------------------------
log "Build container '$BOX' ($BOX_IMAGE)" log "Build container '$BOX' ($BOX_IMAGE)"
if distrobox list 2>/dev/null | awk -F'|' '{gsub(/ /,"",$2); print $2}' | grep -qx "$BOX"; then if distrobox list 2>/dev/null | awk -F'|' '{gsub(/ /,"",$2); print $2}' | grep -qx "$BOX"; then
@@ -84,7 +114,7 @@ set -e
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends \ sudo apt-get install -y -qq --no-install-recommends \
build-essential pkg-config clang curl git ca-certificates \ build-essential pkg-config clang cmake curl git ca-certificates \
libavcodec-dev libavformat-dev libavutil-dev libavfilter-dev libswscale-dev libavdevice-dev \ libavcodec-dev libavformat-dev libavutil-dev libavfilter-dev libswscale-dev libavdevice-dev \
libpipewire-0.3-dev libspa-0.2-dev \ libpipewire-0.3-dev libspa-0.2-dev \
libgbm-dev libegl-dev libgl-dev libdrm-dev libva-dev \ libgbm-dev libegl-dev libgl-dev libdrm-dev libva-dev \
@@ -101,10 +131,13 @@ ok "build deps ready"
# --- 2. build host (+ web) ------------------------------------------------- # --- 2. build host (+ web) -------------------------------------------------
log "Building punktfunk-host (release) — first build is slow (~10-15 min)" log "Building punktfunk-host (release) — first build is slow (~10-15 min)"
# vulkan-encode matches the packaged builds (deb/arch): the raw Vulkan Video HEVC/AV1 backend
# (real RFI loss recovery). Pure-Rust ash — no extra system dep. A featureless hand build would
# silently fall back to libav VAAPI.
distrobox enter "$BOX" -- bash -lc " distrobox enter "$BOX" -- bash -lc "
set -e set -e
export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR' export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'
cd '$SRC' && cargo build -r -p punktfunk-host cd '$SRC' && cargo build -r -p punktfunk-host --features punktfunk-host/vulkan-encode
" "
[ -x "$BIN" ] || die "build did not produce $BIN" [ -x "$BIN" ] || die "build did not produce $BIN"
ok "host binary: $BIN" ok "host binary: $BIN"
@@ -126,8 +159,12 @@ mkdir -p "$CONFIG"
if [ ! -f "$CONFIG/host.env" ]; then if [ ! -f "$CONFIG/host.env" ]; then
cat > "$CONFIG/host.env" <<'EOF' cat > "$CONFIG/host.env" <<'EOF'
# punktfunk Steam Deck host config (sourced by the punktfunk-host user service). # punktfunk Steam Deck host config (sourced by the punktfunk-host user service).
# Auto encoder: VAAPI on the Deck's AMD GPU, NVENC on NVIDIA. # Auto encoder: Vulkan Video (or VAAPI fallback) on the Deck's AMD GPU, NVENC on NVIDIA.
PUNKTFUNK_ENCODER=auto PUNKTFUNK_ENCODER=auto
# Van Gogh (LCD/OLED Deck) RADV still gates VK_KHR_video_encode_* behind this perftest flag;
# without it the Vulkan backend can't open and sessions fall back to libav VAAPI. Harmless on
# GPUs where encode is exposed by default.
RADV_PERFTEST=video_encode
# The host auto-detects the live session (Game Mode gamescope / Desktop KDE) per connect. # The host auto-detects the live session (Game Mode gamescope / Desktop KDE) per connect.
# Override the compositor only if detection misbehaves: PUNKTFUNK_COMPOSITOR=gamescope # Override the compositor only if detection misbehaves: PUNKTFUNK_COMPOSITOR=gamescope
EOF EOF
@@ -136,6 +173,34 @@ else
ok "host.env exists (left as-is)" ok "host.env exists (left as-is)"
fi fi
# KWin authorization for Desktop-Mode streaming (and mid-stream Game↔Desktop switches): KWin
# resolves a connecting client's /proc/<pid>/exe against a .desktop `Exec=` and only then grants
# the restricted Wayland globals it lists (see packaging/linux/io.unom.Punktfunk.Host.desktop).
# Exec must therefore be THIS install's binary path, not the packaged /usr/bin one. KWin reads
# grants at session start — after first install, restart the Desktop session (Game Mode and back).
DESKTOP_DST="$HOME/.local/share/applications/io.unom.Punktfunk.Host.desktop"
# First-time install of the grant: KWin only reads it at session start, so a fresh login is required
# before Desktop-mode capture works. A re-run that just rewrites it needs no relogin.
[ -f "$DESKTOP_DST" ] || NEED_RELOGIN=1
mkdir -p "$HOME/.local/share/applications"
sed "s|^Exec=.*|Exec=$BIN|" "$SRC/packaging/linux/io.unom.Punktfunk.Host.desktop" > "$DESKTOP_DST"
ok "KWin desktop-capture authorization (io.unom.Punktfunk.Host.desktop → $BIN)"
# KDE Desktop-mode INPUT: a normal Plasma login lacks the RemoteDesktop portal grant the host's libei
# input path needs, so it would pop an "Allow remote control?" dialog a headless host can't answer.
# Seed it once (per-user, no root) — mirrors packaging/bazzite/kde-desktop-setup.sh. Game Mode
# (gamescope) needs none of this; the .desktop above already grants org_kde_kwin_fake_input.
GRANT_SRC="$SRC/scripts/headless/kde-authorized"
GRANT_DST="$HOME/.local/share/flatpak/db/kde-authorized"
if [ -s "$GRANT_DST" ]; then
ok "KDE RemoteDesktop grant already present"
elif [ -s "$GRANT_SRC" ]; then
mkdir -p "$(dirname "$GRANT_DST")"
install -m644 "$GRANT_SRC" "$GRANT_DST"
systemctl --user restart xdg-permission-store 2>/dev/null || true
ok "seeded KDE RemoteDesktop grant (Desktop-mode input)"
fi
if [ "$WITH_WEB" = 1 ] && [ ! -f "$CONFIG/web.env" ]; then if [ "$WITH_WEB" = 1 ] && [ ! -f "$CONFIG/web.env" ]; then
# Random login password + session secret for the web console, generated once. # Random login password + session secret for the web console, generated once.
# `|| true` swallows the SIGPIPE `tr` takes when `head` closes the pipe (pipefail would abort). # `|| true` swallows the SIGPIPE `tr` takes when `head` closes the pipe (pipefail would abort).
@@ -151,9 +216,11 @@ else
[ "$WITH_WEB" = 1 ] && ok "web.env exists (login password unchanged)" [ "$WITH_WEB" = 1 ] && ok "web.env exists (login password unchanged)"
fi fi
# --- 4. system tuning (needs sudo; skipped gracefully if unavailable) ------ # --- 4. system tuning (needs sudo: UDP buffers + gamepad udev rule + vhci-hcd + input group) --------
log "System tuning (UDP buffers + input group) — needs sudo" log "System tuning (UDP buffers + gamepad rules + vhci-hcd + input group)"
if sudo -n true 2>/dev/null; then # sudo was acquired up front in preflight (SUDO_OK) so this never stalls behind the long build; a
# skip here (no password / no TTY) was already reported loudly there.
if [ "$SUDO_OK" = 1 ]; then
printf 'net.core.wmem_max=33554432\nnet.core.rmem_max=33554432\n' \ printf 'net.core.wmem_max=33554432\nnet.core.rmem_max=33554432\n' \
| sudo tee /etc/sysctl.d/99-punktfunk-net.conf >/dev/null | sudo tee /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
sudo sysctl -q -p /etc/sysctl.d/99-punktfunk-net.conf >/dev/null sudo sysctl -q -p /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
@@ -161,13 +228,34 @@ if sudo -n true 2>/dev/null; then
if [ -f "$SRC/scripts/60-punktfunk.rules" ]; then if [ -f "$SRC/scripts/60-punktfunk.rules" ]; then
sudo install -m644 "$SRC/scripts/60-punktfunk.rules" /etc/udev/rules.d/60-punktfunk.rules sudo install -m644 "$SRC/scripts/60-punktfunk.rules" /etc/udev/rules.d/60-punktfunk.rules
sudo udevadm control --reload-rules && sudo udevadm trigger || true sudo udevadm control --reload-rules && sudo udevadm trigger || true
ok "installed udev rule (virtual gamepads)" ok "installed udev rule (virtual gamepads + native Steam Deck controller)"
fi
# vhci-hcd: the usbip transport that makes the virtual Steam Deck pad a *real* USB device so Steam
# Input adopts it (else it degrades to plain UHID, which Steam ignores — "no controller appears").
# Persist the autoload AND load it now so passthrough works without waiting for a reboot.
if [ -f "$SRC/scripts/punktfunk-modules.conf" ]; then
sudo install -m644 "$SRC/scripts/punktfunk-modules.conf" /etc/modules-load.d/punktfunk.conf
sudo modprobe vhci-hcd 2>/dev/null || warn "could not load vhci-hcd now (loads on next boot) — needed for the native Steam Deck pad"
ok "vhci-hcd autoload installed (native Steam Deck controller transport)"
fi
if id -nG "$USER" | grep -qw input; then
ok "already in the 'input' group"
else
sudo usermod -aG input "$USER"
NEED_RELOGIN=1
warn "added $USER to the 'input' group (applies on next login)"
fi fi
id -nG "$USER" | grep -qw input || { sudo usermod -aG input "$USER"; warn "added $USER to 'input' group — log out/in (or reboot) for gamepad support"; }
else else
warn "passwordless sudo unavailable — skipping UDP-buffer + udev tuning." warn "no usable sudo — SKIPPED system tuning. Gamepad passthrough + clean streaming need root (udev"
warn "Without it, high-bitrate streaming drops packets. Apply manually later:" warn "rule, 'input' group, vhci-hcd, UDP buffers) — there is no user-space way to do these."
warn " echo -e 'net.core.wmem_max=33554432\\nnet.core.rmem_max=33554432' | sudo tee /etc/sysctl.d/99-punktfunk-net.conf && sudo sysctl --system" warn "A stock SteamOS 'deck' account has NO password, so sudo can't work until you set one:"
warn " passwd # set a sudo password once, then re-run this script"
warn "Or apply it by hand (then reboot):"
warn " sudo install -m644 $SRC/scripts/60-punktfunk.rules /etc/udev/rules.d/ &&"
warn " sudo install -m644 $SRC/scripts/punktfunk-modules.conf /etc/modules-load.d/punktfunk.conf &&"
warn " sudo usermod -aG input $USER &&"
warn " printf 'net.core.wmem_max=33554432\\nnet.core.rmem_max=33554432\\n' | sudo tee /etc/sysctl.d/99-punktfunk-net.conf &&"
warn " sudo sysctl --system && sudo udevadm control --reload-rules && sudo udevadm trigger"
fi fi
# --- 5. systemd user services --------------------------------------------- # --- 5. systemd user services ---------------------------------------------
@@ -248,3 +336,10 @@ else
echo " • Pairing required (secure default). From a client, pick this host and enter the PIN the host shows." echo " • Pairing required (secure default). From a client, pick this host and enter the PIN the host shows."
fi fi
echo " • Update later: bash $SRC/scripts/steamdeck/update.sh" echo " • Update later: bash $SRC/scripts/steamdeck/update.sh"
if [ "$NEED_RELOGIN" = 1 ]; then
echo
warn "ONE MORE STEP before streaming — reboot the Deck (or fully log out and back in)."
echo " KWin only authorizes Desktop-mode screen capture on a fresh session, and the new 'input'"
echo " group (native Steam Deck controller passthrough) only applies to a new login. Streaming"
echo " Game Mode with a generic Xbox pad works now; Desktop capture + the native Deck pad need the reboot."
fi
+65 -1
View File
@@ -21,7 +21,8 @@ if [ "${1:-}" = "--pull" ]; then
fi fi
log "Rebuilding host (release)" log "Rebuilding host (release)"
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'; cd '$SRC' && cargo build -r -p punktfunk-host" # vulkan-encode matches the packaged builds (deb/arch) — see install.sh.
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'; cd '$SRC' && cargo build -r -p punktfunk-host --features punktfunk-host/vulkan-encode"
ok "host rebuilt" ok "host rebuilt"
if [ "$WEB" = 1 ]; then if [ "$WEB" = 1 ]; then
log "Rebuilding web console" log "Rebuilding web console"
@@ -29,6 +30,69 @@ if [ "$WEB" = 1 ]; then
ok "web rebuilt" ok "web rebuilt"
fi fi
# Retrofit config that install.sh now writes but older installs predate (both idempotent):
# RADV_PERFTEST — Van Gogh RADV still gates VK_KHR_video_encode_* behind it; without it the
# Vulkan backend can't open and sessions silently fall back to libav VAAPI. The KWin .desktop —
# KWin only grants the restricted capture/input globals to the exe a .desktop authorizes.
HOST_ENV="$HOME/.config/punktfunk/host.env"
if [ -f "$HOST_ENV" ] && ! grep -q '^RADV_PERFTEST=' "$HOST_ENV"; then
printf '\n# Van Gogh RADV gates VK_KHR_video_encode_* behind this (Vulkan Video encode).\nRADV_PERFTEST=video_encode\n' >> "$HOST_ENV"
ok "host.env: added RADV_PERFTEST=video_encode"
fi
mkdir -p "$HOME/.local/share/applications"
sed "s|^Exec=.*|Exec=$TARGET_DIR/release/punktfunk-host|" "$SRC/packaging/linux/io.unom.Punktfunk.Host.desktop" \
> "$HOME/.local/share/applications/io.unom.Punktfunk.Host.desktop"
ok "KWin desktop-capture authorization refreshed"
# Retrofit the system bits install.sh now sets up but older installs predate (idempotent). vhci-hcd =
# usbip transport for the native Steam Deck pad; 60-punktfunk.rules = /dev/uhid + vhci access; input
# group = uhid write; the kde-authorized grant (per-user, no root) = Desktop-mode input. A stock Deck
# needs a sudo PASSWORD, so PROMPT for it rather than silently skipping (skipping = gamepads stay dead).
SUDO_OK=0
if sudo -n true 2>/dev/null; then
SUDO_OK=1
elif [ -t 0 ]; then
warn "sudo needs your password to (re)apply the gamepad udev rule, vhci-hcd, input group, and UDP buffers:"
sudo -v && SUDO_OK=1 || true
fi
if [ "$SUDO_OK" = 1 ]; then
if [ -f "$SRC/scripts/60-punktfunk.rules" ]; then
sudo install -m644 "$SRC/scripts/60-punktfunk.rules" /etc/udev/rules.d/60-punktfunk.rules
sudo udevadm control --reload-rules >/dev/null 2>&1 || true
sudo udevadm trigger >/dev/null 2>&1 || true
ok "gamepad udev rule ensured"
fi
if [ -f "$SRC/scripts/punktfunk-modules.conf" ]; then
sudo install -m644 "$SRC/scripts/punktfunk-modules.conf" /etc/modules-load.d/punktfunk.conf
sudo modprobe vhci-hcd 2>/dev/null || true
ok "vhci-hcd autoload ensured (native Steam Deck controller)"
fi
# UDP buffers: older installs (or sudo-skipped ones) still run the stock 416 KB cap.
if [ ! -f /etc/sysctl.d/99-punktfunk-net.conf ]; then
printf 'net.core.wmem_max=33554432\nnet.core.rmem_max=33554432\n' | sudo tee /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
sudo sysctl -q -p /etc/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
ok "UDP socket buffers raised to 32 MB (persisted)"
fi
if id -nG "$USER" | grep -qw input; then :; else
sudo usermod -aG input "$USER"
warn "added $USER to the 'input' group — REBOOT (or log out/in) for it to apply"
fi
else
warn "no usable sudo — SKIPPED gamepad/udev/vhci/UDP tuning (all root-only; no user-space alternative)."
warn "A stock SteamOS 'deck' account has NO password — set one with 'passwd', then re-run. Gamepads stay"
warn "Xbox-360 until this runs and you reboot."
fi
echo
warn "If the controller still shows as an Xbox 360 pad, REBOOT the Deck once — the 'input' group and the"
warn "vhci-hcd module only become live for the host service on a fresh login."
GRANT_SRC="$SRC/scripts/headless/kde-authorized"
GRANT_DST="$HOME/.local/share/flatpak/db/kde-authorized"
if [ ! -s "$GRANT_DST" ] && [ -s "$GRANT_SRC" ]; then
mkdir -p "$(dirname "$GRANT_DST")"
install -m644 "$GRANT_SRC" "$GRANT_DST"
ok "seeded KDE RemoteDesktop grant (Desktop-mode input)"
fi
log "Restarting services" log "Restarting services"
systemctl --user restart punktfunk-host.service systemctl --user restart punktfunk-host.service
ok "punktfunk-host restarted" ok "punktfunk-host restarted"
+4 -1
View File
@@ -89,7 +89,10 @@ A complexity ladder in [`examples/`](./examples) — start at the top:
4. [`couch-preset.effect.ts`](./examples/couch-preset.effect.ts) — **advanced, Effect-native**: only if you're composing Effect programs. 4. [`couch-preset.effect.ts`](./examples/couch-preset.effect.ts) — **advanced, Effect-native**: only if you're composing Effect programs.
Examples 13 are the plain Promise facade and cover most automation; you only need example 4's Examples 13 are the plain Promise facade and cover most automation; you only need example 4's
Effect surface for composed, interruptible programs. Run any with `bun examples/<file>.ts`. Effect surface for composed, interruptible programs. Run any **in the repo** with
`bun examples/<file>.ts`. To **deploy** one on a host, install the package into its own directory
(`bun add @punktfunk/host`) and change its `../src/…` import to `@punktfunk/host` — see
[Running a single script as a service](#running-a-single-script-as-a-service).
Plus a real-world recipe: Plus a real-world recipe:
+5
View File
@@ -15,6 +15,11 @@
// VH_DEVICE=couch-deck.11 bun examples/virtualhere-dualsense.ts # address from `-t LIST` // VH_DEVICE=couch-deck.11 bun examples/virtualhere-dualsense.ts # address from `-t LIST`
// VH_DEVICE=DualSense bun examples/virtualhere-dualsense.ts # …or match by name substring // VH_DEVICE=DualSense bun examples/virtualhere-dualsense.ts # …or match by name substring
// //
// To run this *outside* the repo (the normal case), drop it in its own dir, `bun add
// @punktfunk/host`, and change the import below from `../src/index.js` to `@punktfunk/host`; then
// keep it alive as a systemd user unit (its SIGTERM release hands the pad back on `systemctl
// stop`). Full walkthrough: docs → Events & hooks → "full controller passthrough (VirtualHere)".
//
// Env: VH_DEVICE required — a VirtualHere address (`server.port`) or a device-name substring. // Env: VH_DEVICE required — a VirtualHere address (`server.port`) or a device-name substring.
// VH_CLIENT client binary. Default `vhclientx86_64` (Linux); Windows `vhui64.exe`, // VH_CLIENT client binary. Default `vhclientx86_64` (Linux); Windows `vhui64.exe`,
// macOS `vhclientosx`, ARM Linux `vhclientarm64`. // macOS `vhclientosx`, ARM Linux `vhclientarm64`.
File diff suppressed because one or more lines are too long
+64 -20
View File
@@ -61,42 +61,75 @@ export const ensurePluginsDir = (dir = pluginsDirDefault()): string => {
}; };
/** /**
* Ensure `<dir>/bunfig.toml` points the `@punktfunk` scope at the registry so `bun add` resolves * Ensure `<dir>/bunfig.toml` maps every scope we need to its registry, so `bun add` resolves
* first-party plugins. Idempotent: a file already mapping the scope is left untouched; an existing * plugins from the right place. `@punktfunk` Punktfunk's own registry is always mapped;
* bunfig with an `[install.scopes]` table gets our line inserted under it; anything else appends a * `extraScopes` adds others a plugin-store catalog entry carries its own registry, and the scope
* fresh table. * is what binds a package name to it (design D8, which is why catalog entries must be scoped).
*
* Idempotent and non-destructive: a scope already mapped to the same URL is left alone, a scope
* mapped to a *different* URL is rewritten, and any unrelated bunfig content is preserved.
*/ */
export const ensureBunfig = (dir = pluginsDirDefault()): void => { export const ensureBunfig = (
dir = pluginsDirDefault(),
extraScopes: Record<string, string> = {},
): void => {
const file = path.join(dir, "bunfig.toml"); const file = path.join(dir, "bunfig.toml");
const scopeLine = `"@punktfunk" = "${REGISTRY}"`; const wanted: Record<string, string> = { "@punktfunk": REGISTRY, ...extraScopes };
let existing = ""; let existing = "";
try { try {
existing = fs.readFileSync(file, "utf8"); existing = fs.readFileSync(file, "utf8");
} catch { } catch {
// no bunfig yet — write a fresh one below // no bunfig yet — write a fresh one below
} }
if (existing.includes("@punktfunk") && existing.includes(REGISTRY)) return; // already wired
const table = `[install.scopes]\n${scopeLine}\n`; let out = existing;
if (!existing.trim()) { const missing: string[] = [];
fs.writeFileSync(file, table); for (const [scope, url] of Object.entries(wanted)) {
} else if (/^\[install\.scopes\][^\n]*$/m.test(existing)) { // Match `"@scope" = "…"` (quoted or bare key) anywhere in the file.
// Insert our scope line right after the existing table header. const line = new RegExp(`^\\s*"?${escapeRe(scope)}"?\\s*=\\s*".*"\\s*$`, "m");
const replacement = `"${scope}" = "${url}"`;
if (line.test(out)) {
const current = out.match(line)?.[0] ?? "";
if (current.includes(`"${url}"`)) continue; // already correct
out = out.replace(line, replacement);
} else {
missing.push(replacement);
}
}
if (missing.length === 0) {
if (out !== existing) fs.writeFileSync(file, out);
return;
}
const block = missing.join("\n");
if (!out.trim()) {
fs.writeFileSync(file, `[install.scopes]\n${block}\n`);
} else if (/^\[install\.scopes\][^\n]*$/m.test(out)) {
// Insert under the existing table header.
fs.writeFileSync( fs.writeFileSync(
file, file,
existing.replace(/^\[install\.scopes\][^\n]*$/m, (m) => `${m}\n${scopeLine}`), out.replace(/^\[install\.scopes\][^\n]*$/m, (m) => `${m}\n${block}`),
); );
} else { } else {
const sep = existing.endsWith("\n") ? "" : "\n"; const sep = out.endsWith("\n") ? "" : "\n";
fs.writeFileSync(file, `${existing}${sep}\n${table}`); fs.writeFileSync(file, `${out}${sep}\n[install.scopes]\n${block}\n`);
} }
}; };
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export interface PkgOpts extends ResolveOptions { export interface PkgOpts extends ResolveOptions {
/** Plugins dir. Default `<config_dir>/plugins`. */ /** Plugins dir. Default `<config_dir>/plugins`. */
dir?: string; dir?: string;
/** Line sink for progress. Default stdout. */ /** Line sink for progress. Default stdout. */
log?: (line: string) => void; log?: (line: string) => void;
/**
* Record the resolved version exactly (`bun add --exact`) instead of a caret range. The plugin
* store always sets this: a catalog entry pins one reviewed version, and a caret range in
* `package.json` would let a later `bun install` in this tree drift off it.
*/
exact?: boolean;
/** Extra `scope → registry URL` mappings to write into `bunfig.toml` before installing. */
registries?: Record<string, string>;
} }
/** Run `bun add`/`bun remove` in the plugins dir on the current (vendored) bun. */ /** Run `bun add`/`bun remove` in the plugins dir on the current (vendored) bun. */
@@ -104,11 +137,21 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
const dir = opts.dir ?? pluginsDirDefault(); const dir = opts.dir ?? pluginsDirDefault();
const log = opts.log ?? ((l: string) => console.log(l)); const log = opts.log ?? ((l: string) => console.log(l));
ensurePluginsDir(dir); ensurePluginsDir(dir);
if (action === "add") ensureBunfig(dir); if (action === "add") ensureBunfig(dir, opts.registries);
log(`${action === "add" ? "installing" : "removing"} ${pkgs.join(", ")} in ${dir}`); log(`${action === "add" ? "installing" : "removing"} ${pkgs.join(", ")} in ${dir}`);
// `process.execPath` is the bun running this file (the vendored one under the package), so a // `process.execPath` is the bun running this file (the vendored one under the package), so a
// system-wide bun on PATH is not required. Inherit stdio so `bun`'s progress reaches the user. // system-wide bun on PATH is not required. Inherit stdio so `bun`'s progress reaches the user.
const args = [process.execPath, action, ...pkgs]; const args = [process.execPath, action, ...pkgs];
if (action === "add") {
// NEVER run install lifecycle scripts. A plugin is code we chose to run under the runner,
// where it is supervised and (on Windows) de-privileged; a postinstall script runs
// immediately, as whoever is installing — which on a console-triggered install is the host
// service. bun already declines untrusted scripts by default; this makes it explicit and
// unconditional. A plugin that needs a native build step is a review rejection, not a case
// to support.
args.push("--ignore-scripts");
if (opts.exact) args.push("--exact");
}
// Windows: install file COPIES, never bun's default hardlinks. A hardlinked file's canonical // Windows: install file COPIES, never bun's default hardlinks. A hardlinked file's canonical
// path resolves into the installing admin's per-user bun cache // path resolves into the installing admin's per-user bun cache
// (C:\Users\<admin>\.bun\install\cache\…), which the de-privileged LocalService runner cannot // (C:\Users\<admin>\.bun\install\cache\…), which the de-privileged LocalService runner cannot
@@ -156,9 +199,10 @@ export interface InstalledPlugin {
} }
/** /**
* Enumerate installed plugin packages under `<dir>/node_modules` both the scoped first-party * Enumerate installed plugin packages under `<dir>/node_modules` the unscoped convention
* convention (`@punktfunk/plugin-*`) and the unscoped one (`punktfunk-plugin-*`). Mirrors the * (`punktfunk-plugin-*`) and **any** scope's `plugin-*` (`@punktfunk/plugin-rom-manager`,
* discovery in runner.ts so `list` shows exactly what the runner would supervise. * `@retro-hub/plugin-x`). Mirrors the discovery in runner.ts so `list` shows exactly what the
* runner would supervise.
*/ */
export const listInstalled = (dir = pluginsDirDefault()): InstalledPlugin[] => { export const listInstalled = (dir = pluginsDirDefault()): InstalledPlugin[] => {
const modules = path.join(dir, "node_modules"); const modules = path.join(dir, "node_modules");
@@ -182,7 +226,7 @@ export const listInstalled = (dir = pluginsDirDefault()): InstalledPlugin[] => {
for (const entry of entries) { for (const entry of entries) {
if (entry.startsWith("punktfunk-plugin-")) { if (entry.startsWith("punktfunk-plugin-")) {
out.push({ pkg: entry, version: versionOf(path.join(modules, entry)) }); out.push({ pkg: entry, version: versionOf(path.join(modules, entry)) });
} else if (entry === "@punktfunk") { } else if (entry.startsWith("@")) {
let scoped: string[] = []; let scoped: string[] = [];
try { try {
scoped = fs.readdirSync(path.join(modules, entry)).sort(); scoped = fs.readdirSync(path.join(modules, entry)).sort();
+48 -1
View File
@@ -16,6 +16,11 @@
// //
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list] (run the runner) // bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list] (run the runner)
// bun src/runner-cli.ts add playnite [--plugins DIR] (package ops) // bun src/runner-cli.ts add playnite [--plugins DIR] (package ops)
//
// Package-op flags: --exact pins the resolved version instead of a caret range, and
// --registry @scope=https://… maps a scope to its registry in bunfig.toml. Both exist for the
// plugin store (crates/punktfunk-host/src/store), which installs one reviewed version of a
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
import { Effect, Fiber } from "effect"; import { Effect, Fiber } from "effect";
import { addPlugins, listInstalled, removePlugins } from "./plugins.js"; import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
import { discoverUnits, runner } from "./runner.js"; import { discoverUnits, runner } from "./runner.js";
@@ -25,18 +30,56 @@ const arg = (flag: string): string | undefined => {
return i >= 0 ? process.argv[i + 1] : undefined; return i >= 0 ? process.argv[i + 1] : undefined;
}; };
/** Every value of a repeatable flag (`--registry a=b --registry c=d`). */
const argAll = (flag: string): string[] => {
const out: string[] = [];
for (let i = 0; i < process.argv.length; i++) {
if (process.argv[i] === flag && process.argv[i + 1] !== undefined) out.push(process.argv[++i]);
}
return out;
};
/** Flags that take a value — skipped (with their value) when collecting positionals. */
const VALUE_FLAGS = ["--plugins", "--scripts", "--registry"];
const options = { const options = {
scriptsDir: arg("--scripts"), scriptsDir: arg("--scripts"),
pluginsDir: arg("--plugins"), pluginsDir: arg("--plugins"),
}; };
/**
* `--registry @scope=https://registry/` `{ "@scope": "https://registry/" }`. The plugin store
* passes one per catalog entry so a third-party package's scope resolves to *its* registry rather
* than the public npm default.
*/
const parseRegistries = (): Record<string, string> => {
const out: Record<string, string> = {};
for (const spec of argAll("--registry")) {
const eq = spec.indexOf("=");
if (eq <= 0) {
console.error(`[plugins] ignoring malformed --registry '${spec}' (expected @scope=URL)`);
continue;
}
const scope = spec.slice(0, eq).trim();
const url = spec.slice(eq + 1).trim();
if (!scope.startsWith("@") || !url.startsWith("https://")) {
console.error(
`[plugins] ignoring --registry '${spec}' (scope must start with @, url with https://)`,
);
continue;
}
out[scope] = url;
}
return out;
};
// Positional plugin names after the subcommand (argv: [bun, script, <cmd>, …]). Skip flags and the // Positional plugin names after the subcommand (argv: [bun, script, <cmd>, …]). Skip flags and the
// value of `--plugins`/`--scripts` wherever they appear, so ordering doesn't matter. // value of `--plugins`/`--scripts` wherever they appear, so ordering doesn't matter.
const positionals = (): string[] => { const positionals = (): string[] => {
const out: string[] = []; const out: string[] = [];
for (let i = 3; i < process.argv.length; i++) { for (let i = 3; i < process.argv.length; i++) {
const a = process.argv[i]; const a = process.argv[i];
if (a === "--plugins" || a === "--scripts") { if (VALUE_FLAGS.includes(a)) {
i++; // skip its value too i++; // skip its value too
continue; continue;
} }
@@ -51,6 +94,10 @@ const pkgOpts = {
// Opt-in for names that resolve on the public npm registry (supply-chain gate in // Opt-in for names that resolve on the public npm registry (supply-chain gate in
// plugins.ts::resolvePackage). Boolean flag, so positionals() skips it on its own. // plugins.ts::resolvePackage). Boolean flag, so positionals() skips it on its own.
allowPublicRegistry: process.argv.includes("--allow-public-registry"), allowPublicRegistry: process.argv.includes("--allow-public-registry"),
// Pin the exact version in package.json instead of a caret range — the plugin store installs
// one reviewed version and must not let a later `bun install` drift off it.
exact: process.argv.includes("--exact"),
registries: parseRegistries(),
}; };
const runPkgOp = ( const runPkgOp = (
+29 -5
View File
@@ -302,8 +302,27 @@ export const discoverUnits = (
// no scripts dir — fine // no scripts dir — fine
} }
const modules = path.join(pluginsDir, "node_modules"); const modules = path.join(pluginsDir, "node_modules");
// The packages the operator actually installed — `bun add` records them as the plugins dir's
// own `dependencies`. This is what separates a plugin from a plugin's LIBRARY:
// `@punktfunk/plugin-kit` matches the `plugin-*` naming convention exactly but arrives as a
// transitive dependency of every kit-built plugin, and running it as a unit is nonsense.
// `undefined` only when there is no readable `package.json` at all — a hand-assembled tree then
// falls back to the naming convention rather than discovering nothing. A package.json with no
// `dependencies` key yields an EMPTY set, not `undefined`: `bun remove` drops the key when the
// last plugin goes, and orphaned transitive packages can outlive it, so falling back there
// would start running a plugin's library the moment you uninstall the last real plugin.
let topLevel: Set<string> | undefined;
try {
const root = JSON.parse(
fs.readFileSync(path.join(pluginsDir, "package.json"), "utf8"),
) as { dependencies?: Record<string, string> };
topLevel = new Set(Object.keys(root.dependencies ?? {}));
} catch {
// no package.json — fall back to the convention
}
// Read a plugin package's manifest (`module`/`main` entry) and add it as a unit. // Read a plugin package's manifest (`module`/`main` entry) and add it as a unit.
const addPlugin = (dir: string, name: string): void => { const addPlugin = (dir: string, name: string): void => {
if (topLevel && !topLevel.has(name)) return; // a dependency, not an installed plugin
try { try {
const manifest = JSON.parse( const manifest = JSON.parse(
fs.readFileSync(path.join(dir, "package.json"), "utf8"), fs.readFileSync(path.join(dir, "package.json"), "utf8"),
@@ -323,10 +342,15 @@ export const discoverUnits = (
addPlugin(path.join(modules, pkg), pkg); addPlugin(path.join(modules, pkg), pkg);
continue; continue;
} }
// Scoped convention: `@punktfunk/plugin-*` (first-party). A scoped name resolves cleanly // Scoped convention: `<any scope>/plugin-*`. A scoped name resolves cleanly from a
// from a single registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect` // registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect` as shared
// as shared (hoisted) deps rather than bundling its own copy of each. // (hoisted) deps rather than bundling its own copy of each.
if (pkg === "@punktfunk") { //
// ANY scope, not just `@punktfunk`: the plugin store requires catalog entries to be
// scoped precisely so the scope can map to that entry's registry, so a third-party
// plugin necessarily arrives as `@their-scope/plugin-*`. Limiting discovery to the
// first-party scope would let such a plugin install and then never run.
if (pkg.startsWith("@")) {
try { try {
for (const scoped of fs.readdirSync(path.join(modules, pkg)).sort()) { for (const scoped of fs.readdirSync(path.join(modules, pkg)).sort()) {
if (scoped.startsWith("plugin-")) { if (scoped.startsWith("plugin-")) {
@@ -334,7 +358,7 @@ export const discoverUnits = (
} }
} }
} catch { } catch {
// no @punktfunk scope dir — fine // not a readable scope dir — fine
} }
} }
} }
+49
View File
@@ -136,6 +136,55 @@ describe("listInstalled", () => {
{ pkg: "punktfunk-plugin-broken", version: undefined }, { pkg: "punktfunk-plugin-broken", version: undefined },
]); ]);
}); });
test("finds plugins in ANY scope, not just @punktfunk", () => {
// Plugin-store catalog entries must be scoped so the scope can map to that entry's
// registry, so a third-party plugin necessarily arrives as `@their-scope/plugin-*`.
// Discovery limited to @punktfunk would let it install and then never run.
const dir = tmp("list-foreign-scope");
writePkg(dir, path.join("@retro-hub", "plugin-x"), "1.0.0");
writePkg(dir, path.join("@retro-hub", "helper"), "1.0.0"); // scoped non-plugin — ignored
expect(listInstalled(dir)).toEqual([{ pkg: "@retro-hub/plugin-x", version: "1.0.0" }]);
});
});
describe("ensureBunfig with extra scopes", () => {
const read = (dir: string) => fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
test("maps a third-party scope alongside @punktfunk", () => {
const dir = tmp("bunfig-extra");
ensureBunfig(dir, { "@retro-hub": "https://retro.example/npm/" });
const out = read(dir);
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
expect(out).toContain('"@retro-hub" = "https://retro.example/npm/"');
});
test("is idempotent and rewrites a scope whose registry changed", () => {
const dir = tmp("bunfig-rewrite");
ensureBunfig(dir, { "@retro-hub": "https://old.example/npm/" });
ensureBunfig(dir, { "@retro-hub": "https://old.example/npm/" });
expect(read(dir).match(/@retro-hub/g)?.length).toBe(1);
ensureBunfig(dir, { "@retro-hub": "https://new.example/npm/" });
const out = read(dir);
expect(out).toContain('"@retro-hub" = "https://new.example/npm/"');
expect(out).not.toContain("old.example");
// the first-party mapping survives an unrelated scope edit
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
});
test("preserves unrelated scopes already in the file", () => {
const dir = tmp("bunfig-preserve");
fs.writeFileSync(
path.join(dir, "bunfig.toml"),
'[install.scopes]\n"@acme" = "https://acme.example/"\n',
);
ensureBunfig(dir, { "@retro-hub": "https://retro.example/npm/" });
const out = read(dir);
expect(out).toContain('"@acme" = "https://acme.example/"');
expect(out).toContain('"@retro-hub" = "https://retro.example/npm/"');
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
});
}); });
describe("ensurePluginsDir", () => { describe("ensurePluginsDir", () => {
+77
View File
@@ -107,6 +107,83 @@ describe("discovery", () => {
const units = discoverUnits(d); const units = discoverUnits(d);
expect(units.map((u) => u.name)).toEqual(["@punktfunk/plugin-y"]); expect(units.map((u) => u.name)).toEqual(["@punktfunk/plugin-y"]);
}); });
test("discovers plugins in ANY scope, not just @punktfunk", () => {
// Plugin-store catalog entries must be scoped so the scope can map to that entry's
// registry, so a third-party plugin necessarily arrives under its own scope. Discovery
// limited to @punktfunk would let it install and then never run.
const d = mkdirs("discover-foreign-scope");
write(
path.join(d.pluginsDir, "node_modules", "@retro-hub", "plugin-z", "package.json"),
JSON.stringify({ name: "@retro-hub/plugin-z", main: "index.js" }),
);
write(
path.join(d.pluginsDir, "node_modules", "@retro-hub", "plugin-z", "index.js"),
"export default { name: 'z', main: async () => {} };",
);
expect(discoverUnits(d).map((u) => u.name)).toEqual(["@retro-hub/plugin-z"]);
});
test("a plugin's LIBRARY is not a unit — top-level dependencies win", () => {
// Regression from a live install: `@punktfunk/plugin-kit` is the framework kit-built
// plugins depend on. It matches the `plugin-*` convention exactly and lands in
// node_modules transitively, so a convention-only scan would import and run the framework
// as if it were a plugin.
const d = mkdirs("discover-toplevel");
for (const name of ["plugin-real", "plugin-kit"]) {
write(
path.join(d.pluginsDir, "node_modules", "@punktfunk", name, "package.json"),
JSON.stringify({ name: `@punktfunk/${name}`, main: "index.js" }),
);
write(
path.join(d.pluginsDir, "node_modules", "@punktfunk", name, "index.js"),
"export default { name: 'p', main: async () => {} };",
);
}
write(
path.join(d.pluginsDir, "package.json"),
JSON.stringify({ dependencies: { "@punktfunk/plugin-real": "^1.0.0" } }),
);
expect(discoverUnits(d).map((u) => u.name)).toEqual(["@punktfunk/plugin-real"]);
});
test("no package.json at all falls back to the naming convention", () => {
// Hand-assembled or older trees must still run, not silently discover nothing.
const d = mkdirs("discover-nodeps");
write(
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-legacy", "package.json"),
JSON.stringify({ name: "punktfunk-plugin-legacy", main: "index.js" }),
);
write(
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-legacy", "index.js"),
"export default { name: 'l', main: async () => {} };",
);
expect(discoverUnits(d).map((u) => u.name)).toEqual(["punktfunk-plugin-legacy"]);
});
test("an emptied dependency list means nothing to run", () => {
// `bun remove` drops the `dependencies` key when the last plugin goes, while orphaned
// transitive packages linger in node_modules. Falling back to the naming convention there
// would start running a plugin's LIBRARY right after the operator removed the only real
// plugin — seen on-glass.
const d = mkdirs("discover-emptied");
write(
path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-kit", "package.json"),
JSON.stringify({ name: "@punktfunk/plugin-kit", main: "index.js" }),
);
write(
path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-kit", "index.js"),
"export default {};",
);
write(path.join(d.pluginsDir, "package.json"), JSON.stringify({ name: "plugins" }));
expect(discoverUnits(d)).toEqual([]);
write(
path.join(d.pluginsDir, "package.json"),
JSON.stringify({ name: "plugins", dependencies: {} }),
);
expect(discoverUnits(d)).toEqual([]);
});
}); });
describe("windowsSddlUnsafeReason (the sshd rule's Windows half, pure)", () => { describe("windowsSddlUnsafeReason (the sshd rule's Windows half, pure)", () => {
+41 -11
View File
@@ -6,6 +6,7 @@
//! harness adds `tc netem` jitter/reorder on the UDP path. //! harness adds `tc netem` jitter/reorder on the UDP path.
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role}; use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
use punktfunk_core::crypto::SessionKey;
use punktfunk_core::error::PunktfunkError; use punktfunk_core::error::PunktfunkError;
use punktfunk_core::session::Session; use punktfunk_core::session::Session;
use punktfunk_core::transport::loopback_pair; use punktfunk_core::transport::loopback_pair;
@@ -25,22 +26,47 @@ fn config(role: Role, scheme: FecScheme, drop_period: u32) -> Config {
shard_payload: 1024, shard_payload: 1024,
max_frame_bytes: 8 * 1024 * 1024, max_frame_bytes: 8 * 1024 * 1024,
encrypt: false, encrypt: false,
key: [0u8; 16], key: SessionKey::Aes128Gcm([0u8; 16]),
salt: [0u8; 4], salt: [0u8; 4],
loopback_drop_period: drop_period, loopback_drop_period: drop_period,
} }
} }
/// Returns (frames_completed, frames_attempted) for a loss setting. /// Returns (frames_completed, frames_attempted) for a loss setting. `streamed` feeds each AU
fn run(scheme: FecScheme, drop_period: u32, frames: usize, frame_len: usize) -> (usize, usize) { /// through the VIDEO_CAP_STREAMED_AU path (three encoder-chunk pushes + finish — sentinel
/// blocks then real totals) instead of one whole-AU submit, so the two wire shapes' recovery
/// curves can be compared directly (the Phase-2 "more, smaller units must not regress FEC" gate).
fn run(
scheme: FecScheme,
drop_period: u32,
frames: usize,
frame_len: usize,
streamed: bool,
) -> (usize, usize) {
let (h, c) = loopback_pair(drop_period, 0); let (h, c) = loopback_pair(drop_period, 0);
let mut host = Session::new(config(Role::Host, scheme, drop_period), Box::new(h)).unwrap(); let mut host = Session::new(config(Role::Host, scheme, drop_period), Box::new(h)).unwrap();
let mut client = Session::new(config(Role::Client, scheme, drop_period), Box::new(c)).unwrap(); let mut client = Session::new(config(Role::Client, scheme, drop_period), Box::new(c)).unwrap();
let send_wires = |host: &mut Session, wires: Vec<Vec<u8>>| {
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
host.send_sealed(&refs).unwrap();
drop(refs);
host.reclaim_wires(wires);
};
let mut completed = 0; let mut completed = 0;
for f in 0..frames { for f in 0..frames {
let frame: Vec<u8> = (0..frame_len).map(|b| (b ^ f) as u8).collect(); let frame: Vec<u8> = (0..frame_len).map(|b| (b ^ f) as u8).collect();
host.submit_frame(&frame, f as u64, 0).unwrap(); if streamed {
let mut au = host.begin_streamed_frame_at(f as u64, 0, f as u32).unwrap();
for chunk in frame.chunks(frame_len / 3 + 1) {
let wires = host.seal_streamed_chunk(&mut au, chunk).unwrap();
send_wires(&mut host, wires);
}
let wires = host.seal_streamed_finish(au).unwrap();
send_wires(&mut host, wires);
} else {
host.submit_frame(&frame, f as u64, 0).unwrap();
}
match client.poll_frame() { match client.poll_frame() {
Ok(got) => { Ok(got) => {
if got.data == frame { if got.data == frame {
@@ -60,22 +86,26 @@ fn main() {
let periods = [0u32, 32, 16, 8, 6, 4, 3, 2]; let periods = [0u32, 32, 16, 8, 6, 4, 3, 2];
println!("punktfunk loss-harness — 25% FEC, {frames} frames of {frame_len} bytes"); println!("punktfunk loss-harness — 25% FEC, {frames} frames of {frame_len} bytes");
println!("(GF8 = P1/GameStream-compat, GF16 = P2/wall-breaker)\n"); println!("(GF8 = P1/GameStream-compat, GF16 = P2/wall-breaker, strm = streamed-AU wire)\n");
println!( println!(
"{:>10} {:>9} {:>14} {:>14}", "{:>10} {:>9} {:>14} {:>14} {:>14}",
"drop 1/N", "~loss %", "GF8 recovered", "GF16 recovered" "drop 1/N", "~loss %", "GF8 recovered", "GF16 recovered", "GF16 strm"
); );
println!("{}", "-".repeat(56)); println!("{}", "-".repeat(72));
for &p in &periods { for &p in &periods {
let loss = if p == 0 { 0.0 } else { 100.0 / p as f64 }; let loss = if p == 0 { 0.0 } else { 100.0 / p as f64 };
let (g8, n) = run(FecScheme::Gf8, p, frames, frame_len); let (g8, n) = run(FecScheme::Gf8, p, frames, frame_len, false);
let (g16, _) = run(FecScheme::Gf16, p, frames, frame_len); let (g16, _) = run(FecScheme::Gf16, p, frames, frame_len, false);
let (g16s, _) = run(FecScheme::Gf16, p, frames, frame_len, true);
let label = if p == 0 { let label = if p == 0 {
"none".to_string() "none".to_string()
} else { } else {
format!("1/{p}") format!("1/{p}")
}; };
println!("{label:>10} {loss:>8.1}% {:>11}/{n} {:>11}/{n}", g8, g16); println!(
"{label:>10} {loss:>8.1}% {:>11}/{n} {:>11}/{n} {:>11}/{n}",
g8, g16, g16s
);
} }
println!("\nNote: recovery drops off once per-block loss exceeds the 25% recovery budget."); println!("\nNote: recovery drops off once per-block loss exceeds the 25% recovery budget.");
} }
+108 -1
View File
@@ -267,5 +267,112 @@
"stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.", "stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.",
"stats_detail_title": "Aufnahme-Details", "stats_detail_title": "Aufnahme-Details",
"stats_close": "Schließen", "stats_close": "Schließen",
"stats_no_samples": "Diese Aufnahme enthält keine Proben." "stats_no_samples": "Diese Aufnahme enthält keine Proben.",
"store_title": "Plugins",
"store_subtitle": "Durchstöbere den Plugin-Katalog, installiere und entferne Plugins und lege fest, welchen Katalogen dieser Host vertraut.",
"store_tab_browse": "Entdecken",
"store_tab_installed": "Installiert",
"store_tab_sources": "Quellen",
"store_tier_verified": "Geprüft",
"store_tier_verified_hint": "Aus dem eingebauten unom-Katalog — unom hat genau dieses Paket geprüft.",
"store_tier_external": "Externe Quelle",
"store_tier_external_hint": "Festgepinnt und auf Integrität geprüft, aber von jemand anderem als unom kuratiert. Niemand bei unom hat diesen Code geprüft.",
"store_tier_unverified": "Ungeprüft",
"store_tier_unverified_hint": "Aus einer rohen Paketangabe installiert. Kein Katalog, keine Prüfung — diesen Code hat nie jemand kontrolliert.",
"store_tier_cli": "Über die CLI installiert",
"store_tier_cli_hint": "Von der Kommandozeile installiert — der Host weiß daher nicht, woher es stammt.",
"store_from_source": "von",
"store_search_placeholder": "Plugins durchsuchen…",
"store_filter_all": "Alle Quellen",
"store_empty": "Noch keine Plugins im Katalog.",
"store_no_match": "Kein Plugin passt zur Suche.",
"store_by_author": "von {author}",
"store_homepage": "Website",
"store_install": "Installieren",
"store_installed_label": "Installiert",
"store_update_to": "Auf {version} aktualisieren",
"store_incompatible": "Nicht kompatibel mit diesem Host.",
"store_blocked": "Vom Host blockiert: {reason}",
"store_spec_open": "Aus Paketangabe installieren…",
"store_runner_title": "Plugin-Runner",
"store_runner_help": "Der Dienst, in dem jedes Plugin läuft. Ihn abzuschalten stoppt alle Plugins auf einmal, ohne etwas zu deinstallieren.",
"store_runner_not_installed": "Der Plugin-Runner ist auf diesem Host nicht installiert — Plugins können daher nicht starten.",
"store_runner_banner": "Der Plugin-Runner ist ausgeschaltet — Plugins starten erst, wenn du ihn aktivierst.",
"store_runner_enable": "Runner aktivieren",
"store_runner_disable": "Runner deaktivieren",
"store_runner_state_running": "Läuft",
"store_runner_state_stopped": "Gestoppt",
"store_runner_state_disabled": "Deaktiviert",
"store_runner_state_missing": "Nicht installiert",
"store_runner_unit": "Dienst",
"store_runner_principal": "Läuft als",
"store_runner_failed": "Der Plugin-Runner konnte nicht umgeschaltet werden.",
"store_installed_title": "Installierte Plugins",
"store_installed_empty": "Noch keine Plugins installiert.",
"store_running": "Läuft",
"store_stopped": "Läuft nicht",
"store_uninstall": "Deinstallieren",
"store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.",
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
"store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.",
"store_sources_title": "Katalogquellen",
"store_sources_help": "Wo dieser Host nach Plugins sucht. Der eingebaute unom-Katalog ist immer dabei; jede weitere Quelle hast du selbst hinzugefügt und stehst selbst dafür ein.",
"store_refresh_all": "Alle aktualisieren",
"store_refresh_failed": "Die Kataloge konnten nicht aktualisiert werden.",
"store_source_builtin": "Eingebaut",
"store_source_signed": "Signiert",
"store_source_unsigned": "Unsigniert",
"store_source_stale": "Veraltet",
"store_source_entries": "{count} Plugins",
"store_source_fetched": "zuletzt geladen {when}",
"store_source_never": "nie",
"store_source_remove": "Quelle entfernen",
"store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.",
"store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.",
"store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.",
"store_add_source_title": "Katalogquelle hinzufügen",
"store_field_source_name": "Name",
"store_field_source_url": "Index-URL",
"store_field_source_key": "Öffentlicher Schlüssel (optional)",
"store_field_source_key_help": "Ein ed25519:…-Schlüssel. Ist er gesetzt, akzeptiert der Host von dieser Quelle nur einen signierten Index.",
"store_add_source": "Quelle hinzufügen",
"store_add_source_failed": "Die Quelle konnte nicht gespeichert werden — prüfe Name und URL.",
"store_source_trust_title": "Dieser Quelle vertrauen?",
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
"store_install_title": "{title} installieren?",
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
"store_install_confirm": "Installieren",
"store_install_external_title": "{title} aus einer externen Quelle installieren?",
"store_install_external_body": "Version {version} stammt aus „{source}“ — einem Katalog, den du selbst hinzugefügt hast.",
"store_install_external_note": "unom hat diesen Code nicht geprüft. Das Paket ist festgepinnt und auf Integrität geprüft, läuft auf diesem Host aber mit den Rechten des Plugin-Runners.",
"store_install_external_confirm": "Trotzdem installieren",
"store_install_failed": "Die Installation konnte nicht gestartet werden.",
"store_busy": "Der Host installiert oder entfernt gerade schon ein Plugin. Versuche es, sobald das durch ist.",
"store_spec_title": "Aus einer Paketangabe installieren",
"store_spec_lead": "Das installiert Code direkt aus einer Paket-Registry. Er steht in keinem Katalog, niemand hat ihn geprüft, und er läuft auf diesem Host mit den Rechten des Plugin-Runners — mit demselben Zugriff auf deine Dateien und deine Sitzung.",
"store_spec_permanent": "Ein so installiertes Plugin bleibt als „Ungeprüft“ markiert, solange es installiert ist.",
"store_spec_field": "Paketangabe",
"store_spec_field_help": "Zum Beispiel @scope/plugin-name@1.2.3.",
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
"store_spec_confirm": "Ungeprüft installieren",
"store_job_install": "{target} wird installiert",
"store_job_uninstall": "{target} wird entfernt",
"store_job_done_install": "Installiert.",
"store_job_done_uninstall": "Entfernt.",
"store_job_failed": "Der Vorgang ist fehlgeschlagen.",
"store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.",
"store_job_log": "Log anzeigen",
"store_job_dismiss": "Ausblenden",
"store_phase_queued": "In der Warteschlange",
"store_phase_verifying": "Paket wird verifiziert",
"store_phase_installing": "Wird installiert",
"store_phase_removing": "Wird entfernt",
"store_phase_checking": "Installation wird geprüft",
"store_phase_rolling_back": "Wird zurückgerollt",
"store_phase_recording": "Herkunft wird vermerkt",
"store_phase_restarting": "Plugin-Runner startet neu",
"store_phase_done": "Fertig"
} }
+108 -1
View File
@@ -267,5 +267,112 @@
"stats_delete_confirm": "Delete this recording? This can't be undone.", "stats_delete_confirm": "Delete this recording? This can't be undone.",
"stats_detail_title": "Recording detail", "stats_detail_title": "Recording detail",
"stats_close": "Close", "stats_close": "Close",
"stats_no_samples": "This recording has no samples." "stats_no_samples": "This recording has no samples.",
"store_title": "Plugins",
"store_subtitle": "Browse the plugin catalog, install and remove plugins, and choose which catalogs this host trusts.",
"store_tab_browse": "Browse",
"store_tab_installed": "Installed",
"store_tab_sources": "Sources",
"store_tier_verified": "Verified",
"store_tier_verified_hint": "From the built-in unom catalog — unom reviewed this exact package.",
"store_tier_external": "External source",
"store_tier_external_hint": "Pinned and integrity-checked, but curated by someone other than unom. Nobody at unom reviewed this code.",
"store_tier_unverified": "Unverified",
"store_tier_unverified_hint": "Installed from a raw package spec. No catalog, no review — nobody checked this code.",
"store_tier_cli": "Installed via CLI",
"store_tier_cli_hint": "Installed from the command line, so the host has no record of where it came from.",
"store_from_source": "from",
"store_search_placeholder": "Search plugins…",
"store_filter_all": "All sources",
"store_empty": "No plugins in the catalog yet.",
"store_no_match": "No plugin matches your search.",
"store_by_author": "by {author}",
"store_homepage": "Homepage",
"store_install": "Install",
"store_installed_label": "Installed",
"store_update_to": "Update to {version}",
"store_incompatible": "Not compatible with this host.",
"store_blocked": "Blocked by the host: {reason}",
"store_spec_open": "Install from package spec…",
"store_runner_title": "Plugin runner",
"store_runner_help": "The service every plugin runs inside. Disabling it stops all plugins at once without uninstalling anything.",
"store_runner_not_installed": "The plugin runner isn't installed on this host, so plugins can't start.",
"store_runner_banner": "The plugin runner is switched off — plugins won't start until you enable it.",
"store_runner_enable": "Enable runner",
"store_runner_disable": "Disable runner",
"store_runner_state_running": "Running",
"store_runner_state_stopped": "Stopped",
"store_runner_state_disabled": "Disabled",
"store_runner_state_missing": "Not installed",
"store_runner_unit": "Service",
"store_runner_principal": "Runs as",
"store_runner_failed": "Could not change the plugin runner.",
"store_installed_title": "Installed plugins",
"store_installed_empty": "No plugins installed yet.",
"store_running": "Running",
"store_stopped": "Not running",
"store_uninstall": "Uninstall",
"store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.",
"store_uninstall_failed": "Could not start the removal.",
"store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.",
"store_sources_title": "Catalog sources",
"store_sources_help": "Where this host looks for plugins. The built-in unom catalog is always present; every other source is one you added and vouch for yourself.",
"store_refresh_all": "Refresh all",
"store_refresh_failed": "Could not refresh the catalogs.",
"store_source_builtin": "Built in",
"store_source_signed": "Signed",
"store_source_unsigned": "Unsigned",
"store_source_stale": "Out of date",
"store_source_entries": "{count} plugins",
"store_source_fetched": "last fetched {when}",
"store_source_never": "never",
"store_source_remove": "Remove source",
"store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.",
"store_source_remove_failed": "Could not remove the source.",
"store_source_builtin_locked": "The built-in unom catalog can't be removed.",
"store_add_source_title": "Add a catalog source",
"store_field_source_name": "Name",
"store_field_source_url": "Index URL",
"store_field_source_key": "Public key (optional)",
"store_field_source_key_help": "An ed25519:… key. With a key set, the host only accepts a signed index from this source.",
"store_add_source": "Add source",
"store_add_source_failed": "Could not save the source — check the name and the URL.",
"store_source_trust_title": "Trust this source?",
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
"store_source_trust_confirm": "I understand — add the source",
"store_install_title": "Install {title}?",
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
"store_install_confirm": "Install",
"store_install_external_title": "Install {title} from an external source?",
"store_install_external_body": "Version {version} comes from “{source}”, a catalog you added yourself.",
"store_install_external_note": "unom has not reviewed this code. The package is pinned and integrity-checked, but it will run on this host with the plugin runner's privileges.",
"store_install_external_confirm": "Install anyway",
"store_install_failed": "Could not start the install.",
"store_busy": "The host is already installing or removing a plugin. Try again once it's finished.",
"store_spec_title": "Install from a package spec",
"store_spec_lead": "This installs code straight from a package registry. It is in no catalog, nobody has reviewed it, and it will run on this host with the plugin runner's privileges — the same access it has to your files and your session.",
"store_spec_permanent": "A plugin installed this way stays marked Unverified for as long as it is installed.",
"store_spec_field": "Package spec",
"store_spec_field_help": "For example @scope/plugin-name@1.2.3.",
"store_spec_confirm_field": "Type the package spec again to confirm",
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
"store_spec_confirm": "Install unverified",
"store_job_install": "Installing {target}",
"store_job_uninstall": "Removing {target}",
"store_job_done_install": "Installed.",
"store_job_done_uninstall": "Removed.",
"store_job_failed": "The job failed.",
"store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.",
"store_job_log": "Show log",
"store_job_dismiss": "Dismiss",
"store_phase_queued": "Queued",
"store_phase_verifying": "Verifying the package",
"store_phase_installing": "Installing",
"store_phase_removing": "Removing",
"store_phase_checking": "Checking the install",
"store_phase_rolling_back": "Rolling back",
"store_phase_recording": "Recording provenance",
"store_phase_restarting": "Restarting the plugin runner",
"store_phase_done": "Done"
} }
+282
View File
@@ -0,0 +1,282 @@
// The plugin store: catalog, install/uninstall jobs, catalog sources, and the scripting runner.
// Like `api/plugins.ts` this is a hand-written client rather than an orval-generated one — the
// OpenAPI document is regenerated on a Linux box (punktfunk-host doesn't build on macOS), so the
// console must not wait on a regen to talk to these endpoints. It rides the same `/api` BFF path as
// every other call, so the bearer token is injected server-side and the browser only ever sends its
// session cookie.
import {
type QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { apiFetch } from "@/api/fetcher";
/**
* How much a plugin's provenance is worth, from most to least trustworthy:
*
* - `verified` from the built-in `unom` source; unom reviewed that exact tarball.
* - `external` from an operator-added source; pinned and integrity-checked, but curated by
* somebody else. It is NOT reviewed by unom and never wears the verified badge.
* - `unverified` installed from a raw package spec through the high-friction dialog. No catalog,
* no review, no pinning. Stays badged unverified forever.
* - `cli` installed with the CLI, so the host holds no provenance record at all.
*/
export type StoreTier = "verified" | "external" | "unverified" | "cli";
/** The tier a *catalog* entry can carry — the raw-spec/CLI tiers never appear in a catalog. */
export type CatalogTier = Extract<StoreTier, "verified" | "external">;
export interface StoreHostInfo {
version: string;
platform: string;
}
export interface StoreSource {
name: string;
url: string;
/** The `unom` source ships with the host: it can't be edited or removed. */
builtin: boolean;
signed: boolean;
/** The last fetch failed or is too old — entries may be out of date. */
stale: boolean;
/** Unix seconds of the last successful fetch. */
fetched_at: number;
error?: string;
entry_count: number;
public_key?: string;
}
export interface StoreEntry {
id: string;
pkg: string;
title: string;
description: string;
icon?: string;
author: string;
homepage?: string;
license?: string;
version: string;
/** Name of the source this entry came from — the attribution an external entry shows. */
source: string;
tier: CatalogTier;
reviewed_at?: string;
platforms: string[];
min_host?: string;
compatible: boolean;
incompatible_reason?: string;
installed_version?: string;
update_available: boolean;
blocked?: string;
}
export interface StoreCatalog {
host: StoreHostInfo;
sources: StoreSource[];
plugins: StoreEntry[];
/** An install/uninstall is already running — the host takes one at a time. */
busy: boolean;
}
export interface InstalledPlugin {
pkg: string;
version: string;
tier: StoreTier;
source?: string;
entry_id?: string;
/** The plugin's runtime id — how it's keyed in the plugin directory (`api/plugins.ts`). */
plugin_id?: string;
title?: string;
installed_at?: string;
running: boolean;
/** The version available to update to, if any. */
update_available?: string;
blocked?: string;
}
export type JobKind = "install" | "uninstall";
export type JobState = "running" | "done" | "failed";
export interface StoreJob {
id: string;
kind: JobKind;
target: string;
state: JobState;
phase: string;
log: string[];
error?: string;
started_at: number;
finished_at?: number;
}
export interface RuntimeStatus {
installed: boolean;
enabled: boolean;
running: boolean;
unit: string;
principal?: string;
detail?: string;
}
/** What `POST /store/install` and `POST /store/uninstall` answer with (202). */
export interface JobAccepted {
job: string;
}
/** Install a curated catalog entry, or — deliberately awkward — a raw package spec. */
export type InstallBody =
| { source: string; id: string }
| { spec: string; accept_unverified: true };
export interface SourceBody {
url: string;
public_key?: string;
}
const BASE = "/api/v1/store";
/** Query keys, in one place so any mutation can invalidate precisely. */
export const storeKeys = {
all: ["store"] as const,
catalog: ["store", "catalog"] as const,
installed: ["store", "installed"] as const,
sources: ["store", "sources"] as const,
runtime: ["store", "runtime"] as const,
job: (id: string) => ["store", "job", id] as const,
};
const json = (method: string, body: unknown): RequestInit => ({
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
/**
* Refresh everything a completed install/uninstall touches the catalog (installed markers), the
* installed list, the runner state (it restarts), and the plugin directory the nav is built from.
*/
export function invalidateStore(qc: QueryClient): Promise<void> {
return Promise.all([
qc.invalidateQueries({ queryKey: storeKeys.catalog }),
qc.invalidateQueries({ queryKey: storeKeys.installed }),
qc.invalidateQueries({ queryKey: storeKeys.sources }),
qc.invalidateQueries({ queryKey: storeKeys.runtime }),
qc.invalidateQueries({ queryKey: ["plugins"] }),
]).then(() => undefined);
}
/** The merged catalog across every source, plus the sources' own health. */
export function useStoreCatalog() {
return useQuery({
queryKey: storeKeys.catalog,
queryFn: () => apiFetch<StoreCatalog>(`${BASE}/catalog`),
});
}
/** What's installed right now, with each plugin's permanent provenance tier. */
export function useInstalledPlugins() {
return useQuery({
queryKey: storeKeys.installed,
queryFn: () => apiFetch<InstalledPlugin[]>(`${BASE}/installed`),
refetchInterval: 30_000,
});
}
export function useStoreSources() {
return useQuery({
queryKey: storeKeys.sources,
queryFn: () => apiFetch<StoreSource[]>(`${BASE}/sources`),
});
}
export function useStoreRuntime() {
return useQuery({
queryKey: storeKeys.runtime,
queryFn: () => apiFetch<RuntimeStatus>(`${BASE}/runtime`),
});
}
/**
* A single install/uninstall job, polled once a second while it runs and left alone once it
* settles. Pass `null` to park the query (no job in flight).
*/
export function useStoreJob(id: string | null) {
return useQuery({
queryKey: storeKeys.job(id ?? ""),
queryFn: () =>
apiFetch<StoreJob>(`${BASE}/jobs/${encodeURIComponent(id ?? "")}`),
enabled: id !== null,
refetchInterval: (q) => (q.state.data?.state === "running" ? 1_000 : false),
});
}
/** Re-fetch every source's index; answers with the freshly merged catalog. */
export function useRefreshCatalog() {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiFetch<StoreCatalog>(`${BASE}/refresh`, { method: "POST" }),
onSuccess: (catalog) => {
qc.setQueryData(storeKeys.catalog, catalog);
qc.setQueryData(storeKeys.sources, catalog.sources);
},
});
}
/** Start an install. Answers 202 with the job to poll; 409 means another op is already running. */
export function useInstallPlugin() {
return useMutation({
mutationFn: (body: InstallBody) =>
apiFetch<JobAccepted>(`${BASE}/install`, json("POST", body)),
});
}
export function useUninstallPlugin() {
return useMutation({
mutationFn: (pkg: string) =>
apiFetch<JobAccepted>(`${BASE}/uninstall`, json("POST", { pkg })),
});
}
/** Add or update an operator source (the built-in one is not editable). */
export function useSetSource() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ name, ...body }: SourceBody & { name: string }) =>
apiFetch<void>(
`${BASE}/sources/${encodeURIComponent(name)}`,
json("PUT", body),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: storeKeys.sources });
qc.invalidateQueries({ queryKey: storeKeys.catalog });
},
});
}
export function useDeleteSource() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
apiFetch<void>(`${BASE}/sources/${encodeURIComponent(name)}`, {
method: "DELETE",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: storeKeys.sources });
qc.invalidateQueries({ queryKey: storeKeys.catalog });
},
});
}
/** Enable or disable the plugin/script runner service. */
export function useSetRuntime() {
const qc = useQueryClient();
return useMutation({
mutationFn: (enabled: boolean) =>
apiFetch<RuntimeStatus>(`${BASE}/runtime`, json("POST", { enabled })),
onSuccess: (status) => {
qc.setQueryData(storeKeys.runtime, status);
qc.invalidateQueries({ queryKey: ["plugins"] });
},
});
}
+10 -2
View File
@@ -6,6 +6,7 @@ import {
LibraryBig, LibraryBig,
MonitorPlay, MonitorPlay,
MoreHorizontal, MoreHorizontal,
Puzzle,
ScrollText, ScrollText,
Server, Server,
Settings, Settings,
@@ -29,9 +30,15 @@ const NAV = [
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() }, { to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() }, { to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() }, { to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
{ to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() },
{ to: "/settings", icon: Settings, label: () => m.nav_settings() }, { to: "/settings", icon: Settings, label: () => m.nav_settings() },
] as const; ] as const;
// "/plugins" is the store's index route and "/plugins/<id>" a plugin's own UI, so the store entry
// only counts as active on an exact match — otherwise it would light up alongside the plugin's own
// nav entry below. Same reason "/" is exact.
const EXACT: readonly string[] = ["/", "/plugins"];
// On phones a flat 8-tab bar is too cramped, so the first four are pinned and the rest live behind a // On phones a flat 8-tab bar is too cramped, so the first four are pinned and the rest live behind a
// "More" tab that opens a sheet above the bar. Keep it ≤ 5 slots including "More". // "More" tab that opens a sheet above the bar. Keep it ≤ 5 slots including "More".
const MOBILE_PRIMARY = NAV.slice(0, 4); const MOBILE_PRIMARY = NAV.slice(0, 4);
@@ -74,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
to={to} to={to}
activeOptions={{ exact: to === "/" }} activeOptions={{ exact: EXACT.includes(to) }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground" className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{ activeProps={{
className: "bg-primary/15 text-foreground font-medium", className: "bg-primary/15 text-foreground font-medium",
@@ -200,6 +207,7 @@ function MobileNav() {
key={to} key={to}
to={to} to={to}
onClick={() => setMoreOpen(false)} onClick={() => setMoreOpen(false)}
activeOptions={{ exact: EXACT.includes(to) }}
className={cn(tab, "rounded-md")} className={cn(tab, "rounded-md")}
activeProps={{ className: "text-[var(--brand-light)]" }} activeProps={{ className: "text-[var(--brand-light)]" }}
> >
@@ -232,7 +240,7 @@ function MobileNav() {
key={to} key={to}
to={to} to={to}
onClick={() => setMoreOpen(false)} onClick={() => setMoreOpen(false)}
activeOptions={{ exact: to === "/" }} activeOptions={{ exact: EXACT.includes(to) }}
className={tab} className={tab}
activeProps={{ className: "text-[var(--brand-light)]" }} activeProps={{ className: "text-[var(--brand-light)]" }}
> >
+17 -3
View File
@@ -63,11 +63,25 @@ const CardDescription = React.forwardRef<
)); ));
CardDescription.displayName = "CardDescription"; CardDescription.displayName = "CardDescription";
/**
* Card body. Pass `flush` for content that should meet the card's edges a full-bleed table, most
* commonly instead of trying to cancel the padding from the outside.
*
* `className="p-0"` does NOT work for that: tailwind-merge only resolves conflicts *within the same
* variant*, so `p-0` cancels `p-4` but leaves `sm:p-6` standing, and the padding silently returns at
* 640px. Every call site that tried it ended up with a doubled inset once a `CardHeader` (which
* brings its own `sm:p-6`) was nested inside visible as one card whose title sits 24px further in
* than its neighbours'.
*/
const CardContent = React.forwardRef< const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement> & { flush?: boolean }
>(({ className, ...props }, ref) => ( >(({ className, flush = false, ...props }, ref) => (
<div ref={ref} className={cn("p-4 pt-0 sm:p-6 sm:pt-0", className)} {...props} /> <div
ref={ref}
className={cn(!flush && "p-4 pt-0 sm:p-6 sm:pt-0", className)}
{...props}
/>
)); ));
CardContent.displayName = "CardContent"; CardContent.displayName = "CardContent";
+49
View File
@@ -0,0 +1,49 @@
// The console's Dialog IS @unom/ui's radix dialog — brand surface (material gloss + card radius)
// with the shared close/overlay behaviour. @unom/ui ships the SURFACE only and leaves placement to
// the app, so `DialogContent` here is the surface already wrapped in its portal + overlay and
// centred in the viewport; everything else is re-exported unchanged.
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogContent as DialogSurface,
DialogTitle,
DialogTrigger,
} from "@unom/ui/dialog";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const DialogContent = ({
className,
...props
}: ComponentProps<typeof DialogSurface>) => (
<DialogPortal>
<DialogOverlay />
<DialogSurface
className={cn(
"fixed left-1/2 top-1/2 z-100 flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto p-6",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
{...props}
/>
</DialogPortal>
);
DialogContent.displayName = "DialogContent";
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+34
View File
@@ -0,0 +1,34 @@
// The console's Tabs ARE @unom/ui's radix tabs, with one correction: the inactive trigger colour.
//
// @unom/ui styles inactive triggers `text-secondary` and the active one `data-[state=active]:
// text-main`. Those tokens come from @unom/ui's own palette; in the console's theme `text-secondary`
// lands on (near) the tab strip's own background, so every inactive tab renders as an invisible
// gap — the tab bar looks like a single lonely label with dead space beside it. Caught in a browser
// pass, not by types: the markup and the a11y roles are entirely correct.
//
// Same shape as the other `components/ui/*` wrappers: adapt the shared primitive to this app's
// tokens rather than restyling it at every call site.
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger as TabsTriggerBase,
} from "@unom/ui/tabs";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const TabsTrigger = ({
className,
...props
}: ComponentProps<typeof TabsTriggerBase>) => (
<TabsTriggerBase
className={cn(
"text-muted-foreground hover:text-foreground data-[state=active]:text-foreground",
className,
)}
{...props}
/>
);
TabsTrigger.displayName = "TabsTrigger";
export { Tabs, TabsContent, TabsList, TabsTrigger };
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionStore } from "@/sections/Store";
// The plugin store. Sits at /plugins as an index route, so it coexists with the per-plugin UI route
// (`plugins.$pluginId.$.tsx`) without either shadowing the other.
export const Route = createFileRoute("/plugins/")({ component: SectionStore });

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