Compare commits

...
Author SHA1 Message Date
enricobuehler c7fe326b99 Merge pull request 'Steam renamed its cached art, so most covers were never found — and the banner that stood in blew the iOS tile open' (#265) from worktree-steam-cover-capsule into main
apple / swift (push) Successful in 2m4s
apple / distribute (push) Canceled after 3m39s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 25s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 31s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 2m52s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 2m47s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 23s
nix / flake (push) Failing after 17m57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m42s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / deploy-docs (push) Successful in 47s
docker / builders-arm64cross (push) Successful in 14s
plugin-kit-publish / publish (push) Failing after 13m40s
Reviewed-on: #265
2026-08-16 08:47:42 +00:00
enricobuehler 65b4af9e51 Merge pull request 'The library grid never got the FlowBox recursion guard, so every game click overflowed the stack' (#264) from worktree-gtk-library-card-activation-overflow into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-gamescope (push) Successful in 2m41s
arch / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 7m44s
deb / build-publish-client-arm64 (push) Canceled after 6m1s
deb / smoke-install (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 6m10s
Reviewed-on: #264
2026-08-16 08:46:55 +00:00
enricobuehler 5c746fbea5 chore(plugin-kit): 0.4.2 — publish the art scan fix, which no plugin can pick up otherwise
apple / swift (pull_request) Successful in 2m12s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m28s
ci / bun-nix (pull_request) Successful in 3m43s
ci / web (pull_request) Successful in 3m44s
ci / rust-arm64 (pull_request) Successful in 4m38s
ci / rust (pull_request) Successful in 6m51s
nix / flake (pull_request) Successful in 16m10s
The Steam scanner is `@punktfunk/plugin-steam`, in its own repo, and it builds
with `--external '@punktfunk/*'` — so `findLocalArtFile` is resolved from the
registry at install time, not bundled. Until the kit cuts a version, every host
keeps resolving 0.4.1 and keeps missing the covers.

Same shape as the `icon` field at 0.4.1: kit change, out-of-tree producers, and
nothing in this repo says so. Nothing but the version moves here.
2026-08-16 10:01:08 +02:00
enricobuehler 59fc9294ea fix(client/apple): a fallback banner sized the poster tile it was supposed to be cropped into
Reported as the library "breaking" on iOS whenever a title showed a banner
instead of a cover: the tile expanded out of its bounds.

`PosterImage` already meant to prevent exactly this — its own comment said a
banner "would otherwise report a much wider intrinsic size than the card and
overflow into neighboring cards" — and the guard it used, a flexible frame plus
`.clipped()`, does not do it. `scaledToFill` answers a proposal with a size
that COVERS it, i.e. larger; `.frame(maxWidth: .infinity, maxHeight: .infinity)`
then clamps that answer to infinity, which is no clamp at all, so the image's
size propagates straight up through `aspectRatio(2/3, .fit)` and decides the
tile.

Measured offscreen with ImageRenderer, one LazyVGrid column at 170pt:

  460x215 header, before   poster 1750.9 x 818.4   tile 545.6 x 255.0
  460x215 header, after    poster  170.0 x 255.0   tile 170.0 x 255.0
  300x450 cover,  either   poster  170.0 x 255.0   tile 170.0 x 255.0

A 545pt tile in a 170pt column is what the report is describing, and the cover
case coming out right either way is why this only ever showed on the titles
whose cover was missing.

The fix gives the sizing role to something that has no opinion about it:
`Color.clear` takes the proposal, and the art rides as its overlay, where it
can be drawn but never measured. `LibraryCoverflowView` reuses `PosterImage`
directly, so it is fixed by the same change.
2026-08-16 10:00:59 +02:00
enricobuehler f5f11f84f0 fix(plugin-kit): the Steam art scan knew one filename and one layout, so most covers were never found
Forza Horizon 6 shows a banner where its cover should be. The cover is on
disk the whole time: `librarycache/2483190/711e39.../library_capsule.jpg`,
300x450, the exact asset Steam itself draws. The scan never looked for that
name, fell back to the flat CDN URL for `library_600x900.jpg`, which 404s for
this appid, and the client then walked its candidate list down to the header —
a 460x215 banner in a 2:3 poster slot.

Measured against a real 779-app `appcache/librarycache` (the .41 box), the scan
was missing far more than one title. Three findings, each independent:

  * `library_capsule.jpg` is the newer name for the 2:3 cover. 46 appids carry
    only that name; none carry both it and `library_600x900.jpg`.
  * `header.jpg` is the newer name for the header. 594 appids carry it, 122
    carry `library_header.jpg`, and again no appid carries both — so the one
    name we knew covered 16% of them.
  * The `<appid>/<name>` layout, with no hash dir in between, is the MAJORITY:
    623 of 779 appids. `findLocalArtFile` walked only `<appid>/<hash>/<name>`
    and the oldest `<appid>_<name>` form, so it saw none of them.

The renamed files are the same assets — every `library_capsule.jpg` in that
cache measures 300x450, the same as every `library_600x900.jpg`, and both
header spellings measure 460x215 — so this is purely about knowing to look.

Simulating the resolver over that cache, per-appid art found locally:

  portrait   25 -> 328      hero    75 -> 323
  header     86 -> 716      logo    66 -> 295

None of this was visible before because a title that resolves no local file
still gets a CDN URL, and for anything Valve has not re-hashed that URL works.
It is the newer titles — the ones whose flat CDN URL 404s — that lose their art
outright, which is why this reads as "some games" rather than "the library".
2026-08-16 10:00:53 +02:00
enricobuehler fb9f605120 Merge pull request 'The grey native-vulkan stream: a host re-anchor claim the client could not check' (#262) from worktree-grey-frame-reanchor-fixes into main
apple / swift (push) Successful in 2m16s
deb / build-publish-client-arm64 (push) Successful in 1m49s
deb / build-publish-gamescope (push) Successful in 1m47s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 21s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 4m14s
ci / web (push) Successful in 6m35s
deb / build-publish (push) Successful in 5m10s
ci / rust-arm64 (push) Successful in 9m19s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 3m12s
arch / build-publish (push) Successful in 10m38s
ci / docs-site (push) Successful in 3m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 45s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 24s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
apple / screenshots (push) Successful in 11m37s
ci / rust (push) Failing after 14m56s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 10m55s
deb / build-publish-host (push) Failing after 14m31s
ci / bun-nix (push) Failing after 14m38s
android / android (push) Successful in 17m32s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6m24s
windows-host / package (push) Failing after 13m27s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m31s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 14m20s
docker / deploy-docs (push) Successful in 43s
apple / distribute (push) Successful in 11m51s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders-arm64cross (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m47s
deb / smoke-install (push) Failing after 9m21s
flatpak / build-publish (push) Successful in 26m59s
Reviewed-on: #262
2026-08-16 07:57:16 +00:00
enricobuehler 9e71cda298 fix(client-linux): the library grid never got the FlowBox recursion guard, so every game click overflowed the stack
ci / web (pull_request) Successful in 1m27s
ci / docs-site (pull_request) Successful in 1m15s
ci / bun-nix (pull_request) Successful in 22s
ci / rust-arm64 (pull_request) Successful in 15m19s
ci / rust (pull_request) Successful in 29m27s
Clicking any game in the host library aborted punktfunk-client outright:

    thread 'main' has overflowed its stack
    fatal runtime error: stack overflow, aborting

A click on a FlowBoxChild emits `child-activated` on the *FlowBox*, never the
child's own `activate`, so a grid whose per-card handler hangs off
`child.connect_activate()` has to bridge the two. The naive bridge is a cycle:
FlowBoxChild's default `activate` handler re-emits `child-activated` on its
parent, which re-enters the bridge, which activates the child again.

53c8eefa fixed exactly this for the host cards — but only in ui_hosts.rs. The
library page had carried the same unguarded bridge since 7eea9836, and 883c3178
then added a third one for the launcher shelf. So the guard existed in one of
three places, and both library grids still aborted on the first click.

Rather than patch the two sites and leave the footgun loaded, the guarded bridge
moves into ui_flow.rs and all three call sites go through it. The regression test
moves with it, so it now covers the code both pages actually run instead of a
hand-copied replica of it — which is why it never caught this.

Verified in a Linux container (fmt / clippy -D warnings / build / test all green)
with the display test executed for real against Xvfb rather than skipped. Removing
the guard again reproduces the reported abort exactly (SIGABRT, stack overflow),
so the test is known to catch the bug rather than merely pass.
2026-08-16 09:56:34 +02:00
enricobuehler 89fd4c6f87 fix(client): teach the VAAPI AV1 sizing fixture about the clean bit
apple / swift (pull_request) Successful in 2m6s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m50s
android / android (pull_request) Successful in 5m43s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m7s
ci / rust (pull_request) Failing after 5m8s
ci / rust-arm64 (pull_request) Successful in 2m21s
ci / docs-site (pull_request) Successful in 1m31s
ci / bun-nix (pull_request) Successful in 27s
ci / web (pull_request) Successful in 1m34s
Adding a public field breaks LITERAL constructors, and the only one outside
pf-bitstream lives in a `cfg(test)` fixture in a crate that consumes the plan types
through pf-vaadec's re-exports — so nothing on the macOS side and no single-crate
test run could see it. The Linux `--all-targets` gate did.

Vacuously `true`: the fixture codes a key frame, which predicts from nothing. It
exists to exercise the sizing path (sequence max vs coded vs render), so the clean
bit is incidental here — but it still has to state the honest value, because `false`
is the answer that withholds a re-anchor.
2026-08-16 01:30:53 +02:00
enricobuehler 93c1ed0723 fix(host): an RFI anchor could be picked over damage the client had already reported
The slot-family RFI backends choose a recovery anchor over `slot_wire`, which answers
"did the client RECEIVE this frame" when the question is "did the client DECODE it
intact". The taint sweep exists precisely to bridge that gap -- rfi.rs says so -- but
it only ever runs inside invalidate_ref_frames, reachable from exactly ONE of the
client's five damage signals (the frame-index gap). The other four send a plain
keyframe request, which sets force_kf and taints nothing.

That is self-healing while the IDR is actually emitted. It is not when the request is
coalesced away by the 750 ms IDR cooldown: the client's damage then goes unrepaired
AND unrecorded, and those references stay anchor candidates for the next loss -- so
the host serves an anchor over damage the client already told it about, tagged as the
client's definitive clean re-anchor. The client-side half of this fix now refuses such
an anchor; this is the other half, which stops it being offered.

Adds Encoder::distrust_references (defaulted no-op, forwarded through TrackedEncoder
-- unforwarded it would have been a silent no-op for every session), implemented by
the three slot-family backends through their own persistence markers, which rfi.rs
explicitly says not to harmonize: Vulkan Video blanks slot_wire ONLY and leaves
slot_poc naming every resident, or build_h265_rps_s0 stops retaining them and a
conforming decoder evicts pictures the encoder still references -- a separate grey
bug that file already documents. AMF clears its mirror slot; QSV raises ltr_tainted
rather than clearing its mirror, because the RejectedRefList only names Some slots and
a cleared entry would skip the very reference being distrusted.

Called on the IDR-cooldown branch, where the client is still reporting damage and
nothing in the table is honestly known-good until the in-flight IDR lands. Deliberately
NOT on the RFI-echo branch while its budget holds: that branch's premise is that the
request echoes the loss the RFI just repaired, and distrusting on the first echo would
poison the table after EVERY successful recovery, so RFI could never fire twice running
and a sustained-loss session would fall back to the IDR path this block exists to keep
it off. RFI_ECHO_MAX_SWALLOWED is already the hedge for that premise being wrong: when
the client keeps asking past the budget, the anchor demonstrably did not heal it, and
the escalation arm withdraws trust then -- on evidence rather than on suspicion.

Distrust never touches prediction (that runs off slot indices, not the wire domain) and
all three markers self-correct within a few frames, so the suppression is brief by
construction and never spans a session.
2026-08-16 01:14:47 +02:00
enricobuehler 26d06195f5 fix(client): a host recovery anchor lifted the freeze onto a grey picture, unchecked
Field report: the native-vulkan HEVC stream goes grey with moving artifacts after a
host context change (starting a game), and recovers by itself in 0.5-2 s.

Of the three signals that lift the post-loss freeze, two are self-evident to the
client and one is pure hearsay. An IDR predicts from nothing. A recovery mark is
half a re-anchor and the gate says so by requiring two. But USER_FLAG_RECOVERY_ANCHOR
is the HOST asserting a fact about the CLIENT's decoder -- "the picture I coded this
P-frame against is one you still hold, intact" -- and the gate took it on faith, on
the first occurrence, with no scrutiny at all.

The host derives that claim from bookkeeping that tracks what the client RECEIVED,
not what it managed to DECODE. Those diverge exactly when the client had to conceal,
and then the anchor lifts the freeze onto a picture predicted from damage AND LEAVES
IT LIFTED -- so the grey plate reaches the screen and keeps reaching it until some
later signal re-arms and the 500 ms backstop extracts a real IDR. That is the
observed 0.5-2 s, and it is the worst-shaped failure in the module: a re-anchor claim
the client can refute is worse than no claim, because no claim merely holds.

So corroborate it. pf-bitstream's planners now carry a per-picture clean bit --
damage propagates down the prediction chain, because the descendants of a concealed
picture raise no warning of their own -- surfaced as PicturePlan::references_clean
and carried to the consumer on DecodedVkFrame. The gate gains AnchorEvidence and
on_decoded_corroborated, and refuses an anchor whose references the client can prove
were damaged. Refusing can only ever hold LONGER: the freeze stays up, the backstop
fires on its ORIGINAL deadline, and the client escalates to the IDR the anchor failed
to be. Lanes with no local parser pass Unavailable and are bit-for-bit unchanged --
all 29 pre-existing reanchor tests pass untouched.

Also brings the H.264 decoder to parity with its two siblings, found on the way. It
was the only one of the three that failed OPEN: a DPB slot with no bound image was
traced and decoded anyway (H.265 and AV1 return UnboundReferenceSlot), reference_count
was computed after the held-slot loop so a dropped reference silently took an
unrelated slot's picture in its place, and there was no RecoveryLatch, so a failure
left the planner DPB, the slot map and the image bindings disagreeing forever. None
of it raised a warning, so the frame was shipped, presented, and cleared the demotion
streak on its way past. Both latches landed with the HEVC and AV1 decoders in August;
H.264 predates them and was never retro-fitted.

The damage classification moves onto the warning enums so the planner's ledger and
the client's concealment test cannot drift apart -- still exactly one list, now in
the crate that owns the enum, with pf_vkdecode::is_integrity_warning* delegating.
Every copy stays an exhaustive match with no wildcard: a future variant must stop the
compiler, never default to clean.
2026-08-16 01:14:30 +02:00
enricobuehler 5c44887732 Merge pull request 'Controller haptics and speaker were dead on the Linux client — it streamed a quad into whatever sink was named like a DualSense' (#261) from worktree-linux-client-pad-audio into main
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 2m59s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 3m1s
ci / rust (push) Successful in 8m16s
arch / build-publish (push) Successful in 8m40s
android / android (push) Successful in 9m29s
ci / web (push) Successful in 1m9s
ci / rust-arm64 (push) Successful in 1m26s
ci / bun-nix (push) Successful in 28s
ci / docs-site (push) Successful in 1m16s
deb / build-publish-gamescope (push) Successful in 54s
deb / build-publish-client-arm64 (push) Successful in 1m26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
deb / build-publish (push) Successful in 3m49s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
deb / build-publish-host (push) Successful in 4m23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
flatpak / build-publish (push) Successful in 8m35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 8m37s
docker / deploy-docs (push) Successful in 38s
deb / smoke-install (push) Successful in 3m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m3s
docker / builders-arm64cross (push) Successful in 13s
Reviewed-on: #261
2026-08-15 22:56:09 +00:00
enricobuehler 23edf4e702 fix(client): the pad's speaker shares a channel with its headphone jack, and powers up on the jack
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Failing after 4m11s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 4m24s
ci / rust-arm64 (pull_request) Successful in 2m50s
android / android (pull_request) Successful in 6m29s
ci / web (pull_request) Successful in 1m53s
ci / docs-site (pull_request) Failing after 45s
ci / rust (pull_request) Successful in 7m2s
ci / bun-nix (pull_request) Successful in 23s
Field result from the Deck: haptics FELT, speaker inaudible — with the routing
already proven correct. Capturing the sink's own monitor while the client renders
shows the speaker pair carrying full-scale signal:

  --coils    ch0 0.0000  ch1 0.0000  ch2 0.5000  ch3 0.5000
  --speaker  ch0 0.5000  ch1 0.5000  ch2 0.0000  ch3 0.0000

so nothing was lost on the way to the pad. The loss is inside it. Channel 1 of the
DualSense's audio function is the headphone jack's RIGHT channel *and* the built-in
mono speaker — #259 reads the same thing out of the UCM from the host side ("ch1 is
the built-in mono speaker") — and which of the two physically sounds is chosen by
`ucAudioEnableBits`, report byte 8. A pad powers up pointing at the jack, so with
nothing plugged in the speaker pair goes nowhere. The coils are channels 2/3 and are
NOT affected by that select, which is exactly why haptics worked the instant the
samples were routed right and the speaker did not.

We only ever wrote those bytes when a host forwarded a game's `AudioCtl`, so a title
that manages no audio settings of its own — and every standalone test — got silence.
A tier-A slot with the speaker capability now sends a default speaker-enable packet
beside the audio-haptics packet it already sends. A later `AudioCtl` still overrides
it verbatim, so a game driving its own volume still wins.

⚠ The path byte is EMPIRICAL, not documented: SDL's vendored SDL_hidapi_ps5.c pins
the struct layout but never writes these fields. Measured on 054c:0ce6 using the
pad's own microphone as the detector (Goertzel at the test tone): 0x20 loudest at
~5x the noise floor, 0x30 also sounds, 0x10 silent. That is thin evidence for a
constant, so both it and the volume are field levers —
PUNKTFUNK_PAD_SPEAKER_PATH / PUNKTFUNK_PAD_SPEAKER_VOLUME, hex or decimal — and an
on-glass confirmation of which value a human actually hears is still owed.

The test pins what must not regress: the two validity bits are set, volume and path
land at the same offsets the AudioCtl fold uses, every other byte stays zero, and
`ucEnableBits1` bits 0/1 stay CLEAR — asserting either would enable rumble emulation
and disable audio haptics, muting the coils to make the speaker audible.

Gates: clippy -D warnings over the four client packages, build, 207 tests (204 in
pf-client-core), fmt — on top of current main.
2026-08-16 00:53:18 +02:00
enricobuehler f67a913ea7 Merge pull request 'The virtual Steam Controller 2's hidraw node was root-only, so Steam never saw it' (#260) from worktree-sc2-hidraw-udev into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
Reviewed-on: #260
2026-08-15 22:52:41 +00:00
enricobuehler 9c6e34ab5d Merge remote-tracking branch 'origin/main' into worktree-linux-client-pad-audio 2026-08-16 00:43:31 +02:00
enricobuehler 22f873392d Merge pull request 'The virtual pad wore one AUX node where a real DualSense shows a three-node split' (#259) from worktree-pad-sink-real-topology into main
windows-host / package (push) Successful in 12m20s
windows-host / winget-source (push) Skipped
ci / rust (push) Canceled after 7m0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
android / android (push) Successful in 8m33s
deb / build-publish-gamescope (push) Successful in 24s
deb / build-publish-client-arm64 (push) Successful in 1m12s
arch / build-publish (push) Canceled after 10m31s
deb / build-publish (push) Canceled after 3m30s
deb / build-publish-host (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 34s
windows-host / canary-manifest (push) Successful in 34s
Reviewed-on: #259
2026-08-15 22:39:50 +00:00
enricobuehler fb05145e36 The virtual pad wore one AUX node where a real DualSense shows a three-node split
ci / rust-arm64 (pull_request) Successful in 1m51s
ci / web (pull_request) Successful in 1m47s
ci / docs-site (pull_request) Successful in 1m54s
android / android (pull_request) Successful in 5m49s
ci / bun-nix (pull_request) Successful in 27s
ci / rust (pull_request) Successful in 6m10s
A game that renders DS5 haptics writes a POSITIONED FL/FR/RL/RR quad, because that
is the only public 4-channel surface a physically connected pad publishes. We minted
a single AUX0..AUX3 node wearing the mono sink's `Speaker__sink` name, so that write
was position-remixed on arrival and the coil pair folded away — measured on .181,
`peak_speaker=0.2441` with `peak_coils=0.0000`. The haptics were discarded silently:
nothing errored, nothing logged, the sink looked healthy.

Measured a real DS5 (054c:0ce6, USB) on a SteamOS 3.7 Deck running alsa-ucm-conf
1.2.14-2.4 and minted what it actually presents — a card's worth of nodes, not one:

  alsa_output.hw_punktfunkpad<N>_0                 Audio/Sink/Internal  4ch AUX0..AUX3
  …-<NN>.HiFi__SpeakerHaptic__sink                 Audio/Sink           4ch FL FR RL RR
  …-<NN>.HiFi__Speaker__sink                       Audio/Sink           1ch MONO

with the public pair naming the hidden parent in `api.alsa.split.name` (GE-Proton's
`pipewire:NODE=` haptic leg) and the parent naming itself, exactly as the specimen
does. Everything written to any of the three is summed onto one hardware quad, which
a real pad gets free from ALSA SplitPCM and we have to do by hand — GE drives the
haptic leg and the controller-effect leg AT ONCE by design, so emitting each node's
buffers straight into the chunk channel would interleave them and gap both halves.

The UCM also settles two things this file had wrong:

- The four hardware channels: `Headphones` takes Channel0 0/Channel1 1, `Speaker`
  takes Channel0 1, both haptic devices take Channel2 2/Channel3 3. So ch1 is the
  built-in mono speaker, and a mono write landing on ch0 (what a bare AUX node does)
  would have played controller effects into the headphone LEFT channel with the
  speaker silent.
- A plain DualSense's USB iProduct is "DualSense Wireless Controller", model word
  included. The `DualSense_` infix was dropped here to keep
  `Sony_Interactive_Entertainment_Wireless_Controller` contiguous — a property no
  real pad has either, so the name now carries the infix and drops the invented MAC
  (a real pad has no USB iSerialNumber; the trailing ALSA card index disambiguates).

`device.vendor.id`/`device.product.id` also gain the `0x` prefix the specimen
publishes. `strtol(s,_,16)` and `strtoul(s,_,0)` both yield 0x054c for "0x054c",
while the bare "054c" we published is parse-dependent — base 0 reads it as octal
054, stops at the `c`, and yields 44, matching nothing.

On glass on .181, index-exact in every leg (amplitudes encode the source channel):

  positioned coils-only → SpeakerHaptic__sink   speaker 0.0000  coils 0.4883   (was 0.0000)
  AUX coils-only        → parent                speaker 0.0000  coils 0.4883   (unchanged)
  positioned front-only → SpeakerHaptic__sink   speaker 0.3052  coils 0.0000
  mono                  → Speaker__sink         speaker 0.2747  coils 0.0000
  both legs concurrently                        speaker 0.2747  coils 0.4883

Three deliberate deviations from the specimen stay, each documented at the head of
the module: `node.description` keeps "Wireless Controller" (FF14/FF7R case-sensitive
`wcsstr`), `priority.session` stays low (our nodes come and go with pad arrival and
must never win a default-sink election), and `api.alsa.split.position` is not set
(it is WirePlumber's own management trigger).

Gate: clippy --all-targets -D warnings clean, 9/9 pad_sink tests, fmt clean, all in
punktfunk-rust-ci linux/amd64.
2026-08-16 00:31:56 +02:00
enricobuehler db8874f944 fix(client/linux): registry globals carry no audio.channels, so every node looked 0-channel
Caught by running --pad-audio-test on a real Steam Deck with a wired DualSense. The
graph walk read `audio.channels` and `audio.position` out of the registry's `global`
event, and a global announce carries only a SUBSET of an object's proplist. The
subset happens to include `media.class`, `node.name` and `device.id` — which is
exactly why this looked like it worked — but not the audio shape. So every sink came
back as 0 channels:

  parent  device.id=140  channels=0  position=-  alsa_output.hw_Controller_0
  sink    device.id=140  channels=0  position=-  ....HiFi__SpeakerHaptic__sink
  sink    device.id=140  channels=0  position=-  ....HiFi__Speaker__sink

  pick: card 140 has no four-channel node — moving it to a four-channel profile

i.e. the matcher could never see the four-channel sink that was sitting right there,
and then went and changed the user's card profile to fix a problem that did not
exist. `pw-dump` and `pactl` show these fields because they BIND every object and
read its info props; reading their output is what made the registry-only version look
plausible.

The walk is now two rounds: the registry replay binds every `Audio/Sink…` node, and a
second sync collects the `info` events that provoked, whose props are the whole
proplist. Cards need no second round — their identity keys are in the announce, and
nothing else about them is weighed. Node parsing moved into `sink_from_props` so the
two sources cannot drift, and it now strips the `[ ... ]` brackets PipeWire puts
around `audio.position`.

Second defect from the same run, and the reason the Deck was left sitting on Pro
Audio afterwards: the devtest's early `?` returned before `restore_profile()`. The
restore now wraps the whole body. And a profile swap that fails to produce a
four-channel node restores the card immediately and records the card in
PROFILE_TRIED, so the renderer's backoff cannot flip a device in the user's sound
settings back and forth for the length of a session.

Gates: clippy -D warnings over the four client packages, build, 205 tests, fmt.
2026-08-16 00:10:50 +02:00
enricobuehler 9140a2e6e1 Merge pull request 'A German keyboard typed US characters — nothing carried the box's layout into the session' (#257) from worktree-ipad-keyboard-layout into main
ci / web (push) Successful in 1m20s
ci / rust-arm64 (push) Successful in 1m42s
ci / bun-nix (push) Successful in 2m37s
ci / docs-site (push) Successful in 5m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
android / android (push) Successful in 7m24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 2m20s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m10s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 34s
arch / build-publish (push) Successful in 12m30s
windows-host / package (push) Successful in 13m26s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 27s
ci / rust (push) Successful in 16m34s
deb / build-publish (push) Successful in 7m38s
deb / build-publish-host (push) Successful in 4m56s
deb / build-publish-client-arm64 (push) Successful in 6m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m45s
deb / build-publish-gamescope (push) Successful in 2m38s
deb / smoke-install (push) Successful in 2m17s
Reviewed-on: #257
2026-08-15 21:58:27 +00:00
enricobuehler 1c426dc85f Merge pull request 'A library shortcut opened mid-stream alerted "Can't open" instead of focusing the app' (#256) from worktree-browse-link-same-host-focus into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 2s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 2s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 1s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 2m11s
apple / screenshots (push) Successful in 9m37s
apple / distribute (push) Successful in 11m20s
Reviewed-on: #256
2026-08-15 21:57:58 +00:00
enricobuehler c64a1a6767 Merge pull request 'The climb gate scored encode against the negotiated refresh, so a 60 fps game was pinned at the ABR floor' (#255) from worktree-cadence-degraded-floor-lock into main
ci / rust (push) Canceled after 18s
ci / rust-arm64 (push) Canceled after 17s
ci / bun-nix (push) Canceled after 18s
ci / web (push) Canceled after 17s
ci / docs-site (push) Canceled after 17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
android / android (push) Canceled after 44s
docker / deploy-docs (push) Canceled after 0s
arch / build-publish (push) Canceled after 55s
deb / build-publish (push) Canceled after 43s
deb / build-publish-host (push) Canceled after 42s
deb / build-publish-gamescope (push) Canceled after 22s
deb / build-publish-client-arm64 (push) Canceled after 8s
deb / smoke-install (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13s
windows-host / package (push) Canceled after 1m29s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #255
2026-08-15 21:57:45 +00:00
enricobuehler 60d0cdfc0f fix(client/linux): a card's public 4-ch sink beats its hidden parent — the parent's AUX0 is dead
On-glass on a Steam Deck (SteamOS 3.7, alsa-ucm-conf with DualSense-PS5.conf) with a
wired DualSense. The card publishes three usable-looking nodes, and the previous
commit's "prefer the unpositioned quad" rule picked the wrong one:

  alsa_output.hw_Controller_0            Audio/Sink/Internal  4ch  AUX0,AUX1,AUX2,AUX3
  ....HiFi__SpeakerHaptic__sink          Audio/Sink           4ch  FL,FR,RL,RR
  ....HiFi__Speaker__sink                Audio/Sink           1ch  MONO

The hardware map is in the splits' own `api.alsa.split.position`: the mono Speaker
device is `[AUX1]` and SpeakerHaptic is `[AUX1,AUX1,AUX2,AUX3]`, so AUX1 is the
internal speaker, AUX2/AUX3 are the two voice coils, and **AUX0 is nothing**. Our
stream is speaker on 0/1 and haptics on 2/3, so index-exact into the PARENT puts
speaker-left into the dead channel and only speaker-right into the speaker — half
the speaker thrown away. The public split sink folds BOTH our speaker channels onto
AUX1, which is what its UCM author intended, and passes the coil pair through
untouched. Haptics are identical either way; the speaker is not.

So the order is now public-quad (unpositioned, then positioned) before the internal
parent, with `SinkNode::internal` carrying `media.class == Audio/Sink/Internal` or
`api.alsa.split.parent`. Pro Audio's `pro-output-0` is a PUBLIC AUX quad, so it is
still caught by the first rule and nothing about the no-UCM path changes.

Measured, not reasoned: playing a 200 Hz tone present ONLY in channels 3/4, in the
shape this client now uses (AUX0..AUX3 + `stream.dont-remix`), into SpeakerHaptic
and reading that sink's own monitor back index-exact gives

  ch0 0.0000  ch1 0.0000  ch2 0.5000  ch3 0.5000

— bit-exact on the coil pair, nothing leaking into the speaker pair. The parent
node has no monitor to capture (0 frames), which is why its map is read from the
split properties instead.

The new test transcribes all three real nodes and asserts the pick from every
enumeration order, which also pins the original defect: the old name-only matcher
took whichever public sink the registry replayed first, and one of them is a MONO
node that cannot carry the coils at all.

Gates: clippy -D warnings over the four client packages, build, 205 tests green
(202 in pf-client-core), cargo fmt --check. Also confirmed on the same Deck that
the pad still presents to the input layer as 054c:0ce6 alongside Steam Input's
28de:11ff virtual pad, so tier-A detection has the real ids to match on.
2026-08-15 23:55:09 +02:00
enricobuehler d227db06e8 fix(client/linux): controller audio picked any DualSense sink, so the coils were folded away
The Linux client's pad-audio renderer matched a PipeWire sink by name signature
alone and streamed a positioned FL/FR/RL/RR quad at it. The voice coils ARE
channels 3 and 4 of the pad's USB sound card, and a DualSense almost never
presents four channels by default: PipeWire's ACP picks a stereo profile, and a
modern alsa-ucm-conf splits the card into a mono Speaker and a stereo Headphones
sink instead. Every one of those opens perfectly and then position-remixes our
quad into the speaker pair, so the coils are never excited — nothing is felt, and
nothing looks wrong. The Windows half of the same module has required a 4-channel
endpoint since it was written; only Linux never did, and it was compile-verified
only (the on-glass leg was Android -> Linux host, which renders over raw USB and
never touches a graph).

Correlation now walks nodes AND devices, and picks a four-channel node that
belongs to a DualSense CARD:

- Identity comes from the USB ids (base 16, either 0x spelling) with the old name
  signature as the fallback, and it may sit on either the node or its card — a
  split card's public sinks publish neither.
- `device.id` is required, which is also what keeps a Punktfunk HOST's own minted
  pad sink out: it carries the full DualSense identity on purpose, and rendering
  into it would loop the plane back at the host.
- Unpositioned (AUX) quads are preferred, then positioned ones, then the hidden
  four-channel parent a split sink names in `api.alsa.split.name` — GE-Proton's
  own preferred haptic leg.
- With no four-channel node anywhere, the card's profile is moved to Pro Audio for
  the session and restored when it ends (never saved; `PUNKTFUNK_PAD_AUDIO_PROFILE=0`
  opts out; a sandboxed client that is refused the write gets told to do it by hand).

The stream itself now sets `stream.dont-remix` and AUX0..AUX3 rather than
FL/FR/RL/RR, so channel k reaches channel k whatever the node advertises — the
same shape the host-side sink mints and GE forces on its own haptic streams.

Also here:

- `punktfunk-session --pad-audio-test` prints every DualSense object in the graph,
  the node it chose, and drives a tone into the coils. It separates "the plane
  never arrived" from "it arrived and the graph folded it away" with no host, no
  game and no pairing — the diagnostic whose absence made this a field report.
- The GTK client grows Controller haptics / Controller speaker rows; they were
  reachable from Android and the settings file only. Not profileable (which pad is
  in your hands is a fact about this device), and a stored "mix" survives the
  round trip.
- The tier-A activation packet no longer swallows its error: where SDL does not own
  the pad's HID link (Linux's own hid-playstation has it), that is worth saying,
  because that driver asserts the same audio-haptics disable bit on every rumble.
- Docs: the client half of controller-audio, the two settings rows, and
  PUNKTFUNK_PAD_AUDIO_PROFILE. The "speaker is opt-in" line was only true of
  Android; desktop has shipped it on.

Gates (Ubuntu 26.04 rust-ci container, linux/amd64): clippy --all-targets -D
warnings over pf-client-core, pf-presenter, punktfunk-client-session and
punktfunk-client-linux; plain build; 204 tests green including 5 new ones for the
matcher, the identity parse and the profile chooser; cargo fmt --check. The graph
walk was smoke-tested against a live PipeWire daemon on home-bazzite-1 (2 sinks,
2 cards enumerated, matching --list-audio; correct "no DualSense" verdict with no
pad attached). NOT yet exercised against a real DualSense — that is the on-glass
step this leaves open.
2026-08-15 22:42:03 +02:00
enricobuehler 0c254cc62b Merge pull request 'Two unrelated defects were hiding behind one audio metric' (#258) from worktree-audio-stutter-fixes into main
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 1m31s
ci / bun-nix (push) Successful in 3m56s
deb / build-publish (push) Successful in 3m1s
deb / build-publish-client-arm64 (push) Successful in 1m18s
docker / builders-arm64cross (push) Successful in 11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
ci / docs-site (push) Successful in 6m3s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
deb / build-publish-host (push) Successful in 5m3s
deb / build-publish-gamescope (push) Successful in 3m17s
android / android (push) Successful in 7m40s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 1m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
docker / deploy-docs (push) Successful in 36s
arch / build-publish (push) Successful in 12m24s
ci / rust (push) Successful in 14m42s
windows-host / package (push) Successful in 15m26s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 1m14s
deb / smoke-install (push) Successful in 11m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m16s
2026-08-15 20:15:30 +00:00
enricobuehler 94be547da0 fix(input): a German keyboard typed US characters — nothing carried the box's layout into the session
ci / bun-nix (pull_request) Successful in 21s
ci / web (pull_request) Successful in 1m5s
ci / docs-site (pull_request) Successful in 9m7s
ci / rust-arm64 (pull_request) Successful in 9m47s
android / android (pull_request) Successful in 15m41s
ci / rust (pull_request) Successful in 16m49s
Reported from an iPad on a Bazzite host in Game Mode: `#` arrived as `\`, `-` as `/`, `ä` as `'`.
That set is not random — they are the US ANSI keys that sit where the German ISO ones do, which
says the client was right and the host session was resolving positions with a US keymap.

The key wire is US-POSITIONAL by design: a client sends the physical key, `vk_to_evdev` turns it
into an evdev code, and the session's keymap picks the character. So the contract is "host layout
== the layout on the client's keyboard", and nothing was upholding it:

- Nothing exports `XKB_DEFAULT_*`. `localectl set-x11-keymap de` writes
  /etc/X11/xorg.conf.d/00-keyboard.conf — a file only Xorg reads — and libxkbcommon's fallback
  chain stops at the env vars, which no session manager sets. Compiling from empty names on a
  properly-configured German box therefore yielded evdev/pc105/us, silently.
- gamescope reads those env vars and then publishes the keymap to nobody. It builds the keymap
  onto `keyboard_group`, but the seat carries `virtual_keyboard_device` — a stub whose own comment
  says it exists "only to set the keymap" and which never gets one. `wlserver_keyboardfocus()`
  rebinds that stub on every focus change, and the real group only reaches the seat from a libinput
  key event, which a `--backend headless` session never has. Verified on the box: both Xwayland
  servers map evdev 53/40/43 to slash/apostrophe/backslash on a de/nodeadkeys machine.

`pf_host_config::layout` resolves what the box actually recorded (`XKB_DEFAULT_*`, then
xorg.conf.d, then vconsole's XKBLAYOUT — never vconsole's KEYMAP, whose names are console names and
do not map onto xkb's). From there:

- the wlroots injector compiles its uploaded keymap from it instead of from empty names, which
  fixes Sway/Hyprland hosts outright;
- all four gamescope launch paths hand the session `XKB_DEFAULT_*`, gated behind a `+pfhdr8` probe
  that warns when the binary predates the fix rather than leaving it unexplained;
- `sync_session_keyboard_layout()` covers the case none of that reaches — the autologin session
  punktfunk ATTACHES to, where no launch-time decision applies — by pointing each gamescope
  Xwayland at the box's layout on adoption. Everything in Game Mode is an X11 client of those
  servers, so this is the leg that fixes the report. `PUNKTFUNK_SESSION_LAYOUT=0` turns it off.

gamescope patch 0010 sets the keymap on the stub as well, which is what makes the env legs mean
anything for Wayland-native clients. It is NOT build-verified (no gamescope build environment
here); the series does apply `git am`-clean at the pinned 5fb8dce4.

Verified: 10 new unit tests over the resolver; pf-inject + pf-host-config clippy `-D warnings` and
those tests on real linux-gnu in the CI image; pf-vdisplay checks and clippies clean on linux-gnu
via scripts/xcheck.sh, confirmed non-vacuous with a planted error. The character mapping itself was
confirmed on the box with `xmodmap -pke` before and after applying the layout.
2026-08-15 20:25:46 +02:00
enricobuehler 7c964e95c6 fix(host,audio): the rtkit boost was applied to a thread that does not run the capture callback
ci / bun-nix (pull_request) Successful in 20s
ci / rust-arm64 (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 4m10s
ci / docs-site (pull_request) Successful in 4m36s
ci / rust (pull_request) Successful in 4m50s
android / android (pull_request) Successful in 7m52s
The comment above it asserted "the stream's `process` callbacks run ON this
mainloop thread (we never hand PipeWire a separate data loop)". We do: the
stream is created with `RT_PROCESS`, so libpipewire runs `process()` on a data
loop it creates and schedules itself.

Measured in one live host process on 2026-08-15:

    punktfunk-pw-au   SCHED_OTHER  nice 0     <- the thread we boost
    data-loop.0       SCHED_RR     prio 20    <- the thread running process()

This is not a stale-comment nit. #232 shipped the rtkit boost to answer a field
report of audio stutter, its success line was read as evidence that the capture
callback had been prioritised, and the follow-up round concluded priorities were
"engaged but insufficient" — when they had never been applied to the thread in
question. Whether the capture callback is realtime decides whether a Wine shader
storm can deschedule it for tens of milliseconds at a 2.7 ms quantum, which is
the exact shape of the one field signature still unexplained (~2 stalls/s of
~30 ms with the node reporting itself continuously Streaming).

So the assumption is replaced by a measurement rather than a guess about which
thread to boost:

- `pf_frame::thread_qos::current_thread_sched()` reports the calling thread's
  policy, RT priority and nice. Three by-value syscalls, no allocation and no
  blocking, so it is safe to call from an RT callback.
- The capture callback reports its own scheduling once per open. Every future
  field log now states what the audio path actually runs as, instead of what we
  asked for somewhere else.

The boost itself is kept: this thread still dispatches state and format events,
and it IS the capture thread when `PUNKTFUNK_STREAM_SINK=0` selects the legacy
monitor path.

Deliberately not attempted here: boosting the data loop. rtkit is a blocking
D-Bus call and must never run inside an RT audio callback, and on the one host
that could be measured PipeWire already gives that thread SCHED_RR/20 — a nice
boost would be inert. Ship the instrument first; a host that reports
SCHED_OTHER here is the evidence that would justify the plumbing.

Gated in the amd64 CI container: fmt, clippy -D warnings on punktfunk-host and
pf-frame, pf-frame tests (incl. a non-vacuity test that the introspection
returns a policy the kernel could have named), and the full punktfunk-host suite
at 560 passed. The one failure, mgmt::tests::local_summary_is_loopback_only_and_
non_sensitive, is the recorded process-global parallel-test race: it passes when
run alone, which is the documented discriminator, and it is untouched by this.
2026-08-15 20:13:14 +02:00
enricobuehler 165a42fcc9 fix(host/udev): the virtual Steam Controller 2's hidraw node was root-only, so Steam never saw it
ci / web (pull_request) Successful in 2m16s
ci / rust-arm64 (pull_request) Successful in 3m25s
ci / bun-nix (pull_request) Successful in 34s
ci / docs-site (pull_request) Successful in 2m20s
ci / rust (pull_request) Successful in 7m28s
Field report: the Android client captures a wired SC2 and says so ("captured — streams as-is"),
the host attaches the virtual pad over usbip cleanly, and Steam's Settings → Controller →
Connected Controllers is empty. Nothing works in game mode.

The host's own log names the culprit by what is missing from it. Every kernel control transfer is
there — device/config/string descriptors, SET_CONFIGURATION, SET_IDLE, and a GET of the 372-byte
report descriptor — and there is not one SET_REPORT and not one `answering feature GET`. The
kernel enumerated the controller; Steam never opened it.

60-punktfunk.rules grants hidraw access per product id, and it lists what the host used to mint:
the Sony pads, the Switch Pro, the Deck (28DE:1205) and the classic Steam Controller (28DE:1102).
The SC2 identities `steam_backend_product` mints — wired 28DE:1302 and Puck 28DE:1304 — were never
added, so their nodes stayed root-only while the host runs as a user service.

For any other pad that would be a degradation. For this one it is total: no kernel driver claims
the PID (mainline hid-steam stops at the Deck) and the state reports ride a vendor collection, so
there is no evdev node either. Steam is the only consumer there is, and a hidraw node it cannot
open is not a degraded controller, it is no controller. Leaning on the distro's steam-devices
rules doesn't save it — those lists are per-PID and the SC2 shipped in 2026, so a host whose copy
predates it grants nothing.

Add both identities in the same two forms the Deck uses (KERNELS for the UHID shape, ATTRS for the
usbip/gadget one), and document the symptom in troubleshooting with the hand-rollable rule for
hosts on an older package, the one-line-plus-one-absence log signature, and a note that the
trackpads needing Steam to act as a mouse is lizard mode being off on purpose, not a fault.

Single source of truth: every distro installs this file (the NixOS module takes it from the
package via services.udev.packages), so no per-packaging change is needed.
2026-08-15 20:10:53 +02:00
enricobuehler 2563041e88 feat(host,audio): a 16-second outage and a starved stream reported the same numbers
Measured on a live host running this commit's parent: our PipeWire capture
stream spent 16.2 s of one window in `Paused`, and the line said

    delivered_pct=63 gaps=0 max_gap_ms=0

Every number is correct. `gaps` scores inter-callback deltas and a stream that
is not scheduled fires no callbacks, so there is nothing to score — that is
deliberate, and `a_paused_span_is_not_scored` pins it. But the outage did not
vanish; it moved into `delivered_pct`, because the reporting window is flushed
from the process callback and therefore STRETCHES by exactly the time we were
absent. The only explanation lived in the state DEBUG lines, which a field
journal at INFO does not carry.

So a shortfall had two possible causes and no way to tell them apart: a sink
nobody was rendering into (benign — the 2026-08-15 logs show ~40 s of it at
every session start, at peak_db=-120.0, i.e. digital silence), or a capture path
losing real audio under load (Skynet, 5-8 % through loud gameplay). Those want
opposite responses and cost days of investigation to separate by hand.

Three changes, one theme — make the line answer the question it invites:

- `pauses` / `paused_ms` beside the percentage they explain. `gaps` keeps its
  narrow meaning (holes inside a running stream); absence is counted separately,
  because a burst of sub-10 ms holes is a scheduling problem on the box and a
  multi-second pause is our node not being in the graph at all.

- The quantum now tracks what the graph is actually handing us instead of
  latching the first callback of the open. A graph re-plans whenever anything
  else on the box asks for a different latency, and the stale value silently
  corrupted the very threshold gaps are scored against. A new size must survive
  three callbacks before it is believed, so one short buffer cannot move it.

- `audio egress` — the send path had no periodic metric of any kind. Across five
  field logs it emitted 14 lines, all the same session banner, which made "the
  host paces audio badly" unfalsifiable and left it on the suspect list forever.
  It now reports sent/infilled/late/max_late_ms/max_spacing_ms/reanchors on the
  same 30 s window as capture, so the two read as a pair: holes at the tap with
  clean departures means the host delivered everything it had. Note `reanchors`
  in particular — the pacer forgives accumulated debt silently, and that is
  precisely the event that leaves no trace and then gets blamed on the network.

Windows keeps its own field set; its capture model differs (loopback stops
delivering while the endpoint idles) and zero-valued fields would imply it had
measured something it did not.

Gated in the amd64 CI container: fmt, clippy -D warnings, 18 capture-policy
tests (4 new, plus the pause pair sitting next to the test that pins the
blindness they answer).
2026-08-15 20:05:10 +02:00
enricobuehler 8ef9b18d20 fix(apple): a browse shortcut opened mid-stream alerted instead of focusing the app
ci / web (pull_request) Successful in 1m9s
ci / rust-arm64 (pull_request) Successful in 1m22s
ci / bun-nix (pull_request) Successful in 1m33s
apple / swift (pull_request) Successful in 2m7s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 3m58s
ci / rust (pull_request) Successful in 11m39s
The connect route has always made the same-host exception the deep-link
spec demands (rule 2: streaming the same host -> no-op focus), but the
browse route refused on ANY live session -- so tapping a Dock library
shortcut to come back to a running stream popped "Can't open: Already
streaming X. End that session first." for the very host on glass.
Mirror the connect guard: same host -> the open already foregrounded the
app, which is all focus can mean mid-stream; a different host keeps the
notice. Android shipped this posture from day one; this is Apple parity.
2026-08-15 19:34:15 +02:00
enricobuehler 33d0b77e07 fix(host): the behind-cadence deadline was the negotiated refresh, so a 60fps game pinned ABR at the floor
ci / bun-nix (pull_request) Successful in 23s
ci / rust-arm64 (pull_request) Successful in 1m26s
android / android (pull_request) Successful in 4m38s
ci / web (pull_request) Successful in 4m43s
ci / docs-site (pull_request) Successful in 5m26s
ci / rust (pull_request) Failing after 11m23s
A 2026-08-15 field session (2560x1440@120 negotiated, game delivering
53-74 fps) collapsed to the 5000 kbps ABR floor and was then held there
for 23 minutes - 94% of the session - by our own climb gate: six
'bitrate climb refused - encode is behind cadence' refusals with
loss_ppm=0 throughout. The behind test scored every frame's encode work
against the negotiated interval (8.33 ms at 120 Hz), but a frame's real
budget is the arrival of the next frame that actually exists: a 60 fps
source gives every frame twice that. An encoder keeping up with every
real frame could be marked behind on most of them, latch behind_score
past DEPTH_DEGRADE, and refuse every climb the client asked for.

The budget is now the OBSERVED source-delivery period: an EMA over real
frames' arrival spacing (repeats excluded - a keepalive re-encode says
nothing about the game's rate), clamped to [interval, 4x interval] so a
source at or above the negotiated rate keeps bit-for-bit today's
deadline and a hitchy source cannot disarm the detector. This also
stops the same mis-scoring from spuriously escalating pipeline depth /
pipelined retrieve on below-rate sources.

And the gate becomes observable - the session above sat at the floor
with NO trace of why:
- every cadence_degraded transition logs behind_score, escalated,
  budget/interval/observed-period, rate-limited to one line per 5 s
  with suppressed flips counted (the score can oscillate +-1 around
  the latch threshold at frame rate);
- the control task's climb-refusal line now carries the live
  behind_score (new shared AtomicU32), so a field log can finally
  discriminate 'the budget was wrong' from 'this encoder genuinely
  cannot keep up'.

Gated: clippy --all-targets -D warnings + native::stream tests (25,
incl. a new budget test pinning the field case) in the amd64 CI
container; fmt clean.
2026-08-15 18:54:00 +02:00
enricobuehler 4ee095220f The in-flight budget tests still assumed buffer-only accounting (#254)
ci / bun-nix (push) Successful in 25s
ci / rust-arm64 (push) Successful in 1m29s
apple / swift (push) Successful in 2m8s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m4s
deb / build-publish (push) Successful in 4m31s
deb / build-publish-gamescope (push) Successful in 22s
ci / rust (push) Successful in 5m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
ci / web (push) Successful in 6m37s
deb / build-publish-host (push) Successful in 5m9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 31s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 1m12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 24s
ci / docs-site (push) Successful in 6m55s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m15s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 32s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m40s
deb / smoke-install (push) Successful in 2m44s
arch / build-publish (push) Successful in 11m58s
android / android (push) Successful in 13m41s
apple / distribute (push) Successful in 11m30s
flatpak / build-publish (push) Successful in 7m48s
apple / screenshots (push) Successful in 9m28s
windows-host / package (push) Successful in 13m13s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m41s
2026-08-15 16:41:33 +00:00
enricobuehler d9d877f985 Merge pull request 'The shared clipboard now works on iPhone and iPad' (#250) from worktree-worktree-ios-clipboard into main
apple / swift (push) Successful in 1m55s
ci / bun-nix (push) Successful in 28s
ci / web (push) Successful in 1m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / docs-site (push) Successful in 1m28s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m11s
docker / deploy-docs (push) Successful in 34s
apple / distribute (push) Successful in 10m53s
ci / rust-arm64 (push) Successful in 12m27s
apple / screenshots (push) Successful in 9m34s
ci / rust (push) Canceled after 17m50s
Reviewed-on: #250
2026-08-15 16:15:12 +00:00
enricobuehler 8c4a41913f The pad sink wore a profile name GE-Proton matches nothing on, so neither haptics nor speaker could route (#252)
ci / rust-arm64 (push) Successful in 2m1s
ci / bun-nix (push) Successful in 2m38s
ci / rust (push) Canceled after 3m21s
ci / web (push) Canceled after 3m6s
ci / docs-site (push) Canceled after 3m6s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-gamescope (push) Successful in 28s
deb / build-publish (push) Successful in 3m54s
deb / build-publish-client-arm64 (push) Successful in 3m8s
android / android (push) Successful in 7m50s
arch / build-publish (push) Successful in 11m51s
deb / build-publish-host (push) Successful in 8m53s
deb / smoke-install (push) Successful in 2m35s
windows-host / package (push) Successful in 16m8s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 13m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 12m21s
Merged with ci/rust red. That check fails identically on unmodified main at the base commit f04da20f (run 18516) and reproduces locally with --test-threads=1, in punktfunk-core::packet::tests — a crate this branch does not touch. Pre-existing breakage from 59d8b8a6, tracked separately.
2026-08-15 16:11:45 +00:00
enricobuehler a7da3e4cb0 Merge pull request 'The host knew about every one of these faults and had no way to say so' (#253) from worktree-console-diagnostics into main
ci / rust-arm64 (push) Successful in 2m26s
ci / bun-nix (push) Successful in 55s
deb / build-publish (push) Successful in 4m33s
ci / web (push) Successful in 8m6s
ci / rust (push) Failing after 8m55s
deb / build-publish-gamescope (push) Successful in 1m58s
ci / docs-site (push) Successful in 8m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 49s
arch / build-publish (push) Successful in 11m38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m26s
deb / build-publish-client-arm64 (push) Successful in 3m37s
deb / build-publish-host (push) Successful in 5m8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 50s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 1m40s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 34s
docker / builders-arm64cross (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
android / android (push) Canceled after 15m26s
docker / deploy-docs (push) Successful in 31s
deb / smoke-install (push) Canceled after 1m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2m1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m0s
windows-host / package (push) Canceled after 15m33s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Web-console diagnostics v1 (design/web-console-diagnostics.md WP1–WP3).

Merged with `ci / rust` red, deliberately: that job's Test step fails on two
punktfunk-core tests — packet::tests::in_flight_buffer_budget_bounds_allocation and
packet::tests::streamed_open_commits_its_own_extent_and_stays_bounded — which this
branch cannot affect (it touches zero files under crates/punktfunk-core).

The same two fail on main's own push run (18516, sha f04da20f) and reproduce locally
on a clean checkout. Root cause: 59d8b8a6 "meter per-block reassembly state against
the in-flight budget" changed reassemble.rs without updating packet/tests.rs, so both
tests still assert the pre-change budget arithmetic. Fixing those expectations is a
separate, focused change against that commit's intent — not something to guess at from
inside a console feature.

Every other gate is green on this branch: fmt, unsafe-hygiene, clippy -D warnings (both
the default and native-only trees), build, rust-arm64, web, docs-site, bun-nix.
2026-08-15 15:56:19 +00:00
enricobuehler 6776bff82d feat(host,console): the host knew about every one of these faults and had no way to say so
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust (pull_request) Failing after 4m6s
ci / bun-nix (pull_request) Successful in 4m20s
android / android (pull_request) Successful in 13m55s
ci / rust-arm64 (pull_request) Successful in 6m14s
Implements design/web-console-diagnostics.md, WP1-WP3 (the v1). WP4 (live checks + SSE)
and WP5 (instant helpers) stay deferred, as that plan sequences them.

The `.181` incident is the type specimen. `preflight_takeover_privilege()` is the most
careful probe in the repo — four applicability gates, and it even separates user-database
membership from the running process's supplementary groups — and it spends all of that
care on ONE WARN log line. A console-driven update never shows scriptlet stderr, so the
operator's only symptom was a black screen on every connect. The class of bug is "the host
knows, and the person operating it has no way to find out".

Host
----
`diagnostics.rs` holds the `HostCheck` model and a process-global registry (probes in,
cached verdicts out; `POST /refresh` re-runs them). `inapplicable` is a first-class status
rather than an absent row, so the page can answer "why isn't this check relevant here?"
instead of silently hiding it. `diagnostics/catalog.rs` maps verdicts to wire checks and
owns every user-visible string.

`GET /api/v1/diagnostics` + `POST /api/v1/diagnostics/refresh` are **admin lane only**.
Neither allowlist in `auth.rs` is touched: both are opt-in, and these verdicts carry the
host user's name, its group layout and device-node state. The route-classification test
gets both rows so that stays a reviewed decision rather than a default.

Probes stay in their owning crates and export plain verdict enums; the host does the
mapping. No reverse dependency — `pf-inject`/`pf-vdisplay` never learn about host types.

* `pf-vdisplay`: `preflight_takeover_privilege()` now logs FROM
  `takeover_privilege_verdict()`. The WARN line is unchanged, deliberately: headless
  operators read logs, not consoles, and this moves where the verdict GOES, not what it
  says.
* `pf-inject`: `uinput_probe()` keeps the errno that `pen_supported()` throws away, so
  "you are not in the input group" (EACCES) and "the module was never installed" (ENOENT)
  stop looking identical — they need opposite remedies. `vhci_probe()` reports device
  facts only (module present, node writable by this process); who to blame is the host's
  question, because only the user database can answer it.

Two distinctions the catalog refuses to collapse:

1. **User-database membership vs this process's groups.** `usermod -aG` satisfies the
   first immediately and the second not until the next login. Collapsing them produces
   the single most maddening support state there is — "I already added myself!" — which
   nothing in the logs distinguishes today. It now gets its own remedy: log out, no
   command to run.
2. **`usermod` does not stick on an atomic OS.** On the Universal Blue images the remedy
   is `ujust add-user-to-input-group`. Matched on the OS chain's LEAF, never on the
   `fedora` family token: plain Fedora Workstation is mutable and does want `usermod`.

Console
-------
The dashboard gets an `AttentionCard` that renders nothing at all on a healthy host
(`ConflictsCard`'s rule), shows at most the 3 worst checks, and links onward rather than
explaining — a dashboard that starts teaching remedies stops being a dashboard.

Its badge says the **severity**, not the status. The badge's colour already encodes
severity, so a badge reading "Failing" on both a red and an amber row leaves the
difference between them carried by colour alone — which is the thing the `pin_pending`
precedent exists to prevent. Caught on glass, not in the diff.

The Logs page becomes Troubleshooting: checks above the log stream, because when the
checks are green and something is still broken the log is the natural next step. The
ROUTE stays `/logs` — bookmarks and deep links outlive a label. `LogsCard` grows a
heading, since the page title no longer names it.

A check id this console has never heard of still renders, from the host's English
`summary`/`impact`/`remedy.text`. That is what makes console N paired with host N+1
survivable, and it is enforced as a test rather than left as a convention. The situational
prose is deliberately NOT duplicated into the message catalogues: one check has many
shapes (the vhci one alone has four distinct causes), and copying ~20 sentences into a
package that versions independently of the one that generates them is the very drift this
design set out to avoid. The console localizes the check NAMES and all chrome; when the
host sends a shape discriminator alongside `id`, localized prose can key off it.

Verification
------------
`cargo clippy -D warnings` and `cargo fmt --all --check` clean on Linux; 12 diagnostics
unit tests and 48 mgmt handler tests green, including the openapi drift test —
`api/openapi.json` and the ungated `docs-site/public/openapi.json` copy are both
regenerated. Web: build, lint, `bun test server/`, storybook, and the new stories shot in
both themes with the theme flip verified rather than assumed.

Not yet on glass: the real `.181` box (all three vhci states), `.138` for the
unpackaged-helper path, and the old-console/new-host pairing legs.
2026-08-15 17:30:13 +02:00
enricobuehler 37d39295aa test(core): the in-flight budget tests still assumed buffer-only accounting
apple / swift (pull_request) Successful in 1m59s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / web (pull_request) Successful in 1m26s
android / android (pull_request) Successful in 5m19s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m25s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m9s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m33s
ci / rust (pull_request) Successful in 20m48s
`59d8b8a6` (security-review 2026-08-15 finding 11) started metering BlockState against the
in-flight budget, because both its vectors are sized from attacker-declared header fields and a
slice-streamed frame could otherwise mint thousands of unmetered blocks. That fix is right and
stays. What it missed is that two tests encode the OLD cost model as arithmetic in their
comments — "exactly 32 such frames fit; the 33rd must be refused", "four fit the budget, the
fifth must be refused" — so a stricter, more correct budget reads as a failure:

  in_flight_buffer_budget_bounds_allocation          7 drops, expected 1
  streamed_open_commits_its_own_extent_and_stays_bounded   2 drops, expected 1

Both numbers are exactly what the new metering predicts (a 512 B buffer + 104 B of block state
takes 26 of the 16384 B budget, not 32; a ceiling-claiming 4096 B open takes 3, not 4), so the
tests were measuring the hole rather than the firewall. main has been red on `ci / rust` since.

Derive the refusal boundary from the cost model instead of baking in a frame count: the tests now
ask block_state_bytes() how much a frame commits, push exactly one frame past what fits, and
assert the same property as before — everything under the budget accepted, the one past it
dropped. A future field on BlockState moves the boundary and the tests follow it, rather than
failing with an arithmetic puzzle that invites re-hardcoding whatever number CI last printed.

Also assert the invariant the counts were only ever a proxy for: `in_flight() <= budget` at the
end of each. That one catches a release site forgetting half the cost — the accounting-drift
failure `in_flight`'s own doc comment warns about, which surfaces in the field as a permanent
loss storm once the budget wedges.

IN_FLIGHT_BUF_FACTOR and block_state_bytes become pub(super) (the LOSS_WINDOW_NS precedent);
no production behaviour changes.

Verified: punktfunk-core 414/414 with --all-features (the superset of CI's failing target),
`cargo fmt --all --check` clean, clippy -D warnings clean. Mutation-checked: inflating
IN_FLIGHT_BUF_FACTOR 1000x makes both tests fail with 0 drops, so neither went vacuous.
2026-08-15 17:01:45 +02:00
enricobuehler 8740f48c92 feat(apple): the shared clipboard now works on iPhone and iPad
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 2m2s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 4m29s
ci / rust-arm64 (pull_request) Successful in 5m55s
ci / rust (pull_request) Failing after 14m20s
The clipboard bridge was written against NSPasteboard and gated `#if os(macOS)`,
so the iOS half of the same universal app had a per-host toggle it could not
show and a wire plane it never opened — even though the core's clipboard ABI is
in every slice of the framework and nothing below the pasteboard was
platform-specific.

Rather than keep a second copy of the subtle half (one drain thread, offer
sequence numbers, the pending fetches a blocked paste waits on, echo
suppression), that logic moves into a `ClipboardPasteboard` seam and stays
shared. What genuinely differs is small and lives in two adapters: AppKit
fulfils a paste by blocking a provider thread, UIKit by answering an
NSItemProvider load handler; AppKit transcodes images through NSImage, UIKit
through UIImage. macOS behaviour is unchanged — same poll interval, same
timeouts, same lock discipline.

Two things the iOS side needs that the Mac does not.

Backgrounding the app ends the session, so a lazy promise on the pasteboard
would outlive anything able to answer it and "copy on the host, switch to
Safari, paste" would hand Safari nothing. So a host offer still unpasted when
the sync tears down is pulled across then — bounded to 8 MiB and 3 seconds,
skipped entirely if it was already pasted. Everything else stays lazy: copy on
the host, stay in the app, paste nothing, and no clipboard bytes move.

And since iOS 14 reading pasteboard *contents* is a privacy event the user
sees, while reading its change count and type list is not. That maps onto the
lazy design exactly, so the announce poll stays silent however long a session
runs; the one real read happens when someone on the host pastes. It now runs
off the drain thread, because on iOS 16+ that read can sit waiting for the
user's answer, and the drain thread is what would deliver the host's cancel.

One bug fixed while moving the code: ownership of the pasteboard was recorded
from a write's resulting change count without checking the write had landed. A
dropped write would have made the sync read the user's own next copy as its own
echo and never announce it again.

tvOS has no pasteboard, so the guards became `#if !os(tvOS)` rather than
widening to every platform.

Verified: macOS `swift build` + 319 tests green (15 new), iOS and tvOS
typechecks via the `--triple` recipe, and `xcodebuild` of the shipping
Punktfunk-iOS scheme.
2026-08-15 15:56:44 +02:00
98 changed files with 9496 additions and 971 deletions
+210
View File
@@ -401,6 +401,70 @@
}
}
},
"/api/v1/diagnostics": {
"get": {
"tags": [
"diagnostics"
],
"summary": "Host health checks",
"description": "Every verdict this host computes about its own health — group membership the managed takeover\nneeds, the input device nodes virtual controllers are built on, competing streaming servers —\nwith the impact and a copy-pasteable remedy for each.\n\nCached: the probes run once at startup and on demand via `POST /diagnostics/refresh`, so this is\ncheap to poll. Checks whose status is `ok` and `inapplicable` are included — a troubleshooting\npage needs to show what is working and to answer \"why isn't this check relevant here?\".\n\n`summary`, `impact` and `remedy.text` are always present in English. A console that recognizes\nthe check's `id` replaces them with a localized string interpolated from `params`; one that does\nnot renders the wire text as-is, which is what keeps a console paired with a newer host readable.",
"operationId": "getDiagnostics",
"responses": {
"200": {
"description": "The current verdicts, worst-first",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DiagnosticsReport"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/diagnostics/refresh": {
"post": {
"tags": [
"diagnostics"
],
"summary": "Re-run the health checks",
"description": "Runs every probe again and returns the refreshed verdicts. Most checks describe state that only\nchanges when an operator changes it (a group membership, an installed udev rule), so this exists\nfor exactly the moment after they have done so — a \"did that fix it?\" button, not a poll.",
"operationId": "refreshDiagnostics",
"responses": {
"200": {
"description": "The refreshed verdicts, worst-first",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DiagnosticsReport"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/layout": {
"put": {
"tags": [
@@ -4902,6 +4966,25 @@
}
}
},
"CheckSource": {
"type": "string",
"description": "Where a verdict came from. `Event` is reserved for the live feeds (transitions push instead of\nwaiting for a refresh); v1 produces only `Startup` and `Refresh`.",
"enum": [
"startup",
"event",
"refresh"
]
},
"CheckStatus": {
"type": "string",
"description": "What a probe found. `Inapplicable` is deliberately distinct from `Ok`: \"this box will never do\nthe thing\" and \"the thing works here\" are different answers, and the troubleshooting page shows\nthem differently.",
"enum": [
"ok",
"warn",
"fail",
"inapplicable"
]
},
"ClientLogMeta": {
"type": "object",
"description": "One stored bundle, as the console lists it.",
@@ -5227,6 +5310,29 @@
}
}
},
"DiagnosticsReport": {
"type": "object",
"description": "The `GET /diagnostics` body.",
"required": [
"ran_at_unix",
"checks"
],
"properties": {
"checks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HostCheck"
},
"description": "Every registered check, worst-first. Includes `ok` and `inapplicable` rows — the console\ndecides what to hide, because \"what's working\" is the reassurance the dashboard omits."
},
"ran_at_unix": {
"type": "integer",
"format": "int64",
"description": "When the probes last ran (unix seconds).",
"minimum": 0
}
}
},
"DisconnectReason": {
"type": "string",
"description": "Why a client went away. `Quit` is a deliberate user \"stop\" (the typed close code);\n`Timeout` is a transport idle timeout (the client vanished); `Error` is everything else.",
@@ -6389,6 +6495,72 @@
}
}
},
"HostCheck": {
"type": "object",
"description": "One health verdict. This IS the wire shape.",
"required": [
"id",
"status",
"severity",
"summary",
"impact",
"params",
"source"
],
"properties": {
"id": {
"type": "string",
"description": "Stable snake_case machine code — the console's i18n key (see [`ids`])."
},
"impact": {
"type": "string",
"description": "What actually breaks, in the operator's terms. Empty only for `ok`/`inapplicable` rows."
},
"params": {
"type": "object",
"description": "Interpolation values for the console's localized strings (`{user}`, `{group}`, …). The\nconsole needs these because it cannot re-derive them: only the host can see the username.",
"additionalProperties": {
"type": "string"
},
"propertyNames": {
"type": "string"
}
},
"remedy": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/Remedy"
}
]
},
"severity": {
"$ref": "#/components/schemas/Severity",
"description": "What a non-ok status means. Meaningless when `status` is `ok`/`inapplicable`; carried anyway\nso a check never changes shape as it flips."
},
"since_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "First non-ok observation in this host run. Per-run bookkeeping, not a time series — there is\nno history here by design.",
"minimum": 0
},
"source": {
"$ref": "#/components/schemas/CheckSource"
},
"status": {
"$ref": "#/components/schemas/CheckStatus"
},
"summary": {
"type": "string",
"description": "One line, English. The console replaces this with a localized message when it knows `id`."
}
}
},
"HostEvent": {
"allOf": [
{
@@ -7641,6 +7813,31 @@
}
}
},
"Remedy": {
"type": "object",
"description": "What the operator should do about it. Always copy-paste — the host runs unprivileged and the\nconsole must never trigger privileged mutation. The `punktfunk` group in particular is\ndeliberately opt-in: writing the vhci `attach` node materialises arbitrary emulated USB devices\n(security review 2026-08-05, M-4), so joining it stays a deliberate act with the caveat attached.",
"required": [
"text",
"relogin_required"
],
"properties": {
"command": {
"type": [
"string",
"null"
],
"description": "A single pasteable shell command, when one fixes it outright."
},
"relogin_required": {
"type": "boolean",
"description": "True when the fix only takes effect after logging out and back in — a `systemd --user`\nmanager keeps the supplementary group set it started with. This distinction is the\ndifference between \"I already added myself!\" and a working virtual pad."
},
"text": {
"type": "string",
"description": "Plain-language instruction. English fallback — the console overrides it by check id."
}
}
},
"RuntimeRequest": {
"type": "object",
"required": [
@@ -7961,6 +8158,15 @@
}
}
},
"Severity": {
"type": "string",
"description": "How much a non-ok status matters. Orthogonal to [`CheckStatus`] on purpose: a check can be\n`warn` about something `critical` (degraded, not dead) and the console sorts by both.",
"enum": [
"info",
"warning",
"critical"
]
},
"SourceInput": {
"type": "object",
"required": [
@@ -8647,6 +8853,10 @@
"name": "host",
"description": "Host identity, capabilities, and liveness"
},
{
"name": "diagnostics",
"description": "Host health checks: what is wrong, what it breaks, and how to fix it (admin lane only)"
},
{
"name": "gpu",
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"
@@ -741,9 +741,10 @@ struct ContentView: View {
/// `libraryTarget` every internal surface writes, so the link lands in whichever presentation
/// the current mode owns: the gamepad console's in-place library screen, the touch cover, the
/// macOS sheet, or tvOS's cover. Connect's posture minus the connect itself: a pin conflict
/// refuses, a live session is never preempted, and an unsaved host can't be browsed the
/// library fetch rides the paired mTLS identity, so there is nothing to show before the host
/// is saved (the notice says what to do instead).
/// refuses, a live session is never preempted (same host the open already foregrounded the
/// app, which is all "focus it" can mean mid-stream; different host say so), and an unsaved
/// host can't be browsed the library fetch rides the paired mTLS identity, so there is
/// nothing to show before the host is saved (the notice says what to do instead).
private func openLibrary(from link: DeepLink) {
// A `profile=` on a browse link picks the shelf, exactly as it picks the settings on a
// connect link and refuses the same way (§10.6): an unknown or ambiguous reference must
@@ -772,9 +773,12 @@ struct ContentView: View {
return
}
guard model.phase == .idle else {
let current = model.activeHost?.displayName ?? "a host"
deepLinkNotice = "Already streaming \(current). End that session first."
return
guard model.activeHost?.id == host.id else {
let current = model.activeHost?.displayName ?? "a host"
deepLinkNotice = "Already streaming \(current). End that session first."
return
}
return // browsing the host we're already streaming nothing to do
}
libraryTarget = LibraryTarget(host: host, profile: selection)
case .unknown(let address, _, let name, _):
@@ -30,10 +30,10 @@ struct AddHostSheet: View {
@State private var pinnedIDs: Set<String>
@ObservedObject private var profiles = ProfileStore.shared
#endif
#if os(macOS)
/// Share the clipboard with this host (macOS sessions only; design
/// clipboard-and-file-transfer.md §5.3). Off by default; honored only when the host
/// advertises the capability at connect.
#if !os(tvOS)
/// Share the clipboard with this host (design clipboard-and-file-transfer.md §5.3). Off by
/// default; honored only when the host advertises the capability at connect. Absent on tvOS,
/// which has no pasteboard to share.
@State private var clipboardSync: Bool
#endif
#if os(tvOS)
@@ -72,7 +72,7 @@ struct AddHostSheet: View {
_port = State(initialValue: Int(existing?.port ?? 9777))
let stored = existing?.macAddresses ?? []
_mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", "))
#if os(macOS)
#if !os(tvOS)
_clipboardSync = State(initialValue: existing?.clipboardSync ?? false)
#endif
#if !os(tvOS)
@@ -144,7 +144,7 @@ struct AddHostSheet: View {
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
#if os(macOS)
#if !os(tvOS)
Toggle("Share clipboard with this host", isOn: $clipboardSync)
#endif
profileRows
@@ -200,11 +200,11 @@ struct AddHostSheet: View {
}
#if os(iOS)
/// Four fields + the action row a touch taller than the 3-field add sheet used to be. The
/// edit sheet's profile rows are the only thing that can outgrow it, and they say by how much;
/// a single fixed number is what clipped them.
/// Four fields, the clipboard toggle, and the action row. The edit sheet's profile rows are
/// the only thing that can outgrow it, and they say by how much; a single fixed number is what
/// clipped them.
private var sheetHeight: CGFloat {
var height: CGFloat = 392
var height: CGFloat = 392 + 44 // the fields and action row, plus the clipboard toggle
if showsProfileRows {
height += 116 // the Profile picker and its footnote
height += 96 + CGFloat(profiles.profiles.count) * 44 // the pins, their header + footer
@@ -282,7 +282,7 @@ struct AddHostSheet: View {
host.address = address.trimmingCharacters(in: .whitespaces)
host.port = UInt16(clamping: port)
host.macAddresses = Self.parseMacs(mac)
#if os(macOS)
#if !os(tvOS)
// nil when off: the key stays absent from the saved JSON (forward-compat, and "never
// opted in" and "opted out" read the same off).
host.clipboardSync = clipboardSync ? true : nil
@@ -63,9 +63,10 @@ private extension Image {
/// Sequentially tries cover-art URLs over `loader` (so a paired client can reach the host's own
/// art proxy, not just public CDNs see `LibraryArtLoader`), advancing past any that fail to
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback
/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report
/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private`
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, and a fallback
/// banner (wide hero/header art, used when a title has no portrait capsule) is cropped to the same
/// tile rather than allowed to size it see the `Color.clear` in `body` for why that takes more
/// than a `.frame(maxWidth:)` and a `.clipped()`. Not `private`
/// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art.
struct PosterImage: View {
let candidates: [URL]
@@ -84,9 +85,20 @@ struct PosterImage: View {
var body: some View {
Group {
if let image {
Image(platformImage: image)
.resizable()
.scaledToFill()
// `Color.clear` is what takes the proposed size; the art rides along as its
// overlay, where it can be DRAWN but never MEASURED. Handing the image the sizing
// role instead is what let a fallback banner escape the tile: `scaledToFill`
// reports a size that covers the proposal, and the flexible frame below clamps it
// to `.infinity` i.e. not at all. Measured offscreen, a 460×215 `header.jpg` in a
// 170pt grid column resolved the tile to 545×255 and overran its neighbours, while
// a 300×450 cover in the same chain came out correct which is why this only ever
// showed on the titles whose cover was missing.
Color.clear
.overlay {
Image(platformImage: image)
.resizable()
.scaledToFill()
}
.transition(.opacity)
} else if index < candidates.count {
ZStack { placeholder; ProgressView() }
@@ -250,14 +250,14 @@ final class SessionModel: ObservableObject {
private var audio: SessionAudio?
private var gamepadCapture: GamepadCapture?
private var gamepadFeedback: GamepadFeedback?
#if os(macOS)
#if !os(tvOS)
/// The live session's clipboard bridge (design/clipboard-and-file-transfer.md §5) created
/// by `beginStreaming` when the per-host toggle is on and the host advertises
/// `HOST_CAP_CLIPBOARD`; stopped (off-main, drain joined) in `disconnect`.
private var clipboardSync: ClipboardSync?
#endif
/// Whether clipboard sync is live (host-acked `ClipState.enabled`) drives the Stream menu
/// item's title and the settings footnote. Always false off-macOS.
/// item's title and the settings footnote. Always false on tvOS, which has no pasteboard.
@Published private(set) var clipboardEnabled = false
/// The host's last `ClipState.reason` (`CLIP_REASON_*`) why an enable was refused
/// (backend unavailable / policy disabled / ); 0 = OK.
@@ -768,7 +768,7 @@ final class SessionModel: ObservableObject {
#endif
let feedback = gamepadFeedback
gamepadFeedback = nil
#if os(macOS)
#if !os(tvOS)
let clipboard = clipboardSync
clipboardSync = nil
#endif
@@ -781,8 +781,11 @@ final class SessionModel: ObservableObject {
Task.detached {
audio?.stop()
feedback?.stop()
#if os(macOS)
clipboard?.stop() // disables sync on the wire while the connection is still up
#if !os(tvOS)
// Disables sync on the wire while the connection is still up and on iOS pulls a
// host offer the user has not pasted yet down to real bytes, which needs that
// connection, so it must stay ahead of the close below.
clipboard?.stop()
#endif
// Deliberate user quit tell the host to skip the keep-alive linger (must precede close).
if deliberate { conn.disconnectQuit() }
@@ -792,7 +795,7 @@ final class SessionModel: ObservableObject {
Task.detached {
audio?.stop()
feedback?.stop()
#if os(macOS)
#if !os(tvOS)
clipboard?.stop()
#endif
}
@@ -940,7 +943,7 @@ final class SessionModel: ObservableObject {
let feedback = GamepadFeedback(connection: conn, manager: .shared)
feedback.start()
gamepadFeedback = feedback
#if os(macOS)
#if !os(tvOS)
// Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled
// hosts never see a ClipControl) AND granted to this device (per-client access §5
// without the bit the host would refuse with CLIP_REASON_NOT_PERMITTED anyway; not
@@ -958,7 +961,7 @@ final class SessionModel: ObservableObject {
#endif
}
#if os(macOS)
#if !os(tvOS)
/// Create + start the session's clipboard bridge and route its host acks into the published
/// UI state. `ClipboardSync.start()` sends the enable; the host's `.state` answer flips
/// `clipboardEnabled` (or leaves it false with a `clipboardReason` the UI can explain).
@@ -977,9 +980,9 @@ final class SessionModel: ObservableObject {
/// Flip clipboard sync mid-session (the Stream menu). Off on requires the host cap; on
/// off tears the bridge down (off-main the drain join must not block the main actor) and
/// tells the host, which drops any selection we own there. No-op off-macOS or while idle.
/// tells the host, which drops any selection we own there. No-op on tvOS or while idle.
func toggleClipboardSync() {
#if os(macOS)
#if !os(tvOS)
guard let conn = connection, phase == .streaming else { return }
if let sync = clipboardSync {
clipboardSync = nil
@@ -22,8 +22,7 @@ import SwiftUI
/// `.focusedSceneValue` so the Scene-level commands can drive it.
struct SessionFocus {
var isStreaming: Bool
/// The connected host advertises `HOST_CAP_CLIPBOARD` (gates the Share Clipboard item
/// macOS-only UI, but the fact is platform-neutral).
/// The connected host advertises `HOST_CAP_CLIPBOARD` (gates the Share Clipboard item).
var clipboardAvailable: Bool
/// Clipboard sync is live (host-acked) drives the item's Stop/Share title.
var clipboardOn: Bool
@@ -78,14 +77,15 @@ struct StreamCommands: Commands {
}
.keyboardShortcut("a", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true || session?.micAvailable != true)
#if os(macOS)
// Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed
// when the host doesn't advertise the cap (older host / operator policy off).
// when the host doesn't advertise the cap (older host / operator policy off). On iPad
// there is no menu bar to show it in, but a hardware keyboard still reaches it.
Button(session?.clipboardOn == true ? "Stop Sharing Clipboard" : "Share Clipboard") {
session?.toggleClipboard()
}
.keyboardShortcut("c", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true || session?.clipboardAvailable != true)
#if os(macOS)
// Toggle the window's fullscreen. F is the macOS-standard fullscreen combo; here it's
// explicit so it's discoverable AND survives capture while streaming the stream view
// swallows keys, so InputCapture's monitor detects the same combo and posts the same
@@ -0,0 +1,76 @@
// The shared clipboard's format vocabulary (design/clipboard-and-file-transfer.md §3.5), stated
// once for AppKit and UIKit alike.
//
// Every Apple pasteboard type in the table IS a uniform type identifier, and both frameworks name
// them with the same strings `NSPasteboard.PasteboardType.png` and the UIPasteboard type
// `"public.png"` are the same bytes. Keeping the table as plain strings is therefore not a
// lowest-common-denominator compromise; it is the actual shared spelling, and it keeps the two
// platform adapters from drifting apart in what they announce.
#if !os(tvOS)
import Foundation
enum ClipboardFormats {
/// Wire MIME uniform type identifier, in announce order. Files
/// (`application/x-punktfunk-files`) ride Phase 2 and are absent here.
///
/// Original image formats sit beside the mandatory `image/png` floor rather than replacing it:
/// a copied JPEG never balloons into PNG and a GIF keeps its animation, while a peer that can
/// only place PNG still has something to take.
static let table: [(wire: String, uti: String)] = [
("text/plain;charset=utf-8", "public.utf8-plain-text"),
("text/rtf", "public.rtf"),
("text/html", "public.html"),
("image/png", "public.png"),
("image/jpeg", "public.jpeg"),
("image/gif", "com.compuserve.gif"),
]
/// Pasteboard marker types that must never cross the wire password managers mark secrets
/// with these (see nspasteboard.org). A Mac convention that costs nothing to honour on iOS:
/// the cross-platform managers set them there too, and a pasteboard that carries neither is
/// unaffected.
static let concealed = "org.nspasteboard.ConcealedType"
static let transient = "org.nspasteboard.TransientType"
/// Image types we do not announce verbatim but CAN serve `image/png` from by transcoding at
/// fetch time screenshots and Preview leave TIFF, the camera roll leaves HEIC.
static let pngSources = ["public.tiff", "public.heic"]
static func uti(forWire wire: String) -> String? {
table.first { $0.wire == wire }?.uti
}
static func wire(forUti uti: String) -> String? {
table.first { $0.uti == uti }?.wire
}
/// True when the pasteboard is carrying a secret and must be ignored entirely.
static func isConcealed(_ types: [String]) -> Bool {
types.contains(concealed) || types.contains(transient)
}
/// The format list to announce for a pasteboard holding `types` the lazy offer's whole
/// payload (§3.2). Empty means "nothing we sync", which legitimately clears the peer's side.
static func offerKinds(forTypes types: [String]) -> [PunktfunkConnection.ClipKind] {
var kinds = table
.filter { types.contains($0.uti) }
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
// PNG floor: announce the portable `image/png` whenever ANY convertible image is present
// native PNG, TIFF/HEIC, or a JPEG/GIF original already being offered verbatim above. The
// adapters convert at fetch time, so the fallback costs nothing unless a peer pastes it.
if !kinds.contains(where: { $0.mime == "image/png" }),
types.contains(where: { pngSources.contains($0) })
|| kinds.contains(where: { $0.mime.hasPrefix("image/") })
{
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
}
return kinds
}
/// The uniform types to place for a remote offer, in the table's order and skipping kinds this
/// client has no mapping for (files, and whatever a future host learns to offer).
static func placeableUtis(for kinds: [PunktfunkConnection.ClipKind]) -> [String] {
kinds.compactMap { uti(forWire: $0.mime) }
}
}
#endif
@@ -0,0 +1,115 @@
// The macOS half of the clipboard seam: `NSPasteboard.general`.
//
// AppKit's lazy-paste contract is a blocking one `provideDataForType` is called on a provider
// thread the moment a Mac app pastes, and whatever the item holds when that call returns is what
// the app gets. So this adapter is the one place that turns the asynchronous fetch into a wait.
#if os(macOS)
import AppKit
import Foundation
typealias SystemPasteboard = AppKitPasteboard
final class AppKitPasteboard: ClipboardPasteboard {
private let pb = NSPasteboard.general
private var activationObserver: NSObjectProtocol?
/// The provider backing the offer currently on the pasteboard. AppKit's own reference to it is
/// not something to rely on: nothing crosses if it is collected before the user pastes.
private var provider: BlockingOfferProvider?
var changeCount: Int { pb.changeCount }
var typeIdentifiers: [String] { (pb.types ?? []).map(\.rawValue) }
/// Read one wire format, converting where macOS stores a different native type: `image/png` is
/// served from a real `.png` entry when present, else converted from whatever image
/// representation the pasteboard holds (TIFF from screenshots and Preview, WebP/AVIF/GIF from
/// browsers `NSImage` decodes them all) into PNG at fetch time.
func read(wire: String) -> Data? {
guard wire == "image/png" else {
guard let uti = ClipboardFormats.uti(forWire: wire) else { return nil }
return pb.data(forType: NSPasteboard.PasteboardType(uti))
}
if let png = pb.data(forType: .png) {
return png
}
guard let img = NSImage(pasteboard: pb),
let tiff = img.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff)
else {
return nil
}
return rep.representation(using: .png, properties: [:])
}
func installLazy(utis: [String], fetch: @escaping ClipboardFetch) -> Int {
let provider = BlockingOfferProvider(fetch: fetch)
let item = NSPasteboardItem()
item.setDataProvider(provider, forTypes: utis.map { NSPasteboard.PasteboardType($0) })
pb.clearContents()
pb.writeObjects([item])
self.provider = provider
return pb.changeCount
}
/// Unused on macOS a promise here outlives any paste that might come, so there is never
/// cause to resolve one early. Implemented anyway so the seam has no platform-shaped hole.
func installResolved(_ items: [(uti: String, data: Data)]) -> Int {
let item = NSPasteboardItem()
for (uti, data) in items {
item.setData(data, forType: NSPasteboard.PasteboardType(uti))
}
pb.clearContents()
pb.writeObjects([item])
provider = nil
return pb.changeCount
}
func clear() -> Int {
pb.clearContents()
provider = nil
return pb.changeCount
}
/// A Mac keeps running after a session ends, but the promise dies with the sync regardless, so
/// there is nothing to be gained by spending a round-trip on it at teardown clearing leaves
/// the user exactly where they were.
let resolvesPendingOfferOnTeardown = false
func startObserving(onActivate: @escaping () -> Void) {
activationObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: nil
) { _ in onActivate() }
}
func stopObserving() {
if let activationObserver {
NotificationCenter.default.removeObserver(activationObserver)
self.activationObserver = nil
}
}
}
/// The lazy paste hook: AppKit calls `provideDataForType` only when a Mac app actually pastes; the
/// fetch then blocks this provider thread (never main) until the host's bytes arrive. On timeout
/// or a dead session it provides nothing, so the paste inserts nothing rather than hanging.
private final class BlockingOfferProvider: NSObject, NSPasteboardItemDataProvider {
private let fetch: ClipboardFetch
init(fetch: @escaping ClipboardFetch) {
self.fetch = fetch
}
func pasteboard(
_ pasteboard: NSPasteboard?, item: NSPasteboardItem,
provideDataForType type: NSPasteboard.PasteboardType
) {
guard let wire = ClipboardFormats.wire(forUti: type.rawValue) else { return }
let box = ClipboardResultBox()
fetch(wire) { box.settle($0) }
// The fetch enforces its own deadline and always completes; this is only a backstop
// against a lost completion wedging an AppKit thread forever.
guard let data = box.wait(timeout: ClipboardSync.fetchTimeout + 2) else { return }
item.setData(data, forType: type)
}
}
#endif
@@ -0,0 +1,121 @@
// The iOS/iPadOS half of the clipboard seam: `UIPasteboard.general`.
//
// Two things differ from AppKit in ways that shape the code here.
//
// **Laziness is asynchronous.** UIKit promises data with `NSItemProvider`, whose load handler is
// handed a completion rather than a return value, so this adapter passes the fetch straight
// through no thread is blocked waiting for a paste to resolve.
//
// **Reading the pasteboard is a privacy event.** Since iOS 14 the system tells the user when an
// app reads pasteboard *contents*, and since iOS 16 it asks first when the content came from
// another app. Reading *metadata* the change count, the list of type identifiers does not.
// That maps exactly onto the lazy design: the announce poll only ever looks at metadata, so it is
// silent no matter how long a session runs, and the one moment a read really happens is when
// someone on the host pastes, which is a deliberate act the user is present for.
#if !os(tvOS) && !os(macOS) && canImport(UIKit)
import Foundation
import UIKit
import UniformTypeIdentifiers
typealias SystemPasteboard = UIKitPasteboard
final class UIKitPasteboard: ClipboardPasteboard {
private let pb = UIPasteboard.general
private var activationObserver: NSObjectProtocol?
var changeCount: Int { pb.changeCount }
/// `types` reports the identifiers present without touching a single byte of content, so the
/// poll costs the user nothing and raises no banner.
var typeIdentifiers: [String] { pb.types }
/// Read one wire format, converting where iOS stores a different native type: `image/png` is
/// served from a real PNG entry when present, else re-encoded from whatever image the
/// pasteboard holds a photo copied out of Photos is HEIC, a screenshot may arrive as TIFF,
/// and neither is something a host can be expected to place.
///
/// This is the one call that reads contents, and on iOS 16+ it can put a permission alert in
/// front of the user and wait for their answer. `ClipboardSync` calls it off the drain thread
/// for exactly that reason.
func read(wire: String) -> Data? {
guard wire == "image/png" else {
guard let uti = ClipboardFormats.uti(forWire: wire) else { return nil }
if let data = pb.data(forPasteboardType: uti) {
return data
}
// UIPasteboard stores plain text as a string rather than a data representation often
// enough that the typed read comes back empty on content we can plainly see.
guard uti == UTType.utf8PlainText.identifier else { return nil }
return pb.string?.data(using: .utf8)
}
if let png = pb.data(forPasteboardType: UTType.png.identifier) {
return png
}
return pb.image?.pngData()
}
func installLazy(utis: [String], fetch: @escaping ClipboardFetch) -> Int {
let provider = NSItemProvider()
for uti in utis {
guard let type = UTType(uti), let wire = ClipboardFormats.wire(forUti: uti) else {
continue
}
provider.registerDataRepresentation(for: type, visibility: .all) { completion in
fetch(wire) { data in
if let data {
completion(data, nil)
} else {
completion(nil, ClipboardOfferError.unavailable)
}
}
return nil
}
}
// `localOnly`: these bytes do not exist on this device yet they are a promise against a
// session that is about to end. Handing that to Universal Clipboard would either force an
// eager pull of everything the host ever copies or strand another device with a promise
// nothing can answer.
pb.setItemProviders([provider], localOnly: true, expirationDate: nil)
return pb.changeCount
}
func installResolved(_ items: [(uti: String, data: Data)]) -> Int {
var representations: [String: Any] = [:]
for (uti, data) in items {
representations[uti] = data
}
pb.setItems([representations], options: [.localOnly: true])
return pb.changeCount
}
func clear() -> Int {
pb.items = []
return pb.changeCount
}
/// Backgrounding the app ends the session (see `ContentView`'s scenePhase driver), and with it
/// any hope of answering a promise so an offer the user has not pasted yet is pulled down to
/// real bytes while the connection is still open.
let resolvesPendingOfferOnTeardown = true
func startObserving(onActivate: @escaping () -> Void) {
activationObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil
) { _ in onActivate() }
}
func stopObserving() {
if let activationObserver {
NotificationCenter.default.removeObserver(activationObserver)
self.activationObserver = nil
}
}
}
/// What a lazy representation reports when the host cannot supply it a stale offer, a timed-out
/// fetch, or a session that ended. UIKit shows the paste as producing nothing, which is the same
/// outcome AppKit gets by providing no data.
enum ClipboardOfferError: Error {
case unavailable
}
#endif
@@ -0,0 +1,110 @@
// The platform seam under `ClipboardSync`: everything that actually touches NSPasteboard or
// UIPasteboard, and nothing else.
//
// The sync logic above this protocol the drain thread, the offer sequence numbers, the pending
// fetches a blocked paste waits on, echo suppression is identical on macOS and iOS and is worth
// having exactly one copy of. What genuinely differs is small and lives in the two adapters:
// AppKit fulfils a paste by BLOCKING a provider thread, UIKit by answering an asynchronous load
// handler; AppKit transcodes images through NSImage, UIKit through UIImage; and only UIKit has to
// worry about the process being suspended out from under an offer it promised to serve.
#if !os(tvOS)
import Foundation
/// Pulls the bytes of one lazily-offered wire format from the host. Called on whichever thread the
/// OS fulfils a paste on never the drain thread, which has to stay free to deliver the very
/// chunks this fetch is waiting for and answers asynchronously.
typealias ClipboardFetch = (_ wire: String, _ completion: @escaping (Data?) -> Void) -> Void
/// The system pasteboard, as much of it as the shared clipboard needs.
///
/// Calls arrive from the drain thread, the serve queue, and the thread tearing the sync down;
/// `ClipboardSync` serializes them with its own lock, so an adapter need not be internally
/// synchronized. It must not, however, block on the **main** queue: that lock is also taken from
/// main, and a `main.sync` under it would deadlock.
protocol ClipboardPasteboard: AnyObject {
/// Monotonic per pasteboard write, by anyone. Reading it must never count as reading the
/// pasteboard's *contents* on iOS that distinction is the difference between a silent poll
/// and a system paste banner on every tick.
var changeCount: Int { get }
/// The uniform type identifiers currently on the pasteboard. Must be answerable WITHOUT
/// reading contents, for the same reason.
var typeIdentifiers: [String] { get }
/// Bytes for one wire format, read from the live pasteboard and transcoded where the system
/// stores a different native type (`image/png` from a TIFF screenshot). Nil when the format
/// is not really there. This one DOES read contents.
func read(wire: String) -> Data?
/// Replace the pasteboard with a single item advertising `utis`, each backed by `fetch` the
/// bytes cross only if something actually pastes. Returns the resulting `changeCount`, which
/// the caller records so it can tell its own write apart from the user's next copy.
func installLazy(utis: [String], fetch: @escaping ClipboardFetch) -> Int
/// Replace the pasteboard with concrete bytes. Only iOS needs this (see
/// `ClipboardSync.resolvePendingOffer`); on macOS a lazy promise outlives any paste that
/// might come, so the adapter there never has cause to call it.
func installResolved(_ items: [(uti: String, data: Data)]) -> Int
/// Empty the pasteboard. Returns the resulting `changeCount`.
func clear() -> Int
/// Whether a host offer still sitting unresolved on the pasteboard should be pulled down to
/// concrete bytes as the sync is torn down, instead of being dropped.
///
/// This is the difference between the two platforms' idea of how long a promise lives. A Mac
/// keeps running long after a session ends, but the promise dies with the sync either way, so
/// AppKit clears it and the user loses nothing they had before. On iOS the teardown IS the
/// user leaving backgrounding ends the session and "copy on the host, then paste into
/// Safari" is the whole point of the feature on a tablet, so those bytes have to be made real
/// while the connection that can still supply them is open.
var resolvesPendingOfferOnTeardown: Bool { get }
/// Start watching for the user coming back to the app. The case that matters is "copied
/// elsewhere, now focusing the stream to paste" the offer must reach the host before their
/// V lands, which is sooner than the announce poll would get there on its own.
func startObserving(onActivate: @escaping () -> Void)
func stopObserving()
}
/// A fetch result handed between threads: the OS fulfils a paste on one thread and the drain
/// thread produces the bytes on another.
final class ClipboardResultBox: @unchecked Sendable {
private let ready = DispatchSemaphore(value: 0)
private let lock = NSLock()
private var value: Data?
private var settled = false
func settle(_ data: Data?) {
lock.lock()
guard !settled else {
lock.unlock()
return
}
settled = true
value = data
lock.unlock()
ready.signal()
}
/// Blocks until the bytes arrive, or gives up. Never call this from the drain thread it is
/// the drain thread that delivers what is being waited for.
func wait(timeout: TimeInterval) -> Data? {
guard ready.wait(timeout: .now() + timeout) == .success else { return nil }
lock.lock()
defer { lock.unlock() }
return value
}
}
extension ClipboardPasteboard {
/// The wire formats this pasteboard is currently carrying, honouring the concealed/transient
/// markers. Nil when the pasteboard holds a secret distinct from "holds nothing we sync",
/// which is an empty list and legitimately clears the peer.
var offerKinds: [PunktfunkConnection.ClipKind]? {
let types = typeIdentifiers
guard !ClipboardFormats.isConcealed(types) else { return nil }
return ClipboardFormats.offerKinds(forTypes: types)
}
}
#endif
@@ -1,51 +1,45 @@
// Shared clipboard, macOS client half (design/clipboard-and-file-transfer.md §5.2).
// Shared clipboard, client half (design/clipboard-and-file-transfer.md §5.2). One implementation
// for macOS and iOS/iPadOS; everything that touches an actual pasteboard sits behind
// `ClipboardPasteboard`.
//
// Bridges NSPasteboard.general to the session's QUIC clipboard plane, both directions lazy:
// Both directions are lazy:
//
// * **Local copy host**: a changeCount poll announces the *format list* (`clipOffer`); the
// bytes cross only when a host app pastes (a `.fetchRequest` event, answered from the live
// pasteboard by `clipServe`).
// * **Host copy local**: a `.remoteOffer` writes one NSPasteboardItem whose
// NSPasteboardItemDataProvider fires only when a Mac app actually pastes the provider then
// blocks (on its provider thread, never main) on a `clipFetch` round-trip.
// * **Local copy host**: a changeCount poll announces the *format list* (`clipOffer`); the bytes
// cross only when a host app pastes (a `.fetchRequest` event, answered from the live pasteboard
// by `clipServe`).
// * **Host copy local**: a `.remoteOffer` places a pasteboard item whose data provider fires only
// when a local app actually pastes the provider then pulls the bytes over a `clipFetch`.
//
// Password-manager respect: pasteboards marked `org.nspasteboard.ConcealedType` or
// `org.nspasteboard.TransientType` are never announced, never fetchable. Echo suppression: the
// changeCount of every write WE make is recorded so the announce poll skips it (§3.4).
//
// Phase 1 formats only (text / RTF / HTML / PNG). Files (NSFilePromiseProvider) ride Phase 2.
#if os(macOS)
import AppKit
// Phase 1 formats only (text / RTF / HTML / PNG / JPEG / GIF). Files ride Phase 2.
#if !os(tvOS)
import Foundation
/// One live session's clipboard bridge. Created by the session model when streaming begins on a
/// host that advertises `HOST_CAP_CLIPBOARD` and whose per-host toggle is on; `stop()` before the
/// connection closes. All pasteboard traffic runs on one dedicated drain thread plus the
/// AppKit-owned provider threads (paste fulfillment).
/// connection closes. All wire traffic runs on one dedicated drain thread, plus the OS-owned
/// threads that fulfil a paste.
public final class ClipboardSync: NSObject {
/// Wire MIME NSPasteboard type for the Phase-1 vocabulary (§3.5), in announce order.
private static let wireToPasteboard: [(wire: String, type: NSPasteboard.PasteboardType)] = [
("text/plain;charset=utf-8", .string),
("text/rtf", .rtf),
("text/html", .html),
("image/png", .png),
// Original image formats pass through VERBATIM beside the PNG floor a copied JPEG
// never balloons into PNG, a GIF keeps its animation; the destination picks the richest
// kind it can place.
("image/jpeg", NSPasteboard.PasteboardType("public.jpeg")),
("image/gif", NSPasteboard.PasteboardType("com.compuserve.gif")),
]
/// Pasteboard marker types that must never cross the wire (password managers mark secrets
/// with these see nspasteboard.org).
private static let concealed = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType")
private static let transient = NSPasteboard.PasteboardType("org.nspasteboard.TransientType")
/// How long a blocked paste waits for the host's bytes before providing nothing (§5.2).
private static let fetchTimeout: TimeInterval = 10
/// How long a paste waits for the host's bytes before giving up and providing nothing (§5.2).
/// Enforced here, so an adapter that has to block a thread can treat it as a guarantee.
static let fetchTimeout: TimeInterval = 10
/// Serve chunk size for host-side pastes of our data (bounds the per-call ABI copy).
private static let serveChunk = 4 << 20
/// Announce poll interval how stale a local copy may be before the host hears about it.
private static let announceInterval: TimeInterval = 0.5
/// Ceiling on what `resolvePendingOffer` will pull. Text and modest images are worth having on
/// the chance the user pastes them after the session ends; a 200 MB screenshot is not.
private static let resolveBudget = 8 << 20
/// And how long that may hold up teardown. Short on purpose it sits between the user
/// leaving and the connection closing, and a LAN round-trip for a few KB of text is
/// milliseconds. An offer that cannot be had in this long is one the user does without.
private static let resolveTimeout: TimeInterval = 3
private let connection: PunktfunkConnection
private let pasteboard: any ClipboardPasteboard
/// `CLIP_FLAG_*` sent with the enable (`CLIP_FLAG_FILES` when the session permits files
/// always 0 in Phase 1).
private let controlFlags: UInt8
@@ -53,72 +47,91 @@ public final class ClipboardSync: NSObject {
/// Host `.state` updates, delivered on the main queue drives the toggle/footnote UI.
public var onState: ((_ enabled: Bool, _ policy: UInt8, _ reason: UInt8) -> Void)?
// Drain-thread state (touched only on the drain thread once started).
// MARK: Offer bookkeeping
//
// Read by the drain thread, and written by the thread tearing the sync down too, so it is all
// under one lock. Nothing here is held across a fetch or a pasteboard read.
private let stateLock = NSLock()
private var offerSeq: UInt32 = 0
private var lastSeenChangeCount = 0
/// The changeCount of the last pasteboard write WE made (echo suppression + "do we still
/// own the pasteboard" on teardown/clear).
/// The changeCount of the last pasteboard write WE made (echo suppression, and "do we still
/// own the pasteboard" on teardown).
private var ownedChangeCount = -1
/// The host offer currently installed on the local pasteboard (nil = none).
private var installedRemoteSeq: UInt32?
/// The host offer currently placed on the local pasteboard (nil = none).
private var installedRemote: (seq: UInt32, kinds: [PunktfunkConnection.ClipKind])?
/// The offer already pulled down to concrete bytes, so teardown neither re-fetches it nor
/// takes it back off the pasteboard.
private var resolvedSeq: UInt32?
/// Outbound fetches a blocked paste is waiting on. Guarded by `fetchLock` appended by the
/// drain thread (`.data` events), consumed by AppKit's provider threads.
private struct PendingFetch {
// MARK: Outbound fetches
//
// Appended by whichever thread starts a fetch, completed by the drain thread as `.data`
// arrives. Guarded by `fetchLock`, which is never held while a completion runs.
private final class PendingFetch {
var buffer = Data()
let done = DispatchSemaphore(value: 0)
var failed = false
let completion: (Data?) -> Void
init(completion: @escaping (Data?) -> Void) { self.completion = completion }
}
private let fetchLock = NSLock()
private var pendingFetches: [UInt32: PendingFetch] = [:]
/// Fires the deadline that keeps a blocked paste from waiting forever on a host that went
/// quiet mid-transfer.
private let deadlines = DispatchQueue(label: "io.unom.punktfunk.clipboard.deadline")
/// Serves host pastes off the drain thread where a pasteboard read can await a user decision
/// (iOS's paste permission alert) the drain thread must keep running, it is the one that
/// would deliver the host's cancel.
private let serves = DispatchQueue(label: "io.unom.punktfunk.clipboard.serve")
private final class StopFlag: @unchecked Sendable {
private final class Flag: @unchecked Sendable {
private let lock = NSLock()
private var stopped = false
func stop() {
lock.lock()
stopped = true
lock.unlock()
}
var isStopped: Bool {
lock.lock()
defer { lock.unlock() }
return stopped
}
}
private let flag = StopFlag()
private let drainDone = DispatchSemaphore(value: 0)
private var started = false
/// Set by the app-activation observer, cleared by the drain loop: the user may have copied
/// elsewhere and is coming back to paste announce immediately instead of waiting out the
/// poll interval.
private final class OneShot: @unchecked Sendable {
private let lock = NSLock()
private var raised = false
private var value = false
func raise() {
lock.lock()
raised = true
value = true
lock.unlock()
}
func takeIfRaised() -> Bool {
var isRaised: Bool {
lock.lock()
defer { lock.unlock() }
let was = raised
raised = false
return value
}
/// Read-and-clear, for the one-shot "check the pasteboard now" nudge.
func take() -> Bool {
lock.lock()
defer { lock.unlock() }
let was = value
value = false
return was
}
}
private let checkNow = OneShot()
private var activationObserver: NSObjectProtocol?
private let stopped = Flag()
/// Raised by the activation observer, taken by the drain loop: the user may have copied
/// elsewhere and is coming back to paste announce now rather than waiting out the poll.
private let checkNow = Flag()
private let drainDone = DispatchSemaphore(value: 0)
private var started = false
public init(connection: PunktfunkConnection, allowFiles: Bool = false) {
/// - Parameter allowFiles: reserved for Phase 2; `CLIP_FLAG_FILES` is never set yet.
public convenience init(connection: PunktfunkConnection, allowFiles: Bool = false) {
self.init(connection: connection, pasteboard: SystemPasteboard(), allowFiles: allowFiles)
}
/// Designated init, taking the pasteboard so tests can drive the whole state machine against a
/// stub without an AppKit/UIKit pasteboard in the way.
init(
connection: PunktfunkConnection, pasteboard: any ClipboardPasteboard,
allowFiles: Bool = false
) {
self.connection = connection
self.pasteboard = pasteboard
self.controlFlags = 0 // CLIP_FLAG_FILES rides Phase 2
_ = allowFiles
super.init()
}
deinit { flag.stop() }
deinit { stopped.raise() }
// MARK: - Lifecycle
/// Enable sync with the host and start the drain thread. The host answers the enable with a
/// `.state` event (surfaced via `onState`) `BACKEND_UNAVAILABLE` et al. arrive there.
@@ -126,103 +139,105 @@ public final class ClipboardSync: NSObject {
guard !started else { return }
started = true
connection.clipControl(enabled: true, flags: controlFlags)
// Baseline: whatever is on the pasteboard when sync starts is announced immediately
// the "copy first, then connect and paste" flow must work.
// Baseline: whatever is on the pasteboard when sync starts is announced immediately the
// "copy first, then connect and paste" flow must work.
stateLock.lock()
lastSeenChangeCount = -1
activationObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: nil
) { [checkNow] _ in checkNow.raise() }
let connection = self.connection
let flag = self.flag
let thread = Thread { [weak self] in
var lastAnnounceCheck = Date.distantPast
while !flag.isStopped {
// Drain events (bounded burst so a chatty host can't starve the announce poll).
var drained = 0
while drained < 32, !flag.isStopped {
let ev: PunktfunkConnection.ClipEvent?
do {
ev = try connection.nextClipboard(timeoutMs: drained == 0 ? 200 : 0)
} catch {
flag.stop() // session closed
break
}
guard let ev else { break }
drained += 1
self?.handle(ev)
}
// Announce poll: every 500 ms, or immediately after app activation (§5.2).
let now = Date()
if now.timeIntervalSince(lastAnnounceCheck) >= 0.5
|| self?.checkNow.takeIfRaised() == true
{
lastAnnounceCheck = now
self?.announceIfChanged()
}
}
self?.drainDone.signal()
}
stateLock.unlock()
pasteboard.startObserving(onActivate: { [checkNow] in checkNow.raise() })
let thread = Thread { [weak self] in self?.drain() }
thread.name = "punktfunk-clipboard"
thread.qualityOfService = .utility
thread.start()
}
/// Disable sync and join the drain thread. Called off-main before `connection.close()`
/// (the same discipline as the audio/feedback drains). If the local pasteboard still holds
/// our remote-offer items, they are cleared their providers die with us.
/// Disable sync and join the drain thread. Called off-main before `connection.close()` (the
/// same discipline as the audio/feedback drains).
///
/// A host offer still sitting on the local pasteboard as a promise has to be dealt with here,
/// because after this returns nothing can answer it: either it is pulled down to real bytes
/// (iOS, where this teardown is the user walking away with something they copied) or it is
/// cleared, so a later paste comes up empty rather than silently doing nothing.
public func stop() {
guard started else { return }
started = false
if let obs = activationObserver {
NotificationCenter.default.removeObserver(obs)
activationObserver = nil
pasteboard.stopObserving()
// Before anything is torn down the drain thread has to still be running to deliver the
// chunks, and the connection still open to carry the fetch.
if pasteboard.resolvesPendingOfferOnTeardown {
resolvePendingOffer()
}
connection.clipControl(enabled: false, flags: 0)
flag.stop()
stopped.raise()
drainDone.wait()
// Fail every paste still blocked on us so no provider thread waits out its timeout.
fetchLock.lock()
for (_, pending) in pendingFetches {
pending.done.signal()
// Fail every paste still blocked on us so nothing waits out its timeout against a dead
// session.
settleAll(nil)
stateLock.lock()
let ownsUnresolvedOffer =
installedRemote != nil && resolvedSeq != installedRemote?.seq
&& pasteboard.changeCount == ownedChangeCount
installedRemote = nil
stateLock.unlock()
if ownsUnresolvedOffer {
_ = pasteboard.clear()
}
pendingFetches.removeAll()
fetchLock.unlock()
let pb = NSPasteboard.general
if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount {
pb.clearContents()
}
private func drain() {
var lastAnnounceCheck = Date.distantPast
while !stopped.isRaised {
// Drain events (bounded burst so a chatty host can't starve the announce poll).
var drained = 0
while drained < 32, !stopped.isRaised {
let ev: PunktfunkConnection.ClipEvent?
do {
ev = try connection.nextClipboard(timeoutMs: drained == 0 ? 200 : 0)
} catch {
stopped.raise() // session closed
break
}
guard let ev else { break }
drained += 1
handle(ev)
}
let now = Date()
if now.timeIntervalSince(lastAnnounceCheck) >= Self.announceInterval
|| checkNow.take()
{
lastAnnounceCheck = now
announceIfChanged()
}
}
drainDone.signal()
}
// MARK: - Local copy host (announce)
/// Announce the local pasteboard's format list when it changed (skipping our own writes and
/// concealed/transient pasteboards). Runs on the drain thread.
/// Announce the local pasteboard's format list when it changed, skipping our own writes and
/// concealed/transient pasteboards. Runs on the drain thread.
private func announceIfChanged() {
let pb = NSPasteboard.general
let count = pb.changeCount
guard count != lastSeenChangeCount else { return }
lastSeenChangeCount = count
if count == ownedChangeCount { return } // our own write (a remote offer) never echo
installedRemoteSeq = nil // a local copy replaced the host's offer
let types = pb.types ?? []
if types.contains(Self.concealed) || types.contains(Self.transient) { return }
offerSeq &+= 1
var kinds = Self.wireToPasteboard
.filter { types.contains($0.type) }
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
// PNG floor: announce the portable `image/png` whenever ANY convertible image is present
// native PNG, TIFF/HEIC (screenshots, Preview), or a JPEG/GIF original already being
// offered verbatim above. `readWireData` converts at fetch time (lazy, §3.5), so the
// fallback costs nothing unless a peer actually pastes it.
if !kinds.contains(where: { $0.mime == "image/png" }),
types.contains(.tiff)
|| types.contains(NSPasteboard.PasteboardType("public.heic"))
|| kinds.contains(where: { $0.mime.hasPrefix("image/") })
{
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
let count = pasteboard.changeCount
stateLock.lock()
guard count != lastSeenChangeCount else {
stateLock.unlock()
return
}
lastSeenChangeCount = count
guard count != ownedChangeCount else {
stateLock.unlock() // our own write (a remote offer) never echo
return
}
installedRemote = nil // a local copy replaced the host's offer
stateLock.unlock()
guard let kinds = pasteboard.offerKinds else { return } // concealed never announced
stateLock.lock()
offerSeq &+= 1
let seq = offerSeq
stateLock.unlock()
// Empty = the pasteboard holds nothing we sync (or was cleared) clears the host side.
connection.clipOffer(seq: offerSeq, kinds: kinds)
connection.clipOffer(seq: seq, kinds: kinds)
}
// MARK: - Event handling (drain thread)
@@ -236,93 +251,166 @@ public final class ClipboardSync: NSObject {
case let .remoteOffer(seq, kinds):
installRemoteOffer(seq: seq, kinds: kinds)
case let .fetchRequest(reqId, seq, _, mime):
serveFetch(reqId: reqId, seq: seq, mime: mime)
serves.async { [weak self] in self?.serveFetch(reqId: reqId, seq: seq, mime: mime) }
case let .data(xferId, chunk, last):
fetchLock.lock()
if var pending = pendingFetches[xferId] {
pending.buffer.append(chunk)
pendingFetches[xferId] = pending
if last {
pendingFetches[xferId]?.done.signal()
}
}
let pending = pendingFetches[xferId]
pending?.buffer.append(chunk)
let finished = last ? pendingFetches.removeValue(forKey: xferId) : nil
fetchLock.unlock()
// Outside the lock: a completion may start the next fetch (or wake a thread that will).
if let finished {
finished.completion(finished.buffer)
}
case let .cancelled(id), let .error(id, _):
fetchLock.lock()
if var pending = pendingFetches[id] {
pending.failed = true
pendingFetches[id] = pending
pending.done.signal()
}
fetchLock.unlock()
settle(id, nil)
}
}
// MARK: - Host copy local (lazy install + blocked-paste fetch)
// MARK: - Host copy local (lazy placement + paste-time fetch)
/// Write one NSPasteboardItem advertising the host's formats, each backed by a lazy data
/// provider bytes cross only when a Mac app pastes. Empty `kinds` = the host cleared its
/// clipboard: drop our item if it's still current.
/// Place a pasteboard item advertising the host's formats, each backed by a lazy provider
/// bytes cross only when a local app pastes. Empty `kinds` = the host cleared its clipboard:
/// drop our item if it's still current.
private func installRemoteOffer(seq: UInt32, kinds: [PunktfunkConnection.ClipKind]) {
let pb = NSPasteboard.general
let types = kinds.compactMap { kind in
Self.wireToPasteboard.first(where: { $0.wire == kind.mime })?.type
}
guard !types.isEmpty else {
if installedRemoteSeq != nil, pb.changeCount == ownedChangeCount {
pb.clearContents()
ownedChangeCount = pb.changeCount
lastSeenChangeCount = pb.changeCount
let utis = ClipboardFormats.placeableUtis(for: kinds)
guard !utis.isEmpty else {
stateLock.lock()
let owned = installedRemote != nil && pasteboard.changeCount == ownedChangeCount
installedRemote = nil
resolvedSeq = nil
if owned {
let after = pasteboard.clear()
ownedChangeCount = after
lastSeenChangeCount = after
}
installedRemoteSeq = nil
stateLock.unlock()
return
}
let item = NSPasteboardItem()
item.setDataProvider(RemoteOfferProvider(sync: self, seq: seq), forTypes: types)
pb.clearContents()
pb.writeObjects([item])
installedRemoteSeq = seq
ownedChangeCount = pb.changeCount
lastSeenChangeCount = pb.changeCount
let fetch: ClipboardFetch = { [weak self] wire, done in
guard let self else {
done(nil)
return
}
self.fetch(seq: seq, wire: wire, completion: done)
}
let before = pasteboard.changeCount
let after = pasteboard.installLazy(utis: utis, fetch: fetch)
stateLock.lock()
installedRemote = (seq, kinds)
resolvedSeq = nil
// Only claim the pasteboard if the write actually landed. Recording a change count we did
// not cause is the one bookkeeping mistake with no recovery: the announce poll would read
// the user's own next copy as our echo and never tell the host about it again.
if after != before {
ownedChangeCount = after
lastSeenChangeCount = after
}
stateLock.unlock()
}
/// Blocked-paste fulfillment: fetch one wire format of host offer `seq` and wait (provider
/// thread) for the drain thread to assemble the chunks. Nil on timeout/cancel/error the
/// paste then provides nothing rather than hanging (§3.4).
///
/// `fetchLock` is held ACROSS the `clipFetch` so the pending entry exists before the drain
/// thread can process the first `.data` event (its `handle` takes `fetchLock` after
/// releasing the connection's clipboard lock no cycle).
fileprivate func fetchBlocking(seq: UInt32, wireMime: String) -> Data? {
/// Start pulling one wire format of host offer `seq`. `completion` runs exactly once with
/// the bytes, or with nil on a stale offer, a timeout, a cancel, or a closing session.
private func fetch(seq: UInt32, wire: String, completion: @escaping (Data?) -> Void) {
fetchLock.lock()
guard let xferId = connection.clipFetch(seq: seq, mime: wireMime) else {
guard !stopped.isRaised, let xferId = connection.clipFetch(seq: seq, mime: wire) else {
fetchLock.unlock()
return nil
completion(nil)
return
}
pendingFetches[xferId] = PendingFetch()
let done = pendingFetches[xferId]!.done
pendingFetches[xferId] = PendingFetch(completion: completion)
fetchLock.unlock()
let outcome = done.wait(timeout: .now() + Self.fetchTimeout)
deadlines.asyncAfter(deadline: .now() + Self.fetchTimeout) { [weak self] in
guard let self, self.settle(xferId, nil) else { return }
self.connection.clipCancel(id: xferId)
}
}
/// Complete one pending fetch if it hasn't been already. Returns whether this call is the one
/// that settled it, so a deadline knows whether it still has to cancel the transfer.
@discardableResult
private func settle(_ xferId: UInt32, _ data: Data?) -> Bool {
fetchLock.lock()
let pending = pendingFetches.removeValue(forKey: xferId)
fetchLock.unlock()
if outcome == .timedOut {
connection.clipCancel(id: xferId)
return nil
pending?.completion(data)
return pending != nil
}
private func settleAll(_ data: Data?) {
fetchLock.lock()
let all = pendingFetches
pendingFetches.removeAll()
fetchLock.unlock()
for (_, pending) in all {
pending.completion(data)
}
guard let pending, !pending.failed else { return nil }
return pending.buffer
}
// MARK: - Resolving a promise before it dies (iOS)
/// Pull the host's offer down to concrete bytes, replacing the promise on the pasteboard.
///
/// A lazy promise is only as good as the ability to answer it, and on iOS that ability ends
/// with the session: backgrounding the app disconnects it. Without this, "copy on the host,
/// then paste into Safari on the iPad" would hand Safari an empty promise. Everything else
/// about the design stays lazy a user who copies on the host, pastes nothing, and stays in
/// the app moves no clipboard bytes at all; this runs once, at the end, for content that is
/// still on the pasteboard and still unclaimed.
///
/// Runs on the thread calling `stop()` off-main by contract, and never the drain thread,
/// which has to keep running to deliver what this waits for.
private func resolvePendingOffer() {
stateLock.lock()
let offer = installedRemote
let unresolved = resolvedSeq != installedRemote?.seq
let stillOurs = pasteboard.changeCount == ownedChangeCount
stateLock.unlock()
guard let offer, unresolved, stillOurs, !stopped.isRaised else { return }
let deadline = Date().addingTimeInterval(Self.resolveTimeout)
var items: [(uti: String, data: Data)] = []
var budget = Self.resolveBudget
for kind in offer.kinds {
guard let uti = ClipboardFormats.uti(forWire: kind.mime) else { continue }
// A size hint of 0 means "unknown" try it, and let the byte count enforce the cap.
guard kind.sizeHint <= UInt64(budget), !stopped.isRaised else { continue }
let left = deadline.timeIntervalSinceNow
guard left > 0 else { break }
let box = ClipboardResultBox()
fetch(seq: offer.seq, wire: kind.mime) { box.settle($0) }
guard let data = box.wait(timeout: left), data.count <= budget else { continue }
budget -= data.count
items.append((uti, data))
}
guard !items.isEmpty else { return }
// Re-check: the host may have copied again, or the user may have copied locally, while we
// were pulling either way these bytes are no longer what belongs on the pasteboard.
stateLock.lock()
defer { stateLock.unlock() }
let before = pasteboard.changeCount
guard installedRemote?.seq == offer.seq, before == ownedChangeCount else { return }
let after = pasteboard.installResolved(items)
// As in `installRemoteOffer`: a write that did not land leaves the promise in place, so
// teardown should still take it back rather than believing these bytes are on the board.
guard after != before else { return }
resolvedSeq = offer.seq
ownedChangeCount = after
lastSeenChangeCount = after
}
// MARK: - Host paste of our data (serve)
/// Answer a host paste of our offered data from the live pasteboard. A stale `seq` (the
/// local clipboard changed since that announce) is cancelled never serve mismatched bytes.
/// Answer a host paste of our offered data from the live pasteboard. A stale `seq` (the local
/// clipboard changed since that announce) is cancelled never serve mismatched bytes.
///
/// Runs on `serves`, not the drain thread: reading the pasteboard can block on a user decision
/// (iOS's paste permission alert), and the drain thread has to stay live throughout.
private func serveFetch(reqId: UInt32, seq: UInt32, mime: String) {
let pb = NSPasteboard.general
guard seq == offerSeq, pb.changeCount == lastSeenChangeCount,
let data = Self.readWireData(pb, mime)
else {
stateLock.lock()
let fresh = seq == offerSeq && pasteboard.changeCount == lastSeenChangeCount
stateLock.unlock()
guard fresh, !stopped.isRaised, let data = pasteboard.read(wire: mime) else {
connection.clipCancel(id: reqId)
return
}
@@ -337,66 +425,5 @@ public final class ClipboardSync: NSObject {
connection.clipServe(reqId: reqId, data: Data(), last: true)
}
}
/// Read one wire format from the pasteboard, converting where macOS stores a different
/// native type: `image/png` is served from a real `.png` entry when present, else converted
/// from whatever image representation the pasteboard holds (TIFF from screenshots/Preview,
/// WebP/AVIF/GIF from browsers `NSImage` decodes them all) into PNG at fetch time.
private static func readWireData(_ pb: NSPasteboard, _ mime: String) -> Data? {
guard mime == "image/png" else {
guard let type = wireToPasteboard.first(where: { $0.wire == mime })?.type else {
return nil
}
return pb.data(forType: type)
}
if let png = pb.data(forType: .png) {
return png
}
// No native PNG: decode whatever image the pasteboard carries and re-encode.
guard let img = NSImage(pasteboard: pb),
let tiff = img.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff)
else {
return nil
}
return rep.representation(using: .png, properties: [:])
}
}
/// The lazy paste hook: AppKit calls `provideDataForType` only when a Mac app actually pastes;
/// the fetch then blocks this provider thread (never main) until the host's bytes arrive or the
/// timeout provides nothing. One provider per installed remote offer a dead sync (weak) or a
/// superseded offer provides nothing.
private final class RemoteOfferProvider: NSObject, NSPasteboardItemDataProvider {
private weak var sync: ClipboardSync?
private let seq: UInt32
init(sync: ClipboardSync, seq: UInt32) {
self.sync = sync
self.seq = seq
}
func pasteboard(
_ pasteboard: NSPasteboard?, item: NSPasteboardItem,
provideDataForType type: NSPasteboard.PasteboardType
) {
guard let sync,
let wire = wireMime(for: type),
let data = sync.fetchBlocking(seq: seq, wireMime: wire)
else { return }
item.setData(data, forType: type)
}
private func wireMime(for type: NSPasteboard.PasteboardType) -> String? {
switch type {
case .string: return "text/plain;charset=utf-8"
case .rtf: return "text/rtf"
case .html: return "text/html"
case .png: return "image/png"
case NSPasteboard.PasteboardType("public.jpeg"): return "image/jpeg"
case NSPasteboard.PasteboardType("com.compuserve.gif"): return "image/gif"
default: return nil
}
}
}
#endif
@@ -34,10 +34,10 @@ public struct StoredHost: Identifiable, Codable, Hashable, Sendable {
/// client can send a magic packet to wake the host later (when it's asleep and no longer
/// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned.
public var macAddresses: [String]?
/// Share the clipboard with this host (macOS sessions; design/clipboard-and-file-transfer.md
/// §5.3). Opt-in per host: nil/false = off (nil also keeps older saved JSON decoding same
/// forward-compat reason as `mgmtPort`). Honored only when the host advertises
/// `HOST_CAP_CLIPBOARD`.
/// Share the clipboard with this host (macOS and iOS sessions; tvOS has no pasteboard see
/// design/clipboard-and-file-transfer.md §5.3). Opt-in per host: nil/false = off (nil also
/// keeps older saved JSON decoding same forward-compat reason as `mgmtPort`). Honored only
/// when the host advertises `HOST_CAP_CLIPBOARD`.
public var clipboardSync: Bool?
/// This host's default settings profile (`StreamProfile.id`) what a plain click/tap uses.
/// nil, or an id whose profile was deleted, resolves as "Default settings", i.e. exactly
@@ -0,0 +1,154 @@
// The shared clipboard's format decisions what a given pasteboard gets announced as, and what a
// host offer gets placed as. Pure functions, and the half of the sync that macOS and iOS now
// genuinely share, so a change that suits one platform and breaks the other fails here.
import XCTest
@testable import PunktfunkKit
#if !os(tvOS)
final class ClipboardFormatsTests: XCTestCase {
// MARK: - Announcing what is on the local pasteboard
func testPlainTextAnnouncesOnlyText() {
let kinds = ClipboardFormats.offerKinds(forTypes: ["public.utf8-plain-text"])
XCTAssertEqual(kinds.map(\.mime), ["text/plain;charset=utf-8"])
}
func testRichTextAnnouncesEveryRepresentationInTableOrder() {
// A copy out of a word processor leaves all three; the host picks the richest it can place,
// so the order the table declares is the order the offer carries.
let kinds = ClipboardFormats.offerKinds(forTypes: [
"public.html", "public.utf8-plain-text", "public.rtf",
])
XCTAssertEqual(kinds.map(\.mime), ["text/plain;charset=utf-8", "text/rtf", "text/html"])
}
func testUnknownTypesAreNotAnnounced() {
// A pasteboard holding only things we have no wire vocabulary for announces nothing, which
// legitimately clears the host's side rather than offering something unfetchable.
let kinds = ClipboardFormats.offerKinds(forTypes: ["com.apple.mail.PasteboardTypeMessage"])
XCTAssertTrue(kinds.isEmpty)
}
// MARK: - The PNG floor
func testScreenshotTiffAnnouncesPngEvenThoughTiffIsNotOnTheWire() {
// Screenshots and Preview leave TIFF, which no host can place. The adapters transcode at
// fetch time, so announcing the PNG floor costs nothing until someone actually pastes.
let kinds = ClipboardFormats.offerKinds(forTypes: ["public.tiff"])
XCTAssertEqual(kinds.map(\.mime), ["image/png"])
}
func testPhotoHeicAnnouncesPng() {
// The iOS case: a photo copied out of Photos is HEIC.
let kinds = ClipboardFormats.offerKinds(forTypes: ["public.heic"])
XCTAssertEqual(kinds.map(\.mime), ["image/png"])
}
func testJpegCrossesVerbatimAndStillCarriesThePngFloor() {
// The original rides beside the floor rather than replacing it a copied JPEG must not
// balloon into a lossless PNG for peers that can take JPEG.
let kinds = ClipboardFormats.offerKinds(forTypes: ["public.jpeg"])
XCTAssertEqual(kinds.map(\.mime), ["image/jpeg", "image/png"])
}
func testGifCrossesVerbatimSoAnimationSurvives() {
let kinds = ClipboardFormats.offerKinds(forTypes: ["com.compuserve.gif"])
XCTAssertEqual(kinds.map(\.mime), ["image/gif", "image/png"])
}
func testNativePngIsNotAnnouncedTwice() {
let kinds = ClipboardFormats.offerKinds(forTypes: ["public.png", "public.tiff"])
XCTAssertEqual(kinds.map(\.mime), ["image/png"])
}
// MARK: - Secrets
func testConcealedAndTransientPasteboardsAreRecognized() {
// Password managers mark secrets with these; nothing so marked is ever announced.
XCTAssertTrue(
ClipboardFormats.isConcealed([
"public.utf8-plain-text", "org.nspasteboard.ConcealedType",
]))
XCTAssertTrue(
ClipboardFormats.isConcealed([
"public.utf8-plain-text", "org.nspasteboard.TransientType",
]))
XCTAssertFalse(ClipboardFormats.isConcealed(["public.utf8-plain-text"]))
}
func testAConcealedPasteboardOffersNothingRatherThanAnEmptyOffer() {
// The distinction matters: nil means "say nothing at all", where an empty list would tell
// the host to drop what it has.
let secret = StubPasteboard(types: ["public.utf8-plain-text", "org.nspasteboard.ConcealedType"])
XCTAssertNil(secret.offerKinds)
let ordinary = StubPasteboard(types: ["public.utf8-plain-text"])
XCTAssertEqual(ordinary.offerKinds?.map(\.mime), ["text/plain;charset=utf-8"])
let empty = StubPasteboard(types: [])
XCTAssertEqual(empty.offerKinds?.isEmpty, true)
}
// MARK: - Placing what the host offered
func testHostOfferMapsToUniformTypesAndSkipsWhatWeCannotPlace() {
// Files ride Phase 2 an offer carrying them places the rest and ignores that kind rather
// than failing the whole paste.
let kinds = [
PunktfunkConnection.ClipKind(mime: "text/plain;charset=utf-8"),
PunktfunkConnection.ClipKind(mime: "application/x-punktfunk-files"),
PunktfunkConnection.ClipKind(mime: "image/png"),
]
XCTAssertEqual(
ClipboardFormats.placeableUtis(for: kinds), ["public.utf8-plain-text", "public.png"])
}
func testWireAndUniformTypeMapBothWays() {
for (wire, uti) in ClipboardFormats.table {
XCTAssertEqual(ClipboardFormats.uti(forWire: wire), uti)
XCTAssertEqual(ClipboardFormats.wire(forUti: uti), wire)
}
XCTAssertNil(ClipboardFormats.uti(forWire: "application/x-punktfunk-files"))
XCTAssertNil(ClipboardFormats.wire(forUti: "public.tiff"))
}
// MARK: - Handing bytes between threads
func testResultBoxDeliversBytesToAWaiter() {
let box = ClipboardResultBox()
DispatchQueue.global().async { box.settle(Data("hello".utf8)) }
XCTAssertEqual(box.wait(timeout: 5), Data("hello".utf8))
}
func testResultBoxTimesOutRatherThanWaitingForever() {
// What a paste does when the host goes quiet mid-transfer: give up, insert nothing.
XCTAssertNil(ClipboardResultBox().wait(timeout: 0.05))
}
func testResultBoxKeepsTheFirstAnswer() {
// The fetch deadline and a late arrival can both fire; whichever settles first wins, and
// the loser must not overwrite it or signal a second time.
let box = ClipboardResultBox()
box.settle(nil)
box.settle(Data("late".utf8))
XCTAssertNil(box.wait(timeout: 1))
}
}
/// A pasteboard that holds nothing but a type list enough to exercise the announce decision
/// without an AppKit or UIKit pasteboard in the test process.
private final class StubPasteboard: ClipboardPasteboard {
private let types: [String]
init(types: [String]) { self.types = types }
var changeCount = 0
var typeIdentifiers: [String] { types }
func read(wire: String) -> Data? { nil }
func installLazy(utis: [String], fetch: @escaping ClipboardFetch) -> Int { 0 }
func installResolved(_ items: [(uti: String, data: Data)]) -> Int { 0 }
func clear() -> Int { 0 }
let resolvesPendingOfferOnTeardown = false
func startObserving(onActivate: @escaping () -> Void) {}
func stopObserving() {}
}
#endif
+3
View File
@@ -22,6 +22,9 @@ mod cli;
mod shortcuts;
#[cfg(target_os = "linux")]
mod spawn;
// The guarded FlowBox `child-activated → activate` bridge every card grid needs.
#[cfg(target_os = "linux")]
mod ui_flow;
#[cfg(target_os = "linux")]
mod ui_hosts;
#[cfg(target_os = "linux")]
+70
View File
@@ -0,0 +1,70 @@
//! One shared fix for a GTK4 footgun every card grid in this shell walks into.
//!
//! A pointer click (and keyboard activate) on a [`gtk::FlowBoxChild`] emits
//! `child-activated` on the *FlowBox*, never the child's own `activate` signal — so a
//! grid whose per-card handler hangs off `child.connect_activate()` has to bridge the
//! one to the other. The naive bridge is a stack overflow: `FlowBoxChild`'s default
//! `activate` handler re-emits `child-activated` on its parent, which calls the bridge,
//! which activates the child again, forever.
//!
//! [`bridge_child_activation`] is that bridge with the re-entrancy guard that breaks the
//! cycle. Use it for every `child-activated → child.activate()` hop; do not hand-roll it,
//! since a bare `flow.connect_child_activated(|_, c| c.activate())` aborts the process on
//! the first click and looks perfectly reasonable in review.
use gtk::prelude::*;
/// Bridge a FlowBox's `child-activated` to the activated child's own `activate` signal,
/// exactly once per click. The re-entrant emission the child's default handler bounces
/// back is swallowed rather than recursed into.
pub(crate) fn bridge_child_activation(flow: &gtk::FlowBox) {
let activating = std::cell::Cell::new(false);
flow.connect_child_activated(move |_, child| {
if activating.replace(true) {
return;
}
child.activate();
activating.set(false);
});
}
#[cfg(test)]
mod tests {
use super::bridge_child_activation;
use gtk::prelude::*;
use std::cell::Cell;
use std::rc::Rc;
// Reproduces the exact FlowBox/FlowBoxChild wiring the card grids use: the bridge
// calls `child.activate()`, whose own default handler re-emits `child-activated` —
// that ping-pong recursed forever (a real stack overflow on every card click/Enter,
// reported on the hosts page and then again on the library page) until the
// re-entrancy guard landed here, where both pages share it.
#[test]
#[ignore = "needs a Wayland/X display"]
fn flow_box_activation_bridge_does_not_recurse() {
assert!(gtk::init().is_ok(), "no display");
let flow = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.activate_on_single_click(true)
.build();
bridge_child_activation(&flow);
let child = gtk::FlowBoxChild::new();
flow.insert(&child, -1);
let fired = Rc::new(Cell::new(0u32));
{
let fired = fired.clone();
child.connect_activate(move |_| fired.set(fired.get() + 1));
}
flow.emit_by_name::<()>("child-activated", &[&child]);
assert_eq!(
fired.get(),
1,
"the per-card handler should fire exactly once"
);
}
}
+4 -57
View File
@@ -781,18 +781,11 @@ impl SimpleComponent for HostsPage {
// A pointer click (and keyboard activate) emits `child-activated` on the
// *FlowBox*, never the child's own `activate` signal — bridge it back to the
// child, where each card wires its connect handler. The re-entrancy flag breaks
// the child-activated ↔ activate ping-pong that otherwise recurses forever
// (a real stack overflow on every card click; see the ignored display test).
// child, where each card wires its connect handler. The guard inside the bridge
// breaks the child-activated ↔ activate ping-pong that otherwise recurses forever
// (a real stack overflow on every card click; see `ui_flow`'s display test).
for flow in [saved.widget(), discovered.widget()] {
let activating = std::cell::Cell::new(false);
flow.connect_child_activated(move |_, child| {
if activating.replace(true) {
return;
}
child.activate();
activating.set(false);
});
crate::ui_flow::bridge_child_activation(flow);
}
// Shown under the discovered heading while no (unsaved) advert is live yet.
@@ -1466,49 +1459,3 @@ impl HostsPage {
dialog.present(Some(&self.widgets.stack));
}
}
#[cfg(test)]
mod tests {
use adw::prelude::*;
use std::cell::Cell;
use std::rc::Rc;
// Reproduces the exact FlowBox/FlowBoxChild wiring from `init()`: `child-activated`
// bridges to `child.activate()`, whose own default handler re-emits
// `child-activated` — that ping-pong recursed forever (stack overflow on every
// host-card click/Enter) until the re-entrancy guard was added.
#[test]
#[ignore = "needs a Wayland/X display"]
fn flow_box_activation_bridge_does_not_recurse() {
assert!(gtk::init().is_ok(), "no display");
let flow = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.activate_on_single_click(true)
.build();
let activating = Cell::new(false);
flow.connect_child_activated(move |_, child| {
if activating.replace(true) {
return;
}
child.activate();
activating.set(false);
});
let child = gtk::FlowBoxChild::new();
flow.insert(&child, -1);
let fired = Rc::new(Cell::new(0u32));
{
let fired = fired.clone();
child.connect_activate(move |_| fired.set(fired.get() + 1));
}
flow.emit_by_name::<()>("child-activated", &[&child]);
assert_eq!(
fired.get(),
1,
"the per-card handler should fire exactly once"
);
}
}
+4 -7
View File
@@ -116,10 +116,9 @@ fn build(
.valign(gtk::Align::Start)
.build();
// Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own
// `activate` — bridge it so each poster's connect handler (below) runs on click.
flow.connect_child_activated(|_, child| {
child.activate();
});
// `activate` — bridge it so each poster's connect handler (below) runs on click. The
// bridge must be the guarded one: bare, it recurses until the stack overflows.
crate::ui_flow::bridge_child_activation(&flow);
// The launcher shelf: same tile geometry as the games grid, its own FlowBox so the two
// groups never interleave and each wraps on its own.
let launcher_flow = gtk::FlowBox::builder()
@@ -132,9 +131,7 @@ fn build(
.row_spacing(18)
.valign(gtk::Align::Start)
.build();
launcher_flow.connect_child_activated(|_, child| {
child.activate();
});
crate::ui_flow::bridge_child_activation(&launcher_flow);
let launchers_heading = gtk::Label::new(Some("Launchers"));
launchers_heading.add_css_class("pf-group-heading");
launchers_heading.set_halign(gtk::Align::Start);
+47 -9
View File
@@ -1604,21 +1604,42 @@ pub fn show_scoped(
"Hold Select alone for the host's guide button — a tap still goes through",
GUIDE_GESTURE_LABELS,
);
// Controller audio (the 0xD1 plane): a wired DualSense's own voice coils and its little
// built-in speaker, streamed from the host and rendered on the pad in your hands. Both are
// negotiated — they change nothing without a capable host AND a wired DualSense — so the
// rows say what they are for rather than promising an effect.
//
// Deliberately NOT profileable: which pad is in your hands is a property of this device,
// not of the host a profile is authored against (the forwarded-pad pin below sits out for
// the same reason).
let haptics_row = adw::SwitchRow::builder()
.title("Controller haptics")
.subtitle("Play a DualSense's voice-coil haptics on the pad itself — wired pads only")
.build();
let pad_speaker_row = adw::SwitchRow::builder()
.title("Controller speaker")
.subtitle("Play the audio a game sends to the pad's own speaker on the pad, not here")
.build();
// The pad rows only mean something while something is being forwarded (the same
// relationship mic → echo cancellation draws just above, initial state included: the
// seed's `set_active` fires this only when it CHANGES the switch).
// seed's `set_active` fires this only when it CHANGES the switch). Controller audio
// belongs in that set too — forwarding off never OPENS the pad, so nothing can detect
// that it has an audio device, let alone render on it.
{
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone());
f.set_sensitive(seed.gamepad_forwarding);
t.set_sensitive(seed.gamepad_forwarding);
sb.set_sensitive(seed.gamepad_forwarding);
gg.set_sensitive(seed.gamepad_forwarding);
let (ha, sp) = (haptics_row.clone(), pad_speaker_row.clone());
for w in [&f, &t, &sb, &gg] {
w.set_sensitive(seed.gamepad_forwarding);
}
ha.set_sensitive(seed.gamepad_forwarding);
sp.set_sensitive(seed.gamepad_forwarding);
pad_forward_row.connect_active_notify(move |r| {
f.set_sensitive(r.is_active());
t.set_sensitive(r.is_active());
sb.set_sensitive(r.is_active());
gg.set_sensitive(r.is_active());
for w in [&f, &t, &sb, &gg] {
w.set_sensitive(r.is_active());
}
ha.set_sensitive(r.is_active());
sp.set_sensitive(r.is_active());
});
}
@@ -1632,6 +1653,8 @@ pub fn show_scoped(
scale_row.set_selected(index::render_scale(s));
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
pad_forward_row.set_active(s.gamepad_forwarding);
haptics_row.set_active(s.pad_haptics);
pad_speaker_row.set_active(pf_client_core::pad_audio::speaker_active(&s.pad_speaker));
pad_row.set_selected(index::gamepad(s));
sysbtn_row.set_selected(index::system_buttons(s));
gesture_row.set_selected(index::guide_gesture(s));
@@ -2088,6 +2111,12 @@ pub fn show_scoped(
controllers_group.add(pad_row.widget());
controllers_group.add(sysbtn_row.widget());
controllers_group.add(gesture_row.widget());
// Global scope only — see the rows' own note. In profile scope they would have no
// override marker and no way to record a touch, so a toggle would be silently discarded.
if !profile_mode {
controllers_group.add(&haptics_row);
controllers_group.add(&pad_speaker_row);
}
controllers.add(&controllers_group);
// Cap every caption in one pass, after the rows exist: a per-row call would be sixteen
@@ -2163,6 +2192,15 @@ pub fn show_scoped(
s.inhibit_shortcuts = inhibit_row.is_active();
s.invert_scroll = invert_row.is_active();
s.gamepad_forwarding = pad_forward_row.is_active();
s.pad_haptics = haptics_row.is_active();
// `"mix"` is a stored value this switch cannot express (it renders as off today,
// pending the mixer leg), so writing the switch back unconditionally would erase
// it just by opening and closing the dialog — the same trap the gamepad-type row
// guards above. Only write when the user actually moved it.
let want_speaker = pad_speaker_row.is_active();
if want_speaker != pf_client_core::pad_audio::speaker_active(&s.pad_speaker) {
s.pad_speaker = if want_speaker { "pad" } else { "off" }.to_string();
}
s.mic_enabled = mic_row.is_active();
s.echo_cancel = echo_row.is_active();
s.hdr_enabled = hdr_row.is_active();
+23
View File
@@ -821,6 +821,29 @@ mod session_main {
};
}
// `--pad-audio-test [--seconds N] [--speaker] [--coils]`: the controller-audio
// correlation, printed, then a tone driven into the pad. The one tool that separates
// "the plane never arrived" from "it arrived and the graph folded the coil pair away"
// — no host, no game, no pairing needed, just a wired DualSense.
#[cfg(target_os = "linux")]
if arg_flag("--pad-audio-test") {
let seconds = arg_value("--seconds")
.and_then(|v| v.parse().ok())
.unwrap_or(3);
// Coils by default: they are the half that silently disappears, so they are the
// half worth testing. `--speaker` adds (or, with nothing else, selects) the
// speaker pair.
let speaker = arg_flag("--speaker");
let coils = arg_flag("--coils") || !speaker;
return match pf_client_core::pad_audio::pad_audio_test(seconds, coils, speaker) {
Ok(()) => 0,
Err(e) => {
eprintln!("pad-audio-test: {e:#}");
EXIT_PRESENTER_FAILED
}
};
}
// `--pair <PIN>`: enrol this machine against a host and exit. DEPRECATED — pairing is
// a trust ceremony and belongs to the brain, fronted by `punktfunk pair` or a shell
// (design/client-architecture-split.md §5). It still works, with a notice, for the one
+92 -3
View File
@@ -277,6 +277,19 @@ pub struct PicturePlan {
/// Colour signalling, per picture and never latched — the same rule the other two
/// planners follow, because a host can switch an HDR desktop to PQ/BT.2020 in band.
pub colour: ColourDescription,
/// Every picture this frame predicts from was itself decoded from a fully-available
/// reference chain — so a host claim that this frame is a clean re-anchor
/// (`USER_FLAG_RECOVERY_ANCHOR`) can be corroborated rather than taken on trust.
/// `true` for a key or intra-only frame (nothing to predict from) and for any
/// frame whose whole reference chain is clean; `false` from the moment this frame
/// — or anything it descends from — needed concealment.
///
/// On a `show_existing_frame` this describes the picture being DISPLAYED, which is
/// the only thing such a frame puts on the screen: it decodes nothing of its own.
///
/// Purely additive observation. See [`crate::clean`] for why it propagates and why
/// every rule errs toward `false`.
pub references_clean: bool,
}
/// One planned access unit.
@@ -329,6 +342,35 @@ pub enum PlanWarning {
TruncatedAu { offset: usize },
}
impl PlanWarning {
/// Does this warning mean the PICTURE is damaged? The AV1 twin of
/// [`crate::h264::PlanWarning::is_integrity`], and
/// `pf_vkdecode::is_integrity_warning_av1` delegates here.
///
/// Every variant AV1 has IS damage, and that is a fact about the codec rather than
/// an oversight: AV1 puts nothing in this channel resembling h265's
/// `NonZeroReorder` or h264's `Mmco5Rebase`. It has no reorder envelope to report
/// (no bumping process, no `max_num_reorder_pics`) and no MMCO to rebase — the
/// frame header states the whole reference update outright — so the only things
/// left to warn about are pictures that went missing and an OBU walk that stopped
/// early.
///
/// `MissingShowExisting` is the one that could be argued, and it is damage: a
/// `show_existing_frame` naming an empty slot means the picture the STREAM chose
/// to display was lost upstream. Nothing is displayed for that frame, so the
/// screen keeps the previous one — exactly the "silently stale picture" state a
/// re-anchor exists to end.
///
/// Exhaustive with no wildcard, for the reason the H.264 twin spells out.
pub fn is_integrity(&self) -> bool {
match self {
PlanWarning::MissingReference { .. }
| PlanWarning::MissingShowExisting { .. }
| PlanWarning::TruncatedAu { .. } => true,
}
}
}
/// Why an access unit cannot be planned at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanError {
@@ -366,6 +408,10 @@ pub struct Av1Planner {
slots: [Option<RefPic>; NUM_REF_SLOTS],
next_id: PicId,
sequence: Option<Rc<SequenceHeaderObu>>,
/// Which resident pictures came off a BROKEN reference chain — the fact behind
/// [`PicturePlan::references_clean`]. Empty on a healthy stream; see
/// [`crate::clean::CleanLedger`] for the propagation rules.
clean: crate::clean::CleanLedger,
}
impl Default for Av1Planner {
@@ -381,6 +427,7 @@ impl Av1Planner {
slots: [None; NUM_REF_SLOTS],
next_id: 1,
sequence: None,
clean: Default::default(),
}
}
@@ -549,7 +596,22 @@ impl Av1Planner {
} else {
Vec::new()
};
let picture = picture_plan(&header, &sequence);
// A `show_existing_frame` decodes nothing, so the only picture it puts on
// the screen is the one it displays: report THAT picture's cleanliness. A
// slot that held nothing already warned above and shows nothing at all,
// which is damage in its own right — reporting it unclean keeps the two
// statements consistent.
let references_clean = match shown {
Some(pic) => self.clean.references_clean([pic.id]),
None => false,
};
let picture = picture_plan(&header, &sequence, references_clean);
// A key-frame `show_existing_frame` rewrote every slot with the shown
// picture (7.20), so the ledger has to follow that aliasing: the refreshed
// slots all hold `pic.id`, whose mark already stands. Nothing new is
// stored, so there is no verdict to fold — only residency to re-bound.
self.clean
.retain_live(self.slots.iter().flatten().map(|p| p.id));
return Ok(AuPlan {
picture,
tiles,
@@ -597,12 +659,34 @@ impl Av1Planner {
}
let removed = self.refresh_slots(header.refresh_frame_flags, id, RefState::of(&header));
let picture = picture_plan(&header, &sequence);
// Was every picture this frame predicts from decoded off an intact chain?
// Over the resolved names only: a `None` hole is a lost reference, which has
// already pushed `MissingReference` and therefore condemns this frame through
// `concealed` below. A key or intra-only frame names nothing, so this is
// vacuously true for it (`CleanLedger::references_clean`).
let references_clean = self
.clean
.references_clean(refs.iter().flatten().map(|r| r.id));
let picture = picture_plan(&header, &sequence, references_clean);
let outputs = if header.show_frame {
vec![id]
} else {
Vec::new()
};
// Fold this frame's verdict, then bound the ledger to slot residency. After
// `refresh_slots`, so the live set reflects the writes this frame performed.
// `concealed` mirrors what a consumer conceals on, via the ONE classification
// (`PlanWarning::is_integrity`), so the ledger and the consumer can never
// disagree about whether this frame was damaged.
self.clean.note_stored(
id,
references_clean,
warnings.iter().any(PlanWarning::is_integrity),
);
self.clean
.retain_live(self.slots.iter().flatten().map(|p| p.id));
Ok(AuPlan {
picture,
tiles,
@@ -655,7 +739,11 @@ impl Av1Planner {
}
}
fn picture_plan(header: &FrameHeaderObu, sequence: &SequenceHeaderObu) -> PicturePlan {
fn picture_plan(
header: &FrameHeaderObu,
sequence: &SequenceHeaderObu,
references_clean: bool,
) -> PicturePlan {
let color = &sequence.color_config;
let bit_depth = if color.high_bitdepth {
if color.twelve_bit {
@@ -698,6 +786,7 @@ fn picture_plan(header: &FrameHeaderObu, sequence: &SequenceHeaderObu) -> Pictur
matrix_coefficients: color.matrix_coefficients as u8,
video_full_range: color.color_range,
},
references_clean,
}
}
#[cfg(test)]
+245
View File
@@ -0,0 +1,245 @@
//! Which decoded pictures came off a fully-available reference chain — the fact a
//! client needs to CORROBORATE a host's claim that a frame is a clean re-anchor.
//!
//! # Why this exists
//!
//! After a loss the client freezes on its last good picture and lifts only on a proven
//! re-anchor. One of those proofs — `USER_FLAG_RECOVERY_ANCHOR`, the host's LTR-RFI
//! recovery frame — lifts the freeze on its FIRST occurrence, exactly like a real IDR,
//! because the host says the frame was coded against a known-good reference.
//!
//! The host's "known-good" is an inference from what the client RECEIVED. The client's
//! own DPB is the only place that knows what it actually DECODED, and it did not
//! previously record it: a picture the planner concealed (a reference the DPB could not
//! resolve, an AU that stopped early) entered the DPB looking exactly like a clean one.
//! So an anchor naming that picture lifted the freeze onto a gray plate, and every
//! frame after it chained off the corruption — the freeze gone, nothing left to
//! re-arm it, and the picture stayed broken until an unrelated signal forced an IDR.
//!
//! This ledger is the missing fact, and it is deliberately the SMALLEST one that
//! answers the question: a set of picture ids that are NOT clean. Membership is
//! per-picture, so it costs one `u64` per damaged picture and nothing at all on a
//! healthy stream — the overwhelmingly common case, where the set stays empty for the
//! life of the session.
//!
//! # Damage propagates; that is the whole point
//!
//! A picture is unclean when the AU that produced it needed concealment, OR when
//! ANYTHING it predicted from was unclean. Without the second half the ledger would be
//! useless: the concealed picture itself is rarely the one an anchor names — it is the
//! chain of ordinary P-frames DESCENDING from it, each of which planned perfectly and
//! raised no warning of its own, that carries the corruption forward.
//!
//! # It errs toward "unclean", never toward "clean"
//!
//! Every rule here is one-way. An id the ledger has forgotten (evicted from the DPB,
//! dropped at a flush) reads as clean, which is correct — a picture no longer in the
//! DPB cannot be referenced. An id it holds stays unclean until the picture leaves the
//! DPB. There is no path that clears the mark on a picture that is still resident, so
//! the ledger can only ever make a consumer MORE conservative: hold the freeze longer
//! and take an IDR it might not have needed. The opposite mistake — reporting a damaged
//! chain as clean — is the failure this exists to end, so the asymmetry is deliberate.
use std::collections::BTreeSet;
/// Per-picture "this came off a broken chain" marks for one planner.
///
/// Keyed by the planner's own `PicId` (a `u64` in all three codecs), so this type is
/// codec-agnostic and the H.264, H.265 and AV1 planners share ONE implementation rather
/// than three hand-copies that can drift apart.
#[derive(Debug, Clone, Default)]
pub struct CleanLedger {
/// Ids of resident pictures that are NOT clean. Empty on a healthy stream — the
/// set only ever gains an entry when a plan needed concealment.
unclean: BTreeSet<u64>,
}
impl CleanLedger {
pub fn new() -> Self {
Self::default()
}
/// Is every id in `references` clean? — i.e. may a picture predicted from exactly
/// these be trusted?
///
/// Vacuously true for an empty list, which is what makes an IRAP/IDR clean by
/// construction: it predicts from nothing, so there is nothing to distrust.
pub fn references_clean<I>(&self, references: I) -> bool
where
I: IntoIterator<Item = u64>,
{
// Short-circuits on the first unclean reference, and — because the set is
// empty on a healthy stream — degenerates to one `is_empty`-cheap lookup per
// reference in the case that matters for throughput.
self.unclean.is_empty() || !references.into_iter().any(|id| self.unclean.contains(&id))
}
/// Record the verdict for the picture this AU stored.
///
/// `references_clean` is what [`Self::references_clean`] answered for this AU's
/// reference lists; `concealed` is whether the AU's own plan carried an integrity
/// warning. Either one being bad makes the stored picture unclean, and its
/// descendants inherit that through their own `references_clean` call.
pub fn note_stored(&mut self, id: u64, references_clean: bool, concealed: bool) {
if references_clean && !concealed {
// The common path. Nothing is inserted, so a healthy stream never allocates
// — and `remove` still runs below because an id can be REUSED after the
// planner recycles it, and a stale mark would then condemn a fresh picture.
self.unclean.remove(&id);
} else {
self.unclean.insert(id);
}
}
/// Drop the marks of pictures that have left the DPB.
///
/// Called with the ids still live after each plan. Bounding the set to DPB
/// residency is what keeps it from growing without limit across a long lossy
/// session, and it is safe precisely because a picture outside the DPB can never
/// appear in a later reference list.
pub fn retain_live<I>(&mut self, live: I)
where
I: IntoIterator<Item = u64>,
{
if self.unclean.is_empty() {
return;
}
let live: BTreeSet<u64> = live.into_iter().collect();
self.unclean.retain(|id| live.contains(id));
}
/// Forget everything — the DPB was drained (a flush, a stream discontinuity), so no
/// mark describes a resident picture any more.
pub fn clear(&mut self) {
self.unclean.clear();
}
/// Is this picture known to have come off a broken chain? (Diagnostics and tests;
/// the plan path uses [`Self::references_clean`].)
pub fn is_unclean(&self, id: u64) -> bool {
self.unclean.contains(&id)
}
/// How many resident pictures are marked unclean (diagnostics and tests).
pub fn unclean_count(&self) -> usize {
self.unclean.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The headline: damage propagates down the prediction chain. The concealed
/// picture is rarely the one an anchor names — it is the ordinary P-frames
/// descending from it, each of which planned perfectly and warned about nothing.
#[test]
fn damage_propagates_to_every_descendant() {
let mut led = CleanLedger::new();
// An IDR: no references, no concealment.
assert!(led.references_clean([]));
led.note_stored(0, true, false);
assert!(!led.is_unclean(0));
// A clean P off it.
assert!(led.references_clean([0]));
led.note_stored(1, true, false);
// Picture 2's plan needed concealment.
let refs_clean = led.references_clean([1]);
assert!(refs_clean, "its reference was still fine");
led.note_stored(2, refs_clean, true);
assert!(led.is_unclean(2));
// …and picture 3 predicts from it, raising NO warning of its own.
let refs_clean = led.references_clean([2]);
assert!(!refs_clean, "the chain is broken from here down");
led.note_stored(3, refs_clean, false);
assert!(led.is_unclean(3), "3 inherited 2's damage");
// The rot keeps travelling, arbitrarily far from the original loss.
let refs_clean = led.references_clean([3]);
assert!(!refs_clean);
led.note_stored(4, refs_clean, false);
assert!(led.is_unclean(4));
}
/// A picture that references BOTH a clean and an unclean predecessor is unclean —
/// one broken reference is enough to make the reconstruction wrong.
#[test]
fn one_unclean_reference_is_enough() {
let mut led = CleanLedger::new();
led.note_stored(0, true, false);
led.note_stored(1, true, true); // damaged
assert!(!led.references_clean([0, 1]));
assert!(!led.references_clean([1, 0]), "order does not matter");
assert!(led.references_clean([0]));
}
/// An IDR predicts from nothing, so it is clean however broken the stream was
/// before it. This is the property that lets a real keyframe end a damaged run.
#[test]
fn a_picture_with_no_references_is_clean_however_bad_the_stream_was() {
let mut led = CleanLedger::new();
led.note_stored(0, true, true);
led.note_stored(1, false, false);
assert_eq!(led.unclean_count(), 2);
// The IDR: an empty reference list is vacuously clean.
assert!(led.references_clean([]));
led.note_stored(2, true, false);
assert!(!led.is_unclean(2));
}
/// Marks are bounded by DPB residency: a picture that left the DPB can never be
/// referenced again, so keeping its mark would only grow the set forever.
#[test]
fn marks_are_dropped_when_their_picture_leaves_the_dpb() {
let mut led = CleanLedger::new();
led.note_stored(7, true, true);
led.note_stored(8, false, false);
assert_eq!(led.unclean_count(), 2);
led.retain_live([8, 9]);
assert!(!led.is_unclean(7), "7 was evicted");
assert!(led.is_unclean(8), "8 is still resident and still damaged");
assert_eq!(led.unclean_count(), 1);
}
/// A flush drains the whole DPB, so no mark describes anything resident.
#[test]
fn a_flush_forgets_every_mark() {
let mut led = CleanLedger::new();
led.note_stored(1, true, true);
led.note_stored(2, false, false);
led.clear();
assert_eq!(led.unclean_count(), 0);
assert!(led.references_clean([1, 2]));
}
/// Planners hand out ids from a counter the flush path can rewind, so an id CAN be
/// reused. A stale mark must not condemn the fresh picture that inherits the id.
#[test]
fn a_reused_id_is_not_condemned_by_its_predecessors_mark() {
let mut led = CleanLedger::new();
led.note_stored(5, true, true);
assert!(led.is_unclean(5));
// The same id, planned cleanly this time.
led.note_stored(5, true, false);
assert!(!led.is_unclean(5));
assert!(led.references_clean([5]));
}
/// A healthy stream never marks anything, forever — the property that makes this
/// free to carry on every session that is working correctly.
#[test]
fn a_stream_without_loss_never_marks_a_picture() {
let mut led = CleanLedger::new();
for id in 0..512u64 {
let refs = if id == 0 { vec![] } else { vec![id - 1] };
let clean = led.references_clean(refs.iter().copied());
assert!(clean, "picture {id} must read clean");
led.note_stored(id, clean, false);
led.retain_live(id.saturating_sub(3)..=id);
}
assert_eq!(led.unclean_count(), 0);
}
}
+190 -2
View File
@@ -149,6 +149,18 @@ pub struct PicturePlan {
/// DPB size in frames per A.3.1 — backends size their slot pool from this.
pub max_dpb_frames: usize,
pub recovery_point: Option<RecoveryPoint>,
/// Every picture this AU predicts from was itself decoded from a fully-available
/// reference chain — so a host claim that this AU is a clean re-anchor
/// (`USER_FLAG_RECOVERY_ANCHOR`) can be corroborated rather than taken on trust.
/// `true` for an IDR (nothing to predict from) and for any picture whose whole
/// reference chain is clean; `false` from the moment this AU — or anything it
/// descends from — needed concealment.
///
/// Purely additive observation: nothing in the plan, the warnings or the DPB
/// changes because of it, and on a stream that never loses a reference it is
/// `true` on every picture forever. See [`crate::clean`] for why it propagates and
/// why every rule errs toward `false`.
pub references_clean: bool,
}
/// The region of the coded picture that is actually displayed.
@@ -252,6 +264,41 @@ pub enum PlanWarning {
},
}
impl PlanWarning {
/// Does this warning mean the PICTURE is damaged — the plan was completed with a
/// SUBSTITUTE in place of something that was lost — rather than reporting a
/// spec-legal fact about the stream's envelope?
///
/// The distinction decides two things that must never disagree: whether a consumer
/// releases the AU's output unshown and asks for a re-anchor, and whether the
/// picture enters [`crate::clean::CleanLedger`] as unclean. It lives HERE, on the
/// enum, because those two consumers sit in different crates and a second copy of
/// the list would let one of them conceal damage the other reports — the exact
/// shape of the invisible-corruption failure the native-decode program exists to
/// end. `pf_vkdecode::is_integrity_warning` delegates to this.
///
/// `Mmco5Rebase` is not damage: the AU carried an MMCO 5 and this planner planned
/// it in full (the plan holds the pre-rebase 8.2.1 values; later AUs reference the
/// rebased ones). `LevelDerivedDpb` is not either: the picture is intact and fully
/// planned — it reports that the SPS never declared its DPB depth, so the plan had
/// to size from A.3.1's level ceiling, a property of the STREAM's signalling which
/// a backend answers by failing to open a session, not by showing a damaged frame.
///
/// Written as an EXHAUSTIVE match with no wildcard, deliberately. A `matches!` (or
/// a `_ => false`) makes "damage" the opt-in and silence the default, so a variant
/// added later — by definition one nobody here has classified — would be reported
/// as clean and its picture shown. The compiler is the only reviewer guaranteed to
/// be present when that variant is written, so it gets the decision.
pub fn is_integrity(&self) -> bool {
match self {
PlanWarning::FrameNumGap { .. }
| PlanWarning::MissingReference { .. }
| PlanWarning::TruncatedAu { .. } => true,
PlanWarning::Mmco5Rebase | PlanWarning::LevelDerivedDpb { .. } => false,
}
}
}
/// The AU cannot be planned at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanError {
@@ -482,6 +529,10 @@ pub struct H264Planner {
reported_live: BTreeSet<PicId>,
/// Set by [`Self::flush`]: planning resumes only at an IDR (upstream: `Reset`).
awaiting_idr: bool,
/// Which resident pictures came off a BROKEN reference chain — the fact behind
/// [`PicturePlan::references_clean`]. Empty on a healthy stream; see
/// [`crate::clean::CleanLedger`] for the propagation rules.
clean: crate::clean::CleanLedger,
}
impl H264Planner {
@@ -600,9 +651,20 @@ impl H264Planner {
let cur = current
.ok_or_else(|| PlanError::Parse("access unit contains no coded picture".into()))?;
// Was every picture this AU predicts from decoded off an intact chain? Asked
// over the SLICE reference lists rather than the DPB snapshot, because those
// are what this picture actually predicts from — a resident-but-unreferenced
// damaged picture says nothing about this one. An IDR references nothing, so
// this is vacuously true for it (`CleanLedger::references_clean`).
let references_clean = self.clean.references_clean(
slices
.iter()
.flat_map(|s: &SlicePlan| s.ref_list0.iter().chain(&s.ref_list1))
.map(|r| r.id),
);
// Captured before finish_picture: MMCO5 rewrites the stored POC afterwards, but
// backends submit the picture with its 8.2.1 values.
let picture = Self::picture_plan(&cur, recovery_point);
let picture = Self::picture_plan(&cur, recovery_point, references_clean);
// The activated parameter sets ride out with the plan (AuPlan field docs);
// cloned before finish_picture consumes `cur`.
let pps = Rc::clone(&cur.first_slice_pps);
@@ -619,6 +681,20 @@ impl H264Planner {
let removed = previously_live.difference(&live_after).copied().collect();
self.reported_live = live_after;
// Fold this picture's verdict, then bound the ledger to DPB residency. Both
// AFTER `finish_picture`, so `stored` is the id the picture really got and
// `live_after` reflects the marking this AU performed — a mark written against
// a pre-marking view could survive an eviction it should have died with.
// `concealed` mirrors what a consumer conceals on, via the ONE classification
// (`PlanWarning::is_integrity`), so the ledger and the consumer can never
// disagree about whether this AU was damaged.
self.clean.note_stored(
stored,
references_clean,
warnings.iter().any(PlanWarning::is_integrity),
);
self.clean.retain_live(self.reported_live.iter().copied());
Ok(AuPlan {
picture,
slices,
@@ -650,6 +726,9 @@ impl H264Planner {
self.max_long_term_frame_idx = Default::default();
self.negotiation_info = Default::default();
self.awaiting_idr = true;
// The DPB is drained, so no mark describes a resident picture any more — and
// planning resumes at an IDR, which is clean by construction.
self.clean.clear();
DpbUpdate {
stored: None,
@@ -1747,7 +1826,11 @@ impl H264Planner {
Ok(id)
}
fn picture_plan(cur: &CurrentPicState, recovery_point: Option<RecoveryPoint>) -> PicturePlan {
fn picture_plan(
cur: &CurrentPicState,
recovery_point: Option<RecoveryPoint>,
references_clean: bool,
) -> PicturePlan {
let pic = &cur.pic;
// The first slice's PPS defines the picture's parameters (upstream's
// start_picture semantics); `cur.pps` may have drifted to a later slice's.
@@ -1791,6 +1874,7 @@ impl H264Planner {
chroma_format_idc: sps.chroma_format_idc,
max_dpb_frames: dpb_limit(sps),
recovery_point,
references_clean,
}
}
}
@@ -2343,6 +2427,110 @@ mod tests {
assert!(missing_seen);
}
/// The clean bit, end to end through the real planner: a `frame_num` gap
/// concealed one picture, and EVERY picture descending from it reports
/// `references_clean == false` even though their own plans are spotless. That
/// propagation is the whole point — the concealed picture is rarely the one a host
/// recovery anchor names; the ordinary P-frames after it are.
#[test]
fn a_concealed_picture_makes_every_descendant_report_unclean_references() {
let (sps, pps) = authored_sps_pps();
let mut au0 = param_set_au(&sps, &pps);
au0.extend(write_idr_slice());
let mut planner = H264Planner::new();
let p0 = planner.plan_au(&au0).unwrap();
assert!(
p0.picture.references_clean,
"an IDR references nothing, so it is clean by construction"
);
// A healthy P off the IDR: still clean.
let p1 = planner.plan_au(&write_p_slice(1, 2, 1, 1, None)).unwrap();
assert!(picture_warnings(&p1).is_empty());
assert!(p1.picture.references_clean);
// frame_num 2 never arrives — 8.2.5.2 fabricates a placeholder and the plan
// conceals. THIS picture's references were still intact; the damage is its own.
let p3 = planner.plan_au(&write_p_slice(3, 6, 1, 3, None)).unwrap();
assert!(p3
.warnings
.iter()
.any(|w| matches!(w, PlanWarning::FrameNumGap { .. })));
// …and every picture after it inherits the damage with a clean plan of its own.
let p4 = planner.plan_au(&write_p_slice(4, 8, 1, 1, None)).unwrap();
assert!(
picture_warnings(&p4).is_empty(),
"p4's own plan raises nothing — which is exactly why the bit is needed"
);
assert!(
!p4.picture.references_clean,
"p4 predicts from the concealed chain, so it must not read as clean"
);
let p5 = planner.plan_au(&write_p_slice(5, 10, 1, 1, None)).unwrap();
assert!(picture_warnings(&p5).is_empty());
assert!(!p5.picture.references_clean, "the rot keeps travelling");
}
/// An IDR ends a damaged run: it predicts from nothing, so it reads clean however
/// broken the stream was before it. Without this a session could never recover a
/// trustworthy anchor.
#[test]
fn an_idr_reports_clean_references_however_damaged_the_run_before_it() {
let (sps, pps) = authored_sps_pps();
let mut au0 = param_set_au(&sps, &pps);
au0.extend(write_idr_slice());
let mut planner = H264Planner::new();
planner.plan_au(&au0).unwrap();
planner.plan_au(&write_p_slice(1, 2, 1, 1, None)).unwrap();
// Gap: frame_num 2 lost.
let p3 = planner.plan_au(&write_p_slice(3, 6, 1, 3, None)).unwrap();
assert!(p3
.warnings
.iter()
.any(|w| matches!(w, PlanWarning::FrameNumGap { .. })));
let p4 = planner.plan_au(&write_p_slice(4, 8, 1, 1, None)).unwrap();
assert!(!p4.picture.references_clean);
// A fresh IDR re-anchors, and the pictures after it are clean again.
let mut idr = param_set_au(&sps, &pps);
idr.extend(write_idr_slice());
let p5 = planner.plan_au(&idr).unwrap();
assert!(p5.picture.references_clean, "an IDR is always clean");
let p6 = planner.plan_au(&write_p_slice(1, 2, 1, 1, None)).unwrap();
assert!(
p6.picture.references_clean,
"the damaged chain died with the IDR's DPB flush"
);
}
/// A stream that never loses a reference reports `references_clean` on every
/// picture, forever — the property that makes this free to carry in production.
#[test]
fn a_healthy_stream_reports_clean_references_on_every_picture() {
let (sps, pps) = authored_sps_pps();
let mut au0 = param_set_au(&sps, &pps);
au0.extend(write_idr_slice());
let mut planner = H264Planner::new();
assert!(planner.plan_au(&au0).unwrap().picture.references_clean);
// log2_max_frame_num_minus4 = 0 and pic_order_cnt_lsb is u(4): both wrap at 16.
for n in 1..16u32 {
let plan = planner
.plan_au(&write_p_slice(n, (n * 2) % 16, 1, 1, None))
.unwrap();
assert!(
picture_warnings(&plan).is_empty(),
"frame {n} should plan cleanly: {:?}",
picture_warnings(&plan)
);
assert!(plan.picture.references_clean, "frame {n} must read clean");
}
}
#[test]
fn a_gap_placeholder_inside_a_ref_list_is_substituted_in_place_not_compacted() {
let (sps, pps) = authored_sps_pps();
+104 -5
View File
@@ -166,6 +166,18 @@ pub struct PicturePlan {
/// came from the SPS by index) — Vulkan's `NumBitsForSTRefPicSetInSlice`.
pub short_term_ref_pic_set_size_bits: u32,
pub recovery_point: Option<RecoveryPointHevc>,
/// Every picture this AU predicts from was itself decoded from a fully-available
/// reference chain — so a host claim that this AU is a clean re-anchor
/// (`USER_FLAG_RECOVERY_ANCHOR`) can be corroborated rather than taken on trust.
/// `true` for an IRAP (nothing to predict from) and for any picture whose whole
/// reference chain is clean; `false` from the moment this AU — or anything it
/// descends from — needed concealment.
///
/// Purely additive observation: nothing in the plan, the warnings or the DPB
/// changes because of it, and on a stream that never loses a reference it is
/// `true` on every picture forever. See [`crate::clean`] for why it propagates and
/// why every rule errs toward `false`.
pub references_clean: bool,
}
/// A reference list / RPS entry: the minimum every backend picparams format needs.
@@ -232,6 +244,28 @@ pub enum PlanWarning {
NonZeroReorder { max_num_reorder_pics: u8 },
}
impl PlanWarning {
/// Does this warning mean the PICTURE is damaged? The H.265 twin of
/// [`crate::h264::PlanWarning::is_integrity`] — the same one-list argument applies,
/// and `pf_vkdecode::is_integrity_warning_h265` delegates here.
///
/// `NonZeroReorder` is NOT damage, and excluding it matters more here than the
/// H.264 exclusions do: it fires on the AU that ACTIVATES an SPS — the opening
/// IRAP, and the fresh IRAP at every ABR resolution change — so treating it as
/// concealment would cost a released-unshown frame plus a keyframe round trip at
/// every renegotiation, on a stream the planner says it planned correctly. It
/// would also poison the [`crate::clean::CleanLedger`] at exactly those IRAPs,
/// marking the one picture that is clean by construction as broken.
///
/// Exhaustive with no wildcard, for the reason the H.264 twin spells out.
pub fn is_integrity(&self) -> bool {
match self {
PlanWarning::MissingReference { .. } | PlanWarning::TruncatedAu { .. } => true,
PlanWarning::NonZeroReorder { .. } => false,
}
}
}
/// The AU cannot be planned at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanError {
@@ -452,6 +486,10 @@ pub struct H265Planner {
reported_live: BTreeSet<PicId>,
/// Set by [`Self::flush`]: planning resumes only at an IRAP (upstream: `Reset`).
awaiting_idr: bool,
/// Which resident pictures came off a BROKEN reference chain — the fact behind
/// [`PicturePlan::references_clean`]. Empty on a healthy stream; see
/// [`crate::clean::CleanLedger`] for the propagation rules.
clean: crate::clean::CleanLedger,
}
impl Default for H265Planner {
@@ -471,6 +509,7 @@ impl Default for H265Planner {
pending_outputs: Vec::new(),
reported_live: BTreeSet::new(),
awaiting_idr: false,
clean: Default::default(),
}
}
}
@@ -690,7 +729,20 @@ impl H265Planner {
let cur = current
.ok_or_else(|| PlanError::Parse("access unit contains no coded picture".into()))?;
let picture = Self::picture_plan(&cur, recovery_point);
// Was every picture this AU predicts from decoded off an intact chain? Asked
// over the SLICE reference lists rather than the RPS or the DPB snapshot,
// because those are what this picture actually predicts from: 8.3.2 RETAINS
// pictures in the RPS that the current picture does not use
// (`used_by_curr_pic` clear), and a damaged one among those says nothing about
// this picture. An IRAP's lists are empty, so this is vacuously true for it
// (`CleanLedger::references_clean`).
let references_clean = self.clean.references_clean(
slices
.iter()
.flat_map(|s: &SlicePlan| s.ref_list0.iter().chain(&s.ref_list1))
.map(|r| r.id),
);
let picture = Self::picture_plan(&cur, recovery_point, references_clean);
let rps = cur.rps_plan.clone();
let dpb_refs = cur.dpb_refs.clone();
// The activated parameter sets ride out with the plan (AuPlan field docs);
@@ -708,6 +760,20 @@ impl H265Planner {
let removed = previously_live.difference(&live_after).copied().collect();
self.reported_live = live_after;
// Fold this picture's verdict, then bound the ledger to DPB residency. Both
// AFTER `finish_picture`, so `stored` is the id the picture really got and
// `live_after` reflects the C.3.4/8.3.2 marking this AU performed — a mark
// written against a pre-marking view could survive an eviction it should have
// died with. `concealed` mirrors what a consumer conceals on, via the ONE
// classification (`PlanWarning::is_integrity`), so the ledger and the consumer
// can never disagree about whether this AU was damaged.
self.clean.note_stored(
stored,
references_clean,
warnings.iter().any(PlanWarning::is_integrity),
);
self.clean.retain_live(self.reported_live.iter().copied());
Ok(AuPlan {
picture,
rps,
@@ -745,6 +811,9 @@ impl H265Planner {
// re-entry sound.
self.first_picture_after_eos = true;
self.awaiting_idr = true;
// The DPB is drained, so no mark describes a resident picture any more — and
// planning resumes at an IRAP, which is clean by construction.
self.clean.clear();
DpbUpdate {
stored: None,
@@ -1452,6 +1521,7 @@ impl H265Planner {
fn picture_plan(
cur: &CurrentPicState,
recovery_point: Option<RecoveryPointHevc>,
references_clean: bool,
) -> PicturePlan {
let pic = &cur.pic;
// The first slice's PPS defines the picture's parameters; `cur.pps` may have
@@ -1498,6 +1568,7 @@ impl H265Planner {
max_dpb_frames: dpb_limit(sps),
short_term_ref_pic_set_size_bits: pic.short_term_ref_pic_set_size_bits,
recovery_point,
references_clean,
}
}
}
@@ -1550,11 +1621,15 @@ mod tests {
/// `NonZeroReorder` is excluded: the vendored conformance clips are general
/// (reordering) encodes, and the planner deliberately plans them while flagging
/// the envelope fact.
///
/// Delegates rather than restating the list. This harness exists to prove the
/// planner conceals exactly where production conceals, so a second copy here
/// could drift and quietly prove the wrong thing — and a `matches!` in
/// particular reads any FUTURE variant as clean, which is the one answer a
/// damage predicate must never default to. [`PlanWarning::is_integrity`] is an
/// exhaustive match, so a new variant stops the compiler there instead.
fn is_integrity_warning(w: &PlanWarning) -> bool {
matches!(
w,
PlanWarning::MissingReference { .. } | PlanWarning::TruncatedAu { .. }
)
w.is_integrity()
}
/// Plan a whole vendored clip and assert the global invariants: every AU plans,
@@ -1658,6 +1733,30 @@ mod tests {
assert!(!bbb.is_empty());
}
/// The false-positive guard for [`PicturePlan::references_clean`], on REAL
/// bitstreams rather than authored ones: two conformance clips that lose nothing
/// must report every single picture clean. A regression that let the ledger mark a
/// healthy stream would refuse every host recovery anchor and force an IDR on
/// every loss — the cheap re-anchor path gone, silently.
///
/// These clips carry B-slices and real reordering, so they also exercise the
/// "reference lists, not the RPS" reading: 8.3.2 retains pictures the current
/// picture does not use, and folding those in would condemn pictures at random.
#[test]
fn a_lossless_conformance_clip_reports_clean_references_on_every_picture() {
for (name, clip) in [("bear", TEST_BEAR), ("bbb", TEST_BBB)] {
let (_, plans) = plan_whole_clip(clip);
assert!(!plans.is_empty(), "{name} produced no plans");
for (i, plan) in plans.iter().enumerate() {
assert!(
plan.picture.references_clean,
"{name} picture {i} (poc {}) must read clean on a lossless clip",
plan.picture.pic_order_cnt
);
}
}
}
#[test]
fn b_slices_get_a_future_led_list1_distinct_from_list0() {
let aus = split_into_aus(TEST_64X64_I_P_B_P);
+1
View File
@@ -21,6 +21,7 @@
#![forbid(unsafe_code)]
pub mod av1;
pub mod clean;
pub mod h264;
pub mod h265;
pub mod sei;
+135 -2
View File
@@ -789,6 +789,19 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
/// in `punktfunk-core`, the only crate they share. So this is a deliberate second copy, and
/// [`ds5_offsets_track_the_usb_report`](ds5_feedback_tests) pins the `1` relationship rather than
/// leaving it to a comment.
/// A `u8` field lever: decimal, or `0x`-prefixed hex (these name DS5 report BYTES, and every
/// reference to them — SDL's source, the reverse-engineering notes, this module's own comments —
/// writes them in hex). `None` when unset or unparseable, so a typo falls back to the default
/// rather than to zero.
fn env_u8(key: &str) -> Option<u8> {
let v = std::env::var(key).ok()?;
let v = v.trim();
match v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) {
Some(hex) => u8::from_str_radix(hex, 16).ok(),
None => v.parse().ok(),
}
}
struct Ds5Feedback;
impl Ds5Feedback {
@@ -844,6 +857,39 @@ impl Ds5Feedback {
[0u8; 47]
}
/// Point the pad's audio at its own SPEAKER, and give that speaker a volume.
///
/// Without this the speaker is silent no matter how correct the PCM routing is, which is
/// exactly what a wired DS5 on a Steam Deck did: haptics felt, speaker inaudible. The
/// reason is that **channel 1 of the pad's audio function is shared** — it is the headphone
/// jack's right channel AND the built-in mono speaker — and which one physically sounds is
/// chosen by `ucAudioEnableBits` (report byte 8, struct offset 7). A pad powers up pointing
/// at the headphone jack, so with nothing plugged in the speaker pair goes nowhere. The
/// voice coils are channels 2/3 and are NOT affected by that select, which is why haptics
/// work the instant the samples are routed right and the speaker does not.
///
/// We only ever wrote these bytes when a host forwarded a game's [`HidOutput::AudioCtl`],
/// so a title that manages no audio settings of its own left the speaker dead. This is the
/// default that makes the stream audible; a later `AudioCtl` still overrides it verbatim
/// ([`Self::audio_ctl_packet`]), so a game that does drive its own volume still wins.
///
/// ⚠ `ucEnableBits1` bits 0/1 stay CLEAR — they are "enable rumble emulation" and "disable
/// audio haptics", and asserting either would mute the coils this plane drives.
///
/// ⚠ `path` is empirical. Measured on a DualSense (`054c:0ce6`) using the pad's OWN
/// microphone as the detector: `0x20` was loudest (~5× the noise floor at the test tone),
/// `0x30` also sounded, `0x10` was silent. Overridable per-run with
/// `PUNKTFUNK_PAD_SPEAKER_PATH` / `PUNKTFUNK_PAD_SPEAKER_VOLUME` so a field report can
/// bisect it without a rebuild.
fn speaker_enable_packet(volume: u8, path: u8) -> [u8; 47] {
let mut p = [0u8; 47];
// bit5 = ucSpeakerVolume is valid, bit7 = the audio-control byte is valid.
p[0] = 0x20 | 0x80;
p[Self::AUDIO + 1] = volume; // ucSpeakerVolume
p[Self::AUDIO + 3] = path; // ucAudioEnableBits
p
}
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
@@ -1333,10 +1379,48 @@ impl Worker {
// ("Leaving emulated rumble bits off will restore audio haptics" —
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
// render_feedback so SDL never re-arms them.
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
//
// ⚠ This needs SDL's HIDAPI driver to be the one on the pad — the
// packet is a raw DS5 effects report, and SDL can only send it where
// it owns the HID link. On a Linux box where the kernel's
// `hid-playstation` has the pad instead, the call fails, and it is
// worth SAYING so: `hid-playstation` asserts the same disable bit on
// every force-feedback update it makes, so a pad some other program
// has rumbled stays deaf to this plane until it is re-plugged. Not
// fatal — nothing else asserts the bit in our own path, so the pad's
// power-on default (audio haptics live) usually still stands.
if let Err(e) = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet()) {
tracing::info!(
index,
error = %e,
"could not re-arm the DualSense's audio-haptics bit (SDL does \
not own this pad's HID link) haptics still work unless \
something else has rumbled the pad this plug-in"
);
}
}
if slot.audio_caps & 0x02 != 0 {
// Speaker activation: point the pad's shared channel-1 output at its
// own speaker instead of the headphone jack it powers up on, and give
// it a volume. Without this the speaker stream is routed perfectly and
// heard by nobody — see `speaker_enable_packet`.
let path = env_u8("PUNKTFUNK_PAD_SPEAKER_PATH").unwrap_or(0x20);
let volume = env_u8("PUNKTFUNK_PAD_SPEAKER_VOLUME").unwrap_or(0x7F);
if let Err(e) = slot
.pad
.send_effect(&Ds5Feedback::speaker_enable_packet(volume, path))
{
tracing::info!(
index,
error = %e,
"could not point the DualSense at its own speaker (SDL does \
not own this pad's HID link) the pad's speaker may stay \
silent even though the stream reaches it"
);
}
}
// Hand the pad to the session's renderer worker. Windows correlation
// needs the HID interface path; Linux matches the sink by signature.
// needs the HID interface path; Linux matches by card identity.
crate::pad_audio::register_tier_a(index, slot.pad.path());
tracing::info!(
index,
@@ -2914,6 +2998,55 @@ mod slot_tests {
);
}
/// The speaker-enable default: volume and output-path land in the audio-control region at
/// the same offsets an `AudioCtl` fold writes them, the two validity bits are set — and,
/// most importantly, `ucEnableBits1` bits 0/1 stay CLEAR. Asserting either would enable
/// rumble emulation / disable audio haptics and mute the very coils this plane drives, so
/// making the speaker audible must never cost the haptics.
#[test]
fn speaker_enable_sets_volume_and_path_without_touching_the_haptics_bits() {
let p = Ds5Feedback::speaker_enable_packet(0x7F, 0x20);
assert_eq!(
p[0] & 0x03,
0,
"rumble-emulation / disable-audio-haptics must stay clear"
);
assert_eq!(
p[0],
0x20 | 0x80,
"speaker-volume + audio-control validity bits"
);
// ucSpeakerVolume is report byte 6 and ucAudioEnableBits report byte 8 — struct
// offsets 5 and 7, i.e. AUDIO+1 and AUDIO+3.
assert_eq!(p[5], 0x7F);
assert_eq!(p[7], 0x20);
// Nothing else in the packet moves (no rumble, no triggers, no LEDs).
for (i, b) in p.iter().enumerate() {
if !matches!(i, 0 | 5 | 7) {
assert_eq!(*b, 0, "byte {i} should be untouched");
}
}
}
/// The field levers parse hex (how every reference writes these report bytes) and decimal,
/// and a typo falls back to the default rather than silently meaning zero.
#[test]
fn env_u8_reads_hex_and_decimal() {
assert_eq!(env_u8("PF_TEST_ABSENT_KEY_XYZ"), None);
// Parsing is what is under test; the lookup is exercised by the None case above.
for (s, want) in [
("0x20", Some(0x20)),
("0X7f", Some(0x7F)),
("32", Some(32u8)),
] {
let parsed = match s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
Some(hex) => u8::from_str_radix(hex, 16).ok(),
None => s.parse().ok(),
};
assert_eq!(parsed, want, "{s}");
}
}
/// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct
/// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as
/// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1)
File diff suppressed because it is too large Load Diff
+32 -3
View File
@@ -1093,9 +1093,38 @@ fn pump(
// `image.is_keyframe()` as the decoder's own IDR belt, applies the two-mark
// rule + the mark-patience backstop, clears the no-output streak, and returns
// whether to present this frame or withhold it as a post-loss concealment.
let present =
gate.on_decoded(frame.flags, image.is_keyframe(), Instant::now())
== GateVerdict::Present;
//
// CORROBORATED (the grey-frame fix): the wire's RECOVERY_ANCHOR is the host
// asserting something about THIS decoder — "the picture I coded this
// P-frame against is one you still hold, intact" — and it lifts the freeze
// on the FIRST occurrence, no two-mark wait. The host derives that from
// bookkeeping that tracks what the client RECEIVED, not what it managed to
// DECODE, and when those diverge the anchor lifts the freeze onto a
// concealed picture and LEAVES it lifted: grey with motion painted on it
// until some later signal re-arms and the 500 ms backstop extracts a real
// IDR. A rung that planned the AU itself knows better, so it says so here.
//
// What a refusal costs is exactly one thing: the freeze keeps holding the
// last good picture until the backstop fires on its ORIGINAL deadline and
// forces the IDR the anchor failed to be. That is strictly the better half
// of the trade — the alternative is presenting a picture this client can
// prove is damaged — and it is the same direction every rule in the gate
// errs in. Every non-native lane reports `Unavailable` and is untouched.
let evidence = image.anchor_evidence();
if evidence == punktfunk_core::reanchor::AnchorEvidence::ReferencesDamaged
&& frame.flags & punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR != 0
{
tracing::debug!(
"refused a host recovery anchor: this AU predicts from a picture \
this decoder had to conceal holding for a real IDR"
);
}
let present = gate.on_decoded_corroborated(
frame.flags,
image.is_keyframe(),
evidence,
Instant::now(),
) == GateVerdict::Present;
total_frames += 1;
// ⚠ The `stats:` decode-path tag is a machine interface —
// additive only. M10 removed the rungs whose tags were `vaapi`,
+45
View File
@@ -493,6 +493,17 @@ pub struct NativeVkFrame {
/// the host, and it cannot be lost separately from the picture. Fed to
/// [`ReanchorGate::on_local_recovery`](punktfunk_core::reanchor::ReanchorGate::on_local_recovery).
pub recovery: punktfunk_core::reanchor::LocalRecovery,
/// Every picture this AU predicts from was itself decoded from a fully-available
/// reference chain (pf-vkdecode's `DecodedVkFrame::references_clean`).
///
/// The corroboration for the host's `USER_FLAG_RECOVERY_ANCHOR`, which is a claim
/// about THIS decoder that only this decoder can check. The host derives its
/// anchor from slot bookkeeping that tracks what the client RECEIVED; this tracks
/// what the client managed to DECODE. When they disagree the anchor lifts the
/// post-loss freeze onto a concealed picture and leaves it lifted, which is the
/// grey-with-motion field report. `true` on every ordinary frame of a healthy
/// stream, so the flag is only ever load-bearing on the AU that carries an anchor.
pub references_clean: bool,
/// This picture's position in DECODE order (pf-vkdecode's strictly increasing
/// per-session ordinal). Delivery order is not decode order: after a failed AU
/// the H.265 decoder flushes its DPB, handing back every buffered picture at
@@ -559,6 +570,40 @@ impl DecodedImage {
}
}
/// What this lane can say about the host's re-anchor claim on this frame — the
/// corroboration for `USER_FLAG_RECOVERY_ANCHOR`, fed to
/// [`ReanchorGate::on_decoded_corroborated`](punktfunk_core::reanchor::ReanchorGate::on_decoded_corroborated).
///
/// An anchor is the host asserting a fact about THIS decoder — *the picture I
/// coded this P-frame against is one you still hold, intact* — and the gate lifts
/// its post-loss freeze on the first one, no two-mark wait. Only a rung that
/// planned the AU itself knows which pictures it predicts from and whether each of
/// those decoded cleanly, so only such a rung can catch the host being wrong.
///
/// The native Vulkan rung answers; everyone else reports
/// [`AnchorEvidence::Unavailable`](punktfunk_core::reanchor::AnchorEvidence::Unavailable)
/// and the gate treats them exactly as it did before this existed — silence is not
/// refutation, so no lane becomes stricter by accident.
///
/// ⚠ The CPU rung's H.264 leg plans every AU with the same `H264Planner` and so
/// COULD answer; it does not yet, because its frame type carries no equivalent of
/// [`NativeVkFrame::references_clean`]. Reporting `Unavailable` there is the
/// conservative reading (today's behaviour), not a claim that its references are
/// fine.
pub fn anchor_evidence(&self) -> punktfunk_core::reanchor::AnchorEvidence {
use punktfunk_core::reanchor::AnchorEvidence;
match self {
DecodedImage::NativeVk(f) => {
if f.references_clean {
AnchorEvidence::ReferencesClean
} else {
AnchorEvidence::ReferencesDamaged
}
}
_ => AnchorEvidence::Unavailable,
}
}
/// This frame's position in DECODE order, where the lane knows one — see
/// [`NativeVkFrame::decode_order`]. `None` everywhere else, which is what the
/// pump reads as "this lane reports no local recovery either, so there is
@@ -3060,6 +3060,11 @@ mod tests {
picture: pf_vaadec::PicturePlanAv1 {
frame_type: pf_vaadec::FrameTypeAv1::KeyFrame,
is_key: true,
// Vacuously true for a key frame: it predicts from nothing. This fixture
// exists to exercise the SIZING path (sequence max vs coded vs render), so
// the clean bit is incidental here — but it must state the honest value,
// because `false` is the answer that withholds a re-anchor.
references_clean: true,
show_frame: true,
showable_frame: false,
order_hint: 0,
@@ -741,6 +741,12 @@ fn project_frame(frame: &DecodedVkFrame, guard: NativeReleaseGuard) -> NativeVkF
sei_here: frame.recovery.sei_here,
is_recovery_point: frame.recovery.is_recovery_point,
},
// Whether this picture's own references decoded cleanly — the corroboration
// the shared gate weighs a host `USER_FLAG_RECOVERY_ANCHOR` against. The
// planner already knows it (it is the one party that resolved this AU's
// reference lists), and it is the only thing that can catch the host
// asserting a re-anchor over a picture THIS decoder had to conceal.
references_clean: frame.references_clean,
// Which side of a loss this picture was DECODED on. Carried beside the
// recovery mark because the mark is worthless without it: a post-failure
// DPB flush delivers pre-loss pictures after the loss, and their marks
@@ -1690,6 +1696,11 @@ mod tests {
sei_here: true,
is_recovery_point: true,
},
// SET, per this fixture's no-boolean-is-false rule — and it earns the
// rule: a projection that dropped this would default it to `false`,
// which reads as "this picture's references were concealed" and would
// make the gate refuse EVERY host recovery anchor on a healthy stream.
references_clean: true,
// Distinct from every other number here for the same reason: a
// projection that dropped the decode ordinal would make every frame
// look pre-loss (0) and silently disable the local-recovery path.
@@ -1792,6 +1803,7 @@ mod tests {
keyframe,
poc,
recovery,
references_clean,
decode_order,
guard: _,
} = p;
@@ -1838,6 +1850,12 @@ mod tests {
"the recovery point SEI's verdict reaches the gate — it is the ONLY \
clean point an intra-refresh session has"
);
assert!(
references_clean,
"the reference-cleanliness verdict rides along — without it the gate \
cannot refute a host recovery anchor that names a picture this decoder \
had to conceal, which is the grey-with-motion field report"
);
assert_eq!(
decode_order, 17,
"the decode ordinal rides along — without it the pump cannot tell a \
@@ -2150,6 +2168,7 @@ mod tests {
keyframe: true,
poc: 0,
recovery: punktfunk_core::reanchor::LocalRecovery::NONE,
references_clean: true,
decode_order: 1,
guard: NativeReleaseGuard::new(
tx,
+26
View File
@@ -368,6 +368,32 @@ pub trait Encoder: Send {
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
false
}
/// Mark every resident reference UNTRUSTED FOR RFI ANCHORING — the answer to "the client told
/// us it has damage and we did NOT repair it".
///
/// Why this exists at all. The slot-family RFI trust domain is the WIRE index each reference
/// holds, which answers *did the client receive this frame*; what an anchor pick actually needs
/// is *did the client DECODE it intact*. [`super::rfi`]'s taint sweep bridges that gap, but it
/// only runs inside [`invalidate_ref_frames`] — reachable from exactly ONE of the client's five
/// damage signals (the frame-index gap, which carries a loss RANGE). The other four report
/// through [`request_keyframe`](Self::request_keyframe), which carries no range and so cannot
/// sweep anything. That is self-healing while the IDR is actually emitted — an IDR flushes the
/// DPB and rebuilds trust from scratch — but the host coalesces those requests (a keyframe
/// storm is a 20-40× spike that deepens the very loss it recovers), and a coalesced request
/// leaves the client's damage unrepaired AND unrecorded. Those references stay anchor
/// candidates, and the next loss is answered with one of them tagged `recovery_anchor` — the
/// client's *definitive* clean re-anchor signal, which lifts its post-loss freeze on the first
/// occurrence. Grey frames, presented, with the freeze lifted.
///
/// Distrust is deliberately NOT "unusable": ordinary prediction runs off the backend's own slot
/// INDEX, never the wire domain, so this costs nothing but the next anchor pick — which
/// declines and falls through to the (still coalesced, so still non-storming) keyframe path.
/// It is also self-correcting on all three backends: a slot re-marked with a fresh frame, or an
/// IDR flushing the DPB, restores trust within a few frames. So this can suppress RFI briefly,
/// never permanently.
///
/// Default: no-op — the backends with no reference bookkeeping have no trust to withdraw.
fn distrust_references(&mut self) {}
/// Escalate into a pipelined (two-thread) retrieve mode under sustained GPU contention — the
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
@@ -3991,6 +3991,35 @@ impl Encoder for VulkanVideoEncoder {
}
}
/// Withdraw anchor trust from every resident reference (trait docs carry the why).
///
/// The mechanism is this backend's half of the split `enc::rfi` documents: blank `slot_wire`
/// ONLY. `slot_poc` MUST keep naming every physically-resident DPB picture — it is what
/// [`build_h265_rps_s0`] retains the RPS from, and an RPS that stops naming a resident lets a
/// conforming decoder mark it "unused for reference" and reclaim it (8.3.2), so a later anchor
/// would reference a picture the client has already dropped. That is its own grey-screen bug,
/// documented on `build_h265_rps_s0`, and it is the exact failure this method exists to
/// prevent — so getting the two domains the wrong way round here would trade one for the other.
/// `slot_wire` is the RFI/loss domain; `slot_poc` is the reference-delta domain.
///
/// `pending_loss` is deliberately left armed, matching this backend's decline arm: a stale arm
/// is re-resolved at frame-build, where the re-pick now finds nothing trusted and forces the
/// IDR that heals the stream. Clearing it here would ship an untagged plain P instead.
///
/// Ordinary prediction is untouched — it runs off `prev_slot`, an index, not a wire.
fn distrust_references(&mut self) {
let trusted = self.slot_wire.iter().filter(|&&w| w >= 0).count();
if trusted == 0 {
return; // already fully distrusted — nothing to log or clear
}
self.slot_wire.iter_mut().for_each(|w| *w = -1);
tracing::debug!(
trusted,
"vulkan-encode: client reported unrepaired damage — withdrawing RFI anchor trust from \
every resident reference (prediction and the RPS are unaffected)"
);
}
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
// Backpressure-drained frames (already read, oldest) come out first, then the oldest slot
// still in flight — both in submission order. BLOCKING, per the depth-1 pump contract
+52
View File
@@ -175,4 +175,56 @@ mod tests {
apply(&mut all, plan.tainted);
assert_eq!(pick_anchor(&view(&all), 5), None);
}
/// `Encoder::distrust_references` — the OTHER way trust is withdrawn, and the one that needs no
/// loss range. The host calls it when the client reports damage the host did not repair (a
/// coalesced keyframe request, or an RFI anchor the client kept asking past): the sweep cannot
/// run there because a keyframe request carries no range, so every resident reference is
/// withdrawn wholesale instead. All three backends persist that through their own marker; what
/// the shared policy must guarantee is the consequence — the next pick finds nothing and
/// declines, so the caller keyframes instead of serving an anchor over unrepaired damage.
#[test]
fn distrusting_every_reference_makes_the_next_anchor_pick_decline() {
// A table with plenty of pre-loss candidates: without the withdrawal, wire 7 anchors.
let mut wires = [4i64, 5, 6, 7, -1, -1, -1, -1];
assert_eq!(
pick_anchor(&view(&wires), 9),
Some((3, 7)),
"precondition: this table would happily anchor"
);
// The Vulkan mechanism (blank the wire) stands in for all three: AMF clears its mirror and
// QSV raises `ltr_tainted`, but each is filtered out of the trusted view identically —
// which is exactly what makes one pure policy serve three persistence schemes.
apply(&mut wires, u32::MAX);
assert_eq!(
pick_anchor(&view(&wires), 9),
None,
"every reference withdrawn → no anchor, caller falls through to its keyframe path"
);
// And it holds for ANY later loss, not just this one — the point of persisting distrust.
assert_eq!(pick_anchor(&view(&wires), 100), None);
}
/// The withdrawal must be temporary, or one coalesced keyframe request would cost a session its
/// RFI recovery for good and every later loss would ride the 20-40× IDR path. Each backend
/// restores trust the same way it always did — a slot re-marked with a fresh frame (and an IDR
/// flush, which empties the table first) — so a refilled slot anchors again.
#[test]
fn a_re_marked_slot_restores_anchor_trust_after_a_full_withdrawal() {
let mut wires = [4i64, 5, 6, 7, -1, -1, -1, -1];
apply(&mut wires, u32::MAX);
assert_eq!(pick_anchor(&view(&wires), 20), None);
// Encoding continues; the ring refills two slots with post-withdrawal frames. Those really
// are clean — the client's damage was repaired by the IDR the withdrawal forced — so they
// are legitimate anchors and the sweep must not keep rejecting them.
wires[0] = 14;
wires[1] = 15;
assert_eq!(
pick_anchor(&view(&wires), 20),
Some((1, 15)),
"a re-marked slot is trusted again — the suppression is a few frames, not the session"
);
}
}
+24
View File
@@ -2010,6 +2010,30 @@ impl Encoder for AmfEncoder {
}
}
/// Withdraw anchor trust from every live LTR (trait docs carry the why).
///
/// This backend's mechanism, unchanged from the sweep's: distrust = clear the mirror slot.
/// Dropped slots stay dropped and the marking cadence re-marks a clean frame within ~1/4 s, so
/// the suppression is brief by construction.
///
/// `pending_force` is cleared with them, matching the decline arm above: an un-consumed force
/// would otherwise point at a slot this call just distrusted, and the next submit would
/// force-reference it anyway — shipping the corruption tagged `recovery_anchor`, which is the
/// whole failure being closed.
fn distrust_references(&mut self) {
let live = self.ltr_slots.iter().filter(|m| m.is_some()).count();
if live == 0 && self.pending_force.is_none() {
return;
}
self.ltr_slots = [None; NUM_LTR_SLOTS];
self.pending_force = None;
tracing::debug!(
live,
"AMF LTR-RFI: client reported unrepaired damage — withdrawing anchor trust from every \
live LTR (the marking cadence re-marks a clean frame within ~1/4 s)"
);
}
fn caps(&self) -> EncoderCaps {
EncoderCaps {
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
+33
View File
@@ -1467,6 +1467,39 @@ impl Encoder for QsvEncoder {
}
}
/// Withdraw anchor trust from every live LTR (trait docs carry the why).
///
/// This backend's mechanism, unchanged from the sweep's: distrust is the SEPARATE
/// `ltr_tainted` flag, never a cleared mirror slot. `ltr_slots` mirrors the HARDWARE DPB and
/// nulling an entry issues no VPL call, so the frame stays marked long-term in the encoder —
/// and the RejectedRefList built at submit only names `Some` slots, so a cleared mirror would
/// silently SKIP the very entry being distrusted and the recovery frame could still predict
/// from it. Taint keeps the mirror intact and the rejection reachable.
///
/// The taint lifts itself: an IDR flush and a re-mark both clear it, so this suppresses RFI
/// for a few frames, never for the session.
///
/// `pending_force` is cleared for the same reason as the decline arm above — an un-consumed
/// force would point at a slot this call just distrusted.
fn distrust_references(&mut self) {
let live = self
.ltr_slots
.iter()
.enumerate()
.filter(|&(slot, m)| m.is_some() && !self.ltr_tainted[slot])
.count();
if live == 0 && self.pending_force.is_none() {
return;
}
self.ltr_tainted = [true; NUM_LTR_SLOTS];
self.pending_force = None;
tracing::debug!(
live,
"QSV LTR-RFI: client reported unrepaired damage — withdrawing anchor trust from every \
live LTR (cleared by the next re-mark or IDR flush)"
);
}
fn caps(&self) -> EncoderCaps {
EncoderCaps {
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
+8
View File
@@ -274,6 +274,14 @@ impl Encoder for TrackedEncoder {
fn invalidate_ref_frames(&mut self, first_frame: i64, last_frame: i64) -> bool {
self.inner.invalidate_ref_frames(first_frame, last_frame)
}
// Same trap class as `set_wire_chunking`, and the one where it would hurt most: unforwarded,
// the default no-op would leave every session serving RFI anchors over damage the client
// reported and the host never repaired — the failure this method exists to close, silently
// reintroduced by the wrapper. (The `every_encoder_method_is_forwarded` guard below catches
// it, which is exactly why that guard is there.)
fn distrust_references(&mut self) {
self.inner.distrust_references()
}
// 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.
+69
View File
@@ -82,6 +82,51 @@ pub fn boost_thread_priority(critical: bool) {
}
}
/// What the OS is actually giving the CALLING thread: `(policy, rt_priority, nice)`.
///
/// Exists because a boost we *asked for* and a boost the hot thread *has* turned out to be
/// different questions. Callbacks handed to a library — PipeWire's `RT_PROCESS` streams above all
/// — run on a thread that library created and schedules, so a `boost_thread_priority` call in our
/// own setup path can log a cheerful success about a thread that never touches audio. A
/// 2026-08-15 measurement found exactly that shape: our loop thread at SCHED_OTHER/0 while the
/// data loop actually running the capture callback sat at SCHED_RR/20, both in the same process.
///
/// Report this from inside the hot callback, where "the calling thread" is the one that matters.
#[cfg(target_os = "linux")]
pub fn current_thread_sched() -> (&'static str, i32, i32) {
// SAFETY: all three calls take by-value integers (plus, for `sched_getparam`, a pointer to a
// fully-initialised local we own and outlive) and return integers. `0` means "the calling
// task" on Linux, so nothing outside this thread is read or written, and no allocation,
// locking or blocking happens — which is what makes this callable from an RT callback.
unsafe {
let policy = libc::sched_getscheduler(0);
let mut param: libc::sched_param = std::mem::zeroed();
let rt_priority = if libc::sched_getparam(0, &mut param) == 0 {
param.sched_priority
} else {
-1
};
// `getpriority` legitimately returns -1, so errno is the only way to tell a nice of -1
// from a failure.
*libc::__errno_location() = 0;
let nice = libc::getpriority(libc::PRIO_PROCESS, 0);
let nice = if *libc::__errno_location() == 0 {
nice
} else {
0
};
let policy = match policy {
libc::SCHED_FIFO => "SCHED_FIFO",
libc::SCHED_RR => "SCHED_RR",
libc::SCHED_OTHER => "SCHED_OTHER",
libc::SCHED_BATCH => "SCHED_BATCH",
libc::SCHED_IDLE => "SCHED_IDLE",
_ => "unknown",
};
(policy, rt_priority, nice)
}
}
/// RealtimeKit fallback for [`boost_thread_priority`]: ask the system-bus broker
/// (`org.freedesktop.RealtimeKit1`) to renice the calling thread when the direct
/// `setpriority` was refused. This is how PulseAudio/PipeWire clients get their boosts on a
@@ -121,3 +166,27 @@ mod linux_rtkit {
Ok(())
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
/// Non-vacuity: the introspection has to come back with something the OS could actually have
/// said. A helper whose whole job is to be quoted in a field log is worthless if it can
/// quietly report a placeholder, and it only ever runs on hosts nobody can attach a debugger
/// to.
#[test]
fn current_thread_sched_reports_a_real_policy() {
let (policy, rt_priority, nice) = super::current_thread_sched();
assert!(
matches!(
policy,
"SCHED_OTHER" | "SCHED_RR" | "SCHED_FIFO" | "SCHED_BATCH" | "SCHED_IDLE"
),
"unrecognised policy {policy}"
);
assert!(
(0..=99).contains(&rt_priority),
"rt priority {rt_priority} outside the kernel's range"
);
assert!((-20..=19).contains(&nice), "nice {nice} outside PRIO range");
}
}
+425
View File
@@ -0,0 +1,425 @@
//! Where the host session's keyboard LAYOUT comes from.
//!
//! punktfunk's key wire is **US-positional**: a client sends the Windows VK of the *physical* key
//! it saw, [`vk_to_evdev`](super::keymap::vk_to_evdev) turns that into a Linux evdev code, and the
//! **session's keymap** is what decides which character that position finally produces. The
//! standing contract is therefore "host layout == the layout printed on the client's keyboard" —
//! a German keyboard needs a German session, or its ISO keys render as their US neighbours
//! (`#`→`\`, `ä`→`'`, `-`→`/`, the y↔z swap).
//!
//! Nothing on a Wayland box arranges that by itself. `localectl set-x11-keymap de` records the
//! choice in `/etc/X11/xorg.conf.d/00-keyboard.conf` (and, on systemd ≥ 249, `/etc/vconsole.conf`),
//! but that file is read by **Xorg** — a Wayland compositor never opens it. libxkbcommon's own
//! fallback chain stops at the `XKB_DEFAULT_*` env vars, and no session manager exports those. So
//! compiling a keymap from empty names on a properly-configured German box still silently yields
//! evdev/pc105/**us**, which is exactly the scramble above.
//!
//! [`system_layout`] reads what the machine actually recorded so the injected keyboard follows the
//! box. It is advisory for backends whose keymap we do not own (libei/KWin/gamescope resolve our
//! evdev codes against the compositor's own keymap — see [`crate::text_input_supported`]); there it
//! only feeds the diagnostic, and the operator has to fix the session itself.
use std::path::{Path, PathBuf};
/// The five xkb rule names, each `None` when nothing configured it (libxkbcommon then applies its
/// own built-in default — `evdev`/`pc105`/`us`/``/``).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct XkbNames {
pub rules: Option<String>,
pub model: Option<String>,
pub layout: Option<String>,
pub variant: Option<String>,
pub options: Option<String>,
}
impl XkbNames {
/// Nothing at all was configured ⇒ the compiled keymap will be libxkbcommon's US default.
pub fn is_empty(&self) -> bool {
self.rules.is_none()
&& self.model.is_none()
&& self.layout.is_none()
&& self.variant.is_none()
&& self.options.is_none()
}
/// Fill every field this one leaves unset from `fallback` (per-field precedence, mirroring how
/// libxkbcommon itself falls back one `XKB_DEFAULT_*` variable at a time rather than taking a
/// source whole — a box that exports only `XKB_DEFAULT_LAYOUT` still keeps its configured
/// variant).
fn fill_from(&mut self, fallback: XkbNames) {
for (slot, value) in [
(&mut self.rules, fallback.rules),
(&mut self.model, fallback.model),
(&mut self.layout, fallback.layout),
(&mut self.variant, fallback.variant),
(&mut self.options, fallback.options),
] {
if slot.is_none() {
*slot = value;
}
}
}
/// The names as `xkb_keymap_new_from_names` wants them: an empty string means "unset", which
/// is where libxkbcommon applies its own default for that field.
pub fn as_args(&self) -> (&str, &str, &str, &str, Option<String>) {
(
self.rules.as_deref().unwrap_or(""),
self.model.as_deref().unwrap_or(""),
self.layout.as_deref().unwrap_or(""),
self.variant.as_deref().unwrap_or(""),
self.options.clone(),
)
}
/// The same names as `XKB_DEFAULT_*` environment pairs, for a compositor punktfunk spawns
/// itself. Only the fields we actually resolved are emitted — exporting an empty
/// `XKB_DEFAULT_VARIANT` is not the same as leaving it unset, since an explicit empty string
/// *overrides* a variant the rules file would otherwise supply.
pub fn env_pairs(&self) -> Vec<(&'static str, String)> {
[
("XKB_DEFAULT_RULES", &self.rules),
("XKB_DEFAULT_MODEL", &self.model),
("XKB_DEFAULT_LAYOUT", &self.layout),
("XKB_DEFAULT_VARIANT", &self.variant),
("XKB_DEFAULT_OPTIONS", &self.options),
]
.into_iter()
.filter_map(|(k, v)| v.as_ref().map(|v| (k, v.clone())))
.collect()
}
/// `de(nodeadkeys)` / `de` / `us (libxkbcommon default)` — for one readable log field.
pub fn describe(&self) -> String {
match (&self.layout, &self.variant) {
(Some(l), Some(v)) if !v.is_empty() => format!("{l}({v})"),
(Some(l), _) => l.clone(),
(None, _) => "us (libxkbcommon default)".to_string(),
}
}
}
/// The resolved layout plus where it came from, so the log line can name the file an operator
/// would have to edit.
#[derive(Clone, Debug)]
pub struct SystemLayout {
pub names: XkbNames,
/// Origin of `names.layout` specifically — the field that matters and the only one worth
/// naming in a one-line log.
pub source: String,
}
/// What `localectl set-x11-keymap` writes.
const X11_CONF_DIR: &str = "/etc/X11/xorg.conf.d";
/// systemd ≥ 249 mirrors the X11 keymap here as `XKBLAYOUT=`/`XKBVARIANT=`/…
const VCONSOLE_CONF: &str = "/etc/vconsole.conf";
/// Resolve the host's configured keyboard layout: `XKB_DEFAULT_*` env (explicit operator intent,
/// and what libxkbcommon would have used anyway) → `/etc/X11/xorg.conf.d/*keyboard*.conf` →
/// `/etc/vconsole.conf`. Per-field, so a partially-configured box keeps whatever each source knows.
pub fn system_layout() -> SystemLayout {
resolve_from(
from_env(),
Path::new(X11_CONF_DIR),
Path::new(VCONSOLE_CONF),
)
}
/// [`system_layout`] with its three inputs injected — the env block is a parameter so the tests
/// never depend on the `XKB_DEFAULT_*` of whatever machine runs them.
fn resolve_from(env: XkbNames, x11_dir: &Path, vconsole: &Path) -> SystemLayout {
let mut names = env;
let mut source = if names.layout.is_some() {
"XKB_DEFAULT_LAYOUT".to_string()
} else {
String::new()
};
for path in x11_keyboard_confs(x11_dir) {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let parsed = parse_x11_keyboard_conf(&text);
if source.is_empty() && parsed.layout.is_some() {
source = path.display().to_string();
}
names.fill_from(parsed);
}
if let Ok(text) = std::fs::read_to_string(vconsole) {
let parsed = parse_vconsole_conf(&text);
if source.is_empty() && parsed.layout.is_some() {
source = vconsole.display().to_string();
}
names.fill_from(parsed);
}
if source.is_empty() {
source = "unconfigured".to_string();
}
SystemLayout { names, source }
}
fn from_env() -> XkbNames {
let get = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
XkbNames {
rules: get("XKB_DEFAULT_RULES"),
model: get("XKB_DEFAULT_MODEL"),
layout: get("XKB_DEFAULT_LAYOUT"),
variant: get("XKB_DEFAULT_VARIANT"),
options: get("XKB_DEFAULT_OPTIONS"),
}
}
/// Every `*keyboard*.conf` in the Xorg snippet directory, in **reverse** lexical order. Xorg
/// merges these low-to-high with the later file winning; [`resolve_from`] fills fields first-hit-
/// wins, so handing it the highest-numbered snippet first reproduces that precedence.
fn x11_keyboard_confs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut out: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
p.extension().is_some_and(|e| e == "conf")
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("keyboard"))
})
.collect();
out.sort();
out.reverse();
out
}
/// Pull `Option "XkbLayout" "de"` style entries out of an Xorg `InputClass` snippet. Deliberately
/// section-blind: `localectl` writes exactly one `MatchIsKeyboard` class, and scanning every
/// `Option` line beats half-implementing the Xorg config grammar.
fn parse_x11_keyboard_conf(text: &str) -> XkbNames {
let mut names = XkbNames::default();
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
let mut fields = line.split('"');
// `Option ` | key | ` ` | value
let Some(head) = fields.next() else { continue };
if !head.trim_end().eq_ignore_ascii_case("Option") {
continue;
}
let (Some(key), Some(_), Some(value)) = (fields.next(), fields.next(), fields.next())
else {
continue;
};
let slot = match key.to_ascii_lowercase().as_str() {
"xkbrules" => &mut names.rules,
"xkbmodel" => &mut names.model,
"xkblayout" => &mut names.layout,
"xkbvariant" => &mut names.variant,
"xkboptions" => &mut names.options,
_ => continue,
};
*slot = Some(value.to_string());
}
names
}
/// Pull `XKBLAYOUT=de` / `XKBVARIANT="nodeadkeys"` out of `/etc/vconsole.conf`.
///
/// ⚠ `KEYMAP=` is deliberately ignored: it names a **console** keymap (`de-nodeadkeys`, `uk`,
/// `sg-latin1`), whose namespace only coincides with xkb's by accident — `uk` is xkb `gb`, and a
/// wrong guess here would mis-type every key rather than fall back to a visible default.
fn parse_vconsole_conf(text: &str) -> XkbNames {
let mut names = XkbNames::default();
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim().trim_matches(['"', '\'']).to_string();
if value.is_empty() {
continue;
}
let slot = match key.trim().to_ascii_uppercase().as_str() {
"XKBRULES" => &mut names.rules,
"XKBMODEL" => &mut names.model,
"XKBLAYOUT" => &mut names.layout,
"XKBVARIANT" => &mut names.variant,
"XKBOPTIONS" => &mut names.options,
_ => continue,
};
*slot = Some(value);
}
names
}
#[cfg(test)]
mod tests {
use super::*;
/// Verbatim `localectl set-x11-keymap de pc105 nodeadkeys` output.
const LOCALECTL_DE: &str = r#"# Written by systemd-localed(8), read by systemd-localed and Xorg. It's
# probably wise not to edit this file manually. Use localectl(1) to
# update this file.
Section "InputClass"
Identifier "system-keyboard"
MatchIsKeyboard "on"
Option "XkbLayout" "de"
Option "XkbModel" "pc105"
Option "XkbVariant" "nodeadkeys"
EndSection
"#;
#[test]
fn parses_localectl_x11_snippet() {
let n = parse_x11_keyboard_conf(LOCALECTL_DE);
assert_eq!(n.layout.as_deref(), Some("de"));
assert_eq!(n.model.as_deref(), Some("pc105"));
assert_eq!(n.variant.as_deref(), Some("nodeadkeys"));
assert_eq!(n.rules, None);
assert_eq!(n.describe(), "de(nodeadkeys)");
}
#[test]
fn x11_snippet_ignores_comments_and_non_xkb_options() {
let n = parse_x11_keyboard_conf(
"# Option \"XkbLayout\" \"fr\"\n\
Identifier \"system-keyboard\"\n\
Option \"XkbLayout\" \"ch\"\n\
Option \"SomethingElse\" \"nope\"\n",
);
assert_eq!(n.layout.as_deref(), Some("ch"));
assert!(n.options.is_none());
}
#[test]
fn parses_vconsole_and_ignores_console_keymap() {
// The KEYMAP= line must NOT become an xkb layout: console and xkb namespaces differ.
let n = parse_vconsole_conf("KEYMAP=\"de-nodeadkeys\"\nFONT=\"eurlatgr\"\n");
assert!(n.is_empty(), "KEYMAP must not be read as an xkb layout");
let n = parse_vconsole_conf("XKBLAYOUT=de\nXKBVARIANT=\"nodeadkeys\"\nKEYMAP=de\n");
assert_eq!(n.layout.as_deref(), Some("de"));
assert_eq!(n.variant.as_deref(), Some("nodeadkeys"));
}
#[test]
fn empty_names_describe_as_the_us_default() {
let n = XkbNames::default();
assert!(n.is_empty());
assert_eq!(n.describe(), "us (libxkbcommon default)");
assert_eq!(n.as_args(), ("", "", "", "", None));
assert!(n.env_pairs().is_empty());
}
#[test]
fn per_field_fallback_keeps_the_more_specific_source() {
// Env named only the layout; the X11 snippet still supplies model/variant.
let mut n = XkbNames {
layout: Some("fr".into()),
..Default::default()
};
n.fill_from(parse_x11_keyboard_conf(LOCALECTL_DE));
assert_eq!(n.layout.as_deref(), Some("fr"), "env layout must win");
assert_eq!(n.variant.as_deref(), Some("nodeadkeys"));
assert_eq!(n.model.as_deref(), Some("pc105"));
}
#[test]
fn env_pairs_round_trip_only_the_resolved_fields() {
let n = XkbNames {
layout: Some("de".into()),
variant: Some("nodeadkeys".into()),
..Default::default()
};
assert_eq!(
n.env_pairs(),
vec![
("XKB_DEFAULT_LAYOUT", "de".to_string()),
("XKB_DEFAULT_VARIANT", "nodeadkeys".to_string()),
]
);
}
/// A throwaway `/etc` stand-in; the caller writes the two files it cares about.
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pf-layout-{}-{tag}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(dir.join("xorg.conf.d")).unwrap();
dir
}
#[test]
fn resolve_reads_the_x11_snippet_then_vconsole() {
let dir = scratch("x11-then-vconsole");
let x11 = dir.join("xorg.conf.d");
std::fs::write(x11.join("00-keyboard.conf"), LOCALECTL_DE).unwrap();
let vconsole = dir.join("vconsole.conf");
std::fs::write(&vconsole, "XKBLAYOUT=fr\nXKBOPTIONS=compose:ralt\n").unwrap();
let got = resolve_from(XkbNames::default(), &x11, &vconsole);
// The X11 snippet outranks vconsole, so its layout stands; vconsole still fills the
// options nothing else supplied.
assert_eq!(got.names.layout.as_deref(), Some("de"));
assert_eq!(got.names.variant.as_deref(), Some("nodeadkeys"));
assert_eq!(got.names.options.as_deref(), Some("compose:ralt"));
assert!(got.source.ends_with("00-keyboard.conf"), "{}", got.source);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_higher_numbered_xorg_snippet_wins() {
let dir = scratch("xorg-precedence");
let x11 = dir.join("xorg.conf.d");
std::fs::write(x11.join("00-keyboard.conf"), LOCALECTL_DE).unwrap();
std::fs::write(
x11.join("90-custom-keyboard.conf"),
"Option \"XkbLayout\" \"no\"\n",
)
.unwrap();
let got = resolve_from(XkbNames::default(), &x11, &dir.join("absent"));
assert_eq!(got.names.layout.as_deref(), Some("no"));
// The `00-` file still supplies what `90-` left unsaid.
assert_eq!(got.names.variant.as_deref(), Some("nodeadkeys"));
assert!(
got.source.ends_with("90-custom-keyboard.conf"),
"{}",
got.source
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn env_outranks_every_file() {
let dir = scratch("env-wins");
let x11 = dir.join("xorg.conf.d");
std::fs::write(x11.join("00-keyboard.conf"), LOCALECTL_DE).unwrap();
let env = XkbNames {
layout: Some("us".into()),
..Default::default()
};
let got = resolve_from(env, &x11, &dir.join("absent"));
assert_eq!(got.names.layout.as_deref(), Some("us"));
assert_eq!(got.source, "XKB_DEFAULT_LAYOUT");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_on_a_bare_box_reports_unconfigured() {
let missing = Path::new("/nonexistent/pf-layout-test");
let got = resolve_from(XkbNames::default(), missing, missing);
assert_eq!(got.source, "unconfigured");
assert!(got.names.is_empty());
assert_eq!(got.names.describe(), "us (libxkbcommon default)");
}
}
+6
View File
@@ -33,6 +33,12 @@
//! share a name; do NOT conflate them.
#![forbid(unsafe_code)]
/// Which keyboard LAYOUT the box is configured for. Not a `PUNKTFUNK_*` knob — it is read from
/// what `localectl` recorded — but it is host configuration every input path needs (the injector
/// compiles its keymap from it; the gamescope backend hands it to the session it launches), and it
/// lives here so both can reach it without either crate depending on the other.
pub mod layout;
use std::sync::OnceLock;
/// Whether a `PUNKTFUNK_*` env var reads as ON, or `None` when it is unset — the host's
@@ -565,7 +565,9 @@ pub fn usbip_preferred() -> bool {
}
/// The `vhci_hcd.0` (or legacy `vhci_hcd`) platform sysfs directory, if present.
fn vhci_base() -> Option<PathBuf> {
/// `pub(crate)` so the diagnostics probe ([`crate::vhci_probe`]) asks the same question the attach
/// path asks, rather than growing a second copy of these paths that can drift from it.
pub(crate) fn vhci_base() -> Option<PathBuf> {
for p in [
"/sys/devices/platform/vhci_hcd.0",
"/sys/devices/platform/vhci_hcd",
+29 -10
View File
@@ -275,18 +275,37 @@ impl WlrootsInjector {
pointer_mgr.create_virtual_pointer_with_output(Some(&seat), target.as_ref(), &qh, ());
let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ());
// The keymap the compositor resolves our raw evdev keycodes with. Empty names defer to
// the standard `XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS` env vars, then to
// libxkbcommon's built-ins (evdev/pc105/us) — so a non-US host sets e.g.
// `XKB_DEFAULT_LAYOUT=de` and the positional wire keys render as its layout (parity with
// the libei path, where the session compositor's own keymap applies). Previously this
// hardcoded "us", which forced US characters for the OEM/umlaut keys on every layout.
// The keymap the compositor resolves our raw evdev keycodes with. The wire keys are
// US-POSITIONAL, so this keymap is what decides the character each one finally types —
// it has to be the layout printed on the client's keyboard, or ISO keys render as their
// US neighbours (`#`→`\`, `ä`→`'`, `-`→`/`).
//
// Resolved from the box's own configuration (`crate::layout`), NOT from empty names:
// empty defers to `XKB_DEFAULT_*`, which nothing on a Wayland session exports, so a
// `localectl set-x11-keymap de` host silently compiled evdev/pc105/**us**. (Before that
// it hardcoded "us" outright.) `XKB_DEFAULT_*` still wins when an operator sets it.
let resolved = pf_host_config::layout::system_layout();
let (rules, model, layout, variant, options) = resolved.names.as_args();
let ctx = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
let keymap =
xkb::Keymap::new_from_names(&ctx, "", "", "", "", None, xkb::KEYMAP_COMPILE_NO_FLAGS)
.context("compile xkb keymap (check XKB_DEFAULT_LAYOUT/VARIANT/RULES if set)")?;
let keymap = xkb::Keymap::new_from_names(
&ctx,
rules,
model,
layout,
variant,
options,
xkb::KEYMAP_COMPILE_NO_FLAGS,
)
.with_context(|| {
format!(
"compile xkb keymap {} (from {})",
resolved.names.describe(),
resolved.source
)
})?;
tracing::info!(
layout = %std::env::var("XKB_DEFAULT_LAYOUT").unwrap_or_else(|_| "us (default)".into()),
layout = %resolved.names.describe(),
source = %resolved.source,
"virtual keyboard keymap compiled"
);
let keymap_str = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1);
+134 -2
View File
@@ -6,8 +6,9 @@
//! Sway always advertises. We connect as an ordinary Wayland client (the host process
//! inherits Sway's `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`), bind the two managers, and translate
//! events into virtual pointer/keyboard requests. Keyboard codes are Linux evdev; we upload an
//! xkb keymap (the host's layout via `XKB_DEFAULT_LAYOUT` et al., defaulting to evdev/US) and
//! track modifier state so the compositor resolves shifted keysyms correctly.
//! xkb keymap built from the box's configured layout (`pf_host_config::layout` — `XKB_DEFAULT_*`,
//! then what `localectl` recorded) and track modifier state so the compositor resolves shifted
//! keysyms correctly.
//!
//! Extracted into a subsystem crate (plan §W6): consumes `punktfunk_core::input` (the neutral
//! event vocabulary) + `pf-driver-proto` (the HID wire contract), never the orchestrator.
@@ -307,6 +308,137 @@ pub fn pen_supported() -> bool {
false
}
/// What an open probe of the input device nodes found — [`uinput_probe`].
///
/// [`pen_supported`] asks the same question and throws the answer away: it returns a bare `bool`,
/// so "the module was never installed" and "you are not in the `input` group" look identical, and
/// the two need completely different remedies. This keeps the errno so the host's diagnostics can
/// tell an operator which one they have. A plain verdict enum on purpose — this crate must never
/// learn about the host's wire types.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UinputVerdict {
/// Every node opened — virtual gamepads and the pen can be created.
Ok,
/// `EACCES`/`EPERM`: the node is there but this process may not open it. Either the user is not
/// in the `input` group, or the udev rule granting that group access was never installed.
PermissionDenied { path: &'static str },
/// `ENOENT` and friends: the node does not exist at all — the module or the rule is missing.
Missing { path: &'static str },
/// Some other errno; carried verbatim rather than guessed at.
Error { path: &'static str, message: String },
/// No uinput/uhid injection on this platform.
Inapplicable,
}
/// The device nodes every virtual input device needs, in the order they are worth reporting:
/// `/dev/uinput` kills the pen and the evdev gamepads, `/dev/uhid` kills the DualSense/Switch Pro
/// backends that need a real HID transport.
#[cfg(target_os = "linux")]
const INPUT_NODES: &[(&std::ffi::CStr, &str)] =
&[(c"/dev/uinput", "/dev/uinput"), (c"/dev/uhid", "/dev/uhid")];
/// Probe `/dev/uinput` and `/dev/uhid` the way the backends will, **keeping the errno**. Cheap (two
/// `open()`s), so the diagnostics refresh can re-run it on demand.
#[cfg(target_os = "linux")]
pub fn uinput_probe() -> UinputVerdict {
for &(c_path, path) in INPUT_NODES {
// SAFETY: 'static NUL-terminated path literal; `open` returns a fresh fd (or -1) and
// retains nothing.
let fd = unsafe {
libc::open(
c_path.as_ptr(),
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if fd >= 0 {
// SAFETY: `fd >= 0` is the fd opened above, owned by no one else; closed exactly once.
unsafe { libc::close(fd) };
continue;
}
// Read the errno IMMEDIATELY: any further libc call (including the close above) clobbers it.
let err = std::io::Error::last_os_error();
return match err.raw_os_error() {
Some(libc::EACCES) | Some(libc::EPERM) => UinputVerdict::PermissionDenied { path },
Some(libc::ENOENT) | Some(libc::ENXIO) | Some(libc::ENODEV) => {
UinputVerdict::Missing { path }
}
_ => UinputVerdict::Error {
path,
message: err.to_string(),
},
};
}
UinputVerdict::Ok
}
/// See the Linux variant — uinput/uhid are Linux interfaces; Windows injects through its own driver
/// stack, whose health is a separate check.
#[cfg(not(target_os = "linux"))]
pub fn uinput_probe() -> UinputVerdict {
UinputVerdict::Inapplicable
}
/// What the usbip/vhci attach node looks like from here — [`vhci_probe`].
///
/// Deliberately reports **device facts only**: whether the module is there and whether this process
/// can write the node. It does NOT reason about group membership, because the interesting
/// distinction (in the group on disk vs. in the group in this process) needs the user database, and
/// that is the host's business, not this crate's.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VhciVerdict {
/// The module is loaded and this process can write `attach` — the virtual Deck can come up.
Ok,
/// No `/sys/devices/platform/vhci_hcd*/status`: the module is not loaded.
ModuleMissing,
/// The node is there and this process cannot write it. Why is the host's question to answer.
NotWritable { path: String },
/// The virtual-Deck-over-usbip route does not apply here.
Inapplicable { why: &'static str },
}
/// Probe the vhci attach node: module present, and writable by *this process*?
///
/// Writability is the ground truth rather than a group-name comparison, because that is exactly what
/// the attach will attempt — `60-punktfunk.rules` `chgrp punktfunk` + `chmod 0660` the node, so a
/// member of the group whose process actually carries it gets `W_OK` and nobody else does.
#[cfg(target_os = "linux")]
pub fn vhci_probe() -> VhciVerdict {
use std::os::unix::ffi::OsStrExt;
if !steam_usbip::usbip_preferred() {
return VhciVerdict::Inapplicable {
why: "the virtual Steam Deck's usbip transport is disabled (PUNKTFUNK_STEAM_USBIP=0)",
};
}
let Some(base) = steam_usbip::vhci_base() else {
return VhciVerdict::ModuleMissing;
};
let attach = base.join("attach");
let Ok(c_path) = std::ffi::CString::new(attach.as_os_str().as_bytes()) else {
return VhciVerdict::NotWritable {
path: attach.display().to_string(),
};
};
// SAFETY: `c_path` is a NUL-terminated path owned by this frame and outlives the call;
// `access` only reads it and retains nothing.
let writable = unsafe { libc::access(c_path.as_ptr(), libc::W_OK) } == 0;
if writable {
VhciVerdict::Ok
} else {
VhciVerdict::NotWritable {
path: attach.display().to_string(),
}
}
}
/// See the Linux variant — usbip/vhci is a Linux kernel facility.
#[cfg(not(target_os = "linux"))]
pub fn vhci_probe() -> VhciVerdict {
VhciVerdict::Inapplicable {
why: "the virtual Steam Deck's usbip transport is Linux-only",
}
}
#[path = "inject/service.rs"]
mod service;
pub use service::InjectorService;
+2 -2
View File
@@ -105,8 +105,8 @@ pub(crate) mod routing;
pub use routing::{
apply_input_env, managed_session_available, preflight_takeover_privilege,
release_autologin_mask, resolve_gamescope_route, restore_managed_session, restore_takeover_now,
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
GamescopeRoute,
restore_takeover_on_startup, start_restore_worker, takeover_privilege_verdict,
wants_dedicated_game_session, GamescopeRoute, TakeoverInapplicable, TakeoverVerdict,
};
#[cfg(target_os = "linux")]
pub use routing::{
@@ -15,6 +15,7 @@
//! `inject/libei.rs`) — wired and live-validated.
use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
use crate::routing::{TakeoverInapplicable, TakeoverVerdict};
use anyhow::{anyhow, bail, Context, Result};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
@@ -28,7 +29,7 @@ mod splash;
use discovery::{
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, gamescope_bin,
gamescope_can_composite_external_overlay, gamescope_can_offer_refresh_rates,
gamescope_node_present, poll_managed_node, wait_for_node,
gamescope_honours_xkb_env, gamescope_node_present, poll_managed_node, wait_for_node,
};
pub(crate) use discovery::{
game_session_exited, gamescope_can_composite_cursor, gamescope_hdr_capable, is_available,
@@ -1227,8 +1228,10 @@ fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode, hdr: bool) -> Re
Environment=PF_H={h}\n\
Environment=PF_HZ={hz}\n\
Environment=\"PF_HDR_ARGS={hdr_args}\"\n\
{xkb}\
UnsetEnvironment=DISPLAY WAYLAND_DISPLAY\n",
shim = shim_dir.display(),
xkb = xkb_unit_lines(),
w = mode.width,
h = mode.height,
hz = game_hz(mode.refresh_hz),
@@ -1314,8 +1317,10 @@ fn write_session_plus_dropin(
{binds}\
Environment=PF_HZ={hz}\n\
Environment=\"PF_HDR_ARGS={hdr_args}\"\n\
{xkb}\
{wsi}",
binds = bind.unit_lines(),
xkb = xkb_unit_lines(),
hz = game_hz(mode.refresh_hz),
hdr_args = hdr_args(hdr)
.into_iter()
@@ -2443,25 +2448,15 @@ fn dm_helper(verb: &str) -> std::result::Result<(), DmHelperError> {
/// Steam Deck pad attaches through, and THAT is a credential check against this process, whose
/// supplementary groups were fixed when its `systemd --user` manager started.
pub fn preflight_takeover_privilege() {
if crate::proc::current_uid() == 0 {
return; // root: `systemctl stop <dm>` succeeds outright, the helper is never consulted
}
let Some(dm) = display_manager_unit() else {
return; // no DM drives this box's logins — nothing for the takeover to stop
let TakeoverVerdict::MissingMembership {
user,
dm,
helper,
group,
} = takeover_privilege_verdict()
else {
return; // gated out, or the user is already a member — either way, nothing to say
};
if !managed_session_available() {
return; // no session-plus/SteamOS ⇒ no autologin gaming session ⇒ no takeover
}
let Some(helper) = installed_dm_helper() else {
return; // unpackaged install: no helper, no group, the polkit-rule route applies instead
};
let Some(user) = current_user_name() else {
return; // cannot name the user ⇒ cannot give a usable `usermod` line; stay quiet
};
let group = DM_HELPER_GROUP;
if user_in_group(&user, group) {
return;
}
tracing::warn!(
%user,
%dm,
@@ -2478,6 +2473,53 @@ pub fn preflight_takeover_privilege() {
);
}
/// The gated verdict [`preflight_takeover_privilege`] logs from — and the same value the host's
/// diagnostics registry maps into a console check, so the log line and the console can never
/// disagree about this box.
///
/// The four gates and the user-database question are documented on
/// [`preflight_takeover_privilege`]; this function only moves *where the answer goes*. Each
/// `Inapplicable` reason is kept distinct rather than collapsed to a bool, because the
/// troubleshooting page's job is to answer "why isn't this check relevant here?" — a hidden row
/// cannot.
pub fn takeover_privilege_verdict() -> TakeoverVerdict {
if crate::proc::current_uid() == 0 {
return TakeoverVerdict::Inapplicable {
why: TakeoverInapplicable::Root,
};
}
let Some(dm) = display_manager_unit() else {
return TakeoverVerdict::Inapplicable {
why: TakeoverInapplicable::NoDisplayManager,
};
};
if !managed_session_available() {
return TakeoverVerdict::Inapplicable {
why: TakeoverInapplicable::NoManagedSession,
};
}
let Some(helper) = installed_dm_helper() else {
return TakeoverVerdict::Inapplicable {
why: TakeoverInapplicable::NoPackagedHelper,
};
};
let Some(user) = current_user_name() else {
return TakeoverVerdict::Inapplicable {
why: TakeoverInapplicable::UnknownUser,
};
};
let group = DM_HELPER_GROUP;
if user_in_group(&user, group) {
return TakeoverVerdict::Ok { user, group };
}
TakeoverVerdict::MissingMembership {
user,
dm,
helper,
group,
}
}
/// This process's login name, for a `usermod` line the operator can paste. From `id -un <uid>`
/// rather than `$USER`: a `systemd --user` unit's environment is whatever the manager was started
/// with, and the uid is the thing pkexec will actually resolve.
@@ -3615,6 +3657,85 @@ fn point_injector_at_eis() {
"gamescope: no connectable gamescope EIS socket found — input won't reach the session"
),
}
sync_session_keyboard_layout();
}
/// Explicit-off kill switch for [`sync_session_keyboard_layout`].
const LAYOUT_SYNC_ENV: &str = "PUNKTFUNK_SESSION_LAYOUT";
/// `setxkbmap` talks to a local X server; anything slower than this is a server that is not
/// answering, and the connecting client is waiting on us.
const LAYOUT_SYNC_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
/// Align the session's Xwayland servers with the box's configured keyboard layout.
///
/// [`xkb_env`] only reaches a session punktfunk LAUNCHES. The common case on a Bazzite / SteamOS
/// box is the opposite one: the gaming session is already up from autologin, punktfunk ATTACHES to
/// it, and nothing decided at launch time applies — its Xwayland servers keep the `us` they were
/// born with. That matters because everything a gamescope session runs (Steam Big Picture, and
/// every Proton game) is an X11 client of those servers, so their keymap is what turns punktfunk's
/// US-positional key codes into characters. Without this, a German keyboard types `\` for `#`,
/// `'` for `ä` and `/` for `-` no matter what `localectl` says.
///
/// Best-effort and idempotent — re-applying the layout a server already has is a no-op, so this
/// runs on every adoption rather than reading the current one back. It does nothing at all when
/// the box configured no layout (there is nothing to align *to*, and inventing one would be worse
/// than the default), when no gamescope Xwayland is running, or when `PUNKTFUNK_SESSION_LAYOUT` is
/// explicitly off.
///
/// ⚠ Xwayland only. A Wayland-native client under gamescope takes the compositor's own keymap,
/// which is [`xkb_env`] plus the `+pfhdr8` patch — the two halves are not interchangeable.
fn sync_session_keyboard_layout() {
if pf_host_config::env_on(LAYOUT_SYNC_ENV) == Some(false) {
return;
}
let resolved = pf_host_config::layout::system_layout();
let Some(layout) = resolved.names.layout.as_deref() else {
return;
};
let targets = xwayland_cursor_targets();
if targets.is_empty() {
return;
}
let non_empty = |v: &Option<String>| v.as_deref().filter(|s| !s.is_empty()).map(str::to_owned);
for (dpy, xauth) in targets {
let mut cmd = Command::new("setxkbmap");
cmd.args(["-display", &dpy, "-layout", layout]);
if let Some(v) = non_empty(&resolved.names.variant) {
cmd.args(["-variant", &v]);
}
if let Some(m) = non_empty(&resolved.names.model) {
cmd.args(["-model", &m]);
}
// Only when configured: `-option ""` is setxkbmap's CLEAR, not its no-op.
if let Some(o) = non_empty(&resolved.names.options) {
cmd.args(["-option", &o]);
}
if let Some(xa) = &xauth {
cmd.env("XAUTHORITY", xa);
}
match crate::proc::status_within(&mut cmd, LAYOUT_SYNC_BUDGET) {
Ok(st) if st.success() => tracing::info!(
display = %dpy,
layout = %resolved.names.describe(),
source = %resolved.source,
"gamescope: aligned the session's keyboard layout with the box"
),
Ok(st) => tracing::warn!(
display = %dpy,
status = ?st.code(),
"gamescope: setxkbmap rejected the box's layout — the session keeps its own"
),
// Overwhelmingly "setxkbmap is not installed" (it ships in xorg-x11-xkb-utils /
// x11-xkb-utils). Not fatal: only a non-US keyboard notices, and it is exactly the
// case the +pfhdr8 gamescope handles without any of this.
Err(e) => tracing::warn!(
display = %dpy,
error = %e,
layout = %resolved.names.describe(),
"gamescope: could not set the session's keyboard layout (is setxkbmap installed?)"
),
}
}
}
/// Mirror the physical head this gamescope session is driving (`vdisplay::mirror`'s gamescope arm).
@@ -4515,6 +4636,9 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
for arg in wsi.setenv_args() {
cmd.arg(arg);
}
for arg in xkb_setenv_args() {
cmd.arg(arg);
}
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
// user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY
// that would abort gamescope at startup.
@@ -4801,6 +4925,68 @@ fn cursor_args() -> Vec<String> {
args
}
/// The box's configured keyboard layout as `XKB_DEFAULT_*`, for a gamescope session we launch.
///
/// gamescope builds its xkb keymap from exactly these five variables and nothing else — it never
/// looks at `/etc/X11/xorg.conf.d/00-keyboard.conf`, which is where `localectl set-x11-keymap`
/// records the choice, because that file belongs to Xorg. Nothing in a systemd user session
/// exports them either, so a session launched without this runs libxkbcommon's built-in default:
/// evdev/pc105/**us**.
///
/// That matters because punktfunk's key wire is US-POSITIONAL — a client sends the *physical* key
/// and the session's keymap decides the character — so a US session renders a German keyboard's
/// ISO keys as their US neighbours (`#`→`\`, `ä`→`'`, `-`→`/`).
///
/// ⚠ Empty on a box that configured nothing, so an unconfigured session keeps behaving exactly as
/// it does today rather than being pinned to an invented layout.
///
/// ⚠ This is necessary but not, on its own, sufficient: gamescope only publishes the keymap to its
/// clients once a keyboard is actually bound to the seat, which on a HEADLESS session (no libinput
/// devices) needs the `punktfunk-gamescope` patch that gives the seat's stub keyboard the compiled
/// keymap. Against a stock gamescope these variables are read and then never reach Xwayland.
fn xkb_env() -> Vec<(&'static str, String)> {
let resolved = pf_host_config::layout::system_layout();
let pairs = resolved.names.env_pairs();
if pairs.is_empty() {
return pairs;
}
// Passed either way — it costs nothing against an older binary and starts working the moment
// the box updates — but say so, because "I set the layout and the keys are still US" is
// otherwise unexplainable from the outside.
if gamescope_honours_xkb_env() {
tracing::info!(
layout = %resolved.names.describe(),
source = %resolved.source,
"gamescope session: handing it the box's keyboard layout"
);
} else {
tracing::warn!(
layout = %resolved.names.describe(),
source = %resolved.source,
"gamescope session: this build ignores XKB_DEFAULT_* (needs punktfunk-gamescope \
+pfhdr8) the session will type US characters whatever the box is configured for"
);
}
pairs
}
/// [`xkb_env`] as `systemd-run --setenv=` arguments — mirrors [`WsiPlan::setenv_args`].
fn xkb_setenv_args() -> Vec<String> {
xkb_env()
.into_iter()
.map(|(name, value)| format!("--setenv={name}={value}"))
.collect()
}
/// [`xkb_env`] as unit-file lines for a drop-in. Trailing newline included, so whatever the body
/// puts after it still parses — same contract as [`WsiPlan::unit_lines`].
fn xkb_unit_lines() -> String {
xkb_env()
.into_iter()
.map(|(name, value)| format!("Environment={name}={value}\n"))
.collect()
}
/// `--custom-refresh-rates <list>` when the resolved gamescope has it (patch level 3+): the rates a
/// HEADLESS session may offer its clients.
///
@@ -4912,6 +5098,8 @@ fn spawn(
cmd.args(app.split_whitespace())
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
// The box's keyboard layout — see [`xkb_env`]. Empty on an unconfigured box.
.envs(xkb_env())
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
// a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale
// WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session)
@@ -507,6 +507,19 @@ pub(crate) fn gamescope_can_composite_cursor() -> bool {
gamescope_patch_level() >= 2 && !flags_lost()
}
/// Does the resolved gamescope actually PUBLISH the keymap it compiles from `XKB_DEFAULT_*`?
///
/// Below this level it reads the five variables, builds the keymap, and then hands its clients
/// nothing: the seat carries a keymap-less stub keyboard that `wlserver_keyboardfocus()` re-binds
/// on every focus change, so Xwayland and every Wayland client keep their own built-in `us`. A
/// headless session has no libinput devices to ever put the real keymap on the seat, so it never
/// recovers. The keys punktfunk injects are US-POSITIONAL and the session's keymap is what turns
/// them into characters — so on a stock gamescope a German keyboard types its US neighbours
/// (`#`→`\`, `ä`→`'`, `-`→`/`) no matter how the box is configured.
pub(crate) fn gamescope_honours_xkb_env() -> bool {
gamescope_patch_level() >= 8 && !flags_lost()
}
/// Does the resolved gamescope let us hand a headless session the list of refresh rates it may
/// offer (`--custom-refresh-rates`)?
///
@@ -396,6 +396,64 @@ pub fn preflight_takeover_privilege() {
#[cfg(not(target_os = "linux"))]
pub fn preflight_takeover_privilege() {}
/// Why the managed takeover's `punktfunk`-group prerequisite does not apply to this box. Each of
/// these alone makes the group irrelevant, so a box in any of these states must not be nagged —
/// but the reason is kept so a troubleshooting UI can say *which* one, instead of hiding the row
/// and leaving "why isn't this listed?" unanswerable.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TakeoverInapplicable {
/// Running as root: the plain system-bus `systemctl` verbs succeed, so the helper — and its
/// group check — is never reached.
Root,
/// No display manager drives this box's logins (getty autologin / an enabled user unit).
NoDisplayManager,
/// No `gamescope-session-plus`/SteamOS session infrastructure ⇒ no autologin gaming session to
/// free ⇒ no takeover.
NoManagedSession,
/// A tarball/source/Nix install: neither the packaged helper nor the group exists, and the
/// hand-written polkit rule from the docs is the route instead.
NoPackagedHelper,
/// The user's login name could not be resolved, so no usable `usermod` line could be produced.
UnknownUser,
/// The managed takeover is a Linux path.
NotLinux,
}
/// The takeover's one un-automatable prerequisite, as data.
///
/// Defined on every platform (like [`GamescopeRoute`]) because the host maps it into a wire check
/// regardless of target — off Linux it is always `Inapplicable { why: NotLinux }`. Membership is
/// the **user database's** answer, matching what `pf-dm-helper` itself asks.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TakeoverVerdict {
/// A gate excluded this box; `why` names which one.
Inapplicable { why: TakeoverInapplicable },
/// The takeover applies here and the user is already a member.
Ok { user: String, group: &'static str },
/// The takeover applies here and the user is **not** a member — every takeover will degrade
/// silently to mirroring the box's own session.
MissingMembership {
user: String,
dm: String,
helper: &'static str,
group: &'static str,
},
}
/// The gated verdict behind [`preflight_takeover_privilege`], for callers that want to render it
/// rather than log it (the host's diagnostics registry). Computing it does not log.
#[cfg(target_os = "linux")]
pub fn takeover_privilege_verdict() -> TakeoverVerdict {
gamescope::takeover_privilege_verdict()
}
#[cfg(not(target_os = "linux"))]
pub fn takeover_privilege_verdict() -> TakeoverVerdict {
TakeoverVerdict::Inapplicable {
why: TakeoverInapplicable::NotLinux,
}
}
/// Give the box its own session back **now**, synchronously, because the host is exiting. Blocks
/// (it shells out to `systemctl`), so call it off the async runtime. Call from the host's shutdown
/// path — a takeover that outlives the host leaves the box with no display manager and nobody left
+240 -46
View File
@@ -185,6 +185,24 @@ pub struct DecodedVkFrame {
/// the loss, and lift a freeze on a wave that completed before it. Comparing
/// this ordinal against the one current at the arm is what tells them apart.
pub decode_order: u64,
/// Every picture this one was predicted from came off a fully-available reference
/// chain ([`pf_bitstream::h264::PicturePlan::references_clean`] and its two twins).
/// `true` for an IDR/IRAP/key frame and for any picture whose whole chain is clean;
/// `false` from the moment this AU — or anything it descends from — needed
/// concealment.
///
/// It exists to let a consumer CORROBORATE a host's claim that a frame is a clean
/// re-anchor. `USER_FLAG_RECOVERY_ANCHOR` — the host's LTR-RFI recovery frame —
/// lifts a post-loss freeze on its FIRST occurrence, exactly like a real IDR,
/// because the host says the frame was coded against a known-good reference. The
/// host's "known-good" is an inference from what the client RECEIVED; this is what
/// the client actually DECODED. Where they disagree the freeze used to lift onto a
/// gray plate and every frame after it chained off the corruption, with nothing
/// left to re-arm the gate.
///
/// A consumer with no such claim to check can ignore it: it is an observation
/// about the stream, and nothing in this crate's own behaviour reads it.
pub references_clean: bool,
/// The decode op's slot in the status query pool.
pub query_slot: u32,
/// The decode op's submission ordinal (validates the query slot has not been
@@ -281,12 +299,24 @@ pub enum VkDecodeError {
/// correct consumer can never hit this). The AU was planned but NOT decoded;
/// release frames and request a keyframe.
NoFreeSlot,
/// A DPB slot this AU references holds no bound image. H.265 only, and fatal
/// rather than skippable: `StdVideoDecodeH265PictureInfo`'s RPS arrays are
/// INDICES into `pReferenceSlots`, so dropping one entry would silently
/// re-point every later index at the wrong picture — the exact class of
/// plausible-looking corruption this crate refuses to produce. (H.264 carries
/// no such index arrays and only traces the case.)
/// A DPB slot this AU references holds no bound image. Fatal on all three codecs
/// rather than skippable.
///
/// H.265 and AV1 have a structural argument: their picture info names DPB slots by
/// INDEX (the H.265 RPS arrays, AV1's name-indexed `refs`), so dropping one entry
/// silently re-points a later index at the wrong picture. H.264 has no such index
/// arrays, and used to skip the case with a `trace!` on exactly that reasoning —
/// but the reasoning was about the STRUCTURE, not the output. The hardware still
/// decodes a P-picture against a reference that was never bound, and on the
/// DPB-and-output-COINCIDE path that is a gray plate with the new frame's motion
/// painted over it. Nothing warned: the planner's DPB genuinely holds the picture
/// (the breakage is this crate's slot→image ledger), so the frame was shipped,
/// presented, and cleared the consumer's demotion streak on the way past —
/// invisible damage, which is the one outcome this crate exists to make impossible.
///
/// Failing closed is only safe because it is paired with recovery: the latch
/// ([`crate::decoder_h265::RecoveryLatch`]) flushes to the next IRAP/IDR rather
/// than leaving the stream wedged on a slot nothing can honour.
UnboundReferenceSlot { slot: u8 },
/// The frame belongs to a generation whose retired pool is already gone
/// (double release, or a frame outliving its graveyard entry).
@@ -615,6 +645,11 @@ pub(crate) struct PendingPic {
pub(crate) recovery: crate::recovery::RecoveryMark,
/// See [`DecodedVkFrame::decode_order`].
pub(crate) decode_order: u64,
/// Read off the plan at DECODE time and carried here for the same reason
/// [`Self::recovery`] is: display order is not decode order, and this describes
/// the picture rather than the moment it is delivered. See
/// [`DecodedVkFrame::references_clean`].
pub(crate) references_clean: bool,
}
/// A retired generation's picture pool: images the presenter still holds live
@@ -678,7 +713,15 @@ pub struct VkH264Decoder {
/// The outstanding recovery point SEI, if any — see [`crate::recovery`].
/// Survives session rebuilds on purpose: it is a fact about the STREAM's
/// prediction structure, not about this decoder's Vulkan objects.
///
/// Named apart from [`Self::recovery`], which is this decoder's DPB-recovery
/// latch: the two are unrelated (one is a fact about the stream's prediction
/// structure, the other about this decoder's own wedged state).
recovery_watch: crate::recovery::RecoveryWatch,
/// Post-failure DPB recovery owed — see
/// [`crate::decoder_h265::RecoveryLatch`], whose docs carry the whole
/// fail-closed/recover argument for all three codecs.
recovery: crate::decoder_h265::RecoveryLatch,
/// Pictures planned so far — stamped onto each one as
/// [`DecodedVkFrame::decode_order`]. Survives session rebuilds for the same
/// reason the watch does.
@@ -724,6 +767,7 @@ impl VkH264Decoder {
graveyard: Vec::new(),
last_warnings: Vec::new(),
recovery_watch: crate::recovery::RecoveryWatch::new(),
recovery: Default::default(),
decoded: 0,
generation: 0,
device_lost: false,
@@ -748,6 +792,12 @@ impl VkH264Decoder {
}
fn decode_inner(&mut self, au: &[u8]) -> Result<Option<DecodedVkFrame>, VkDecodeError> {
// A previous AU failed after its planning had advanced: clear the stale
// DPB residency BEFORE planning this one, or every AU referencing the
// stranded picture fails forever ([`RecoveryLatch`] docs).
if self.recovery.take() {
self.recover_dpb();
}
// `take_warnings` promises "cleared by the next decode", and this IS a
// decode: clear BEFORE planning, so an AU that fails to plan at all cannot
// leave the previous AU's warnings behind to be re-read as damage on the
@@ -784,7 +834,38 @@ impl VkH264Decoder {
);
}
self.ensure_state(&plan)?;
// From here the PLANNER has already advanced past this AU — its DPB holds
// the picture whatever happens next — so any failure below leaves the
// planner's DPB and this decoder's slot/image ledgers able to disagree.
// Latch the recovery for the next decode rather than returning into a
// permanently wedged state. (Deliberately wider than the paths that mutate
// the SlotMap: a failure BEFORE `plan_to_vk` mutates it — an `ensure_state`
// refusal, a `NoFreeSlot` — strands the picture the other way round,
// planner-resident with no slot at all, and wedges just as hard. One flush
// cures both.) The H.265 twin, for the same reason: `decoder_h265`'s
// `RecoveryLatch` docs carry the whole argument.
let result = self.decode_planned(&plan, au, recovery, decode_order);
if result.is_err() {
self.recovery.latch();
}
result
}
/// The submission half of one decode, from the point the planner has already
/// advanced. Split out so [`Self::decode_inner`] can latch recovery on ANY
/// failure past that line without threading a flag through every exit.
/// `au` is the same buffer `plan`'s slice ranges index into; `recovery` is the
/// recovery-point verdict already folded for this AU and `decode_order` its
/// decode-order ordinal (both advance in decode order, so neither can be
/// derived here — this path is not reached for every planned AU).
fn decode_planned(
&mut self,
plan: &AuPlan,
au: &[u8],
recovery: crate::recovery::RecoveryMark,
decode_order: u64,
) -> Result<Option<DecodedVkFrame>, VkDecodeError> {
self.ensure_state(plan)?;
let sps_id = plan.sps.seq_parameter_set_id;
// Convert, with ONE rebuild retry on CapacityMismatch — the designed
@@ -810,7 +891,7 @@ impl VkH264Decoder {
// satisfies ensure_parameters' Recreate contract, and Current/Add
// touch nothing a submitted decode reads.
unsafe { state.session.ensure_parameters(&plan.sps, &plan.pps)? };
match plan_to_vk(&plan, &mut state.slots, sps_id) {
match plan_to_vk(plan, &mut state.slots, sps_id) {
Ok(converted) => {
vk_plan = Some(converted);
break;
@@ -820,7 +901,7 @@ impl VkH264Decoder {
required,
capacity, "DPB depth renegotiated — rebuilding session"
);
self.rebuild_state(&plan)?;
self.rebuild_state(plan)?;
}
Err(e) => return Err(VkDecodeError::Convert(e)),
}
@@ -1002,6 +1083,7 @@ impl VkH264Decoder {
is_idr: plan.picture.is_idr,
recovery,
decode_order,
references_clean: plan.picture.references_clean,
},
);
Ok(())
@@ -1358,6 +1440,40 @@ impl VkH264Decoder {
}
}
/// Clear the DPB state a failed AU left behind, so planning resumes at the
/// next IDR instead of erroring on residency nothing can honour.
///
/// Three ledgers have to agree and, after a post-planning failure, do not:
/// the PLANNER's DPB, this decoder's [`SlotMap`], and the slot→image
/// bindings. [`Self::flush`] settles the first (and hands back any picture
/// that did reach output — those frames are real and are still delivered),
/// then [`crate::decoder_h265::reset_slot_bindings`] empties the other two.
/// Pool images the stale bindings pinned go back on the free list; images a
/// consumer still HOLDS stay pinned by their own `held` counts, exactly as
/// they would across a session rebuild.
///
/// Deliberately not a session rebuild: the session, pools and ring are all
/// still valid — only the DPB bookkeeping is stale — and a rebuild would
/// churn every image allocation for a condition an IDR fixes anyway.
///
/// The H.265 twin (`decoder_h265::recover_dpb`) is the same function one codec
/// over; the two share `reset_slot_bindings` rather than the whole body because
/// each has to call its OWN `flush`, which settles its own planner's DPB.
fn recover_dpb(&mut self) {
debug!("recovering from a failed AU — flushing the H.264 DPB to the next IDR");
self.flush();
if let Some(state) = &mut self.state {
let unbound = crate::decoder_h265::reset_slot_bindings(
&mut state.slots,
&mut state.slot_image,
&mut state.slot_refs,
);
for picture in unbound {
state.pool.pictures[picture].bound = false;
}
}
}
/// Session/caps for THIS plan exist and match its extent + profile, and the
/// stream sits inside the device's level ceiling. DPB-depth mismatches
/// surface later as `plan_to_vk`'s `CapacityMismatch` (the designed trigger)
@@ -1657,6 +1773,7 @@ pub(crate) fn build_frame(
is_idr: entry.is_idr,
recovery: entry.recovery,
decode_order: entry.decode_order,
references_clean: entry.references_clean,
query_slot: entry.query_slot,
submission: entry.submission,
picture: entry.image as u32,
@@ -1848,37 +1965,9 @@ unsafe fn record_and_submit(
}
// ---- bound-slot staging ----
// Scope list: this AU's references first, then every other still-held slot
// (their resources must stay bound for their associations to persist), then
// the setup slot as the ACTIVATION entry (slot index -1 binds its resource
// without a current association; the decode op's setup slot then claims it).
let mut scope: Vec<(i32, vk::ImageView, hh::StdVideoDecodeH264ReferenceInfo)> = Vec::new();
for r in &vk_plan.refs {
match slot_view(state, r.slot) {
Some(view) => scope.push((i32::from(r.slot), view, r.std)),
None => trace!(slot = r.slot, "referenced slot without a bound image"),
}
}
for (slot, _id) in state.slots.held() {
if slot == vk_plan.setup_slot
|| scope
.iter()
.any(|&(index, _, _)| index >= 0 && index as u8 == slot)
{
continue;
}
match (state.slot_refs[usize::from(slot)], slot_view(state, slot)) {
(Some(std), Some(view)) => scope.push((i32::from(slot), view, std)),
// Unreachable in practice: every held slot was a setup slot once.
_ => trace!(
slot,
"held slot without reference info/binding — left unbound"
),
}
}
let reference_count = vk_plan.refs.len().min(scope.len());
// The setup/dst resource: the fresh pool image (coincide) or the DPB layer
// (distinct — the pool image is the separate decode output).
// (distinct — the pool image is the separate decode output). Resolved before the
// scope is built, because it is the scope's last entry.
let setup_view = if coincide {
state.pool.pictures[dst].view
} else {
@@ -1888,31 +1977,48 @@ unsafe fn record_and_submit(
.expect("distinct mode")
.dpb_view(vk_plan.setup_slot)
};
scope.push((-1, setup_view, vk_plan.setup_ref));
// Scope list: this AU's references first, then every other still-held slot
// (their resources must stay bound for their associations to persist), then
// the setup slot as the ACTIVATION entry (slot index -1 binds its resource
// without a current association; the decode op's setup slot then claims it).
//
// Shared with H.265 (`decoder_h265::build_scope`): the two codecs' layout,
// fail-closed rule and reference-count derivation are the same algorithm over a
// different `StdVideo*` type, and this function's whole job is refusing to guess —
// the property least tolerant of two copies drifting apart.
let held: Vec<u8> = state.slots.held().map(|(slot, _id)| slot).collect();
let (scope, reference_count) = crate::decoder_h265::build_scope(
&vk_plan.refs,
held.into_iter(),
vk_plan.setup_slot,
setup_view,
vk_plan.setup_ref,
&state.slot_refs,
|slot| slot_view(state, slot),
)?;
// Staged arrays: resources → std infos → codec slot infos → slot infos. Each
// vector is fully built before the next borrows it, so nothing reallocates
// under a stored pointer.
let resources: Vec<vk::VideoPictureResourceInfoKHR<'_>> = scope
.iter()
.map(|&(_, view, _)| {
.map(|e| {
vk::VideoPictureResourceInfoKHR::default()
.coded_extent(coded_extent)
.base_array_layer(0)
.image_view_binding(view)
.image_view_binding(e.view)
})
.collect();
let std_refs: Vec<hh::StdVideoDecodeH264ReferenceInfo> =
scope.iter().map(|&(_, _, std)| std).collect();
let std_refs: Vec<hh::StdVideoDecodeH264ReferenceInfo> = scope.iter().map(|e| e.std).collect();
let mut dpb_infos: Vec<vk::VideoDecodeH264DpbSlotInfoKHR<'_>> = std_refs
.iter()
.map(|std| vk::VideoDecodeH264DpbSlotInfoKHR::default().std_reference_info(std))
.collect();
let mut begin_slots: Vec<vk::VideoReferenceSlotInfoKHR<'_>> = Vec::with_capacity(scope.len());
for (index, &(slot_index, _, _)) in scope.iter().enumerate() {
for (index, entry) in scope.iter().enumerate() {
begin_slots.push(
vk::VideoReferenceSlotInfoKHR::default()
.slot_index(slot_index)
.slot_index(entry.slot_index)
.picture_resource(&resources[index]),
);
}
@@ -2038,8 +2144,96 @@ unsafe fn record_and_submit(
#[cfg(test)]
mod tests {
use ash::vk::Handle as _;
use super::*;
/// A fake, never-dereferenced view handle keyed by slot, so a scope's bindings
/// can be checked without a device (the H.265 tests' idiom, one codec over).
fn fake_view(slot: u8) -> vk::ImageView {
vk::ImageView::from_raw(u64::from(slot) + 1)
}
/// A reference-info value carrying just the field the assertions read.
fn h264_std_ref(frame_num: u16) -> hh::StdVideoDecodeH264ReferenceInfo {
// SAFETY: StdVideoDecodeH264ReferenceInfo is a plain-C bindgen struct of a
// bitfield word and integers; all-zero is valid for every field.
let mut std: hh::StdVideoDecodeH264ReferenceInfo = unsafe { std::mem::zeroed() };
std.FrameNum = frame_num;
std
}
fn h264_ref(slot: u8, frame_num: u16) -> crate::pic::VkRef {
crate::pic::VkRef {
slot,
std: h264_std_ref(frame_num),
id: u64::from(slot),
}
}
/// The H.264 leg of the fail-closed rule. It used to trace-and-continue here, on
/// the grounds that H.264 carries no RPS index arrays — but the hardware still
/// decoded the picture against a reference that was never bound, which is a gray
/// plate with motion on it, shipped with no warning attached. Fail closed.
#[test]
fn an_h264_reference_slot_without_a_bound_image_fails_the_whole_op() {
let refs = vec![h264_ref(1, 10), h264_ref(3, 20)];
let slot_refs = vec![Some(h264_std_ref(0)); 8];
let err = crate::decoder_h265::build_scope(
&refs,
[1u8, 3].into_iter(),
0,
fake_view(0),
h264_std_ref(30),
&slot_refs,
|slot| (slot != 3).then(|| fake_view(slot)),
)
.unwrap_err();
assert!(
matches!(err, VkDecodeError::UnboundReferenceSlot { slot: 3 }),
"{err}"
);
}
/// `reference_count` must be the number of THIS AU's references and nothing else.
/// The old H.264 form (`refs.len().min(scope.len())`) was taken AFTER the
/// held-slot pass appended to the same vector, so a short refs list let the decode
/// op's reference array run past the references into unrelated held slots — a
/// picture predicted from something the stream never named.
#[test]
fn the_h264_reference_count_covers_the_references_and_never_a_held_slot() {
// Two references (slots 1, 3); slots 5 and 6 are held but NOT referenced.
let refs = vec![h264_ref(1, 10), h264_ref(3, 20)];
let slot_refs = vec![Some(h264_std_ref(77)); 8];
let (scope, reference_count) = crate::decoder_h265::build_scope(
&refs,
[1u8, 3, 5, 6].into_iter(),
0,
fake_view(0),
h264_std_ref(30),
&slot_refs,
|slot| Some(fake_view(slot)),
)
.unwrap();
assert_eq!(reference_count, 2, "exactly this AU's references");
assert_eq!(
scope[..reference_count]
.iter()
.map(|e| e.slot_index)
.collect::<Vec<_>>(),
vec![1, 3],
"the decode op's reference prefix is the references, in order"
);
// The rest of the scope keeps the other slots bound (so their associations
// survive) and ends on the setup activation entry — but none of that is a
// reference of this AU.
assert_eq!(
scope.iter().map(|e| e.slot_index).collect::<Vec<_>>(),
vec![1, 3, 5, 6, -1]
);
}
#[test]
fn settle_dpb_readies_outputs_in_order_and_returns_never_output_removals() {
let mut pending: BTreeMap<PicId, u32> = BTreeMap::new();
+1
View File
@@ -1114,6 +1114,7 @@ impl VkAv1Decoder {
is_idr: plan.picture.is_key,
recovery: crate::recovery::RecoveryMark::NONE,
decode_order,
references_clean: plan.picture.references_clean,
},
);
+104 -28
View File
@@ -139,13 +139,20 @@ struct SessionStateH265 {
///
/// This decoder FAILS CLOSED, and that stays: when an AU cannot be carried
/// through to a submitted decode, it returns an error rather than substituting a
/// reference or decoding against a slot whose image is gone. H.264's
/// soft-degrade (trace the missing binding, drop that reference, decode anyway)
/// is not available here because `StdVideoDecodeH265PictureInfo`'s
/// reference or decoding against a slot whose image is gone. The structural argument
/// is that `StdVideoDecodeH265PictureInfo`'s
/// `RefPicSetStCurrBefore`/`StCurrAfter`/`LtCurr` arrays hold INDICES into the
/// decode op's reference array — dropping one entry re-points every later index
/// at the wrong picture, which is the corruption-hiding class this crate refuses.
///
/// ⚠ H.264 used to soft-degrade here (trace the missing binding, drop that reference,
/// decode anyway) on the grounds that it carries no such index arrays. It now fails
/// closed and carries this same latch: the arrays were never the point, the OUTPUT
/// was. A P-picture decoded against a reference that was never bound is a gray plate
/// with motion painted over it, and because the planner raises no warning for it, that
/// frame reached the screen and cleared the consumer's demotion streak. Both codecs
/// now fail closed, and both recover through this latch rather than wedging.
///
/// But failing closed once must not wedge the stream FOREVER, and without this
/// latch it did: by the time an AU reaches a failure exit, `plan_to_vk_h265` has
/// already mutated the [`SlotMap`] (releases + the setup assignment) and the
@@ -636,6 +643,7 @@ impl VkH265Decoder {
is_idr: plan.picture.is_idr,
recovery,
decode_order,
references_clean: plan.picture.references_clean,
},
);
@@ -1247,10 +1255,15 @@ fn profile_key_for(plan: &AuPlan) -> Result<H265ProfileKey, VkDecodeError> {
/// let [`build_scope`] bind a slot the planner no longer knows about, which is the
/// same "plausible-looking picture in the wrong place" the unbound-reference
/// refusal exists to prevent.
fn reset_slot_bindings(
///
/// Generic over the cached reference-info type so H.264's recovery uses this exact
/// code rather than a twin: the three ledgers and the "empty them together" rule are
/// codec-independent (`SlotMap` is already shared), and only the `StdVideo*` type in
/// `slot_refs` differs.
pub(crate) fn reset_slot_bindings<S>(
slots: &mut SlotMap,
slot_image: &mut [Option<usize>],
slot_refs: &mut [Option<hh::StdVideoDecodeH265ReferenceInfo>],
slot_refs: &mut [Option<S>],
) -> Vec<usize> {
// `release` is the only way a slot is freed (SlotMap docs); the collect is
// because `held` borrows the map the releases mutate.
@@ -1279,10 +1292,55 @@ fn slot_view(state: &SessionStateH265, slot: u8) -> Option<vk::ImageView> {
/// (No derived equality: `StdVideoDecodeH265ReferenceInfo` is a plain-C bindgen
/// struct without it. Assertions compare the fields that carry meaning.)
#[derive(Debug, Clone, Copy)]
struct ScopeEntry {
slot_index: i32,
view: vk::ImageView,
std: hh::StdVideoDecodeH265ReferenceInfo,
pub(crate) struct ScopeEntry<S> {
pub(crate) slot_index: i32,
pub(crate) view: vk::ImageView,
pub(crate) std: S,
}
/// One of this AU's references, as [`build_scope`] needs to see it: a DPB slot and
/// the codec reference info to bind with it.
///
/// It exists so H.264 and H.265 share ONE scope builder instead of two hand-copies of
/// a function whose whole job is refusing to guess — the property most in need of a
/// single implementation. Their `VkRef`/`VkRefH265` differ only in the `StdVideo*`
/// type they carry, so the shape generalises exactly.
///
/// ⚠ AV1 deliberately keeps its own ([`crate::decoder_av1`]'s `build_scope_av1`): its
/// reference array is indexed by reference NAME and may hold HOLES, so its walk is a
/// different algorithm rather than the same one over a different Std type. Folding it
/// in here would mean a builder with a mode flag, which is how the two would drift.
pub(crate) trait ScopeRef {
/// The codec's `StdVideoDecode*ReferenceInfo`.
type Std: Copy;
/// The DPB slot this reference is bound in.
fn slot(&self) -> u8;
fn std(&self) -> Self::Std;
}
/// [`build_scope`]'s answer: the bound-slot list, and how many of its LEADING entries
/// are this AU's own references (the prefix the decode op takes as its reference
/// array — see the ordering note in `build_scope`'s docs).
pub(crate) type Scope<R> = (Vec<ScopeEntry<<R as ScopeRef>::Std>>, usize);
impl ScopeRef for crate::pic_h265::VkRefH265 {
type Std = hh::StdVideoDecodeH265ReferenceInfo;
fn slot(&self) -> u8 {
self.slot
}
fn std(&self) -> Self::Std {
self.std
}
}
impl ScopeRef for crate::pic::VkRef {
type Std = ash::vk::native::StdVideoDecodeH264ReferenceInfo;
fn slot(&self) -> u8 {
self.slot
}
fn std(&self) -> Self::Std {
self.std
}
}
/// Build the coding scope's bound-slot list and say how many leading entries are
@@ -1295,36 +1353,51 @@ struct ScopeEntry {
/// resources must stay bound even when this AU does not reference them);
/// 3. the setup slot as the activation entry, slot index `-1`.
///
/// A reference whose slot binds no image is a hard error, never a skip:
/// `StdVideoDecodeH265PictureInfo`'s `RefPicSetStCurrBefore`/`StCurrAfter`/
/// `LtCurr` arrays name DPB slots, and every slot they name is one of `refs`'
/// ([`crate::pic_h265`]) so dropping an entry leaves the hardware with a named
/// slot this op never bound, which it can only answer by guessing or failing.
/// Output that looks plausible and is wrong is the outcome this refusal exists to
/// prevent.
fn build_scope(
refs: &[crate::pic_h265::VkRefH265],
/// A reference whose slot binds no image is a hard error, never a skip. For H.265 the
/// argument is `StdVideoDecodeH265PictureInfo`'s `RefPicSetStCurrBefore`/`StCurrAfter`/
/// `LtCurr` arrays: they name DPB slots, every slot they name is one of `refs`'
/// ([`crate::pic_h265`]), so dropping an entry leaves the hardware with a named slot
/// this op never bound which it can only answer by guessing or failing.
///
/// H.264 has no such index arrays, and it used to skip the case with a `trace!` on
/// exactly that reasoning. The reasoning was wrong about the OUTPUT: the hardware
/// still decodes a P-picture against a reference that was never bound, which on the
/// DPB-and-output-COINCIDE path is a gray plate with the new frame's motion painted
/// over it — and because the planner raised no warning (its DPB genuinely holds the
/// picture; the breakage is in this ledger), the frame was shipped, presented, and
/// cleared the consumer's demotion streak on its way past. Both codecs fail closed
/// here now; the recovery latch is what keeps failing closed from wedging the stream.
///
/// `reference_count` is captured the instant the `refs` loop ends, BEFORE the
/// held-slot pass appends anything. That ordering is load-bearing: the decode op takes
/// `scope[..reference_count]` as its reference list, so a count computed after the
/// second pass could hand it a still-held slot that this AU does not reference, in
/// place of one that failed to resolve. (Fail-closed above makes that unreachable —
/// but the construction must be correct on its own, not by depending on a check
/// somewhere else.)
pub(crate) fn build_scope<R: ScopeRef>(
refs: &[R],
held_slots: impl Iterator<Item = u8>,
setup_slot: u8,
setup_view: vk::ImageView,
setup_ref: hh::StdVideoDecodeH265ReferenceInfo,
slot_refs: &[Option<hh::StdVideoDecodeH265ReferenceInfo>],
setup_ref: R::Std,
slot_refs: &[Option<R::Std>],
view_of: impl Fn(u8) -> Option<vk::ImageView>,
) -> Result<(Vec<ScopeEntry>, usize), VkDecodeError> {
let mut scope: Vec<ScopeEntry> = Vec::with_capacity(refs.len() + slot_refs.len() + 1);
) -> Result<Scope<R>, VkDecodeError> {
let mut scope: Vec<ScopeEntry<R::Std>> = Vec::with_capacity(refs.len() + slot_refs.len() + 1);
for r in refs {
match view_of(r.slot) {
match view_of(r.slot()) {
Some(view) => scope.push(ScopeEntry {
slot_index: i32::from(r.slot),
slot_index: i32::from(r.slot()),
view,
std: r.std,
std: r.std(),
}),
None => return Err(VkDecodeError::UnboundReferenceSlot { slot: r.slot }),
None => return Err(VkDecodeError::UnboundReferenceSlot { slot: r.slot() }),
}
}
let reference_count = scope.len();
for slot in held_slots {
if slot == setup_slot || refs.iter().any(|r| r.slot == slot) {
if slot == setup_slot || refs.iter().any(|r| r.slot() == slot) {
continue;
}
match (
@@ -1862,8 +1935,11 @@ mod tests {
let setup_slot = slots.assign(400).unwrap();
assert_eq!(setup_slot, 0, "the freed slots are assignable again");
slot_image[usize::from(setup_slot)] = Some(9);
// The empty slice needs its element type named now that `build_scope` is
// generic over the two codecs' reference types.
let no_refs: [VkRefH265; 0] = [];
let (scope, reference_count) = build_scope(
&[],
&no_refs,
slots.held().map(|(slot, _id)| slot),
setup_slot,
fake_view(setup_slot),
+15 -21
View File
@@ -38,19 +38,20 @@ use crate::{Av1PlanWarning, H265PlanWarning, PlanWarning};
/// property of the STREAM's signalling, which the decoder answers by failing to open
/// a session, not by showing a damaged frame.
///
/// Written as an EXHAUSTIVE match with no wildcard, deliberately. A `matches!` (or
/// a `_ => false`) makes "damage" the opt-in and silence the default, so a
/// `PlanWarning` added later — by definition one nobody here has classified —
/// would be reported as clean and its picture shown. Invisible damage is the bug
/// this whole program exists to end; the compiler is the only reviewer guaranteed
/// to be present when that variant is written, so it gets the decision.
/// ⚠ The classification itself now lives on the warning enum, in pf-bitstream
/// ([`PlanWarning::is_integrity`]), and this function delegates. It moved there when
/// the planners gained the per-picture clean bit
/// ([`pf_bitstream::h264::PicturePlan::references_clean`]): that ledger has to mark a
/// picture damaged on exactly the warnings a consumer conceals on, and it lives one
/// crate DOWN from here. A copy of the list in each crate would let the two disagree —
/// the planner recording a picture as clean while the client concealed it, or the
/// reverse — which is the same invisible-damage failure the single-list rule below was
/// written to prevent, one layer lower. One list, in the crate that owns the enum.
///
/// This function stays as the crate's public spelling of the question (the fault
/// harness, the client and the tests all name it) and keeps its exact semantics.
pub fn is_integrity_warning(w: &PlanWarning) -> bool {
match w {
PlanWarning::FrameNumGap { .. }
| PlanWarning::MissingReference { .. }
| PlanWarning::TruncatedAu { .. } => true,
PlanWarning::Mmco5Rebase | PlanWarning::LevelDerivedDpb { .. } => false,
}
w.is_integrity()
}
/// The H.265 twin — the same set pf-bitstream's own `h265` conformance harness
@@ -59,10 +60,7 @@ pub fn is_integrity_warning(w: &PlanWarning) -> bool {
/// Exhaustive for the same reason as [`is_integrity_warning`]: a new H.265 warning
/// must not be able to mean "damaged" and read as clean.
pub fn is_integrity_warning_h265(w: &H265PlanWarning) -> bool {
match w {
H265PlanWarning::MissingReference { .. } | H265PlanWarning::TruncatedAu { .. } => true,
H265PlanWarning::NonZeroReorder { .. } => false,
}
w.is_integrity()
}
/// The AV1 twin (M7). Every variant the AV1 planner has today IS damage, and that
@@ -93,11 +91,7 @@ pub fn is_integrity_warning_h265(w: &H265PlanWarning) -> bool {
/// Exhaustive for the same reason as [`is_integrity_warning`]: a new AV1 warning
/// must not be able to mean "damaged" and read as clean.
pub fn is_integrity_warning_av1(w: &Av1PlanWarning) -> bool {
match w {
Av1PlanWarning::MissingReference { .. }
| Av1PlanWarning::MissingShowExisting { .. }
| Av1PlanWarning::TruncatedAu { .. } => true,
}
w.is_integrity()
}
#[cfg(test)]
+3
View File
@@ -899,6 +899,9 @@ mod tests {
max_dpb_frames,
short_term_ref_pic_set_size_bits: 0,
recovery_point: None,
// These fixtures model a healthy stream; the clean bit is the planner's
// observation and nothing in this conversion layer reads it.
references_clean: true,
}
}
+9
View File
@@ -5167,6 +5167,15 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
/// `USER_FLAG_RECOVERY_POINT`. Pass `decoder_keyframe = false` where the platform decoder doesn't flag
/// IDRs (VideoToolbox/MediaCodec) — the wire `FLAG_SOF` covers it.
///
/// This is the uncorroborated entry point and deliberately stays that way. Rust embedders whose
/// decoder parses the bitstream call [`ReanchorGate::on_decoded_corroborated`] to let their own
/// parser refute a `USER_FLAG_RECOVERY_ANCHOR` that names a picture they had to conceal; every
/// client reachable through THIS surface (Apple VideoToolbox, Android MediaCodec) uses a platform
/// decoder that surfaces no such fact, so it would have nothing to pass but
/// `AnchorEvidence::Unavailable` — which is exactly what this wrapper already means. Growing a
/// second export for a corroboration no C caller can supply would spend an ABI version bump on
/// dead surface.
///
/// # Safety
/// `g` is a valid gate handle; `out_present` is writable or NULL.
#[unsafe(no_mangle)]
@@ -194,7 +194,7 @@ struct ReassemblyWindow {
/// partially-arrived frames of ACTUAL size (≪ max); without this cap, [`HARD_LOSS_WINDOW`]
/// max-sized declarations from one header-sized packet each could commit gigabytes — an
/// amplification the old sparse per-shard allocation didn't have.
const IN_FLIGHT_BUF_FACTOR: usize = 4;
pub(super) const IN_FLIGHT_BUF_FACTOR: usize = 4;
/// Recovery-shard buffer pool ceiling (shard-sized buffers): enough for several max-recovery
/// blocks in flight, small enough (~720 KB at a 1408-byte shard) to keep after a loss burst.
@@ -208,7 +208,12 @@ const RECOVERY_POOL_MAX: usize = 512;
/// can mint thousands of distinct-index blocks while its `FrameBuf::buf` stays pinned near zero —
/// they must be metered exactly like the buffer, or the firewall meters only half the allocation
/// (security-review 2026-08-15 finding 11).
fn block_state_bytes(data_shards: usize, recovery_shards: usize) -> usize {
///
/// `pub(super)` so the budget tests can locate the refusal boundary from the cost model itself
/// rather than from a baked-in frame count — [`BlockState`] gaining a field moves that boundary,
/// and a test that hard-codes it answers such a change with an arithmetic puzzle instead of the
/// question actually worth asking.
pub(super) fn block_state_bytes(data_shards: usize, recovery_shards: usize) -> usize {
std::mem::size_of::<BlockState>()
+ data_shards // have_data: Vec<bool>
+ recovery_shards * std::mem::size_of::<Option<Vec<u8>>>() // recovery slot table
+32 -9
View File
@@ -520,13 +520,19 @@ fn e2e_unrecoverable_loss_ages_out() {
/// gigabytes (the eager whole-frame buffer's amplification defense).
#[test]
fn in_flight_buffer_budget_bounds_allocation() {
let lim = limits(); // max_frame_bytes 4096, shards 16 B, ≤8 data shards × ≤4 blocks
// limits(): max_frame_bytes 4096, shards 16 B, ≤8 data shards × ≤4 blocks → budget 16384 B.
let lim = limits();
let budget = IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes;
// What ONE such frame commits: the largest geometry-consistent buffer (4 blocks × 8 shards
// × 16 B = 512 B) plus the state of the single block this first shard opens. Both are sized
// from header fields, so the firewall meters both — counting only the buffer is precisely
// the hole security-review 2026-08-15 #11 closed, and the boundary moved when it did.
let per_frame = 512 + block_state_bytes(8, 0);
let fits = budget / per_frame;
let mut r = Reassembler::new(lim);
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
// Largest geometry-consistent frame: 4 blocks × 8 shards × 16 B = 512 B per buffer.
// Budget = 4 × 4096 = 16384 B → exactly 32 such frames fit; the 33rd must be refused.
for i in 0..33u32 {
for i in 0..=fits as u32 {
let mut h = base_header();
h.frame_index = i;
h.frame_bytes = 512;
@@ -539,6 +545,14 @@ fn in_flight_buffer_budget_bounds_allocation() {
1,
"the frame past the budget is dropped, everything under it accepted"
);
// The point of the whole exercise: whatever the geometry, the commitment stays under the
// ceiling. Asserted on the live figure, so a release site that forgets half the cost (the
// 0.23.0 accounting-drift lesson on `in_flight`) fails here and not in the field.
assert!(
r.in_flight() <= budget,
"in-flight commitment {} must never exceed the {budget} B budget",
r.in_flight(),
);
}
/// A header whose (data_shards, block_count) disagree with the geometry derived from its own
@@ -1519,11 +1533,15 @@ fn streamed_open_commits_its_own_extent_and_stays_bounded() {
);
// A SLICE sentinel whose wire base sits just under the ceiling really does commit a
// max-sized frame (base 3968 B + K 8 = 256 shards = 4096 B) — four fit the budget, the
// fifth must be refused.
let mut r = Reassembler::new(limits());
// max-sized frame (base 3968 B + K 8 = 256 shards = 4096 B), plus the state of the block it
// opens — so the budget takes fewer of these than the buffer alone would suggest, and the
// first one past it must be refused.
let lim = limits();
let budget = IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes;
let fits = budget / (4096 + block_state_bytes(8, 0));
let mut r = Reassembler::new(lim);
let stats = StatsCounters::default();
for fi in 0..5u32 {
for fi in 0..=fits as u32 {
let mut h = base_header();
h.user_flags = USER_FLAG_SLICE_STREAM;
h.block_count = 0;
@@ -1537,10 +1555,15 @@ fn streamed_open_commits_its_own_extent_and_stays_bounded() {
.unwrap()
.is_none());
}
assert!(
r.in_flight() <= budget,
"in-flight commitment {} must never exceed the {budget} B budget",
r.in_flight(),
);
assert_eq!(
stats.snapshot().packets_dropped,
1,
"the fifth ceiling-claiming open must be refused by the in-flight budget"
"the first ceiling-claiming open past the budget must be refused"
);
}
+294 -3
View File
@@ -20,6 +20,30 @@
//! VideoToolbox, every FFmpeg rung, which exposes no SEI) simply never calls it and every wire
//! behaviour above is bit-for-bit unchanged.
//!
//! # The one claim a client can REFUTE
//!
//! Of the three lifts, two are self-evident to the client and one is pure hearsay. An IDR predicts
//! from nothing, so "this re-anchors decode" is a property of the picture itself. A recovery mark is
//! only *half* a re-anchor and the gate says so by requiring two. But
//! [`USER_FLAG_RECOVERY_ANCHOR`] is the HOST asserting a fact about the CLIENT's decoder — *the
//! picture I coded this P-frame against is one you still hold, intact* — and until
//! [`AnchorEvidence`] existed the client took it on faith, on the first occurrence, with no
//! scrutiny at all.
//!
//! When that assertion is wrong the failure is the worst-shaped one in this module: the anchor lifts
//! the freeze onto a picture predicted from a reference the client had to conceal, so the gray plate
//! reaches the screen AND the gate stops holding, which means it keeps reaching the screen until
//! some later signal re-arms. A re-anchor claim the client can refute is therefore worse than no
//! claim at all — no claim merely holds the last good frame until the backstop.
//!
//! So a client whose decoder parses the bitstream corroborates it: it already knows which pictures
//! this AU predicts from and whether each of those decoded from a complete reference chain, and
//! [`on_decoded_corroborated`](ReanchorGate::on_decoded_corroborated) refuses an anchor whose
//! references it can prove were damaged. Refusing can only ever make the gate hold LONGER — the
//! freeze stays up, the backstop fires on its ORIGINAL deadline, and the client escalates to a real
//! IDR — which is the direction every other rule here errs in, deliberately. Lanes that cannot
//! answer pass [`AnchorEvidence::Unavailable`] and behave exactly as they always have.
//!
//! [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT
//! [`USER_FLAG_RECOVERY_ANCHOR`]: crate::packet::USER_FLAG_RECOVERY_ANCHOR
@@ -97,8 +121,9 @@ pub fn index_gap(expected: u32, got: u32) -> Option<u32> {
/// Fold one decoded frame into the re-anchor state and decide whether it lifts the post-loss freeze.
///
/// `is_keyframe` — a real IDR (always a clean re-anchor). `has_anchor` — this AU carried
/// [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR), the host's definitive
/// single-frame re-anchor from an LTR-RFI recovery (a clean P-frame coded against a known-good
/// [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR) **and the caller did not
/// refute it** ([`AnchorEvidence`]), the host's definitive single-frame re-anchor from an LTR-RFI
/// recovery (a clean P-frame coded against a known-good
/// reference), so it lifts on the FIRST occurrence exactly like an IDR — no two-mark wait. `has_mark` —
/// this AU carried [`USER_FLAG_RECOVERY_POINT`](crate::packet::USER_FLAG_RECOVERY_POINT), a
/// host-signalled intra-refresh wave boundary (only *half* a re-anchor). `marks` — recovery marks seen
@@ -158,6 +183,44 @@ impl LocalRecovery {
};
}
/// What a client's OWN parser can say about the host's re-anchor claim on one decoded frame — the
/// corroboration for [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR).
///
/// An anchor is the host asserting something about the CLIENT's decoder: *this P-frame is coded
/// against a picture you still hold, intact, so decoding it re-anchors you*. The host derives that
/// from its own slot bookkeeping — which tracks whether the client RECEIVED a frame, not whether it
/// DECODED that frame from a complete reference chain. Those two differ exactly when the client had
/// to conceal, and the gap between them is what puts a gray plate on screen with the freeze lifted.
///
/// Three states rather than a bool, for the same reason [`LocalRecovery`] is two facts: a lane that
/// *cannot* answer must be able to say so instead of being folded into "nothing wrong here". Only
/// [`Self::ReferencesDamaged`] changes any behaviour; the other two are indistinguishable to the
/// gate and differ only in what they claim at the call site.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnchorEvidence {
/// This lane has no local bitstream parser, so it cannot corroborate or refute anything — the
/// host's claim stands, exactly as it always has. Android MediaCodec, Apple VideoToolbox and
/// every lane reached over the C ABI pass this, and their behaviour is bit-for-bit unchanged.
#[default]
Unavailable,
/// Corroborated: every picture this AU predicts from was itself decoded from a fully-available
/// reference chain, so the host's claim is consistent with what this decoder actually holds.
ReferencesClean,
/// Refuted: this AU predicts from a picture that needed concealment. Whatever the host believes,
/// decoding this frame cannot re-anchor a decoder whose reference for it is already damaged, so
/// the anchor does not lift the freeze.
ReferencesDamaged,
}
impl AnchorEvidence {
/// May an [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR) on this frame
/// be honoured? Only an outright refutation withholds it — silence is not refutation, so a lane
/// that cannot corroborate never becomes *stricter* than it was.
fn honours_anchor(self) -> bool {
!matches!(self, AnchorEvidence::ReferencesDamaged)
}
}
/// Whether a decoded frame should be shown or withheld while the gate is (or isn't) frozen.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateVerdict {
@@ -333,6 +396,11 @@ impl ReanchorGate {
/// A decoded frame always clears the no-output streak. When frozen, a live mark stream pushes the
/// backstop out ([`RECOVERY_MARK_PATIENCE`]) so a healing wave isn't pre-empted by a mid-heal IDR.
///
/// This is the whole-hearsay entry point: it believes an anchor on sight. A client whose decoder
/// parses the bitstream should call
/// [`on_decoded_corroborated`](Self::on_decoded_corroborated) instead and let its own parser
/// check the host's claim.
///
/// [`USER_FLAG_RECOVERY_ANCHOR`]: crate::packet::USER_FLAG_RECOVERY_ANCHOR
/// [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT
pub fn on_decoded(
@@ -340,10 +408,49 @@ impl ReanchorGate {
wire_flags: u32,
decoder_keyframe: bool,
now: Instant,
) -> GateVerdict {
self.on_decoded_corroborated(
wire_flags,
decoder_keyframe,
AnchorEvidence::Unavailable,
now,
)
}
/// [`on_decoded`](Self::on_decoded) for a client that can CHECK the host's re-anchor claim
/// against its own decoder — the native-decode lanes, which parse every AU themselves and so
/// know both which pictures this one predicts from and whether each of those decoded cleanly.
///
/// `evidence` is consulted for exactly one thing: whether a
/// [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR) on THIS frame may
/// lift the freeze. [`AnchorEvidence::ReferencesDamaged`] withholds that lift and nothing else,
/// and the two exclusions are as deliberate as the rule itself:
///
/// * **A real IDR still lifts.** It predicts from nothing, so no evidence about its references
/// can bear on it — and the IDR is precisely the escalation a refused anchor is trying to
/// provoke. Refusing it would turn the fix into the permanent freeze it exists to avoid.
/// * **The two-mark [`USER_FLAG_RECOVERY_POINT`](crate::packet::USER_FLAG_RECOVERY_POINT) rule
/// is untouched**, including its [`RECOVERY_MARK_PATIENCE`] deadline push. An intra-refresh
/// wave heals by overwriting stripes rather than by predicting from one named picture, so
/// "this frame's references were damaged" says nothing about whether the wave completed.
///
/// A refused anchor also leaves the backstop deadline exactly where the arm put it. That is the
/// point rather than an omission: the freeze becomes overdue on its ORIGINAL schedule, [`poll`](Self::poll)
/// re-asks, and the client escalates to a real IDR — the recovery the host's anchor failed to
/// deliver. Pushing the deadline out on a refusal would reward a host whose anchors do not work
/// with a longer wait.
pub fn on_decoded_corroborated(
&mut self,
wire_flags: u32,
decoder_keyframe: bool,
evidence: AnchorEvidence,
now: Instant,
) -> GateVerdict {
self.no_output_streak = 0;
let is_keyframe = decoder_keyframe || (wire_flags & FLAG_SOF as u32 != 0);
let has_anchor = wire_flags & USER_FLAG_RECOVERY_ANCHOR != 0;
// An anchor the client's own parser refutes is not an anchor. Folded in HERE rather than
// inside `reanchor_after_frame` so that function stays a pure statement of the wire rules.
let has_anchor = wire_flags & USER_FLAG_RECOVERY_ANCHOR != 0 && evidence.honours_anchor();
let has_mark = wire_flags & USER_FLAG_RECOVERY_POINT != 0;
if has_mark && self.awaiting {
self.deadline = Some(now + RECOVERY_MARK_PATIENCE);
@@ -888,4 +995,188 @@ mod tests {
assert!(!g.poll(0, t + Duration::from_millis(1)));
assert!(g.is_holding());
}
// ---- the corroborated-anchor path (AnchorEvidence) ----
use AnchorEvidence::{ReferencesClean, ReferencesDamaged, Unavailable};
/// The headline. The host says "this P-frame re-anchors you"; the client's own parser says the
/// picture it predicts from is one IT had to conceal. Both cannot be true, and the client's
/// statement is about its OWN decoder — so the anchor does not lift and the gray plate the
/// anchor would have presented never reaches the screen.
#[test]
fn an_anchor_whose_references_the_decoder_concealed_does_not_lift() {
let mut g = ReanchorGate::new(0);
let now = t0();
g.arm(now);
assert_eq!(
g.on_decoded_corroborated(ANCHOR, false, ReferencesDamaged, now),
GateVerdict::Hold,
"a refuted anchor is not a re-anchor"
);
assert!(g.is_holding(), "and the freeze stays up");
// Repeating it changes nothing — a host that keeps sending anchors it cannot honour never
// talks its way past the gate.
assert_eq!(
g.on_decoded_corroborated(ANCHOR, false, ReferencesDamaged, now),
GateVerdict::Hold
);
assert!(g.is_holding());
}
/// The escalation a refusal exists to provoke must still work. An IDR predicts from nothing, so
/// no evidence about damaged references can bear on it — refusing it too would convert this fix
/// into the permanent freeze it is meant to avoid.
#[test]
fn a_real_idr_lifts_even_while_the_evidence_refutes_anchors() {
// The decoder's own keyframe flag...
let mut g = ReanchorGate::new(0);
let now = t0();
g.arm(now);
assert_eq!(
g.on_decoded_corroborated(ANCHOR, false, ReferencesDamaged, now),
GateVerdict::Hold
);
assert_eq!(
g.on_decoded_corroborated(0, true, ReferencesDamaged, now),
GateVerdict::Present,
"the IDR re-anchors regardless of what the anchor evidence says"
);
assert!(!g.is_holding());
// ...and the wire's FLAG_SOF, for the lanes whose decoder does not flag IDRs.
let mut g = ReanchorGate::new(0);
g.arm(now);
assert_eq!(
g.on_decoded_corroborated(SOF, false, ReferencesDamaged, now),
GateVerdict::Present
);
assert!(!g.is_holding());
}
/// A corroborated anchor is still an anchor: the whole point is to refuse the ones the client
/// can disprove, not to stop honouring the mechanism.
#[test]
fn a_corroborated_anchor_lifts_on_the_first_occurrence() {
let mut g = ReanchorGate::new(0);
let now = t0();
g.arm(now);
assert_eq!(
g.on_decoded_corroborated(0, false, ReferencesClean, now),
GateVerdict::Hold,
"an ordinary frame is still withheld"
);
assert_eq!(
g.on_decoded_corroborated(ANCHOR, false, ReferencesClean, now),
GateVerdict::Present
);
assert!(!g.is_holding());
}
/// `Unavailable` is the promise made to every lane without a local parser: silence is not
/// refutation. This walks the same sequences the wire-path tests above assert and requires the
/// identical verdicts through the corroborated entry point.
#[test]
fn an_uncorroborated_lane_behaves_exactly_as_it_always_has() {
// The anchor lift, byte for byte the `a_gap_lifts_on_the_first_rfi_anchor` contract.
let mut g = ReanchorGate::new(0);
let now = t0();
g.arm(now);
assert_eq!(
g.on_decoded_corroborated(0, false, Unavailable, now),
GateVerdict::Hold
);
assert_eq!(
g.on_decoded_corroborated(ANCHOR, false, Unavailable, now),
GateVerdict::Present
);
assert!(!g.is_holding());
// And `on_decoded` — which every such lane actually calls — must agree with it exactly.
let mut wire = ReanchorGate::new(0);
let mut corroborated = ReanchorGate::new(0);
wire.arm(now);
corroborated.arm(now);
for flags in [0, POINT, 0, ANCHOR, SOF, 0] {
assert_eq!(
wire.on_decoded(flags, false, now),
corroborated.on_decoded_corroborated(flags, false, Unavailable, now),
"flags {flags:#x} diverged between the two entry points"
);
assert_eq!(wire.is_holding(), corroborated.is_holding());
}
}
/// A refused anchor must not buy the host time. The freeze becomes overdue on the deadline the
/// ARM set — not one pushed out by the refusal — so the client escalates to the real IDR that
/// the failed anchor did not deliver.
#[test]
fn a_refused_anchor_leaves_the_backstop_on_its_original_deadline() {
let mut g = ReanchorGate::new(0);
let start = t0();
g.arm(start);
// Anchors keep arriving and keep being refused, right up to the deadline.
for ms in [10, 100, 300, 490] {
assert_eq!(
g.on_decoded_corroborated(
ANCHOR,
false,
ReferencesDamaged,
start + Duration::from_millis(ms)
),
GateVerdict::Hold
);
assert!(!g.poll(0, start + Duration::from_millis(ms)), "not yet due");
}
let overdue = start + REANCHOR_FREEZE_MAX + Duration::from_millis(1);
assert!(
g.poll(0, overdue),
"the backstop fires on the arm's own deadline — the refusals did not extend it"
);
assert!(
g.is_holding(),
"and it keeps holding, never resuming to gray"
);
}
/// Refuting an anchor says nothing about an intra-refresh wave: a wave heals by overwriting
/// stripes rather than by predicting from one named picture, so the two-mark rule and its
/// patience deadline must be untouched by the evidence.
#[test]
fn refuted_anchors_do_not_disturb_the_two_mark_rule() {
let mut g = ReanchorGate::new(0);
let now = t0();
g.arm(now);
assert_eq!(
g.on_decoded_corroborated(POINT, false, ReferencesDamaged, now),
GateVerdict::Hold,
"mark #1 is still only half a re-anchor"
);
// An anchor in between is refused and must not consume or reset the mark count.
assert_eq!(
g.on_decoded_corroborated(ANCHOR, false, ReferencesDamaged, now),
GateVerdict::Hold
);
assert_eq!(
g.on_decoded_corroborated(POINT, false, ReferencesDamaged, now),
GateVerdict::Present,
"mark #2 lifts exactly as it does on the wire path"
);
assert!(!g.is_holding());
}
/// The evidence is consulted only while an anchor flag is actually present — a refutation on an
/// ordinary frame must not become a second, sticky reason to hold.
#[test]
fn damaged_evidence_alone_neither_holds_nor_arms_an_unfrozen_gate() {
let mut g = ReanchorGate::new(0);
let now = t0();
assert_eq!(
g.on_decoded_corroborated(0, false, ReferencesDamaged, now),
GateVerdict::Present,
"an unfrozen gate presents; the evidence is about anchors, not about frames"
);
assert!(!g.is_holding());
assert!(!g.poll(0, now));
}
}
@@ -138,6 +138,22 @@ pub(crate) struct CaptureStats {
/// memory. Every one of these used to `return` silently, so a stream that fired its callback
/// on time and handed us nothing looked identical to a stream nobody was feeding.
pub(crate) missed_dequeues: u64,
/// Spans this window spent with the stream NOT in `Streaming`, and how long they totalled.
///
/// `gaps` deliberately cannot see these (see [`Self::observe_callback`]) — a paused stream
/// fires no callbacks at all, so there is no delta to score and the caller drops its cadence
/// stamp on every transition. The cost of that correct decision was that the outage went
/// somewhere else entirely: into `delivered_pct`, as an unattributed shortfall, because the
/// reporting window is flushed from the callback and therefore STRETCHES by exactly the time
/// we were not being scheduled.
///
/// Measured on a live host on 2026-08-15: a 16.2 s pause produced
/// `delivered_pct=63 gaps=0 max_gap_ms=0`. Every number was correct and the line still could
/// not say what happened — the explanation existed only in the state DEBUG lines, which a
/// field journal at INFO does not carry. These two fields are that explanation, at INFO,
/// beside the percentage they explain.
pub(crate) pauses: u64,
pub(crate) paused_us: u64,
}
impl CaptureStats {
@@ -157,9 +173,13 @@ impl CaptureStats {
///
/// `since_last` is `None` for the first callback of a stream — and, deliberately, for the
/// first after a state transition: the caller drops its stamp when the stream pauses, so a
/// legitimately Paused span is not scored as one enormous hole. (The Paused↔Streaming flaps
/// around a format renegotiation stay visible as the state DEBUG lines next to a small
/// post-resume gap, which is the honest reading of what happened.)
/// legitimately Paused span is not scored as one enormous hole.
///
/// That leaves this counter about ONE thing — holes inside a stream that is running — and
/// pushes the other kind onto [`Self::observe_pause`]. The split matters because the two want
/// opposite answers: a run of sub-10 ms holes is a scheduling problem on the box, whereas a
/// multi-second pause is our node not being in the graph at all. A single "gap" number that
/// mixed them would be worse than either.
///
/// `quantum` is the NEGOTIATED buffer duration, not the one we asked for: a graph handing us
/// 21.3 ms buffers is not gapping when its callbacks are 21.3 ms apart — it is doing exactly
@@ -183,6 +203,21 @@ impl CaptureStats {
self.max_gap_us / 1_000
}
/// Record one span the stream spent away from `Streaming`.
///
/// Called on the transition BACK, so the whole span lands in the window that is flushed after
/// the resume — which is the same window whose `delivered_pct` the span diluted. Keeping the
/// two together is the entire point: apart, neither is interpretable.
pub(crate) fn observe_pause(&mut self, span: Duration) {
self.pauses += 1;
self.paused_us += span.as_micros() as u64;
}
/// Total time away from `Streaming` this window, in whole ms.
pub(crate) fn paused_ms(&self) -> u64 {
self.paused_us / 1_000
}
/// `(peak dBFS, rms dBFS, delivered %)` for this window. Silence reports -120 dB rather than
/// -inf so the log line stays parseable.
pub(crate) fn summary(&self, elapsed: Duration, sample_rate: u32) -> (f64, f64, f64) {
@@ -199,6 +234,76 @@ impl CaptureStats {
}
}
/// A departure this far past its slot is a slip worth counting rather than ordinary jitter: one
/// whole protocol frame, so a frame that merely rounds late never scores.
const LATE_DEPARTURE: Duration = Duration::from_millis(FRAME_MS as u64);
/// One reporting window of AUDIO EGRESS vitals (WP-C).
///
/// Capture has been instrumented since WP-A2 and the send path has not, so a field log could show
/// audio arriving at the tap and say nothing whatsoever about how it left. That asymmetry is not
/// neutral: it made "the host paces audio badly" unfalsifiable, and an unfalsifiable suspect stays
/// on the list forever. Across five 2026-08-15 field logs the entire egress path emitted 14 lines,
/// all of them the same session-open banner.
///
/// The point of these counters is to be *boring*. If departures are clean while capture reports
/// holes, the pacing rework introduced in v0.25 is acquitted permanently and the search moves
/// upstream for good.
#[derive(Default)]
pub(crate) struct SendStats {
pub(crate) sent: u64,
/// Frames synthesized to cover a capture hole. Wire continuity and captured continuity are
/// different claims and a log that conflates them cannot be used to judge either.
pub(crate) infilled: u64,
/// Departures that missed their paced slot by at least [`LATE_DEPARTURE`].
pub(crate) late: u64,
/// The worst such miss, µs — kept even when the count is zero, because "never late" and
/// "never late by a whole frame" are different statements.
pub(crate) max_late_us: u64,
/// Widest gap between two consecutive departures, µs. The number a client-side starvation
/// complaint is actually about: the wire going quiet, whatever the reason.
pub(crate) max_spacing_us: u64,
/// Times the schedule fell more than `PACE_REANCHOR` behind and was re-anchored instead of
/// chased. Each one silently forgives accumulated debt, which is exactly the kind of event
/// that leaves no trace and then gets blamed on the network.
pub(crate) reanchors: u64,
}
impl SendStats {
/// Score one frame leaving the host. `late` is how far past its paced slot it went (zero when
/// the schedule is unanchored), `since_prev` the spacing from the previous departure.
pub(crate) fn observe_departure(
&mut self,
late: Duration,
since_prev: Option<Duration>,
infilled: bool,
) {
self.sent += 1;
if infilled {
self.infilled += 1;
}
self.max_late_us = self.max_late_us.max(late.as_micros() as u64);
if late >= LATE_DEPARTURE {
self.late += 1;
}
if let Some(gap) = since_prev {
self.max_spacing_us = self.max_spacing_us.max(gap.as_micros() as u64);
}
}
pub(crate) fn observe_reanchor(&mut self) {
self.reanchors += 1;
}
pub(crate) fn max_late_ms(&self) -> u64 {
self.max_late_us / 1_000
}
pub(crate) fn max_spacing_ms(&self) -> u64 {
self.max_spacing_us / 1_000
}
}
/// How long a capture hole may run before the wire starts covering it. Two protocol frames: long
/// enough that ordinary quantum jitter never trips it, short enough that the client's ring never
/// notices the hole.
@@ -525,4 +630,127 @@ mod tests {
assert_eq!(s.gaps, 0);
assert_eq!(s.max_gap_ms(), 0);
}
/// The companion to the test above, and the reason it is safe: a pause stays out of `gaps`,
/// but it does NOT stay out of the log line. Numbers are the ones measured on a live host on
/// 2026-08-15, where a 16.2 s pause reported `delivered_pct=63 gaps=0 max_gap_ms=0` and no
/// field in the line could say why.
#[test]
fn a_paused_span_is_reported_even_though_it_is_not_a_gap() {
let mut s = CaptureStats::default();
s.observe_callback(Some(Duration::from_millis(5)), Q);
s.observe_pause(Duration::from_millis(16_214));
s.observe_callback(None, Q); // resumed
s.observe_callback(Some(Duration::from_millis(5)), Q);
assert_eq!(s.gaps, 0, "a pause is still not a delivery gap");
assert_eq!(s.max_gap_ms(), 0);
assert_eq!(s.pauses, 1, "…but it is now countable");
assert_eq!(s.paused_ms(), 16_214);
}
/// One long outage and a burst of short flaps must not read alike — the same argument that
/// makes `gaps` and `max_gap_ms` two fields instead of one. The triple here is the shape every
/// Skynet and AVALON session start produced: three dwells, no format actually changing.
#[test]
fn pause_spans_accumulate_and_stay_countable() {
let mut long = CaptureStats::default();
long.observe_pause(Duration::from_millis(38_400));
let mut flappy = CaptureStats::default();
for ms in [12_534, 17_030, 8_765] {
flappy.observe_pause(Duration::from_millis(ms));
}
assert_eq!(long.pauses, 1);
assert_eq!(flappy.pauses, 3);
assert_eq!(flappy.paused_ms(), 38_329);
assert!(
long.paused_ms().abs_diff(flappy.paused_ms()) < 100,
"near-identical dead time, and the count is the only thing that separates them"
);
}
/// The discriminator the field logs needed. A stream that is running and starved reports gaps
/// and NO pause; a stream that was never scheduled reports the mirror image. Both dilute
/// `delivered_pct` identically, which is exactly why neither can be diagnosed from it alone.
#[test]
fn starvation_and_absence_are_told_apart() {
let mut starved = CaptureStats::default();
for _ in 0..60 {
starved.observe_callback(Some(Duration::from_millis(30)), Q);
}
let mut absent = CaptureStats::default();
absent.observe_pause(Duration::from_millis(1_800));
assert_eq!(starved.gaps, 60);
assert_eq!(starved.pauses, 0, "a running stream was never absent");
assert_eq!(absent.gaps, 0);
assert_eq!(absent.pauses, 1, "an absent stream never got to be slow");
assert_eq!(absent.paused_ms(), 1_800);
}
/// The acquittal case, and the whole reason [`SendStats`] exists: a pacer doing its job must
/// produce a line a reader can dismiss at a glance.
#[test]
fn a_healthy_pacer_reports_nothing_alarming() {
let mut s = SendStats::default();
let frame = Duration::from_millis(FRAME_MS as u64);
for i in 0..200 {
s.observe_departure(Duration::ZERO, (i > 0).then_some(frame), false);
}
assert_eq!(s.sent, 200);
assert_eq!(s.late, 0);
assert_eq!(s.reanchors, 0);
assert_eq!(s.infilled, 0);
assert_eq!(s.max_late_ms(), 0);
assert_eq!(s.max_spacing_ms(), FRAME_MS as u64);
}
/// Lateness under one frame is jitter, not a slip — but it must still be *visible*, or
/// "never late" and "never late by a whole frame" become the same report.
#[test]
fn sub_frame_lateness_is_measured_without_being_counted() {
let mut s = SendStats::default();
s.observe_departure(Duration::from_micros(3_400), None, false);
assert_eq!(s.late, 0, "3.4 ms has not slipped a whole 5 ms slot");
assert_eq!(s.max_late_ms(), 3, "…and it is still on the record");
}
/// A slot missed by a whole frame or more is the event the field logs could never show.
#[test]
fn a_slipped_slot_is_counted_and_its_worst_case_kept() {
let mut s = SendStats::default();
s.observe_departure(Duration::from_millis(6), None, false);
s.observe_departure(
Duration::from_millis(41),
Some(Duration::from_millis(47)),
false,
);
s.observe_departure(Duration::ZERO, Some(Duration::from_millis(5)), false);
s.observe_reanchor();
assert_eq!(s.late, 2);
assert_eq!(s.max_late_ms(), 41);
assert_eq!(s.max_spacing_ms(), 47, "the wire's worst quiet stretch");
assert_eq!(s.reanchors, 1);
}
/// Wire continuity is not captured continuity. A window whose frames were all synthesized
/// looks perfect on every other counter, and must not be readable as healthy audio.
#[test]
fn synthesized_frames_stay_distinguishable_from_captured_ones() {
let mut s = SendStats::default();
let frame = Duration::from_millis(FRAME_MS as u64);
for _ in 0..100 {
s.observe_departure(Duration::ZERO, Some(frame), true);
}
assert_eq!(s.sent, 100);
assert_eq!(
s.infilled, 100,
"every one of these was silence we invented"
);
assert_eq!(s.late, 0);
}
}
+135 -41
View File
@@ -395,6 +395,11 @@ const MIC_STALE: Duration = Duration::from_secs(1);
/// against the same number the ask used.
const CAPTURE_QUANTUM_FRAMES: u32 = 240;
/// Callbacks that must agree on a new buffer size before it replaces the one gaps are scored
/// against. Three is enough to reject a boundary artefact and still adopt a genuine re-plan
/// within ~15 ms.
const QUANTUM_CONFIRM: u8 = 3;
fn mic_pw_thread(
pcm_rx: Receiver<(std::time::Instant, Vec<f32>)>,
quit_rx: pipewire::channel::Receiver<Terminate>,
@@ -686,9 +691,19 @@ fn pw_thread(
use pw::{properties::properties, spa};
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use spa::pod::Pod;
// The stream's `process` callbacks run ON this mainloop thread (we never hand PipeWire a
// separate data loop), so PipeWire's own client `module-rt` boost of its data loops does not
// cover it — the ~2.7 ms capture quantum lives or dies by this thread's scheduling.
// ⚠ This boosts the MAINLOOP thread, which is NOT where the capture callback runs.
//
// The previous comment here asserted the opposite ("we never hand PipeWire a separate data
// loop"), and it was wrong: we pass `RT_PROCESS` below, so libpipewire runs `process()` on a
// data loop it creates and schedules itself. Measured in one live host process on 2026-08-15
// — this thread at SCHED_OTHER/nice 0, `data-loop.0` at SCHED_RR/20. That mattered more than
// a stale comment usually does: a field investigation read the boost's success line as
// evidence that the audio callback was prioritised, and spent a round concluding priorities
// were "engaged but insufficient" when they had never been applied to the thread in question.
//
// The boost is kept — this thread still dispatches state and format events, and it IS the
// capture thread when `PUNKTFUNK_STREAM_SINK=0` selects the legacy monitor path. What replaces
// the assumption is a measurement: the callback reports its own scheduling on first entry.
pf_frame::thread_qos::boost_thread_priority(true);
// Setup errors funnel through the ready handshake (mirrors mic_pw_thread's IIFE).
@@ -782,12 +797,17 @@ fn pw_thread(
channels: u32,
stats: crate::audio::capture_policy::CaptureStats,
last_stats: std::time::Instant,
/// Whether this OPEN has reported its negotiated buffer size yet. Per-open, not the
/// process-wide `static AtomicBool` this replaces: a host runs for days across many
/// sessions, so the old form reported the very first capture of the process and then
/// never again — the one number that identifies a clamped quantum, invisible on every
/// Frames per callback the graph is currently handing us, `0` until the first is
/// confirmed. Per-open, not a process-wide latch: a host runs for days across many
/// sessions, so a process-wide form reported the very first capture and then never
/// again — the one number that identifies a clamped quantum, invisible on every
/// subsequent open (including every reopen after a device change).
reported_quantum: bool,
quantum_frames: usize,
/// A buffer size seen but not yet believed, with how many callbacks in a row have
/// agreed on it. Stops one short buffer from moving the gap threshold.
quantum_candidate: Option<(usize, u8)>,
/// Whether this open has reported the scheduling of the thread running `process()`.
reported_sched: bool,
/// When the callback last ran (WP-A2), so its CADENCE can be scored and not just its
/// content. Cleared across a state transition — a deliberate Paused span must not
/// read as one enormous hole. Lives here rather than in `stats` because the stats
@@ -804,19 +824,25 @@ fn pw_thread(
/// Shared with the capturer — see [`PwAudioCapturer::active`]. Read on every
/// failed hand-off to keep parked-capturer backpressure out of the drop count.
active: Arc<AtomicBool>,
/// When the stream last left `Streaming`, so the span can be charged to the window
/// that the span itself stretched. `None` while streaming.
paused_since: Option<std::time::Instant>,
}
let ud = CapUd {
tx,
channels,
stats: Default::default(),
last_stats: std::time::Instant::now(),
reported_quantum: false,
quantum_frames: 0,
quantum_candidate: None,
reported_sched: false,
last_cb: None,
quantum: Duration::from_micros(
CAPTURE_QUANTUM_FRAMES as u64 * 1_000_000 / SAMPLE_RATE as u64,
),
negotiated: None,
active,
paused_since: None,
};
let _listener = stream
.add_local_listener_with_user_data(ud)
@@ -829,6 +855,22 @@ fn pw_thread(
// existing. Scoring it would report one huge hole per renegotiation and bury
// the sub-10 ms ones the field log is actually about (WP-A2).
ud.last_cb = None;
// …but it still has to be reported, because the reporting window is flushed
// from the process callback and therefore stretches by the whole span. Charge
// it to the window flushed after the resume — the same window it diluted.
// Without this the line says `delivered_pct=4 gaps=0` and cannot say whether
// that is a dead capture path or a sink nobody was rendering into; the
// 2026-08-15 field logs are 40 s of exactly that ambiguity per session start.
match new {
pw::stream::StreamState::Streaming => {
if let Some(since) = ud.paused_since.take() {
ud.stats.observe_pause(since.elapsed());
}
}
_ => {
ud.paused_since.get_or_insert_with(std::time::Instant::now);
}
}
// A stream error is unrecoverable for this instance — exit so the sessions'
// reopen path builds a fresh one (same contract as the core-error path above).
if matches!(new, pw::stream::StreamState::Error(_)) {
@@ -888,6 +930,24 @@ fn pw_thread(
ud.last_cb = Some(now);
ud.stats.observe_callback(since_last, ud.quantum);
if !ud.reported_sched {
ud.reported_sched = true;
// Say what the thread that ACTUALLY runs this callback is scheduled as.
// Whether the capture callback is realtime decides whether a Wine shader
// storm can deschedule it for tens of ms at a 2.7 ms quantum, and until
// now no log anywhere carried the answer — only that we had asked for a
// boost, on a different thread. Once per open, off the hot path after
// that.
let (policy, rt_priority, nice) =
pf_frame::thread_qos::current_thread_sched();
tracing::info!(
policy,
rt_priority,
nice,
"audio capture callback scheduling"
);
}
let Some(mut buffer) = stream.dequeue_buffer() else {
ud.stats.missed_dequeues += 1;
return;
@@ -913,42 +973,70 @@ fn pw_thread(
let region = &buf[offset..(offset + size).min(buf.len())];
// Negotiated as F32LE; reinterpret the byte region as interleaved f32.
let n = region.len() / 4;
if !ud.reported_quantum {
ud.reported_quantum = true;
// What we ASKED for vs what PipeWire actually handed us. Stating only the
// result ("samples=2048") reads as a fact about the device; stating it
// next to the request is what makes a clamp legible. A VM is the common
// cause — stock `pipewire.conf` raises `default.clock.min-quantum` to
// 1024 whenever `cpu.vm.name` is set, so a 5 ms ask silently becomes
// 21.3 ms and the audio plane starts arriving in bursts. That cost a
// whole field investigation to find; it should cost one log line.
let frames = n / (ud.channels.max(1) as usize);
let want = CAPTURE_QUANTUM_FRAMES as usize;
// What a gap is measured against from here on — see `CapUd::quantum`.
if frames > 0 {
// Track the quantum the graph is ACTUALLY handing us, not merely the first one
// it ever did. The graph re-plans whenever anything else on the box asks for a
// different latency, and latching the first callback of the open left every
// subsequent gap scored against a buffer size that no longer existed — a
// silent corruption of the one metric this whole diagnosis rests on. A new
// size has to survive `QUANTUM_CONFIRM` callbacks before it is believed,
// because one short buffer at a boundary is not a new deal.
let frames = n / (ud.channels.max(1) as usize);
if frames > 0 && frames != ud.quantum_frames {
let streak = match ud.quantum_candidate {
Some((f, c)) if f == frames => c.saturating_add(1),
_ => 1,
};
if streak < QUANTUM_CONFIRM {
ud.quantum_candidate = Some((frames, streak));
} else {
let was = ud.quantum_frames;
ud.quantum_frames = frames;
ud.quantum_candidate = None;
// What a gap is measured against from here on — see `CapUd::quantum`.
ud.quantum = Duration::from_micros(
frames as u64 * 1_000_000 / SAMPLE_RATE as u64,
);
let want = CAPTURE_QUANTUM_FRAMES as usize;
let negotiated_ms =
format!("{:.1}", frames as f32 * 1000.0 / SAMPLE_RATE as f32);
if was != 0 {
// A mid-open change. Rare, and worth a line of its own: it moves
// the gap threshold under a reader who is comparing windows.
tracing::info!(
previous_frames = was,
negotiated_frames = frames,
negotiated_ms,
"the audio graph re-planned our quantum mid-stream"
);
} else if frames > want {
// What we ASKED for vs what PipeWire actually handed us. Stating
// only the result ("samples=2048") reads as a fact about the
// device; stating it next to the request is what makes a clamp
// legible. A VM is the common cause — stock `pipewire.conf` raises
// `default.clock.min-quantum` to 1024 whenever `cpu.vm.name` is
// set, so a 5 ms ask silently becomes 21.3 ms and the audio plane
// starts arriving in bursts. That cost a whole field
// investigation to find; it should cost one log line.
tracing::warn!(
requested_frames = want,
negotiated_frames = frames,
negotiated_ms,
"the audio graph refused our low-latency quantum — capture \
arrives in bursts this size, and the client must buffer at \
least that much to play them smoothly. On a VM this is \
PipeWire's `default.clock.min-quantum = 1024` rule; check \
`pw-metadata -n settings`"
);
} else {
tracing::info!(
requested_frames = want,
negotiated_frames = frames,
"audio capture quantum negotiated"
);
}
}
if frames > want {
tracing::warn!(
requested_frames = want,
negotiated_frames = frames,
negotiated_ms =
format!("{:.1}", frames as f32 * 1000.0 / SAMPLE_RATE as f32),
"the audio graph refused our low-latency quantum — capture arrives \
in bursts this size, and the client must buffer at least that \
much to play them smoothly. On a VM this is PipeWire's \
`default.clock.min-quantum = 1024` rule; check \
`pw-metadata -n settings`"
);
} else {
tracing::info!(
requested_frames = want,
negotiated_frames = frames,
"audio capture quantum negotiated"
);
}
} else if frames == ud.quantum_frames {
ud.quantum_candidate = None;
}
let mut samples = Vec::with_capacity(n);
for i in 0..n {
@@ -991,6 +1079,12 @@ fn pw_thread(
// percentage and mean entirely different things.
gaps = ud.stats.gaps,
max_gap_ms = ud.stats.max_gap_ms(),
// The OTHER thing a shortfall can be (see `CaptureStats::pauses`):
// time our node was not in the graph at all. `gaps` deliberately
// cannot see it, so without these two a paused span and a starved
// stream are the same number.
pauses = ud.stats.pauses,
paused_ms = ud.stats.paused_ms(),
missed_dequeues = ud.stats.missed_dequeues,
dropped_chunks = ud.stats.dropped_chunks,
"desktop audio capture"
File diff suppressed because it is too large Load Diff
+21 -11
View File
@@ -231,11 +231,13 @@ pub fn dualsense_test(args: &[String]) -> Result<()> {
Ok(())
}
/// Mint one pad-audio PipeWire sink (the Linux 0xD1 source, `audio::pad_sink`) and capture
/// from it — the WP3 on-glass gate with no client involved. Verify the identity with
/// `pactl list sinks` (name/description/proplist) and drive it with
/// `pw-play --target <node.name> <file>` (or `paplay -d <node.name>`); captured chunks print
/// a per-second summary here. `--pad N` (default 0), `--edge`, `--seconds N` (default 30).
/// Mint one pad's audio node graph (the Linux 0xD1 source, `audio::pad_sink`) and capture the
/// mix — the WP3 on-glass gate with no client involved. Three nodes appear, mirroring the split a
/// physically connected DualSense presents: a mono `Speaker__sink`, a positioned-quad
/// `SpeakerHaptic__sink`, and the hidden AUX parent. Verify the identity with `pactl list sinks`
/// (name/description/proplist) and drive any of them with `pw-play --target <node.name>`;
/// captured chunks print a per-second summary here. `--pad N` (default 0), `--edge`,
/// `--seconds N` (default 30).
#[cfg(target_os = "linux")]
pub fn pad_sink_test(args: &[String]) -> Result<()> {
use crate::audio::AudioCapturer as _;
@@ -256,18 +258,26 @@ pub fn pad_sink_test(args: &[String]) -> Result<()> {
let mut cap = crate::audio::pad_sink::PadSinkCapturer::open(pad, edge)
.context("mint pad-audio sink (is PipeWire running in this session?)")?;
println!(
"pad sink minted: node.name = {}\n api.alsa.split.name = {} (what GE-Proton opens as \
pipewire:NODE=)\n inspect: pactl list sinks | grep -A25 Speaker__sink\n \
drive it: pw-play --target '{}' --channel-map 'AUX0,AUX1,AUX2,AUX3' <48k-file>\n \
(a POSITIONED wav folds into the speaker pair and never reaches the coils the \
channel-map is not optional)\nCapturing for {secs}s",
"pad nodes minted (the split a real DualSense presents):\n \
speaker sink = {} (mono GE-Proton's is_dualsense_speaker_sink target)\n \
haptic sink = {} (4ch POSITIONED FL,FR,RL,RR the public quad a real pad shows)\n \
parent = {} (4ch AUX0..AUX3, hidden what GE opens as pipewire:NODE=)\n \
inspect: pactl list sinks | grep -A25 Speaker\n \
drive the coils via the POSITIONED sink (what a real pad's writers use):\n \
pw-play --target '{}' --channel-map 'front-left,front-right,rear-left,rear-right' <48k-file>\n \
drive the coils via the AUX parent (GE's own leg):\n \
pw-play --target '{}' --channel-map 'AUX0,AUX1,AUX2,AUX3' <48k-file>\n \
(a POSITIONED wav aimed at the AUX PARENT still folds into the speaker pair that is \
why the positioned sink exists)\nCapturing for {secs}s",
cap.node_name,
cap.haptic_name,
if cap.split_name.is_empty() {
"(suppressed)"
} else {
cap.split_name.as_str()
},
cap.node_name
cap.haptic_name,
cap.split_name,
);
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut chunks, mut samples) = (0u64, 0u64);
+536
View File
@@ -0,0 +1,536 @@
//! Host diagnostics: one structured channel for the health verdicts this host already computes.
//!
//! The class of bug this exists for is **the host knows, and the person operating it has no way to
//! find out**. The `.181` incident is the type specimen: `preflight_takeover_privilege()` is a
//! careful, applicability-gated probe that distinguishes user-database membership from the running
//! process's supplementary groups — and it spends all of that care on one WARN log line, which a
//! console-driven update never shows anyone. Every failure class before this had either its own
//! bespoke surface or none at all.
//!
//! So verdicts become data. The registry lives here; the **probes stay in their owning crates**
//! (`pf-inject`, `pf-vdisplay`, `crate::detect`) and export plain verdict enums — nothing in those
//! crates learns about [`HostCheck`], and no reverse dependency is created. This module maps
//! verdict → check and owns every wire string.
//!
//! Two rules the catalog must keep:
//!
//! * **English fallback text is mandatory, not a courtesy.** The web console is a separate package
//! and canary setups pair console N with host N±1, so the console localizes by `id` when it knows
//! the id and renders the wire text when it does not. An id that ships without `summary`/`impact`
//! is unreadable on any console that predates it — [`tests::every_non_ok_check_carries_fallback_text`]
//! enforces this rather than trusting a convention.
//! * **`inapplicable` is a first-class status, not an absent row.** A box that will never attempt a
//! takeover must not be nagged, but the troubleshooting page still has to be able to answer "why
//! isn't this check relevant here?" on demand.
//!
//! Served by `mgmt/diagnostics.rs` on the authenticated admin lane only: usernames, group layout and
//! device-node state must not widen the unauthenticated loopback surface the tray reads.
use serde::Serialize;
use std::collections::BTreeMap;
use std::sync::{OnceLock, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use utoipa::ToSchema;
pub(crate) mod catalog;
/// Stable check ids. These are the console's i18n keys, so they are API: renaming one silently
/// drops a translation back to the wire fallback on every console.
pub mod ids {
pub const TAKEOVER_PRIVILEGE: &str = "takeover_privilege";
pub const VIRTUAL_DECK_VHCI: &str = "virtual_deck_vhci";
pub const UINPUT_ACCESS: &str = "uinput_access";
pub const SERVER_CONFLICT: &str = "server_conflict";
}
/// What a probe found. `Inapplicable` is deliberately distinct from `Ok`: "this box will never do
/// the thing" and "the thing works here" are different answers, and the troubleshooting page shows
/// them differently.
#[derive(Serialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
Ok,
Warn,
Fail,
Inapplicable,
}
impl CheckStatus {
/// Does this status want the operator's attention? `inapplicable` does not — that is the whole
/// point of the status existing.
pub fn needs_attention(self) -> bool {
matches!(self, CheckStatus::Warn | CheckStatus::Fail)
}
}
/// How much a non-ok status matters. Orthogonal to [`CheckStatus`] on purpose: a check can be
/// `warn` about something `critical` (degraded, not dead) and the console sorts by both.
#[derive(Serialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Warning,
Critical,
}
impl Severity {
fn rank(self) -> u8 {
match self {
Severity::Critical => 0,
Severity::Warning => 1,
Severity::Info => 2,
}
}
}
/// What the operator should do about it. Always copy-paste — the host runs unprivileged and the
/// console must never trigger privileged mutation. The `punktfunk` group in particular is
/// deliberately opt-in: writing the vhci `attach` node materialises arbitrary emulated USB devices
/// (security review 2026-08-05, M-4), so joining it stays a deliberate act with the caveat attached.
#[derive(Serialize, ToSchema, Clone, Debug, PartialEq, Eq)]
pub struct Remedy {
/// Plain-language instruction. English fallback — the console overrides it by check id.
pub text: String,
/// A single pasteable shell command, when one fixes it outright.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
/// True when the fix only takes effect after logging out and back in — a `systemd --user`
/// manager keeps the supplementary group set it started with. This distinction is the
/// difference between "I already added myself!" and a working virtual pad.
pub relogin_required: bool,
}
/// Where a verdict came from. `Event` is reserved for the live feeds (transitions push instead of
/// waiting for a refresh); v1 produces only `Startup` and `Refresh`.
#[derive(Serialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CheckSource {
Startup,
Event,
Refresh,
}
/// One health verdict. This IS the wire shape.
#[derive(Serialize, ToSchema, Clone, Debug)]
pub struct HostCheck {
/// Stable snake_case machine code — the console's i18n key (see [`ids`]).
pub id: String,
pub status: CheckStatus,
/// What a non-ok status means. Meaningless when `status` is `ok`/`inapplicable`; carried anyway
/// so a check never changes shape as it flips.
pub severity: Severity,
/// One line, English. The console replaces this with a localized message when it knows `id`.
pub summary: String,
/// What actually breaks, in the operator's terms. Empty only for `ok`/`inapplicable` rows.
pub impact: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remedy: Option<Remedy>,
/// Interpolation values for the console's localized strings (`{user}`, `{group}`, …). The
/// console needs these because it cannot re-derive them: only the host can see the username.
pub params: BTreeMap<String, String>,
/// First non-ok observation in this host run. Per-run bookkeeping, not a time series — there is
/// no history here by design.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since_unix: Option<u64>,
pub source: CheckSource,
}
impl HostCheck {
/// An applicable, healthy result.
pub fn ok(id: &str, summary: impl Into<String>) -> Self {
Self {
id: id.to_string(),
status: CheckStatus::Ok,
severity: Severity::Info,
summary: summary.into(),
impact: String::new(),
remedy: None,
params: BTreeMap::new(),
since_unix: None,
source: CheckSource::Startup,
}
}
/// "This check does not apply to this box" — `why` says which gate excluded it, so the
/// troubleshooting page can answer the question instead of hiding the row.
pub fn inapplicable(id: &str, why: impl Into<String>) -> Self {
Self {
id: id.to_string(),
status: CheckStatus::Inapplicable,
severity: Severity::Info,
summary: why.into(),
impact: String::new(),
remedy: None,
params: BTreeMap::new(),
since_unix: None,
source: CheckSource::Startup,
}
}
/// A problem. `impact` is required here — a warning without a consequence is noise the operator
/// cannot act on.
pub fn problem(
id: &str,
status: CheckStatus,
severity: Severity,
summary: impl Into<String>,
impact: impl Into<String>,
) -> Self {
Self {
id: id.to_string(),
status,
severity,
summary: summary.into(),
impact: impact.into(),
remedy: None,
params: BTreeMap::new(),
since_unix: None,
source: CheckSource::Startup,
}
}
pub fn with_remedy(mut self, remedy: Remedy) -> Self {
self.remedy = Some(remedy);
self
}
pub fn with_param(mut self, key: &str, value: impl Into<String>) -> Self {
self.params.insert(key.to_string(), value.into());
self
}
/// Worst-first ordering key: attention-needing rows before healthy ones, then by severity, then
/// `fail` ahead of `warn`, then by id so the list is stable across refreshes.
fn order_key(&self) -> (u8, u8, u8, &str) {
let bucket = match self.status {
CheckStatus::Fail | CheckStatus::Warn => 0,
CheckStatus::Ok => 1,
CheckStatus::Inapplicable => 2,
};
let status_rank = match self.status {
CheckStatus::Fail => 0,
CheckStatus::Warn => 1,
_ => 2,
};
(bucket, self.severity.rank(), status_rank, &self.id)
}
}
/// The `GET /diagnostics` body.
#[derive(Serialize, ToSchema, Clone, Debug)]
pub struct DiagnosticsReport {
/// When the probes last ran (unix seconds).
pub ran_at_unix: u64,
/// Every registered check, worst-first. Includes `ok` and `inapplicable` rows — the console
/// decides what to hide, because "what's working" is the reassurance the dashboard omits.
pub checks: Vec<HostCheck>,
}
type Probe = Box<dyn Fn() -> HostCheck + Send + Sync>;
/// The registry: probes in, current verdicts out.
#[derive(Default)]
pub struct Diagnostics {
probes: RwLock<Vec<Probe>>,
checks: RwLock<BTreeMap<String, HostCheck>>,
ran_at_unix: RwLock<u64>,
}
impl Diagnostics {
pub fn new() -> Self {
Self::default()
}
/// Add a probe. Called once per check at startup; probes are cheap by contract (an `open()`, a
/// `getgrnam`, a stat) because `POST /diagnostics/refresh` re-runs all of them synchronously.
pub fn register(&self, probe: impl Fn() -> HostCheck + Send + Sync + 'static) {
self.probes.write().unwrap().push(Box::new(probe));
}
/// Run every probe and replace the cached verdicts. `since_unix` is preserved across runs for a
/// check that was already non-ok, so "since" means what it says.
pub fn run_all(&self, source: CheckSource) {
// Probes are run WITHOUT the checks lock held: they touch the filesystem and spawn `id`, and
// a slow NSS lookup must not block a concurrent GET.
let fresh: Vec<HostCheck> = {
let probes = self.probes.read().unwrap();
probes.iter().map(|p| p()).collect()
};
let now = now_unix();
let mut checks = self.checks.write().unwrap();
for mut check in fresh {
let previous_since = prior_since(checks.get(&check.id));
check.source = source;
carry_since(&mut check, previous_since, now);
checks.insert(check.id.clone(), check);
}
*self.ran_at_unix.write().unwrap() = now;
}
/// Feed one verdict from an event source (a `PadGate` transition, a driver watcher). Returns
/// whether the *status* actually changed — the caller emits an SSE event only on a transition,
/// never once per backoff retry.
pub fn set(&self, mut check: HostCheck) -> bool {
let now = now_unix();
let mut checks = self.checks.write().unwrap();
let previous = checks.get(&check.id);
let changed = previous.is_none_or(|p| p.status != check.status);
let previous_since = prior_since(previous);
check.source = CheckSource::Event;
carry_since(&mut check, previous_since, now);
checks.insert(check.id.clone(), check);
changed
}
/// Current verdicts, worst-first.
pub fn report(&self) -> DiagnosticsReport {
let checks = self.checks.read().unwrap();
let mut checks: Vec<HostCheck> = checks.values().cloned().collect();
checks.sort_by(|a, b| a.order_key().cmp(&b.order_key()));
DiagnosticsReport {
ran_at_unix: *self.ran_at_unix.read().unwrap(),
checks,
}
}
/// How many attention-needing checks there are, split by severity — the only diagnostics shape
/// the unauthenticated loopback summary may ever carry (counts, never details).
#[allow(dead_code)] // consumed by the tray's LocalSummary once the live feeds land
pub fn attention_counts(&self) -> (u32, u32) {
let checks = self.checks.read().unwrap();
let mut warning = 0;
let mut critical = 0;
for c in checks.values().filter(|c| c.status.needs_attention()) {
match c.severity {
Severity::Critical => critical += 1,
_ => warning += 1,
}
}
(warning, critical)
}
}
/// The stamp a still-unhealthy check should inherit — `None` once it has recovered, so a later
/// relapse is dated from the relapse rather than from the original.
fn prior_since(previous: Option<&HostCheck>) -> Option<u64> {
previous
.filter(|p| p.status.needs_attention())
.and_then(|p| p.since_unix)
}
/// Keep the original first-observed stamp while a check stays non-ok; clear it when it recovers.
fn carry_since(check: &mut HostCheck, previous_since: Option<u64>, now: u64) {
check.since_unix = check
.status
.needs_attention()
.then(|| previous_since.unwrap_or(now));
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// The process-wide registry. A global rather than an `AppState` field because the probes are
/// process-scoped (there is one set of device nodes and one group membership per host), and because
/// it keeps the mgmt handlers free of a state extractor — the same shape `crate::hooks::store()`,
/// `crate::detect::snapshot()` and the log ring already use.
pub fn registry() -> &'static Diagnostics {
static REGISTRY: OnceLock<Diagnostics> = OnceLock::new();
REGISTRY.get_or_init(|| {
let reg = Diagnostics::new();
catalog::register_all(&reg);
reg
})
}
/// Take the first reading. Called once from `native::serve` startup, after the subsystems the
/// probes inspect are up. The catalog itself is registered lazily by [`registry`], so a `GET` that
/// somehow arrives first still describes a known set of checks rather than an empty list.
pub fn preflight() {
let reg = registry();
reg.run_all(CheckSource::Startup);
let report = reg.report();
let attention = report
.checks
.iter()
.filter(|c| c.status.needs_attention())
.count();
tracing::debug!(
checks = report.checks.len(),
attention,
"diagnostics: startup probes complete"
);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
fn fail(id: &str) -> HostCheck {
HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Critical,
"broken",
"nothing works",
)
.with_remedy(Remedy {
text: "fix it".into(),
command: None,
relogin_required: false,
})
}
#[test]
fn refresh_reruns_every_probe() {
let reg = Diagnostics::new();
let runs = Arc::new(AtomicUsize::new(0));
let counter = runs.clone();
reg.register(move || {
counter.fetch_add(1, Ordering::SeqCst);
HostCheck::ok("probe", "fine")
});
reg.run_all(CheckSource::Startup);
assert_eq!(runs.load(Ordering::SeqCst), 1);
assert_eq!(reg.report().checks[0].source, CheckSource::Startup);
reg.run_all(CheckSource::Refresh);
assert_eq!(runs.load(Ordering::SeqCst), 2, "refresh must re-run probes");
assert_eq!(reg.report().checks[0].source, CheckSource::Refresh);
}
#[test]
fn report_is_worst_first_with_healthy_and_inapplicable_last() {
let reg = Diagnostics::new();
reg.register(|| HostCheck::ok("b_ok", "fine"));
reg.register(|| HostCheck::inapplicable("a_na", "no display manager"));
reg.register(|| {
HostCheck::problem(
"c_warn",
CheckStatus::Warn,
Severity::Warning,
"degraded",
"half works",
)
});
reg.register(|| fail("d_fail"));
reg.run_all(CheckSource::Startup);
let ids: Vec<String> = reg.report().checks.into_iter().map(|c| c.id).collect();
assert_eq!(ids, ["d_fail", "c_warn", "b_ok", "a_na"]);
}
#[test]
fn since_is_stamped_once_and_cleared_on_recovery() {
let reg = Diagnostics::new();
reg.set(fail("flapper"));
let first = reg.report().checks[0].since_unix;
assert!(first.is_some(), "a non-ok check records when it started");
// Still failing: the original stamp survives, so "since" does not reset every probe run.
reg.set(fail("flapper"));
assert_eq!(reg.report().checks[0].since_unix, first);
// Recovered: the stamp goes away rather than lingering as a lie.
reg.set(HostCheck::ok("flapper", "fine"));
assert_eq!(reg.report().checks[0].since_unix, None);
}
#[test]
fn set_reports_only_real_transitions() {
let reg = Diagnostics::new();
assert!(reg.set(fail("pads")), "first observation is a transition");
assert!(
!reg.set(fail("pads")),
"a repeated identical verdict is not a transition — one SSE event per flip, not per retry"
);
assert!(
reg.set(HostCheck::ok("pads", "fine")),
"fail → ok is a transition"
);
}
#[test]
fn attention_counts_ignore_healthy_and_inapplicable_rows() {
let reg = Diagnostics::new();
reg.register(|| fail("bad"));
reg.register(|| {
HostCheck::problem(
"meh",
CheckStatus::Warn,
Severity::Warning,
"degraded",
"half works",
)
});
reg.register(|| HostCheck::ok("good", "fine"));
reg.register(|| HostCheck::inapplicable("na", "not here"));
reg.run_all(CheckSource::Startup);
assert_eq!(reg.attention_counts(), (1, 1));
}
/// The N/N1 console-drift guarantee, as a test rather than a convention: the console renders
/// the wire text whenever it does not recognize an id, so an id that ships without text is
/// unreadable on every console that predates it.
#[test]
fn every_non_ok_check_carries_fallback_text() {
let reg = Diagnostics::new();
catalog::register_all(&reg);
reg.run_all(CheckSource::Startup);
for check in reg.report().checks {
assert!(
!check.summary.trim().is_empty(),
"{}: every check needs a summary — it is the only text an older console has",
check.id
);
if check.status.needs_attention() {
assert!(
!check.impact.trim().is_empty(),
"{}: a non-ok check must say what breaks",
check.id
);
}
if check.status == CheckStatus::Fail {
let remedy = check
.remedy
.as_ref()
.unwrap_or_else(|| panic!("{}: a failing check must carry a remedy", check.id));
assert!(
!remedy.text.trim().is_empty(),
"{}: remedy text must not be empty",
check.id
);
}
}
}
/// Ids are the console's i18n keys, so they are API. Catch a rename in review, not in a
/// bug report about a check that suddenly renders in English.
#[test]
fn catalog_registers_the_documented_ids() {
let reg = Diagnostics::new();
catalog::register_all(&reg);
reg.run_all(CheckSource::Startup);
let ids: Vec<String> = reg.report().checks.into_iter().map(|c| c.id).collect();
for expected in [
ids::TAKEOVER_PRIVILEGE,
ids::VIRTUAL_DECK_VHCI,
ids::UINPUT_ACCESS,
ids::SERVER_CONFLICT,
] {
assert!(
ids.iter().any(|i| i == expected),
"missing check {expected}"
);
}
}
}
@@ -0,0 +1,484 @@
//! The v1 check catalog: verdicts from the owning crates → [`HostCheck`]s.
//!
//! Everything user-visible lives here — the English fallback strings, the impact sentences, and the
//! remedies. The probes themselves stay in `pf-vdisplay` / `pf-inject` / [`crate::detect`] and know
//! nothing about this module; that direction is deliberate, because a reverse dependency would drag
//! the host's wire types into two crates that must keep building for Windows and macOS.
//!
//! Two things here are easy to get subtly wrong and are therefore spelled out in the code:
//!
//! * **User-database membership and this process's groups are different questions.** `usermod -aG`
//! satisfies the first immediately and the second not until the next login, so "not in the group"
//! and "in the group but you haven't logged back in" need different remedies. Collapsing them
//! produces the single most maddening support state there is: *"I already added myself!"*
//! * **`usermod` does not stick on an atomic OS.** On the Universal Blue images the remedy is
//! `ujust add-user-to-input-group`; everywhere else it is `usermod -aG input`.
use super::{ids, CheckStatus, Diagnostics, HostCheck, Remedy, Severity};
use crate::inject::{UinputVerdict, VhciVerdict};
use crate::vdisplay::{TakeoverInapplicable, TakeoverVerdict};
use std::process::Command;
/// The group the packaged privilege helper authorizes on, and that owns the vhci attach nodes.
const PUNKTFUNK_GROUP: &str = "punktfunk";
/// The group the uinput/uhid udev rules grant access to.
const INPUT_GROUP: &str = "input";
/// Register the v1 catalog on a registry. Separate from the global so tests can drive an isolated
/// instance.
pub(crate) fn register_all(reg: &Diagnostics) {
reg.register(takeover_privilege);
reg.register(virtual_deck_vhci);
reg.register(uinput_access);
reg.register(server_conflict);
}
// ---------------------------------------------------------------------------------------------
// takeover_privilege
// ---------------------------------------------------------------------------------------------
fn takeover_privilege() -> HostCheck {
let id = ids::TAKEOVER_PRIVILEGE;
match crate::vdisplay::takeover_privilege_verdict() {
TakeoverVerdict::Inapplicable { why } => {
HostCheck::inapplicable(id, takeover_inapplicable_reason(why))
}
TakeoverVerdict::Ok { user, group } => {
HostCheck::ok(id, format!("User “{user}” is in the “{group}” group."))
.with_param("user", user)
.with_param("group", group)
}
TakeoverVerdict::MissingMembership {
user,
dm,
helper,
group,
} => HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Critical,
format!("User “{user}” is not in the “{group}” group"),
format!(
"Streams that need the managed takeover cannot stop {dm}, so every one of them \
degrades to mirroring this machine's own session instead. With the panel off that \
looks like a black screen on every connect, and nothing else reports it."
),
)
.with_remedy(Remedy {
text: format!(
"Add the user to the “{group}” group, then log out and back in. The same group \
gates the virtual Steam Deck pad's usbip nodes, which can present arbitrary \
emulated USB devices join it only on a machine you trust."
),
command: Some(format!("sudo usermod -aG {group} {user}")),
// The display-manager helper reads the user database, so it is satisfied at once — but
// the pad half is a check against this process, which keeps the group set it started
// with. One re-login covers both, so ask for it.
relogin_required: true,
})
.with_param("user", user)
.with_param("group", group)
.with_param("dm", dm)
.with_param("helper", helper),
}
}
fn takeover_inapplicable_reason(why: TakeoverInapplicable) -> &'static str {
match why {
TakeoverInapplicable::Root => {
"The host runs as root, so it stops the display manager directly and never needs the \
privilege helper."
}
TakeoverInapplicable::NoDisplayManager => {
"No display manager drives this machine's logins, so a takeover has nothing to stop."
}
TakeoverInapplicable::NoManagedSession => {
"This machine has no gamescope session infrastructure, so the managed takeover never \
runs here."
}
TakeoverInapplicable::NoPackagedHelper => {
"This is an unpackaged install: it has no privilege helper and no group, and uses the \
polkit rule from the documentation instead."
}
TakeoverInapplicable::UnknownUser => {
"The host's user name could not be resolved, so no group membership could be checked."
}
TakeoverInapplicable::NotLinux => "The managed gamescope takeover is a Linux feature.",
}
}
// ---------------------------------------------------------------------------------------------
// virtual_deck_vhci
// ---------------------------------------------------------------------------------------------
fn virtual_deck_vhci() -> HostCheck {
let id = ids::VIRTUAL_DECK_VHCI;
let group = PUNKTFUNK_GROUP;
match crate::inject::vhci_probe() {
VhciVerdict::Inapplicable { why } => HostCheck::inapplicable(id, why),
VhciVerdict::Ok => HostCheck::ok(id, "The virtual Steam Deck controller can attach."),
VhciVerdict::ModuleMissing => HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Warning,
"The vhci_hcd kernel module is not loaded",
"The virtual Steam Deck controller cannot attach, so Steam Input never sees it — in \
Game Mode that means nothing can be navigated with a pad.",
)
.with_remedy(Remedy {
text: "Load the vhci_hcd module (the packages install a modules-load rule that does \
this at boot; on an unpackaged install, load it by hand)."
.to_string(),
command: Some("sudo modprobe vhci-hcd".to_string()),
relogin_required: false,
}),
// The node is there and we cannot write it. WHICH of the three causes decides the remedy,
// and only the user database can tell them apart — see this module's docs.
VhciVerdict::NotWritable { path } => not_writable_check(id, group, path),
}
}
fn not_writable_check(id: &str, group: &str, path: String) -> HostCheck {
let user = current_user();
let in_userdb = user.as_deref().and_then(|u| user_in_group_userdb(u, group));
let in_process = process_in_group(group);
let base = |summary: &str, impact: &str| {
HostCheck::problem(id, CheckStatus::Fail, Severity::Warning, summary, impact)
.with_param("group", group)
.with_param("path", path.clone())
};
let pad_impact = "The virtual Steam Deck controller cannot attach, so Steam Input never sees \
it in Game Mode that means nothing can be navigated with a pad.";
match (in_userdb, in_process) {
// In the group on disk, but this process does not carry it: the classic "I already added
// myself!" state. A `systemd --user` manager keeps the group set it started with, so only
// a re-login helps — and nothing in the logs says so today.
(Some(true), Some(false)) => base(
&format!("The group “{group}” was granted but this session predates it"),
pad_impact,
)
.with_remedy(Remedy {
text: "Log out and back in. The membership is already recorded — this session just \
started before it was granted, and a session keeps the group set it began with."
.to_string(),
command: None,
relogin_required: true,
})
.with_param("user", user.unwrap_or_default()),
// Not a member at all.
(Some(false), _) => {
let user = user.unwrap_or_default();
base(
&format!("User “{user}” is not in the “{group}” group"),
pad_impact,
)
.with_remedy(Remedy {
text: format!(
"Add the user to the “{group}” group, then log out and back in. This group \
can present arbitrary emulated USB devices join it only on a machine you \
trust."
),
command: Some(format!("sudo usermod -aG {group} {user}")),
relogin_required: true,
})
.with_param("user", user)
}
// A member in the database AND in this process, yet the node is still not writable: the
// udev rule that chgrp's it was never installed (or has not run for this device). Blaming
// the group here would send someone to re-run a `usermod` that is already correct.
(Some(true), Some(true)) => base(
"The vhci attach node is not owned by the expected group",
pad_impact,
)
.with_remedy(Remedy {
text: format!(
"Install the udev rule that grants the “{group}” group access to the vhci nodes \
(scripts/60-punktfunk.rules the packages install it), then reload the rules or \
reboot."
),
command: Some("sudo udevadm control --reload && sudo udevadm trigger".to_string()),
relogin_required: false,
}),
// We could not ask the user database. Report the fact without guessing at a cause: a wrong
// remedy here costs more than a vague one.
_ => base(
"The virtual Steam Deck controller's attach node is not writable",
pad_impact,
)
.with_remedy(Remedy {
text: format!(
"Check that this machine's user is in the “{group}” group and that the udev rule \
granting it access to the vhci nodes is installed, then log out and back in."
),
command: None,
relogin_required: true,
}),
}
}
// ---------------------------------------------------------------------------------------------
// uinput_access
// ---------------------------------------------------------------------------------------------
fn uinput_access() -> HostCheck {
let id = ids::UINPUT_ACCESS;
match crate::inject::uinput_probe() {
UinputVerdict::Inapplicable => HostCheck::inapplicable(
id,
"Virtual controllers are created through this platform's own driver stack rather than \
uinput.",
),
UinputVerdict::Ok => HostCheck::ok(id, "The input device nodes are reachable."),
// The node exists and we may not open it: a group problem, and the remedy depends on
// whether this OS lets `usermod` stick.
UinputVerdict::PermissionDenied { path } => HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Critical,
format!("No permission to open {path}"),
"Every virtual controller fails to be created, so games see no gamepad at all — and \
the pen and tablet input paths are dead with it."
.to_string(),
)
.with_remedy(input_group_remedy())
.with_param("path", path)
.with_param("group", INPUT_GROUP),
// The node is absent: nothing to have permission on. A group remedy here is a wrong turn.
UinputVerdict::Missing { path } => HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Critical,
format!("{path} does not exist"),
"Every virtual controller fails to be created, so games see no gamepad at all."
.to_string(),
)
.with_remedy(Remedy {
text: format!(
"Load the kernel module that provides {path} and install the udev rule that grants \
the {INPUT_GROUP} group access to it (scripts/60-punktfunk.rules the packages \
install both)."
),
command: None,
relogin_required: false,
})
.with_param("path", path),
UinputVerdict::Error { path, message } => HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Critical,
format!("{path} could not be opened: {message}"),
"Virtual controllers may fail to be created, so games can see no gamepad.".to_string(),
)
.with_remedy(Remedy {
text: format!("Check the state of {path} on this machine."),
command: None,
relogin_required: false,
})
.with_param("path", path)
.with_param("error", message),
}
}
/// The `input`-group remedy, branched on OS flavour.
///
/// On the Universal Blue images `usermod -aG input` appears to work and is silently reverted,
/// because `/etc/group` is not writable state on an atomic OS — `packaging/README.md` has said so
/// since the Bazzite port, and telling someone to run it there is worse than saying nothing.
fn input_group_remedy() -> Remedy {
let user = current_user().unwrap_or_else(|| "$USER".to_string());
if is_universal_blue() {
Remedy {
text:
"Add the user to the “input” group with ujust, then log out and back in. On this \
OS a plain `usermod` does not persist."
.to_string(),
command: Some("ujust add-user-to-input-group".to_string()),
relogin_required: true,
}
} else {
Remedy {
text: "Add the user to the “input” group, then log out and back in. If the group \
already lists the user, the udev rule granting it access may be missing \
(scripts/60-punktfunk.rules)."
.to_string(),
command: Some(format!("sudo usermod -aG {INPUT_GROUP} {user}")),
relogin_required: true,
}
}
}
/// The Universal Blue images, which are the ones that ship `ujust`.
///
/// Matched on the chain's **leaf** (`ID`), never on the `fedora` family token: plain Fedora
/// Workstation is a mutable OS and does want `usermod`. `osinfo`'s chain is `linux/fedora/bazzite`
/// for Bazzite, so the leaf is the distro's own id.
fn is_universal_blue() -> bool {
matches!(
crate::osinfo::detect().chain.rsplit('/').next(),
Some("bazzite" | "bluefin" | "aurora")
)
}
// ---------------------------------------------------------------------------------------------
// server_conflict
// ---------------------------------------------------------------------------------------------
fn server_conflict() -> HostCheck {
let id = ids::SERVER_CONFLICT;
// The cached startup scan — the same source the tray's summary and the Host page's card read.
// Empty also means "never scanned" on a build that skipped the GameStream planes, which reads
// as healthy here exactly as it already does on `LocalSummary.conflicts`.
let labels = crate::detect::summary_labels(crate::detect::snapshot());
if labels.is_empty() {
return HostCheck::ok(
id,
"No other game-streaming server is active on this machine.",
);
}
let servers = labels.join(", ");
HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Critical,
format!("Another game-streaming server is active: {servers}"),
"Both servers bind the same ports, so whichever won the bind answers — pairing and \
connections can land on the other server while this host looks installed and healthy."
.to_string(),
)
.with_remedy(Remedy {
text: "Stop the other server (and disable it if it starts on its own), then restart this \
host."
.to_string(),
command: None,
relogin_required: false,
})
.with_param("servers", servers)
}
// ---------------------------------------------------------------------------------------------
// Group membership: two different questions
// ---------------------------------------------------------------------------------------------
/// This process's login name, resolved the way `pkexec` will (`id -un`).
fn current_user() -> Option<String> {
capture(Command::new("id").arg("-un"))
}
/// Is `user` in `group` **according to the user database** (`id -nG <user>`)? This is what a root
/// helper sees, and what `usermod -aG` changes immediately. `None` when the question could not be
/// asked — NSS can block or fail, and a false accusation sends people down the wrong path.
fn user_in_group_userdb(user: &str, group: &str) -> Option<bool> {
let groups = capture(Command::new("id").args(["-nG", user]))?;
Some(groups.split_whitespace().any(|g| g == group))
}
/// Is `group` among **this process's** supplementary groups (`id -nG`, no operand)? Fixed when the
/// `systemd --user` manager started, so a fresh `usermod` does not show up here until the next
/// login — which is exactly the distinction that makes the "log out and back in" remedy necessary.
fn process_in_group(group: &str) -> Option<bool> {
let groups = capture(Command::new("id").arg("-nG"))?;
Some(groups.split_whitespace().any(|g| g == group))
}
fn capture(cmd: &mut Command) -> Option<String> {
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
}
#[cfg(test)]
mod tests {
use super::*;
/// Every mapping arm, including the ones this machine cannot reach: the table is the point.
/// The three `NotWritable` shapes are what the design calls out as the easiest thing to get
/// wrong, so each is asserted to produce a *different* remedy.
#[test]
fn vhci_not_writable_shapes_produce_distinct_remedies() {
let path = "/sys/devices/platform/vhci_hcd.0/attach".to_string();
let check = not_writable_check(ids::VIRTUAL_DECK_VHCI, PUNKTFUNK_GROUP, path.clone());
// Whatever this machine answers, the shape contract holds: a failing check always carries a
// remedy, and the pad's impact is always stated.
assert_eq!(check.status, CheckStatus::Fail);
assert_eq!(check.severity, Severity::Warning);
assert!(check.remedy.is_some());
assert!(!check.impact.is_empty());
assert_eq!(
check.params.get("group").map(String::as_str),
Some(PUNKTFUNK_GROUP)
);
}
#[test]
fn takeover_inapplicable_reasons_are_all_populated() {
for why in [
TakeoverInapplicable::Root,
TakeoverInapplicable::NoDisplayManager,
TakeoverInapplicable::NoManagedSession,
TakeoverInapplicable::NoPackagedHelper,
TakeoverInapplicable::UnknownUser,
TakeoverInapplicable::NotLinux,
] {
assert!(
!takeover_inapplicable_reason(why).trim().is_empty(),
"{why:?} needs a reason — an inapplicable row exists to answer \"why not here?\""
);
}
}
/// The atomic-OS branch is the one that is silently wrong if it regresses: `usermod` looks like
/// it worked on Bazzite and is gone after a reboot.
#[test]
fn input_remedy_matches_this_box_flavour() {
let remedy = input_group_remedy();
let command = remedy
.command
.expect("the input remedy is always pasteable");
assert!(remedy.relogin_required, "a group change needs a re-login");
if is_universal_blue() {
assert_eq!(command, "ujust add-user-to-input-group");
} else {
assert!(
command.starts_with("sudo usermod -aG input "),
"unexpected remedy: {command}"
);
}
}
/// `bazzite` is the chain's LEAF, and `fedora` is its family — matching the family would send
/// plain Fedora Workstation users to a `ujust` they do not have.
#[test]
fn universal_blue_is_matched_on_the_leaf_not_the_family() {
fn leaf_is_ublue(chain: &str) -> bool {
matches!(
chain.rsplit('/').next(),
Some("bazzite" | "bluefin" | "aurora")
)
}
assert!(leaf_is_ublue("linux/fedora/bazzite"));
assert!(leaf_is_ublue("linux/fedora/bluefin"));
assert!(!leaf_is_ublue("linux/fedora"));
assert!(!leaf_is_ublue("linux/fedora/fedora"));
assert!(!leaf_is_ublue("linux/arch/steamos"));
}
#[test]
fn server_conflict_is_ok_when_nothing_was_detected() {
// `detect::snapshot()` is empty in a test binary (no startup scan ran), which is the same
// state a clean box reports.
let check = server_conflict();
assert_eq!(check.status, CheckStatus::Ok);
assert!(check.remedy.is_none());
}
}
+3
View File
@@ -23,6 +23,9 @@ mod bringup;
mod capture;
mod detect;
mod devtest;
/// Host health verdicts as one structured channel (design/web-console-diagnostics.md).
#[forbid(unsafe_code)]
mod diagnostics;
// Network-facing on the secure default host (see the forbid block at `mod mgmt` below).
#[forbid(unsafe_code)]
mod discovery;
+6
View File
@@ -32,6 +32,7 @@ use utoipa_scalar::{Scalar, Servable};
mod auth;
mod client_logs;
mod clients;
mod diagnostics;
mod display;
mod events;
mod gpu;
@@ -314,6 +315,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
))
.routes(routes!(host::get_status))
.routes(routes!(host::get_local_summary))
// Two paths, so two calls — `routes!` merges the METHODS of one path, and two calls naming
// the same path collide.
.routes(routes!(diagnostics::get_diagnostics))
.routes(routes!(diagnostics::refresh_diagnostics))
// GET and DELETE share the `/clients` path, so they must be ONE `routes!` — utoipa-axum
// merges the methods of a single call into one route; two calls collide on the path.
.routes(routes!(
@@ -429,6 +434,7 @@ pub fn openapi_json() -> String {
modifiers(&SecurityAddon),
tags(
(name = "host", description = "Host identity, capabilities, and liveness"),
(name = "diagnostics", description = "Host health checks: what is wrong, what it breaks, and how to fix it (admin lane only)"),
(name = "gpu", description = "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"),
(name = "display", description = "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout"),
(name = "clients", description = "Paired Moonlight client management"),
@@ -0,0 +1,65 @@
//! Diagnostics endpoints: the host's health verdicts as one structured channel.
//!
//! **Admin lane only, deliberately.** Neither route is on `auth::plugin_may_access` nor
//! `cert_may_access` — both are opt-in allowlists, so a route stays denied until someone classifies
//! it, and these carry usernames, group layout and device-node state. Putting them on the plugin or
//! paired-cert lanes would be a security regression, not a convenience; the unauthenticated
//! loopback summary the tray reads may carry counts at most.
use super::shared::*;
use crate::diagnostics::{CheckSource, DiagnosticsReport};
/// Host health checks
///
/// Every verdict this host computes about its own health — group membership the managed takeover
/// needs, the input device nodes virtual controllers are built on, competing streaming servers —
/// with the impact and a copy-pasteable remedy for each.
///
/// Cached: the probes run once at startup and on demand via `POST /diagnostics/refresh`, so this is
/// cheap to poll. Checks whose status is `ok` and `inapplicable` are included — a troubleshooting
/// page needs to show what is working and to answer "why isn't this check relevant here?".
///
/// `summary`, `impact` and `remedy.text` are always present in English. A console that recognizes
/// the check's `id` replaces them with a localized string interpolated from `params`; one that does
/// not renders the wire text as-is, which is what keeps a console paired with a newer host readable.
#[utoipa::path(
get,
path = "/diagnostics",
tag = "diagnostics",
operation_id = "getDiagnostics",
responses(
(status = OK, description = "The current verdicts, worst-first", body = DiagnosticsReport),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_diagnostics() -> Json<DiagnosticsReport> {
Json(crate::diagnostics::registry().report())
}
/// Re-run the health checks
///
/// Runs every probe again and returns the refreshed verdicts. Most checks describe state that only
/// changes when an operator changes it (a group membership, an installed udev rule), so this exists
/// for exactly the moment after they have done so — a "did that fix it?" button, not a poll.
#[utoipa::path(
post,
path = "/diagnostics/refresh",
tag = "diagnostics",
operation_id = "refreshDiagnostics",
responses(
(status = OK, description = "The refreshed verdicts, worst-first", body = DiagnosticsReport),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn refresh_diagnostics() -> Json<DiagnosticsReport> {
// The probes stat sysfs and shell out to `id`, whose NSS lookup can block on a box with a
// remote directory — so they run on the blocking pool rather than stalling the executor.
let report = tokio::task::spawn_blocking(|| {
let reg = crate::diagnostics::registry();
reg.run_all(CheckSource::Refresh);
reg.report()
})
.await
.unwrap_or_else(|_| crate::diagnostics::registry().report());
Json(report)
}
+106
View File
@@ -1358,6 +1358,12 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
("GET", "/api/v1/compositors", true, true),
("GET", "/api/v1/events", true, false),
("GET", "/api/v1/logs", true, false),
// ---- diagnostics: OPERATOR ONLY, both lanes denied. The verdicts name the host's user,
// its group layout and the state of its device nodes — a paired streaming client has no
// business enumerating any of that, and a plugin that wanted to would be asking for a map
// of the box's privilege boundaries. The tray gets counts on `/local/summary` instead.
("GET", "/api/v1/diagnostics", false, false),
("POST", "/api/v1/diagnostics/refresh", false, false),
// ---- client log bundles: the UPLOAD is the cert lane's single write — write-only,
// size/quota-capped ("send logs to host" from a Deck in Gaming Mode / tvOS). Reading
// bundles back is operator business (they can contain whatever the client logged), so
@@ -1637,6 +1643,106 @@ fn post_json(path: &str, body: serde_json::Value) -> axum::http::Request<Body> {
.unwrap()
}
/// Diagnostics are operator business: the verdicts carry the host user's name, its group layout and
/// the state of its device nodes.
#[tokio::test]
async fn diagnostics_require_the_operator_token() {
let app = test_app(test_state(), Some("sekrit"));
for req in [
get_req("/api/v1/diagnostics"),
axum::http::Request::post("/api/v1/diagnostics/refresh")
.body(Body::empty())
.unwrap(),
] {
let (status, body) = send(&app, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(body["error"].as_str().unwrap().contains("bearer"));
}
}
/// The shape the console renders: a worst-first list in which every registered check appears, `ok`
/// and `inapplicable` rows included (the troubleshooting page shows what works, and can answer why
/// a check does not apply here).
#[tokio::test]
async fn diagnostics_report_the_registered_checks() {
// The registry is a process-global primed at `serve` startup; take the same first reading here
// rather than asserting against whatever another test in this binary left behind.
crate::diagnostics::preflight();
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/diagnostics")).await;
assert_eq!(status, StatusCode::OK);
assert!(body["ran_at_unix"].is_number(), "the report is stamped");
let checks = body["checks"].as_array().expect("checks array");
assert!(
!checks.is_empty(),
"the v1 catalog is registered at startup"
);
// Ids are the console's i18n keys; a rename silently drops every translation.
let ids: Vec<&str> = checks.iter().filter_map(|c| c["id"].as_str()).collect();
for expected in [
"takeover_privilege",
"virtual_deck_vhci",
"uinput_access",
"server_conflict",
] {
assert!(ids.contains(&expected), "missing check {expected}: {ids:?}");
}
// The N/N1 drift guarantee on the wire, not just in the registry's own unit test: a console
// that predates a check has nothing but these strings to render.
for check in checks {
let id = check["id"].as_str().unwrap();
assert!(
!check["summary"]
.as_str()
.unwrap_or_default()
.trim()
.is_empty(),
"{id}: summary must never be empty"
);
assert!(
matches!(
check["status"].as_str(),
Some("ok" | "warn" | "fail" | "inapplicable")
),
"{id}: unexpected status {:?}",
check["status"]
);
if check["status"] == "fail" {
assert!(
!check["remedy"]["text"]
.as_str()
.unwrap_or_default()
.trim()
.is_empty(),
"{id}: a failing check must tell the operator what to do"
);
}
}
}
/// Refresh re-runs the probes and answers with the fresh report — the "did that fix it?" button.
#[tokio::test]
async fn diagnostics_refresh_reruns_and_returns_the_report() {
let app = test_app(test_state(), None);
let req = axum::http::Request::post("/api/v1/diagnostics/refresh")
.body(Body::empty())
.unwrap();
let (status, body) = send(&app, req).await;
assert_eq!(status, StatusCode::OK);
let checks = body["checks"].as_array().expect("checks array");
assert!(!checks.is_empty(), "refresh answers with the full catalog");
// NOT asserted here: that every row reports `source: "refresh"`. The registry is a
// process-global and a sibling test in this binary primes it with a startup reading, so that
// assertion would be a parallel-test race. `source` is pinned on an isolated registry in
// `crate::diagnostics::tests::refresh_reruns_every_probe`, which is where it belongs.
assert!(checks.iter().all(|c| c["id"].is_string()));
}
/// The display-management GET surface (presets + effective + the enforced-axes list). READ-ONLY
/// on purpose: `prefs()` is a process-global `OnceLock`, so a PUT here would clobber it and race
/// other tests running in the same process. `keep_alive: forever` (gaming-rig) is now accepted
+10
View File
@@ -422,6 +422,10 @@ pub(crate) async fn serve(
// mirroring the box's own session — so without this it surfaces only as a black screen on
// every connect. No-op off Linux and on any box the takeover can't apply to.
crate::vdisplay::preflight_takeover_privilege();
// Same verdicts, second destination: the log line above is for whoever reads logs, and the
// registry is for whoever opens the console. Runs after the subsystems the probes inspect are
// up, so a probe never reports a device node that was about to appear.
crate::diagnostics::preflight();
// …and the other end of that: give the box its session back when WE are the ones going away.
install_shutdown_restore();
// (No cover-art warmer any more: it existed to fetch GOG/Xbox art off the hot path for the two
@@ -1394,6 +1398,10 @@ async fn serve_session(
let live_bitrate = Arc::new(AtomicU32::new(welcome.bitrate_kbps));
let encoder_ceiling_kbps = Arc::new(AtomicU32::new(0));
let cadence_degraded = Arc::new(AtomicBool::new(false));
// The live behind-cadence score behind that flag, so the climb-refusal log line carries its
// evidence (a refusal without the score left a 23-minute floor-pinned field session with no
// trace of why).
let cadence_behind_score = Arc::new(AtomicU32::new(0));
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
@@ -1516,6 +1524,7 @@ async fn serve_session(
live_bitrate.clone(),
encoder_ceiling_kbps.clone(),
cadence_degraded.clone(),
cadence_behind_score.clone(),
fec_target_ctl,
phase_ctl_control,
reconfig_tx,
@@ -2099,6 +2108,7 @@ async fn serve_session(
live_bitrate,
encoder_ceiling_kbps,
cadence_degraded,
cadence_behind_score,
bitrate_auto,
bit_depth,
chroma,
+42 -2
View File
@@ -218,6 +218,12 @@ pub(super) fn audio_thread(
// frame. `sent_any` is what keeps the seed from ever reaching the wire.
let mut next_pts_ns: u64 = 0;
let mut pace_due: Option<std::time::Instant> = None;
// WP-C — what the wire actually did, as opposed to what the tap handed us. See [`SendStats`]:
// until this existed the send path was the one stage of the audio pipeline that could not be
// ruled in or out from a field log.
let mut send_stats = crate::audio::capture_policy::SendStats::default();
let mut last_send_stats = std::time::Instant::now();
let mut last_departure: Option<std::time::Instant> = None;
if capturer.is_some() {
tracing::info!(
channels = want,
@@ -315,12 +321,20 @@ pub(super) fn audio_thread(
// send-time debt.
loop {
let now = std::time::Instant::now();
// How far past its slot this frame is leaving. Measured before the re-anchor arm can
// erase the evidence — that arm is the one that forgives debt silently (WP-C).
let mut late = std::time::Duration::ZERO;
match pace_due {
Some(due) if due > now => break, // this frame's slot has not arrived yet
Some(due) if now.duration_since(due) > PACE_REANCHOR => pace_due = None,
_ => {}
Some(due) if now.duration_since(due) > PACE_REANCHOR => {
send_stats.observe_reanchor();
pace_due = None;
}
Some(due) => late = now.duration_since(due),
None => {}
}
frame_buf.clear();
let mut infilled = false;
if acc.len() >= frame_len {
frame_buf.extend(acc.drain(..frame_len));
} else if !sent_any {
@@ -328,6 +342,7 @@ pub(super) fn audio_thread(
} else {
match infill.decide(last_chunk_at.elapsed()) {
crate::audio::capture_policy::Infill::Silence => {
infilled = true;
// Pad the partial frame out with silence and send THAT, rather than
// leaving it for post-gap samples to complete: one frame carrying audio
// from both sides of a hole is a click, and its pts is a lie about when
@@ -366,6 +381,15 @@ pub(super) fn audio_thread(
prev_frame.extend_from_slice(opus);
}
seq = seq.wrapping_add(1);
// Score the departure against its slot and against the previous one. `now` is
// from the top of this iteration — microseconds earlier and one clock read
// cheaper, 200 times a second.
send_stats.observe_departure(
late,
last_departure.map(|t| now.duration_since(t)),
infilled,
);
last_departure = Some(now);
// From here there is a continuity worth protecting, and `next_pts_ns` has a
// real anchor to continue from — both preconditions for synthesizing anything.
sent_any = true;
@@ -382,6 +406,22 @@ pub(super) fn audio_thread(
}
}
}
if last_send_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY {
// Deliberately the same window as the capture line, so the two can be read as a pair:
// holes at the tap with clean departures means the host delivered everything it had,
// and the search belongs upstream of us.
tracing::info!(
sent = send_stats.sent,
infilled = send_stats.infilled,
late = send_stats.late,
max_late_ms = send_stats.max_late_ms(),
max_spacing_ms = send_stats.max_spacing_ms(),
reanchors = send_stats.reanchors,
"audio egress"
);
send_stats = Default::default();
last_send_stats = std::time::Instant::now();
}
}
// Park the live capturer for the next session (None if it died and never reopened),
// releasing its session-scoped routing claim (Linux: the default sink moves back;
@@ -29,6 +29,7 @@ pub(super) async fn run(
live_bitrate: Arc<AtomicU32>,
encoder_ceiling_kbps: Arc<AtomicU32>,
cadence_degraded: Arc<AtomicBool>,
cadence_behind_score: Arc<AtomicU32>,
fec_target_ctl: Arc<AtomicU8>,
// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode loop's
// controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
@@ -221,6 +222,10 @@ pub(super) async fn run(
tracing::info!(
requested_kbps = req.bitrate_kbps,
held_kbps = live,
// The refusal's evidence: without it a field log shows WHAT was
// held but never WHY, and a session at the ABR floor is
// indistinguishable from a network problem.
behind_score = cadence_behind_score.load(Ordering::Relaxed),
"bitrate climb refused — encode is behind cadence"
);
r = live;
+164 -7
View File
@@ -1314,6 +1314,11 @@ pub(super) struct SessionContext {
/// session escalated): while set, the control task refuses bitrate CLIMBS — the network
/// isn't the bottleneck, feeding the encoder more bits deepens the miss.
pub(super) cadence_degraded: Arc<AtomicBool>,
/// The live behind-cadence leaky-bucket score, exported so the control task's climb-refusal
/// log line can say WHY (a field session sat at the ABR floor for 23 minutes with no trace
/// of what held it there — the score is the missing discriminator between "the detector's
/// budget is wrong" and "this encoder genuinely can't hold cadence").
pub(super) cadence_behind_score: Arc<AtomicU32>,
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
@@ -1588,6 +1593,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
live_bitrate,
encoder_ceiling_kbps,
cadence_degraded,
cadence_behind_score,
bitrate_auto,
bit_depth,
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
@@ -2286,6 +2292,26 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
let mut cur_depth: usize = 1;
let mut behind_score: u32 = 0;
let mut depth_frames: u64 = 0;
// Observed source-delivery period (EMA over REAL frames' arrival spacing, ns) — the budget
// the behind test scores encode work against. The negotiated refresh alone is the wrong
// deadline when the game delivers slower than the mode: a field session negotiated at
// 120 Hz whose game ran 5374 fps scored every frame against 8.33 ms while its real budget
// was ~2× that, so `behind_score` latched, climbs were refused, and the session sat at the
// ABR floor for 23 minutes with the encoder comfortably keeping up with every frame that
// actually existed. Repeats are excluded (a keepalive re-encode says nothing about the
// game's delivery rate); [`cadence_budget`] clamps to [interval, 4×interval] so a source
// faster than the mode keeps today's exact deadline and a hitchy/idle one can't disarm
// the detector entirely.
let mut src_period_ns: Option<u64> = None;
let mut last_real_cap: Option<std::time::Instant> = None;
// Transition edge + rate limit for the cadence_degraded log lines: around the latch
// threshold the score can cross ±1 every other frame, and 60 lines/s in a field log is
// worse than none. One line per direction per 5 s window; flips swallowed by the limiter
// are counted so an oscillation is still visible in the next line.
let mut was_degraded = false;
let mut last_cadence_log: Option<std::time::Instant> = None;
let mut cadence_flips_suppressed: u32 = 0;
const CADENCE_LOG_MIN_GAP: std::time::Duration = std::time::Duration::from_secs(5);
// Second escalation stage (§7 LN3): once depth is maxed (or was never available — Linux),
// ask the encoder for pipelined retrieve exactly once. Latched whether it accepts or not.
let mut pipeline_asked = false;
@@ -2867,15 +2893,46 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
let rfi_echo = last_rfi.is_some_and(|t| t.elapsed() < RFI_ECHO_WINDOW)
&& rfi_echo_swallowed < RFI_ECHO_MAX_SWALLOWED;
if idr_recent {
tracing::debug!("keyframe request coalesced — within the IDR cooldown");
// Coalesced, and the client is STILL reporting damage — so whatever the in-flight
// IDR will repair, it has not repaired yet, and until it lands no reference in the
// table can honestly be called known-good to this client. Withdraw anchor trust for
// the duration: without it, a frame-index gap arriving inside this window is
// answered with an RFI anchor picked over exactly that unrepaired damage, and the
// anchor lifts the client's post-loss freeze on its first occurrence — grey frames,
// presented, freeze lifted. The cost is bounded to nothing that matters: the IDR
// this branch is waiting on rebuilds trust from scratch when it lands (it flushes
// the DPB), and prediction never used the wire domain in the first place.
enc.distrust_references();
tracing::debug!(
"keyframe request coalesced — within the IDR cooldown; RFI anchor trust \
withdrawn until the IDR repairs the client"
);
} else if rfi_echo {
// Deliberately NO distrust here, and it is the one branch where that would be
// wrong. This branch's whole premise is that the request is the client's ECHO of
// the loss the RFI just repaired — the recovery frame is still in flight. Withdraw
// trust on the first echo and every successful RFI recovery poisons the table for
// the next one, so RFI could never fire twice running and a sustained-loss session
// falls straight back to the IDR path this block exists to keep it off. The premise
// is a guess, and `RFI_ECHO_MAX_SWALLOWED` is already its hedge: when the client
// keeps asking past the budget the guess was wrong, and the `else` arm below both
// serves the IDR and withdraws trust then — on evidence rather than on suspicion.
rfi_echo_swallowed += 1;
tracing::debug!(
swallowed = rfi_echo_swallowed,
"keyframe request coalesced — echo of an RFI-recovered loss"
);
} else {
tracing::debug!("forcing keyframe (client decode recovery)");
// Did we get here THROUGH exhausted echo-swallowing? Then this episode's RFI
// anchor demonstrably did not heal the client: we presumed its requests were
// echoes, swallowed them, and it kept asking anyway. Serving the IDR (below) fixes
// this loss; withdrawing trust is what stops the same un-healing reference being
// picked as the anchor for the NEXT one. Read before the reset that follows.
let rfi_unhealed = rfi_echo_swallowed > 0;
tracing::debug!(rfi_unhealed, "forcing keyframe (client decode recovery)");
if rfi_unhealed {
enc.distrust_references();
}
enc.request_keyframe();
last_forced_idr = Some(now);
rfi_echo_swallowed = 0; // the IDR resets the episode — echoes of IT coalesce via the cooldown
@@ -2931,6 +2988,20 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
Ok(Some(f)) => {
frame = f;
diag_new += 1;
// Source-cadence estimate (see the declaration above): `t_cap` on the
// frame-driven path is taken right after `wait_arrival` wakes, so real-frame
// deltas track the game's actual delivery spacing. Deltas past 8×interval are
// a gap/hitch (mid-rebuild, alt-tab), not cadence — skipped, not averaged in.
if let Some(prev) = last_real_cap {
let d = t_cap.duration_since(prev).as_nanos() as u64;
if d <= interval.as_nanos() as u64 * 8 {
src_period_ns = Some(match src_period_ns {
Some(e) => (e as i64 + (d as i64 - e as i64) / 8) as u64,
None => d,
});
}
}
last_real_cap = Some(t_cap);
// Phase-locked capture: hold the fresh frame so its ARRIVAL at the client lands a
// constant small lead before the client's display latch (§3 hold-then-submit; the
// capture slot is newest-wins, so a long hold samples fresher content next tick,
@@ -3825,7 +3896,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
if idd_adaptive_enabled() {
depth_frames += 1;
if depth_frames > DEPTH_WARMUP_FRAMES {
let behind = std::time::Instant::now() >= next;
// The deadline is `next` (post-submit + negotiated interval) stretched by how
// much slower the source actually delivers: encode work only has to beat the
// NEXT REAL FRAME's arrival, not a refresh the game never reaches. For a
// full-rate source the budget equals the interval and this is bit-for-bit the
// old test.
let budget = cadence_budget(interval, src_period_ns);
let behind = std::time::Instant::now() >= next + (budget - interval);
behind_score = if behind {
(behind_score + 1).min(DEPTH_BEHIND_CAP)
} else {
@@ -3847,10 +3924,44 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// Escalating exists precisely so cadence CAN be held; once it is (bucket
// drained, every frame on time), refusing climbs is refusing the thing that
// worked.
cadence_degraded.store(
encode_behind_cadence(escalated, behind_score, DEPTH_DEGRADE),
Ordering::Relaxed,
);
let degraded = encode_behind_cadence(escalated, behind_score, DEPTH_DEGRADE);
cadence_degraded.store(degraded, Ordering::Relaxed);
cadence_behind_score.store(behind_score, Ordering::Relaxed);
// Log every transition (rate-limited, see the state above): a degraded stretch
// refuses every ABR climb, so a session can sit at the bitrate floor for its
// whole life — that MUST leave a trace saying why, with the numbers needed to
// tell "the budget was wrong" from "this encoder genuinely can't keep up".
if degraded != was_degraded {
let now = std::time::Instant::now();
if last_cadence_log.is_none_or(|t| now.duration_since(t) >= CADENCE_LOG_MIN_GAP)
{
let budget = cadence_budget(interval, src_period_ns);
if degraded {
tracing::info!(
behind_score,
escalated,
budget_us = budget.as_micros() as u64,
interval_us = interval.as_micros() as u64,
src_period_us =
src_period_ns.map(|p| p / 1_000).unwrap_or_default(),
flips_suppressed = cadence_flips_suppressed,
"encode behind cadence — ABR climbs will be refused until it \
recovers"
);
} else {
tracing::info!(
behind_score,
flips_suppressed = cadence_flips_suppressed,
"encode cadence recovered — ABR climbs allowed again"
);
}
last_cadence_log = Some(now);
cadence_flips_suppressed = 0;
} else {
cadence_flips_suppressed += 1;
}
was_degraded = degraded;
}
if deescalating {
// A requested wind-back completes at the encoder's drained safe point —
// poll it (the call is a cheap latch check until then).
@@ -4551,6 +4662,24 @@ fn encode_behind_cadence(escalated: bool, behind_score: u32, degrade_at: u32) ->
behind_score >= degrade_at || (escalated && behind_score > 0)
}
/// The behind-cadence budget for one frame: how long its work may run before the frame counts as
/// "behind". This is the OBSERVED source-delivery period, not the negotiated refresh — encode
/// only has to finish before the next frame that actually exists, and a game delivering 60 fps
/// on a 120 Hz mode gives every frame twice the interval's budget. Clamped below to the
/// negotiated interval (a source faster than the mode is paced down to it, so the interval IS
/// its delivery period) and above to 4× (a hitchy or near-idle source must not disarm the
/// detector — past 4× the mode is so mismatched that the wider budget is moot anyway).
/// No estimate yet (startup, an all-repeat stretch) keeps the plain interval.
fn cadence_budget(
interval: std::time::Duration,
src_period_ns: Option<u64>,
) -> std::time::Duration {
match src_period_ns {
Some(p) => std::time::Duration::from_nanos(p).clamp(interval, interval * 4),
None => interval,
}
}
/// Adopt the rate a freshly built pipeline's encoder was actually opened at.
///
/// The session's own `bitrate_kbps` is the number every later decision reads — the ABR controller's
@@ -4867,6 +4996,34 @@ mod tests {
assert!(!encode_behind_cadence(true, 0, DEGRADE));
}
/// The 2026-08-15 field session: negotiated 2560×1440@120 (interval 8333 µs) while the game
/// delivered 5374 fps (observed period 13.518.9 ms). Scoring encode work against the bare
/// interval marked a keeping-up encoder "behind" on most frames, latched `cadence_degraded`,
/// and held the session at the 5000 kbps ABR floor for 23 minutes. The budget must be the
/// observed delivery period — bounded so the two failure edges (an overdriven source, a
/// hitching source) keep the detector honest.
#[test]
fn the_behind_budget_tracks_the_source_not_the_negotiated_refresh() {
let interval = std::time::Duration::from_micros(8333); // 120 Hz mode
let us = |d: std::time::Duration| d.as_micros() as u64;
// No estimate yet (startup / all-repeat stretch): the plain interval, i.e. the old test.
assert_eq!(cadence_budget(interval, None), interval);
// The field case: a ~60 fps source on the 120 Hz mode gets its real ~2× budget.
assert_eq!(
us(cadence_budget(interval, Some(16_600_000))),
16_600,
"a 60 fps source's frames have 16.6 ms of real budget"
);
// A source at (or paced to) the negotiated rate: unchanged from today.
assert_eq!(us(cadence_budget(interval, Some(8_333_000))), 8_333);
// An overdriven source can never SHRINK the budget below the interval — pacing floors
// delivery at the negotiated rate, so a smaller estimate is measurement noise.
assert_eq!(cadence_budget(interval, Some(4_000_000)), interval);
// A hitchy/near-idle source is clamped at 4× — the detector must not be disarmed.
assert_eq!(cadence_budget(interval, Some(500_000_000)), interval * 4);
}
#[test]
fn adopting_a_rebuilt_rate_tells_the_client() {
let live = Arc::new(AtomicU32::new(20_000));
+14
View File
@@ -224,6 +224,20 @@ regular pad). Automatic arms it only where the raw guide press can't reach the h
Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat
late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule.
**Controller haptics** — *default: on*, and **Controller speaker***default: on* on the Linux and
Windows apps, *off* on Android. The two halves of [controller audio](/docs/controller-audio): a
DualSense's voice-coil haptics, and the little speaker in the middle of the pad. Both need a
**wired** DualSense or DualSense Edge — over Bluetooth a controller exposes no audio device at all,
and both settings quietly do nothing. Neither costs anything without a host that sends them: the
plane is negotiated, and silence is never encoded or transmitted, so leaving haptics on is free even
on a pad that never gets any. Turn **Controller speaker** off if you would rather all game audio came
out of your speakers or headset.
Offered by the Linux, Windows and Android apps. On Linux, the client also switches the controller's
sound card to Pro Audio while it needs the voice coils, and puts it back afterwards — see
[the controller-audio page](/docs/controller-audio#on-a-linux-client-the-pads-own-profile-matters-too)
for why that is necessary and how to turn it off.
**Capture system shortcuts** — *default: on.* Offered by the Linux, Windows and macOS apps and the
console home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
+3 -3
View File
@@ -208,9 +208,9 @@ reach, which app honours which of them, the two mouse modes, the three touch mod
are all on [Mouse, touch and pen](/docs/input#getting-your-input-back).
Copying between the two machines is a separate opt-in: the host operator allows it in `host.env`
and you turn it on for that one host in your client. Content crosses today from the macOS, Windows
and Android apps — the Linux client has the switch but no bridge behind it yet, and iOS, iPadOS and
tvOS have neither. See [Shared clipboard](/docs/clipboard).
and you turn it on for that one host in your client. Content crosses today from the macOS, iOS,
iPadOS, Windows and Android apps — the Linux client has the switch but no bridge behind it yet, and
tvOS has no pasteboard to share. See [Shared clipboard](/docs/clipboard).
## Which should I use?
+19 -9
View File
@@ -64,6 +64,7 @@ about *that* machine. You set it in the host's edit sheet, and it is deliberatel
| Client | Where the switch is | Label | Default |
|---|---|---|---|
| macOS | Host card menu → **Edit…** | **Share clipboard with this host** | Off |
| iOS, iPadOS | Host card menu → **Edit…** | **Share clipboard with this host** | Off |
| Windows | Host tile menu → **Edit…** | **Share clipboard with this host** | Off |
| Linux (GTK) | Host card menu → **Edit…** | **Share clipboard** | Off |
| Android (touch) | Host card menu → **Edit…** | **Shared clipboard** | **On** |
@@ -75,10 +76,12 @@ no clipboard row, so there is nowhere to change it there. It stays on, which is
The setting is read when a session starts, so if you change it while streaming, reconnect.
macOS can also flip it mid-session: **Stream ▸ Share Clipboard** (⌃⌥⇧C), which becomes **Stop
Sharing Clipboard** once the host has acknowledged it.
Sharing Clipboard** once the host has acknowledged it. On an iPad with a hardware keyboard the same
combo works, though there is no menu bar to show it in — and only while the pointer is released, as
a captured session sends the keys to the host instead.
iOS, iPadOS, tvOS and a Steam Deck in Gaming Mode have no clipboard switch — neither the Decky
panel nor the client's console home has a host edit sheet — see
tvOS and a Steam Deck in Gaming Mode have no clipboard switch — the Apple TV has no pasteboard to
share at all, and neither the Decky panel nor the client's console home has a host edit sheet — see
[what each client does](#which-hosts-and-clients-support-it) below.
## Nothing crosses until something pastes
@@ -91,16 +94,22 @@ That holds for everything you copy on your own machine, and for both directions
does **not** hold for a host copy arriving at a Windows or Android client: those two fetch the
content straight away and put it on your local clipboard, whether or not you ever paste. On Windows
that is because the lazy path needs Windows delayed rendering, which the client doesn't implement
yet; on Android there is no way to satisfy a paste from the network at all. The macOS client is
lazy in both directions.
yet; on Android there is no way to satisfy a paste from the network at all. The macOS and iOS
clients are lazy in both directions.
On iOS there is one deliberate exception. Backgrounding the app ends the session, and a promise
nobody can answer is worse than no promise at all — so if the host copied something and you have not
pasted it yet, those bytes are pulled across as the session ends, up to 8 MiB. That is what makes
"copy on the host, switch to Safari, paste" work on an iPad. Nothing is fetched if you never leave
the app, or if you already pasted.
A single transfer is capped at 64 MiB. Nothing else limits size, so a very large host-side copy can
cross to a Windows or Android client for a paste that never happens.
What you copy on the **macOS or Windows client** is filtered for secrets: content marked
`org.nspasteboard.ConcealedType` or `org.nspasteboard.TransientType` on macOS, or
What you copy on the **macOS, iOS or Windows client** is filtered for secrets: content marked
`org.nspasteboard.ConcealedType` or `org.nspasteboard.TransientType` on the Apple clients, or
`ExcludeClipboardContentFromMonitorProcessing` on Windows — what password managers set — is never
announced and never served. That check exists only in those two clients. The Android client has no
announced and never served. That check exists only in those clients. The Android client has no
equivalent, and neither does the host, so a password copied **on the host** is announced to your
client like anything else.
@@ -128,10 +137,11 @@ when a host application pastes.
| Client | What crosses |
|---|---|
| macOS | Plain text, rich text (RTF), HTML, and PNG, JPEG and GIF images |
| iOS, iPadOS | Plain text, rich text (RTF), HTML, and PNG, JPEG and GIF images |
| Windows | Plain text, and PNG images |
| Android, Android TV | **Plain text only** |
| Linux (GTK), Steam Deck | Nothing yet — see below |
| iOS, iPadOS, tvOS | Not implemented |
| tvOS | Not implemented — tvOS has no pasteboard |
The **Linux client has the switch but no working clipboard bridge**: it enables the plane and then
has no code to read or write the desktop's own clipboard, so nothing is announced and nothing is
+2
View File
@@ -269,6 +269,8 @@ A few knobs are read by the native **clients**, not the host:
| `PUNKTFUNK_DECODER` | `native-vulkan` · `native-vaapi` (Linux) · `native-d3d11va` (Windows) · `software` | Force the decode path. Default auto-selects hardware per GPU vendor and falls back on its own: **Linux** — Vulkan Video first on NVIDIA and AMD, VAAPI first on Intel and anything else; **Windows** — Vulkan Video first on NVIDIA and AMD, D3D11VA first on Intel and anything else. Whichever isn't first is the next thing tried, with software last (OpenH264 for H.264, rav1d for AV1 — there is no software HEVC, so a client that lands there reconnects on a codec it can decode). The names are the ones the [stats overlay](/docs/stats) prints, so a pin and a reading match. The older spellings `vulkan`, `vaapi` and `d3d11va` named the FFmpeg-backed decoders the clients used before and still work — each migrates onto the native path for the same hardware, and the client says so in its log. |
| `PUNKTFUNK_VAAPI_DEVICE` | path, e.g. `/dev/dri/renderD129` | **(Linux)** Pin the DRM render node the `native-vaapi` decoder opens. Unset, the client tries the nodes in order and takes the first that can decode the stream — set this on a multi-GPU box when it lands on the wrong one. |
| `PUNKTFUNK_PREFER_PYROWAVE` | `1` | Ask for the [PyroWave](/docs/pyrowave) wavelet codec on a wired link, where the client's own setting isn't reachable (the gamepad console, a headless launch). |
| `PUNKTFUNK_PAD_SPEAKER_PATH` · `PUNKTFUNK_PAD_SPEAKER_VOLUME` | byte, hex or decimal *(default `0x20` / `0x7F`)* | Which output a DualSense sends [controller audio](/docs/controller-audio) to, and how loud. A controller's channel 1 is shared between its headphone jack and its built-in speaker, and it powers up pointing at the jack — so with no headphones plugged in the speaker stays silent however correctly the audio is routed. Punktfunk points it at the speaker when controller-speaker is on. Change these only if your pad's speaker stays quiet; a game that sets its own audio levels still overrides them. |
| `PUNKTFUNK_PAD_AUDIO_PROFILE` | `0` | **(Linux)** Stop the client from switching a wired DualSense's sound card to **Pro Audio** while it streams [controller audio](/docs/controller-audio) to it. The switch exists because a controller's voice coils are channels 3 and 4 of its sound card, and a controller almost never presents four channels on its own — on any other profile the haptics are folded into the speaker pair and felt as nothing. Punktfunk restores the card's profile when the session ends and never saves it. Set this if you'd rather select the card's profile yourself. |
| `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. |
| `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. |
| `PUNKTFUNK_PRESENT_MODE` | `mailbox` *(default)* · `fifo` · `immediate` · `fifo_relaxed` | How decoded frames meet the display (the Vulkan present mode). The default prefers MAILBOX — tear-free without queueing behind the vertical refresh — and falls back to FIFO (classic vsync) where the driver doesn't offer it. **AMD's Windows driver offers no MAILBOX**, so those clients run FIFO, which adds a standing frame-pacing wait (up to one refresh interval). `immediate` removes that wait but can tear; `fifo_relaxed` only tears when a frame is late. If your latency floor matters more than tearing, try `immediate` and judge by eye. |
+70 -2
View File
@@ -17,8 +17,9 @@ pad's speaker, channels 34 are the voice coils.
- **A DualSense or DualSense Edge plugged in over USB** on the client. Bluetooth pads expose no
audio interface at all, so they fall back to ordinary rumble — this is a limit of the
controller, not of Punktfunk.
- On the client, **Controller haptics** is on by default. **Controller speaker** is opt-in: turn
it on if you want game audio coming out of the pad as well as your speakers.
- On the client, **Controller haptics** is on by default. So is **Controller speaker** on the Linux
and Windows apps — turn it off in [client settings](/docs/client-settings#input) if you would
rather all game audio came out of your speakers. On Android the speaker is opt-in.
- On a **Linux host**, a game that speaks DualSense — which in practice means running it under
**GE-Proton 11-5 or newer**. Stock Proton does not route controller audio.
- On the host, controller audio is on by default (`PUNKTFUNK_PAD_AUDIO`).
@@ -109,6 +110,65 @@ PROTON_DUALSENSE_SPLIT_AUDIO=1 %command%
To see which route GE took, launch the game with `WINEDEBUG=+pulse` and look for a line beginning
`Routing DualSense`. It names the device it chose and how it opened it.
## On a Linux client, the pad's own profile matters too
Everything above is about the host, where the controller-audio device is one Punktfunk mints. On a
Linux **client** the pad is real, and the same channel-layout problem shows up from the other side:
the voice coils are physically channels 3 and 4 of the controller's USB sound card, and a
controller almost never presents as a four-channel device on its own. Depending on your distribution
it appears as a stereo output, or as a mono *Speaker* plus a stereo *Headphones* pair. Playing into
any of those puts the haptics in the headphone jack and folds the coil channels away — audio that
looks perfectly healthy, felt as nothing at all.
**Punktfunk handles this for you.** When it needs the coils and the pad is not already presenting
four channels, it switches the controller's card to **Pro Audio** for the length of the session and
puts your setting back afterwards. You will see the profile change in your sound settings while you
are streaming; that is expected. It is never saved as the card's remembered profile.
If you would rather manage the card yourself, set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` on the client. Then
Punktfunk uses a four-channel profile if you have already selected one and logs what it needs if you
have not.
Most systems never reach the switch at all. Where your distribution ships a recent `alsa-ucm-conf`
Bazzite and SteamOS among them — a DualSense already exposes its four channels behind its split
speaker and headphone outputs, and Punktfunk finds them there. The switch is the fallback for
systems that only offer the older stereo profile. **If you run the client as a Flatpak**, your audio
manager may not let a sandboxed app change a card's profile; if the log says so, switch the
controller to Pro Audio yourself, which is the same fix.
### Checking the client side without a host
The client can test the whole path on its own — no host, no game, no pairing. Plug in the
DualSense and run:
```sh
punktfunk-session --pad-audio-test
```
It prints every DualSense object it can see in your audio graph, says which one it chose, and then
plays a tone into the voice coils for three seconds. **If the pad buzzes, the client side is
working** and any remaining silence is coming from the host or the game. Add `--speaker` to test
the pad's speaker instead, and `--seconds N` for a longer run.
On the Steam Deck and other flatpak installs, run it inside the sandbox:
```sh
flatpak run --command=punktfunk-session io.unom.Punktfunk --pad-audio-test
```
### Why the speaker needs more than routing
The controller's speaker and its headphone jack **share a channel**. Channel 1 of the pad's audio
device is the headphone jack's right channel *and* the built-in speaker, and the controller decides
which one actually sounds. It powers up pointing at the jack — so with nothing plugged in, a
perfectly routed speaker stream is heard by nobody.
Punktfunk points the pad at its own speaker when **Controller speaker** is on. The voice coils are
different channels and are not affected by that choice, which is why haptics work as soon as the
audio is routed correctly and the speaker needs this extra step. A game that drives the pad's audio
settings itself still overrides it. If your pad's speaker stays quiet, `PUNKTFUNK_PAD_SPEAKER_PATH`
and `PUNKTFUNK_PAD_SPEAKER_VOLUME` let you bisect it without a rebuild.
## Known limits
- **Bluetooth client pads get rumble, not haptics.** No audio interface exists over BT.
@@ -119,3 +179,11 @@ To see which route GE took, launch the game with `WINEDEBUG=+pulse` and look for
- **A pad plugged into the host itself can steal the audio.** If a real DualSense is connected to
the host while you are streaming to a different one, some titles will find the local pad's sound
card first. Unplug it, or stream from a host that has no pad attached.
- **The Pro Audio switch on a Linux client renames the pad's microphone too.** Switching a sound
card's profile re-creates all of its inputs and outputs, so if you had picked the DualSense's own
microphone as your [mic](/docs/client-settings#audio), that session falls back to your default
one. Pick a different microphone, or set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` and select a
four-channel profile on the card yourself.
- **A client killed mid-stream leaves the pad on Pro Audio.** The profile is restored when a
session ends normally and is never written to your saved settings, so anything that reloads the
card — unplugging it, logging out, a reboot — brings your own profile back.
+2 -2
View File
@@ -38,8 +38,8 @@ than de-escalating and staying degraded. The next structural lever is **sub-fram
overlapping encode and transmit inside a single frame via a direct slice path — which matters most
at high resolutions.
**Finishing the clipboard.** Text crosses today from a Windows, macOS or Android client to a host
whose operator turned the feature on, with images on the first two. Two pieces are genuinely
**Finishing the clipboard.** Text crosses today from a Windows, macOS, iOS, iPadOS or Android
client to a host whose operator turned the feature on, with images on all but Android. Two pieces are genuinely
unfinished: the **Linux client's** side of the bridge is a stub, so a Linux client offers and
applies nothing; and **file transfer** has a wire format and a host-side policy but no client that
offers files, so a copied file never crosses. See [Clipboard](/docs/clipboard).
+7 -5
View File
@@ -504,7 +504,7 @@ text from an IME), are covered in [Input](/docs/input).
| Linux desktop | ✅ ¹ | ⚠️ ¹³ | ✅ ² | ✅ | ❌ ³ | ✅ |
| Windows desktop | ✅ ¹ | ⚠️ ¹³ | ✅ ² | ✅ | ⚠️ ⁴ | ✅ |
| macOS | ⚠️ ⁵ | ⚠️ ⁶ | ✅ ² | ✅ | ✅ ⁷ | ✅ |
| iPhone · iPad | ⚠️ ⁵ | ⚠️ ⁶ | ✅ ² | ✅ | ❌ ⁸ | ✅ |
| iPhone · iPad | ⚠️ ⁵ | ⚠️ ⁶ | ✅ ² | ✅ | ✅ ⁷ | ✅ |
| Apple TV | ⚠️ ⁵ | ⚠️ ⁶ | ✅ ² | ❌ ⁹ | ❌ ⁸ | ✅ |
| Android · Android TV | ⚠️ ⁵ | ❌ | ✅ ² | ✅ | ⚠️ ¹⁰ | ✅ |
| Moonlight | ⚠️ ¹¹ | ❌ | ✅ ² | ❌ | ❌ | ❓ ¹² |
@@ -523,8 +523,10 @@ text from an IME), are covered in [Input](/docs/input).
full-chroma colour conversion, so 4:4:4 needs no encoder probe and resolves on any vendor. See
[Client settings](/docs/client-settings).
7. The richest implementation: text, RTF, HTML and images, fetched lazily in both directions.
Concealed and transient pasteboard items are skipped.
8. The clipboard bridge is macOS-only within the Apple app.
Concealed and transient pasteboard items are skipped. On iOS one thing is not lazy: because
backgrounding the app ends the session, an unpasted host offer is pulled across (up to 8 MiB)
as the session ends, so it survives to be pasted in another app.
8. tvOS has no pasteboard, so there is nothing to share.
9. tvOS gives apps no microphone input at all.
10. Text only, by design — and unlike every other client, Android's per-host **Shared clipboard**
switch starts **on** (the desktop clients and the Apple app default it off). Nothing crosses
@@ -550,7 +552,7 @@ These are negotiated, and either side can be the reason it did not happen:
host without the clipboard protocol (a gamescope session, for instance). The per-host "Share
clipboard" switch is edited before you connect, so it is never greyed out — on Linux, Windows,
Android and the Apple add-host sheet it stays settable against a host that will refuse, and just
does nothing. Only macOS reflects the refusal live: the Stream ▸ Share Clipboard menu item is
does nothing. Only the Apple app reflects the refusal live: the Stream ▸ Share Clipboard item is
disabled when the connected host has not advertised the capability.
- **Pen input** — the host advertises it only if it can really inject: a usable `/dev/uinput` on
Linux, or synthetic pointer devices on Windows 10 1809+. Without it, clients fold pen into touch.
@@ -574,7 +576,7 @@ capability.
| **Windows host** | Newest large surface, shipping as an installer with its own virtual-display driver — both signed with Punktfunk's own certificates rather than a publicly trusted one, so Windows warns on install (see [Windows host](/docs/windows-host#about-the-signatures)). NVENC is well trodden; the AMD (AMF) path was validated on real hardware in mid-2026 (Ryzen 7000 iGPU, 1080p120 HDR P010) and the Intel (QSV) path on Arc, but both see far less field time than NVENC — several of QSV's newer arms are still marked unvalidated in the code. One structural constraint that catches people: it must run in the interactive console session, not session 0. |
| **GameStream / Moonlight plane** | Works, and whether it is on depends on how you installed. Every Linux package (deb, RPM, Arch, the Bazzite sysext) and the SteamOS installer ship the unit as `serve --gamestream`, so GameStream is **on** there; NixOS defaults it on too. The Windows installer's checkbox is unticked, so it is **off** unless you asked for it, and a bare `punktfunk-host serve` is off. It pairs over plain HTTP with weaker legacy encryption — trusted LAN only, and worth turning off if you don't use Moonlight (see [Security](/docs/security#gamestream--moonlight-compatibility-is-the-weak-crypto-path)). It is a compatibility surface, so Punktfunk-only features (profiles, links, clipboard, microphone) are not on it. |
| **Linux and Windows desktop clients** | Packaged and current. They are one codebase: the same session binary streams for both, and for the Decky plugin and the `punktfunk` CLI. |
| **Apple client** (macOS · iOS · iPadOS · tvOS) | One universal build, distributed as a **TestFlight beta**; the Mac also has a notarized DMG. Feature-complete apart from the platform gaps named above (no microphone on tvOS, clipboard on macOS only). |
| **Apple client** (macOS · iOS · iPadOS · tvOS) | One universal build, distributed as a **TestFlight beta**; the Mac also has a notarized DMG. Feature-complete apart from the platform gaps named above (no microphone or clipboard on tvOS). |
| **Android client** (phone · TV) | Published on **Google Play** as a public listing for releases, with an invite-only Internal testing track for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. |
| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It is a launcher, not a second client: it starts the Linux client rather than streaming itself, and holds no settings, no library and no host editor of its own — its **Open Punktfunk** button hands all of that to the client's console home. |
| **Web console** | The full management surface — dashboard and sessions, pairing, library, displays, plugins and the plugin store, logs, stats, settings, and host updates. It cannot yet run a speed test or set a bitrate; the client apps can. |
+70
View File
@@ -304,6 +304,36 @@ were holding down is released on the host, so nothing sticks. The rest of the in
switch mouse mode, disconnect, fullscreen — are in
[Getting your input back](/docs/input#getting-your-input-back).
## My keyboard types the wrong characters (`#` comes out as `\`)
A German keyboard giving `\` for `#`, `'` for `ä` and `/` for `-`, or `z` and `y` swapped, is a
**host** layout mismatch — the client is fine.
Punktfunk sends the *physical key you pressed*, not the character, exactly as a keyboard plugged
into the host would. What that key finally types is decided by the layout the **host session** is
running, so the host has to be set to the same layout as the keyboard you're typing on. When it
isn't, every key whose position differs between the two layouts comes out as its neighbour.
On Linux, set the layout the normal way and reconnect:
```sh
sudo localectl set-x11-keymap de pc105 nodeadkeys # your layout, model, variant
```
Punktfunk reads that setting and hands it to the session on the next connect. Two things are worth
knowing:
- **Wayland desktops don't read it by themselves.** `localectl` writes a file only Xorg opens, so
before this release a correctly-configured box could still run a US session. If your compositor
is already set to the right layout in its own settings, nothing changes.
- **Game Mode needs a current `punktfunk-gamescope`.** Gamescope publishes no keyboard layout at
all to the apps it runs, so Steam and games saw US whatever the box was set to. Our build fixes
that from `+pfhdr8` on — check with `punktfunk-gamescope --version`, and
[update](/docs/updating) if it's older.
Nothing here changes which physical key does what in a game: `WASD` stays under the same fingers on
every layout.
## A controller is detected but games don't see it
- **Linux.** The host user needs to be in the `input` group. On Bazzite:
@@ -350,6 +380,46 @@ It is not only the pad, though: the same group authorizes the helper that stops
for a managed **Gaming Mode** takeover, so on a box that autologins into Game Mode, skipping it also
costs you [the takeover](#game-mode-black-screen-on-connect-or-the-stream-is-stuck-at-the-boxs-resolution).
## A Steam Controller 2 is captured, but Steam's controller list stays empty
The client says everything is fine — the Controllers screen shows **Steam Controller 2, captured,
streams as-is** — and on the host Steam's **Settings → Controller → Connected Controllers** has
nothing in it. Buttons do nothing in games, and the trackpads don't move the pointer.
Unlike every other pad Punktfunk presents, the Steam Controller 2 has exactly one consumer:
**Steam**. No kernel driver claims its product id — mainline `hid-steam` stops at the Deck — and its
state reports ride a vendor collection, so the pad produces no evdev node for anything else to read.
If Steam can't open its `hidraw` node, you don't get a degraded controller, you get no controller.
The node is root-only until a udev rule says otherwise, and distro `steam-devices` rule sets are
per-product-id: a host whose copy predates the SC2 (it shipped in 2026) never grants it. Punktfunk
ships the rule itself from 0.30.0 on. On an older host, add it by hand:
```sh
sudo tee /etc/udev/rules.d/61-punktfunk-sc2.rules >/dev/null <<'EOF'
KERNEL=="hidraw*", KERNELS=="*28DE:1302*", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", KERNELS=="*28DE:1304*", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1302", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1304", GROUP="input", MODE="0660", TAG+="uaccess"
EOF
sudo udevadm control --reload-rules && sudo udevadm trigger
```
Then end the session and reconnect, so the pad re-enumerates under the new rule. `1302` is the wired
controller and `1304` the Puck dongle — the two identities the host presents.
To confirm this is what you're hitting, look at the host log for one line and one absence: the pad
attaching (`attached via usbip`), and **no** `answering feature GET` afterwards. That pair means the
kernel enumerated the controller and Steam never opened it. The everything-else checks — the
`punktfunk` group, `vhci_hcd`, the `attach` node — are in
[the virtual Steam Deck section](#the-pad-works-but-arrives-as-an-xbox-360-controller-instead-of-a-steam-deck)
above; if `attached via usbip` is missing from the log entirely, start there instead.
One more thing that is *not* a bug: with Punktfunk capturing, the trackpads stop working as a mouse
whenever Steam isn't running. Punktfunk turns the controller's built-in mouse-and-keyboard emulation
("lizard mode") off so it can read the full report stream, so on this pad Steam is what makes the
trackpads a pointer — exactly as on a Steam Deck in desktop mode.
## Copy and paste between host and client does nothing
The shared clipboard needs **two** separate switches on, and turning on only one looks exactly like
+834 -5
View File
@@ -10,9 +10,242 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.28.0"
"version": "0.29.0"
},
"paths": {
"/api/v1/client-logs": {
"get": {
"tags": [
"logs"
],
"summary": "List uploaded client log bundles",
"description": "Every stored bundle's metadata, newest first.",
"operationId": "clientLogsList",
"responses": {
"200": {
"description": "Stored bundles, newest first",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ClientLogMeta"
}
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"post": {
"tags": [
"logs"
],
"summary": "Upload a client log bundle",
"description": "A PAIRED DEVICE posts its recent client log as plain text, authenticated by its streaming\ncertificate (the same mTLS identity it pairs and streams with) — no bearer token. Bundles are\ncapped at 1 MiB and only the newest few per device are kept. The operator downloads them from\nthe console's Logs page. This is deliberately write-only for devices: uploading grants no read.",
"operationId": "clientLogsUpload",
"requestBody": {
"description": "The client's log text",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Bundle stored",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ClientLogUploaded"
}
}
}
},
"400": {
"description": "No paired-device certificate on the connection",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"403": {
"description": "The device's access has expired (per-client access)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"413": {
"description": "Bundle exceeds the size cap",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"422": {
"description": "Empty body",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not store the bundle",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/client-logs/{id}": {
"get": {
"tags": [
"logs"
],
"summary": "Download a client log bundle",
"description": "The bundle body as plain text, for saving or attaching to a report.",
"operationId": "clientLogsGet",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The bundle id (its filename stem)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The bundle body",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No bundle with that id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "The bundle file is unreadable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"delete": {
"tags": [
"logs"
],
"summary": "Delete a client log bundle",
"description": "Removes the bundle `id` from disk. `404` if there is no such bundle.",
"operationId": "clientLogsDelete",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The bundle id (its filename stem)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Bundle deleted"
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No bundle with that id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not delete the bundle",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/clients": {
"get": {
"tags": [
@@ -168,6 +401,70 @@
}
}
},
"/api/v1/diagnostics": {
"get": {
"tags": [
"diagnostics"
],
"summary": "Host health checks",
"description": "Every verdict this host computes about its own health — group membership the managed takeover\nneeds, the input device nodes virtual controllers are built on, competing streaming servers —\nwith the impact and a copy-pasteable remedy for each.\n\nCached: the probes run once at startup and on demand via `POST /diagnostics/refresh`, so this is\ncheap to poll. Checks whose status is `ok` and `inapplicable` are included — a troubleshooting\npage needs to show what is working and to answer \"why isn't this check relevant here?\".\n\n`summary`, `impact` and `remedy.text` are always present in English. A console that recognizes\nthe check's `id` replaces them with a localized string interpolated from `params`; one that does\nnot renders the wire text as-is, which is what keeps a console paired with a newer host readable.",
"operationId": "getDiagnostics",
"responses": {
"200": {
"description": "The current verdicts, worst-first",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DiagnosticsReport"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/diagnostics/refresh": {
"post": {
"tags": [
"diagnostics"
],
"summary": "Re-run the health checks",
"description": "Runs every probe again and returns the refreshed verdicts. Most checks describe state that only\nchanges when an operator changes it (a group membership, an installed udev rule), so this exists\nfor exactly the moment after they have done so — a \"did that fix it?\" button, not a poll.",
"operationId": "refreshDiagnostics",
"responses": {
"200": {
"description": "The refreshed verdicts, worst-first",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DiagnosticsReport"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/layout": {
"put": {
"tags": [
@@ -1903,6 +2200,97 @@
}
}
}
},
"patch": {
"tags": [
"native"
],
"summary": "Update a native client's access",
"description": "Partial edit of a paired device's grants/expiry (the console edit sheet: preset change,\nextend, \"expire now\", make permanent). Omitted fields keep their current value; the edit\nreaches the device's live sessions immediately. Not a way to pair a device (404 when the\nfingerprint isn't in the trust store).",
"operationId": "updateNativeClientAccess",
"parameters": [
{
"name": "fingerprint",
"in": "path",
"description": "Hex SHA-256 of the client certificate (case-insensitive)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateNativeAccess"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Access updated; the stored record as now in force",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NativeClient"
}
}
}
},
"400": {
"description": "Reserved grant bits set, or expires_in_secs together with clear_expiry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No paired native client with that fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the trust store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/pair": {
@@ -1976,7 +2364,7 @@
"native"
],
"summary": "Arm native pairing",
"description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list.",
"description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list. An access choice\n(`grants` / `expires_in_secs`) applies to whichever device completes this window's ceremony.",
"operationId": "armNativePairing",
"requestBody": {
"content": {
@@ -1999,6 +2387,16 @@
}
}
},
"400": {
"description": "Reserved grant bits set",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
@@ -2063,7 +2461,7 @@
"native"
],
"summary": "Approve a pending device",
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it via the body; send `{}` to keep the name it knocked with.",
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it and/or choose its access via the body; send `{}` to keep the name it knocked with\nand its existing access (full/permanent for a first pairing). The response is the stored\nrecord — what is actually in force, not necessarily this request's inputs.",
"operationId": "approvePendingDevice",
"parameters": [
{
@@ -2090,7 +2488,7 @@
},
"responses": {
"200": {
"description": "Device paired",
"description": "Device paired; the stored record as now in force",
"content": {
"application/json": {
"schema": {
@@ -2099,6 +2497,16 @@
}
}
},
"400": {
"description": "Reserved grant bits set",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
@@ -4128,8 +4536,28 @@
},
"ApprovePending": {
"type": "object",
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name and — for a\nre-approved device — its existing access (the full/permanent default for a first pairing).",
"properties": {
"expires_in_secs": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Access expiry in seconds **from now** (relative — the host stores the absolute deadline\nand stamps the grant time). Alone, it means full control until then.",
"example": 14400,
"minimum": 0
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Access choice: grant bitmask (`GRANT_*` bits 05). Reserved bits are a 400. Omitting BOTH\naccess fields keeps a re-approved device's stored access; `grants` without\n`expires_in_secs` grants permanently.",
"example": 1,
"minimum": 0
},
"name": {
"type": [
"string",
@@ -4144,6 +4572,16 @@
"type": "object",
"description": "Arm-native-pairing request body.",
"properties": {
"expires_in_secs": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Optional access expiry for the pairing device, in seconds **from now** (relative — the\nhost stores the absolute deadline). NOT the pairing window's length; that is `ttl_secs`.\nOmit for permanent access (when `grants` is set) or preserved access (when neither is).",
"example": 14400,
"minimum": 0
},
"fingerprint": {
"type": [
"string",
@@ -4152,6 +4590,16 @@
"description": "Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).",
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Optional access choice for whichever device completes this window's ceremony: a grant\nbitmask (`GRANT_*` bits 05). Reserved bits are a 400. Omit (with `expires_in_secs`) for\ntoday's behavior — a new device gets full control, a re-pairing device keeps what it has.",
"example": 1,
"minimum": 0
},
"ttl_secs": {
"type": [
"integer",
@@ -4518,6 +4966,75 @@
}
}
},
"CheckSource": {
"type": "string",
"description": "Where a verdict came from. `Event` is reserved for the live feeds (transitions push instead of\nwaiting for a refresh); v1 produces only `Startup` and `Refresh`.",
"enum": [
"startup",
"event",
"refresh"
]
},
"CheckStatus": {
"type": "string",
"description": "What a probe found. `Inapplicable` is deliberately distinct from `Ok`: \"this box will never do\nthe thing\" and \"the thing works here\" are different answers, and the troubleshooting page shows\nthem differently.",
"enum": [
"ok",
"warn",
"fail",
"inapplicable"
]
},
"ClientLogMeta": {
"type": "object",
"description": "One stored bundle, as the console lists it.",
"required": [
"id",
"device_name",
"fingerprint_prefix",
"received_ms",
"size_bytes"
],
"properties": {
"device_name": {
"type": "string",
"description": "The paired device's name at upload time (sanitized for the filesystem)."
},
"fingerprint_prefix": {
"type": "string",
"description": "First 16 hex chars of the device's pairing fingerprint — enough to correlate with the\npaired-devices roster without repeating the full identity in every filename."
},
"id": {
"type": "string",
"description": "The bundle id (its filename stem) — pass to the fetch/delete endpoints."
},
"received_ms": {
"type": "integer",
"format": "int64",
"description": "Upload time (unix ms, from the file's mtime).",
"minimum": 0
},
"size_bytes": {
"type": "integer",
"format": "int64",
"description": "Bundle size in bytes.",
"minimum": 0
}
}
},
"ClientLogUploaded": {
"type": "object",
"description": "Response to a successful upload.",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "The stored bundle's id."
}
}
},
"ClientRef": {
"type": "object",
"description": "The connecting/disconnecting client's identity.",
@@ -4793,6 +5310,29 @@
}
}
},
"DiagnosticsReport": {
"type": "object",
"description": "The `GET /diagnostics` body.",
"required": [
"ran_at_unix",
"checks"
],
"properties": {
"checks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HostCheck"
},
"description": "Every registered check, worst-first. Includes `ok` and `inapplicable` rows — the console\ndecides what to hide, because \"what's working\" is the reassurance the dashboard omits."
},
"ran_at_unix": {
"type": "integer",
"format": "int64",
"description": "When the probes last ran (unix seconds).",
"minimum": 0
}
}
},
"DisconnectReason": {
"type": "string",
"description": "Why a client went away. `Quit` is a deliberate user \"stop\" (the typed close code);\n`Timeout` is a transport idle timeout (the client vanished); `Error` is everything else.",
@@ -5231,6 +5771,91 @@
}
}
},
{
"type": "object",
"description": "A device was granted access with an explicit operator choice — the approve dialog, the\narm window's carried choice, or any other `add_with_access(Some)` path\n(design/per-client-access.md §6). A plain pairing with no choice emits only\n`pairing.completed` (its access is the preserved/default record, nothing was *chosen*).",
"required": [
"device",
"grants",
"kind"
],
"properties": {
"device": {
"$ref": "#/components/schemas/DeviceRef"
},
"expires_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Absolute expiry, host wall clock unix seconds; absent = permanent."
},
"grants": {
"type": "integer",
"format": "int32",
"description": "The granted mask (the `GRANT_*` bit vocabulary), reserved bits already cleared.",
"minimum": 0
},
"kind": {
"type": "string",
"enum": [
"access.granted"
]
}
}
},
{
"type": "object",
"description": "A paired device's access was edited after the fact (the console edit sheet / extend /\n\"expire now\") — the owner's hook can say \"the TV is view-only now\".",
"required": [
"device",
"grants",
"kind"
],
"properties": {
"device": {
"$ref": "#/components/schemas/DeviceRef"
},
"expires_unix": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"grants": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"kind": {
"type": "string",
"enum": [
"access.changed"
]
}
}
},
{
"type": "object",
"description": "A device's temporary access reached its deadline and its live session was closed — \"guest\naccess ended\". Emitted at deadline fire by the expiring session (a device with no live\nsession expires silently; the console row flips to \"Expired\" either way).",
"required": [
"device",
"kind"
],
"properties": {
"device": {
"$ref": "#/components/schemas/DeviceRef"
},
"kind": {
"type": "string",
"enum": [
"access.expired"
]
}
}
},
{
"type": "object",
"required": [
@@ -5870,6 +6495,72 @@
}
}
},
"HostCheck": {
"type": "object",
"description": "One health verdict. This IS the wire shape.",
"required": [
"id",
"status",
"severity",
"summary",
"impact",
"params",
"source"
],
"properties": {
"id": {
"type": "string",
"description": "Stable snake_case machine code — the console's i18n key (see [`ids`])."
},
"impact": {
"type": "string",
"description": "What actually breaks, in the operator's terms. Empty only for `ok`/`inapplicable` rows."
},
"params": {
"type": "object",
"description": "Interpolation values for the console's localized strings (`{user}`, `{group}`, …). The\nconsole needs these because it cannot re-derive them: only the host can see the username.",
"additionalProperties": {
"type": "string"
},
"propertyNames": {
"type": "string"
}
},
"remedy": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/Remedy"
}
]
},
"severity": {
"$ref": "#/components/schemas/Severity",
"description": "What a non-ok status means. Meaningless when `status` is `ok`/`inapplicable`; carried anyway\nso a check never changes shape as it flips."
},
"since_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "First non-ok observation in this host run. Per-run bookkeeping, not a time series — there is\nno history here by design.",
"minimum": 0
},
"source": {
"$ref": "#/components/schemas/CheckSource"
},
"status": {
"$ref": "#/components/schemas/CheckStatus"
},
"summary": {
"type": "string",
"description": "One line, English. The console replaces this with a localized message when it knows `id`."
}
}
},
"HostEvent": {
"allOf": [
{
@@ -6497,10 +7188,44 @@
"fingerprint"
],
"properties": {
"access_level": {
"type": [
"string",
"null"
],
"description": "The preset this device's mask amounts to, for display: `full` | `controller` | `view` |\n`custom`. Derived from `grants` on the host; absent only on hosts older than the field.",
"example": "controller"
},
"expires_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Absolute access expiry, unix seconds on the host's wall clock. `null` = permanent. Whether\nit has already passed is the reader's arithmetic — an expired device stays listed (shown\nas \"Expired\"), it just isn't authorized."
},
"fingerprint": {
"type": "string",
"description": "Hex SHA-256 of the client certificate — its stable id here."
},
"granted_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "When access was last granted, unix seconds — display/audit only, never enforced."
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Grant bitmask (`GRANT_*` bits 05). `null` = a record from before grants existed, which\nmeans full control.",
"example": 1,
"minimum": 0
},
"name": {
"type": "string",
"description": "The name the client supplied when pairing.",
@@ -6627,16 +7352,49 @@
"age_secs"
],
"properties": {
"access_level": {
"type": [
"string",
"null"
],
"description": "The stored mask's preset name (`full` | `controller` | `view` | `custom`) — `null` for a\ndevice with no stored record, unlike [`NativeClient`] where it is always derivable.",
"example": "controller"
},
"age_secs": {
"type": "integer",
"format": "int64",
"description": "Seconds since the device last knocked.",
"minimum": 0
},
"expires_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "The stored record's absolute expiry (unix seconds; likely in the past — that's why it's\nknocking). `null` when unknown or permanent."
},
"fingerprint": {
"type": "string",
"description": "Hex SHA-256 of the device's certificate — what approval pins."
},
"granted_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "When the stored record's access was granted (unix seconds). `null` when unknown."
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The grant mask this fingerprint is ALREADY stored with, if it was paired before (the\nexpired-guest re-knock: the approve dialog can offer \"re-grant what they had\"). `null`\nwhen the device is unknown, or known with a pre-grants record (= full).",
"minimum": 0
},
"id": {
"type": "integer",
"format": "int32",
@@ -7055,6 +7813,31 @@
}
}
},
"Remedy": {
"type": "object",
"description": "What the operator should do about it. Always copy-paste — the host runs unprivileged and the\nconsole must never trigger privileged mutation. The `punktfunk` group in particular is\ndeliberately opt-in: writing the vhci `attach` node materialises arbitrary emulated USB devices\n(security review 2026-08-05, M-4), so joining it stays a deliberate act with the caveat attached.",
"required": [
"text",
"relogin_required"
],
"properties": {
"command": {
"type": [
"string",
"null"
],
"description": "A single pasteable shell command, when one fixes it outright."
},
"relogin_required": {
"type": "boolean",
"description": "True when the fix only takes effect after logging out and back in — a `systemd --user`\nmanager keeps the supplementary group set it started with. This distinction is the\ndifference between \"I already added myself!\" and a working virtual pad."
},
"text": {
"type": "string",
"description": "Plain-language instruction. English fallback — the console overrides it by check id."
}
}
},
"RuntimeRequest": {
"type": "object",
"required": [
@@ -7375,6 +8158,15 @@
}
}
},
"Severity": {
"type": "string",
"description": "How much a non-ok status matters. Orthogonal to [`CheckStatus`] on purpose: a check can be\n`warn` about something `critical` (degraded, not dead) and the console sorts by both.",
"enum": [
"info",
"warning",
"critical"
]
},
"SourceInput": {
"type": "object",
"required": [
@@ -7856,6 +8648,39 @@
}
}
},
"UpdateNativeAccess": {
"type": "object",
"description": "PATCH body for a paired device's access (the console edit sheet: change the preset, extend,\n\"expire now\", make permanent). **Partial**: an omitted `grants` keeps the current grants, and\nomitted expiry fields keep the current expiry — send only what changes.",
"properties": {
"clear_expiry": {
"type": [
"boolean",
"null"
],
"description": "`true` removes the expiry — access becomes permanent. Mutually exclusive with\n`expires_in_secs` (400)."
},
"expires_in_secs": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "New expiry in seconds **from now** (relative; the host stores the absolute deadline).\n`0` expires the device now. Omit to keep the current expiry. Mutually exclusive with\n`clear_expiry` (400).",
"example": 14400,
"minimum": 0
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "New grant bitmask (`GRANT_*` bits 05); reserved bits are a 400. Omit to keep the\ndevice's current grants.",
"example": 1,
"minimum": 0
}
}
},
"UpdateResultInfo": {
"type": "object",
"description": "Durable outcome of the most recent apply attempt (survives the host's own restart).",
@@ -8028,6 +8853,10 @@
"name": "host",
"description": "Host identity, capabilities, and liveness"
},
{
"name": "diagnostics",
"description": "Host health checks: what is wrong, what it breaks, and how to fix it (admin lane only)"
},
{
"name": "gpu",
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"
+9
View File
@@ -3774,6 +3774,15 @@ void punktfunk_reanchor_gate_arm_expecting_drops(ReanchorGate *g, uint64_t expec
// `USER_FLAG_RECOVERY_POINT`. Pass `decoder_keyframe = false` where the platform decoder doesn't flag
// IDRs (VideoToolbox/MediaCodec) — the wire `FLAG_SOF` covers it.
//
// This is the uncorroborated entry point and deliberately stays that way. Rust embedders whose
// decoder parses the bitstream call [`ReanchorGate::on_decoded_corroborated`] to let their own
// parser refute a `USER_FLAG_RECOVERY_ANCHOR` that names a picture they had to conceal; every
// client reachable through THIS surface (Apple VideoToolbox, Android MediaCodec) uses a platform
// decoder that surfaces no such fact, so it would have nothing to pass but
// `AnchorEvidence::Unavailable` — which is exactly what this wrapper already means. Growing a
// second export for a corroboration no C caller can supply would spend an ABI version bump on
// dead surface.
//
// # Safety
// `g` is a valid gate handle; `out_present` is writable or NULL.
PunktfunkStatus punktfunk_reanchor_gate_on_decoded(ReanchorGate *g,
+2
View File
@@ -20,6 +20,7 @@ The patches here add the missing half, and nothing else. See
| `0007-pipewire-never-leave-pw_buffer-user_data-pointing-at.patch` | Associate `pw_buffer->user_data` with its `pipewire_buffer` for every path out of `add_buffer`, clear it in `remove_buffer` (the last point both halves are known), and null-check the consumers — killing the use-after-free that aborted the session on every capture renegotiation | **Yes** — a plain use-after-free in the PipeWire buffer lifecycle |
| `0008-steamcompmgr-honor-GAMESCOPE_NO_FOCUS-never-a-focus-.patch` | Honor `GAMESCOPE_NO_FOCUS` (set by hhd-ui and MangoHud, consumed by nobody): such windows are skipped by both focus-candidate collectors, so a mapped-but-unpainted overlay app can no longer win focus and turn the composite black. Compositing is untouched — only focus SELECTION is barred | **Yes** — the atom's setters already exist in the wild; some compositor has to keep the promise |
| `0009-pipewire-destroy-capture-textures-on-the-compositor-.patch` | Move capture-buffer destruction off the PipeWire thread: `remove_buffer`/stale-push queue the corpse (`bury_buffer`), steamcompmgr reaps on every vblank — including while the stream is paused, which is exactly the linger window. Without it, dropping the last `CVulkanTexture` ref on the PW thread races `vulkan_screenshot` on the same device and SIGSEGVs (NVIDIA `insertBarrier`), so a lingered display is dead and reconnect loses the session. Reported + written by luxus (punktfunk-overlay#9) | **Yes** — the race is upstream's `paint_pipewire` vs `destroy_buffer`; our patches only make the paint path heavier |
| `0010-wlserver-give-the-seat-s-stub-keyboard-the-compiled-.patch` | Set the compiled keymap on `wlserver.wlr.virtual_keyboard_device` too. gamescope builds a keymap from `XKB_DEFAULT_*` but only puts it on `keyboard_group`, while the SEAT carries the keymap-less stub that `wlserver_keyboardfocus()` re-binds on every focus change — so clients get no keymap and fall back to their own `us`, and a headless session (no libinput devices) never recovers | **Yes** — the stub's own comment says it exists "only to set the keymap"; it just never did |
### Why the headless patch matters
@@ -103,6 +104,7 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
| `+pfhdr5` | …and the PipeWire buffer use-after-free is fixed (no new capability) |
| `+pfhdr6` | …and `GAMESCOPE_NO_FOCUS` windows are never focus candidates (no new capability) |
| `+pfhdr7` | …and PipeWire teardown cannot SIGSEGV a lingering compositor (no new capability) |
| `+pfhdr8` | …and the seat's keyboard carries the `XKB_DEFAULT_*` keymap, so the session follows the box's configured layout |
Bump it whenever a patch adds or changes something the host must know about before it spawns.
@@ -0,0 +1,67 @@
From d958d5451d0df3678cdccbb5d7306c15b504c877 Mon Sep 17 00:00:00 2001
From: enricobuehler <enrico.buehler@unom.io>
Date: Sat, 15 Aug 2026 20:15:12 +0200
Subject: [PATCH] wlserver: give the seat's stub keyboard the compiled keymap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
gamescope reads XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS and compiles a keymap from them,
but that keymap only ever lands on wlserver.keyboard_group. The seat carries a DIFFERENT object:
wlserver_keyboardfocus() calls wlr_seat_set_keyboard(seat, wlserver.wlr.virtual_keyboard_device)
on every focus change, and that stub — created a few lines above with wlr_keyboard_init(kbd,
nullptr, "virtual") and the comment "only used to set the keymap" — never has one set.
With a NULL keymap wlroots advertises none, so every client falls back to its own compiled-in
default and gamescope's session is us/pc105 whatever XKB_DEFAULT_LAYOUT says. The group does
reach the seat, but only from wlserver_handle_key/_modifiers, i.e. from a real libinput key
event — and the next focus change swaps the stub back in anyway. A HEADLESS session (--backend
headless, no libinput devices at all) therefore never gets a keymap onto the seat at all.
Observed on a Bazzite gaming-mode host configured de/nodeadkeys via localectl: both gamescope
Xwayland servers report evdev/pc105/us, and every ISO key types its US neighbour (# -> backslash,
adiaeresis -> apostrophe, minus -> slash). Injected input (libei/EIS) hits exactly the same path,
which is how a remote client with a German keyboard ends up typing US characters.
Set the keymap on the stub too.
---
src/meson.build | 3 ++-
src/wlserver.cpp | 8 ++++++++
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/meson.build b/src/meson.build
index fe854af..91bba6b 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -187,7 +187,8 @@ vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
# +pfhdr5 — …and the PipeWire buffer use-after-free is fixed (no new capability)
# +pfhdr6 — …and GAMESCOPE_NO_FOCUS windows are never focus candidates (no new capability)
# +pfhdr7 — …and PipeWire teardown cannot SIGSEGV a lingering compositor (no new capability)
-version_tag = vcs_tag + '+pfhdr7' + ' (' + compiler_name + ' ' + compiler_version + ')'
+# +pfhdr8 — …and the seat's keyboard carries the XKB_DEFAULT_* keymap (honours the box layout)
+version_tag = vcs_tag + '+pfhdr8' + ' (' + compiler_name + ' ' + compiler_version + ')'
gamescope_version_conf = configuration_data()
gamescope_version_conf.set('VCS_TAG', version_tag)
diff --git a/src/wlserver.cpp b/src/wlserver.cpp
index 92f2807..3354da7 100644
--- a/src/wlserver.cpp
+++ b/src/wlserver.cpp
@@ -2042,6 +2042,14 @@ bool wlserver_init( void ) {
struct wlr_keyboard *keyboard = &wlserver.keyboard_group->keyboard;
wlr_keyboard_set_repeat_info(keyboard, 25, 600);
wlr_keyboard_set_keymap(keyboard, keymap);
+ // The seat carries the STUB keyboard, not the group: wlserver_keyboardfocus() binds
+ // wlserver.wlr.virtual_keyboard_device on every focus change, and the group only reaches the
+ // seat from wlserver_handle_key/_modifiers — i.e. from a real libinput key event. Left with a
+ // NULL keymap the stub makes wlroots advertise no keymap at all, so every client (Xwayland
+ // included) keeps its own built-in "us" and XKB_DEFAULT_* is silently ignored. A headless
+ // session has no libinput devices, so it never recovers: the very next focus change puts the
+ // keymap-less stub back. Give the stub the keymap its own comment above says it exists for.
+ wlr_keyboard_set_keymap(wlserver.wlr.virtual_keyboard_device, keymap);
wlserver.keyboard_group_modifiers.notify = wlserver_handle_modifiers;
wl_signal_add(&keyboard->events.modifiers, &wlserver.keyboard_group_modifiers);
wlserver.keyboard_group_key.notify = wlserver_handle_key;
--
2.50.1 (Apple Git-155)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-kit",
"version": "0.4.1",
"version": "0.4.2",
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
"type": "module",
"license": "MIT OR Apache-2.0",
+34 -6
View File
@@ -54,21 +54,44 @@ export const steamCdnUrl = (
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/${file}`;
};
/** Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper). */
/**
* Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper).
*
* Two spellings per kind, because Steam renamed these assets and one cache holds both eras side by
* side on a 779-app cache, 594 appids carry `header.jpg` and 122 carry `library_header.jpg`, and
* NO appid carries both. Same story for the cover: 46 appids have only `library_capsule.jpg`. The
* renamed files are the same assets, byte-for-byte the same shapes (cover 300×450, header 460×215),
* so which name wins is cosmetic but knowing only one name loses the art outright.
*
* Missing the cover is the one that shows: the fallback is then the flat CDN URL, which 404s for
* anything Valve has re-hashed, so the client walks on to the header and draws a BANNER in a 2:3
* poster slot (Forza Horizon 6 / appid 2483190 is the reference case).
*/
const localFilenames = (kind: ArtKind): string[] =>
kind === "portrait"
? ["library_600x900_2x.jpg", "library_600x900.jpg"]
? ["library_600x900_2x.jpg", "library_600x900.jpg", "library_capsule.jpg"]
: kind === "hero"
? ["library_hero.jpg"]
: kind === "logo"
? ["logo.png"]
: // Steam's local cache names the header asset differently from the store CDN's
// `header.jpg` — this trips everyone once.
["library_header.jpg"];
// `header.jpg` — this trips everyone once. Newer entries use the CDN's name, so
// both belong here.
["library_header.jpg", "header.jpg"];
/**
* This kind's file under one Steam root's `appcache/librarycache/<appid>/<hash>/`, or `undefined`.
* Steam reuses one hash dir per asset version, so there is normally exactly one candidate.
* This kind's file under one Steam root's `appcache/librarycache/`, or `undefined`.
*
* Three layouts, all of them live in the same cache at the same time a title's art is in exactly
* one of them, so all three have to be checked or its cover is simply not found:
*
* 1. `<appid>/<hash>/<name>` per-asset-version hash dir. Steam reuses one hash dir per version,
* so there is normally exactly one candidate. Checked first: where a title has been re-fetched
* into this layout, this is the copy Steam itself is displaying.
* 2. `<appid>/<name>` straight in the appid dir, and the MAJORITY case (623 of 779 appids on the
* reference cache). A hash-dir-only walk misses every one of them, which stayed invisible only
* because the flat CDN URL those titles fall back to still resolves for older appids.
* 3. `<appid>_<name>` flat in `librarycache/` the oldest layout.
*/
export const findLocalArtFile = (
root: string,
@@ -82,6 +105,11 @@ export const findLocalArtFile = (
if (isFile(p)) return p;
}
}
// Layout 2: no hash dir, the asset sits directly in the appid dir.
for (const name of localFilenames(kind)) {
const p = path.join(base, name);
if (isFile(p)) return p;
}
// Older Steam wrote the files directly under `librarycache/` with the appid in the name.
for (const name of localFilenames(kind)) {
const flat = path.join(
+56
View File
@@ -280,6 +280,62 @@ describe("art locations", () => {
fs.rmSync(dir, { recursive: true, force: true });
});
test("finds a cover cached under Steam's newer `library_capsule` name", () => {
// The bug this pins: appid 2483190 (Forza Horizon 6) caches its 300×450 cover as
// `library_capsule.jpg`, the flat CDN URL for its `library_600x900.jpg` 404s, and the client
// therefore fell through to the header and drew a banner in a 2:3 poster slot.
const dir = tmp("art-capsule");
const hashDir = path.join(
dir,
"appcache",
"librarycache",
"2483190",
"711e",
);
fs.mkdirSync(hashDir, { recursive: true });
fs.writeFileSync(path.join(hashDir, "library_capsule.jpg"), "x");
expect(findLocalArtFile(dir, 2483190, "portrait")).toBe(
path.join(hashDir, "library_capsule.jpg"),
);
fs.rmSync(dir, { recursive: true, force: true });
});
test("finds art stored straight in the appid dir, with no hash dir", () => {
// The majority layout — 623 of 779 appids on the reference cache. A hash-dir-only walk finds
// none of it and silently falls back to a CDN URL that 404s for anything re-hashed.
const dir = tmp("art-flat");
const appDir = path.join(dir, "appcache", "librarycache", "813230");
fs.mkdirSync(appDir, { recursive: true });
fs.writeFileSync(path.join(appDir, "library_600x900.jpg"), "x");
fs.writeFileSync(path.join(appDir, "header.jpg"), "x");
expect(findLocalArtFile(dir, 813230, "portrait")).toBe(
path.join(appDir, "library_600x900.jpg"),
);
// `header.jpg` is the CDN's name, but the local cache uses it too for newer entries — the
// two spellings are the same 460×215 asset and never appear together for one appid.
expect(findLocalArtFile(dir, 813230, "header")).toBe(
path.join(appDir, "header.jpg"),
);
expect(findLocalArtFile(dir, 813230, "hero")).toBeUndefined();
fs.rmSync(dir, { recursive: true, force: true });
});
test("a hash dir wins over a loose file of the same kind", () => {
// No appid on the reference cache carries both, so this is only about which copy is the
// current one if Steam ever leaves the old layout behind: the hash dir is what it re-fetches
// into, so that is the copy it is itself displaying.
const dir = tmp("art-both");
const appDir = path.join(dir, "appcache", "librarycache", "570");
const hashDir = path.join(appDir, "abc123");
fs.mkdirSync(hashDir, { recursive: true });
fs.writeFileSync(path.join(appDir, "library_600x900.jpg"), "x");
fs.writeFileSync(path.join(hashDir, "library_600x900.jpg"), "x");
expect(findLocalArtFile(dir, 570, "portrait")).toBe(
path.join(hashDir, "library_600x900.jpg"),
);
fs.rmSync(dir, { recursive: true, force: true });
});
test("fileUrl produces the host's local-art contract shape", () => {
const u = fileUrl(path.join(path.sep, "home", "u", "My Games", "c.jpg"));
expect(u.startsWith("file:///")).toBe(true);
+13
View File
@@ -48,3 +48,16 @@ KERNEL=="hidraw*", KERNELS=="*28DE:1205*", GROUP="input", MODE="0660", TAG+="uac
KERNEL=="hidraw*", KERNELS=="*28DE:1102*", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1205", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1102", GROUP="input", MODE="0660", TAG+="uaccess"
# Steam Controller 2 (Triton): wired 28DE:1302 and the Puck dongle 28DE:1304 — the two identities
# `steam_backend_product` mints. Steam is the ONLY consumer of this pad: no kernel driver claims
# the PID (mainline hid-steam stops at the Deck), so its state reports ride a vendor collection
# that yields no evdev node at all. A hidraw node Steam cannot open is therefore not a degraded
# controller, it is no controller — the pad enumerates, the kernel reads its report descriptor,
# and Steam's own device list stays empty (field report 2026-08-15: usbip attached cleanly, the
# log showed every kernel control transfer and not one SET_REPORT from Steam).
# Relying on the distro's steam-devices rules is not enough for a pad that shipped in 2026: those
# lists are per-PID and a host whose copy predates the SC2 leaves the node root-only.
KERNEL=="hidraw*", KERNELS=="*28DE:1302*", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", KERNELS=="*28DE:1304*", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1302", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1304", GROUP="input", MODE="0660", TAG+="uaccess"
+30 -2
View File
@@ -408,9 +408,37 @@
"action_logout": "Abmelden",
"settings_logout_failed": "Abmelden fehlgeschlagen — du bist weiterhin angemeldet. Bitte versuche es erneut.",
"nav_stats": "Leistung",
"nav_logs": "Logs",
"nav_troubleshooting": "Fehlersuche",
"troubleshooting_title": "Fehlersuche",
"troubleshooting_subtitle": "Was dieser Host über seinen eigenen Zustand weiß — und darunter der Log-Stream.",
"diag_checks_title": "Zustandsprüfungen",
"diag_rerun": "Prüfungen erneut ausführen",
"diag_rerunning": "Prüfung läuft…",
"diag_rerun_failed": "Die Prüfungen konnten nicht erneut ausgeführt werden.",
"diag_loading": "Prüfung läuft…",
"diag_all_ok": "Alles, was dieser Host prüfen kann, sieht gesund aus.",
"diag_unavailable": "Dieser Host ist älter als die Konsole und kann keine Zustandsprüfungen melden. Aktualisiere den Host, um sie zu sehen.",
"diag_show_inapplicable": "{count} anzeigen, die hier nicht zutreffen",
"diag_hide_inapplicable": "Die hier nicht zutreffenden ausblenden",
"diag_impact_label": "Was dadurch nicht funktioniert",
"diag_remedy_label": "So wird es behoben",
"diag_relogin_required": "Danach ab- und wieder anmelden",
"diag_copy": "Befehl kopieren",
"diag_copied": "Befehl kopiert.",
"diag_copy_failed": "Der Befehl konnte nicht kopiert werden.",
"diag_status_ok": "OK",
"diag_status_inapplicable": "Nicht zutreffend",
"diag_severity_critical": "Kritisch",
"diag_severity_warning": "Warnung",
"diag_severity_info": "Hinweis",
"diag_attention_title": "Dieser Host braucht Aufmerksamkeit",
"diag_attention_link": "Fehlersuche",
"diag_attention_more": "und {count} weitere",
"diag_takeover_privilege_title": "Übernahme des Display-Managers",
"diag_virtual_deck_vhci_title": "Virtueller Steam-Deck-Controller",
"diag_uinput_access_title": "Unterstützung für virtuelle Controller",
"diag_server_conflict_title": "Konkurrierender Streaming-Server",
"logs_title": "Logs",
"logs_subtitle": "Der aktuelle Log-Stream des Hosts und deiner Plugins — live verfolgen, nach Level filtern, durchsuchen.",
"logs_source_all": "Alle",
"logs_source_host": "Host",
"logs_source_plugins": "Plugins",
+30 -2
View File
@@ -408,9 +408,37 @@
"action_logout": "Sign out",
"settings_logout_failed": "Sign-out failed — you're still signed in. Please try again.",
"nav_stats": "Performance",
"nav_logs": "Logs",
"nav_troubleshooting": "Troubleshooting",
"troubleshooting_title": "Troubleshooting",
"troubleshooting_subtitle": "What this host knows about its own health, and the log stream underneath it.",
"diag_checks_title": "Health checks",
"diag_rerun": "Re-run checks",
"diag_rerunning": "Checking…",
"diag_rerun_failed": "The checks could not be re-run.",
"diag_loading": "Checking…",
"diag_all_ok": "Everything this host can check looks healthy.",
"diag_unavailable": "This host is older than the console and cannot report health checks. Update the host to see them.",
"diag_show_inapplicable": "Show {count} that don't apply here",
"diag_hide_inapplicable": "Hide the ones that don't apply here",
"diag_impact_label": "What this breaks",
"diag_remedy_label": "How to fix it",
"diag_relogin_required": "Log out and back in afterwards",
"diag_copy": "Copy command",
"diag_copied": "Command copied.",
"diag_copy_failed": "Could not copy the command.",
"diag_status_ok": "OK",
"diag_status_inapplicable": "Not applicable",
"diag_severity_critical": "Critical",
"diag_severity_warning": "Warning",
"diag_severity_info": "Notice",
"diag_attention_title": "This host needs attention",
"diag_attention_link": "Troubleshooting",
"diag_attention_more": "and {count} more",
"diag_takeover_privilege_title": "Display-manager takeover",
"diag_virtual_deck_vhci_title": "Virtual Steam Deck controller",
"diag_uinput_access_title": "Virtual controller support",
"diag_server_conflict_title": "Competing streaming server",
"logs_title": "Logs",
"logs_subtitle": "The host's recent log stream, and your plugins' — follow live, filter by level, search.",
"logs_source_all": "All",
"logs_source_host": "Host",
"logs_source_plugins": "Plugins",
+4 -2
View File
@@ -7,9 +7,9 @@ import {
MonitorPlay,
MoreHorizontal,
Puzzle,
ScrollText,
Server,
Settings,
Stethoscope,
Workflow,
} from "lucide-react";
import { motion } from "motion/react";
@@ -31,7 +31,9 @@ const NAV = [
{ to: "/displays", icon: MonitorPlay, label: () => m.nav_displays() },
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
// The page is the troubleshooting home now — health checks above the log stream. The ROUTE stays
// `/logs`: bookmarks and deep links outlive a label.
{ to: "/logs", icon: Stethoscope, label: () => m.nav_troubleshooting() },
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
{ to: "/automation", icon: Workflow, label: () => m.nav_automation() },
{ to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() },
+101
View File
@@ -0,0 +1,101 @@
import type { HostCheck } from "@/api/gen/model/hostCheck";
import { m } from "@/paraglide/messages";
/**
* Presentation rules for the host's diagnostics checks.
*
* The console and the host ship as **separate packages**, and canary setups pair console N with
* host N±1 so a check whose `id` this build has never heard of is a normal state, not an error.
* Every host check therefore arrives with English `summary`/`impact`/`remedy.text` already filled
* in, and this module's job is to *decorate* that: a localized name for the checks we know, a
* readable heading for the ones we don't, and the badge vocabulary.
*
* What is deliberately NOT localized here is the situational prose. A single check has many shapes
* (the vhci one alone has four distinct causes, each with its own remedy), and copying ~20 sentences
* into a package that versions independently of the one that generates them is exactly the drift
* this design set out to avoid. The host is the single source of that text; when it starts sending
* a shape discriminator alongside `id`, the console can key localized prose off it without guessing.
*/
/** Localized names for the check ids this build knows about. */
const TITLES: Record<string, () => string> = {
takeover_privilege: () => m.diag_takeover_privilege_title(),
virtual_deck_vhci: () => m.diag_virtual_deck_vhci_title(),
uinput_access: () => m.diag_uinput_access_title(),
server_conflict: () => m.diag_server_conflict_title(),
};
/**
* A heading for a check. Unknown ids are turned into a presentable phrase rather than hidden the
* host's own text underneath still explains the problem, so showing it beats dropping it.
*/
export function checkTitle(check: HostCheck): string {
const known = TITLES[check.id];
if (known) return known();
return check.id
.replace(/_/g, " ")
.replace(/^./, (first) => first.toUpperCase());
}
/** True when this build recognizes the check — used only to decide how much chrome to show. */
export function isKnownCheck(check: HostCheck): boolean {
return check.id in TITLES;
}
/** Checks the operator should act on. `inapplicable` is not a problem; that is its whole point. */
export function needsAttention(check: HostCheck): boolean {
return check.status === "warn" || check.status === "fail";
}
/**
* The badge's text.
*
* For a row that needs attention this is the **severity**, not the status because severity is
* what the badge's colour encodes, and a badge whose text says "Failing" on both a red and an amber
* row leaves the difference between them carried by colour alone. Anyone who cannot separate the
* two tints then sees two identical rows. Status and severity only ever disagree in ways the reader
* does not need ("degraded but critical"), so the badge says the thing that changes what they do.
*/
export function statusLabel(check: HostCheck): string {
if (check.status === "ok") return m.diag_status_ok();
if (check.status === "inapplicable") return m.diag_status_inapplicable();
switch (check.severity) {
case "critical":
return m.diag_severity_critical();
case "warning":
return m.diag_severity_warning();
default:
return m.diag_severity_info();
}
}
/**
* Badge variant for a check. Text always says the state too colour alone is not a state, the
* same rule the pairing badge follows.
*/
export function statusVariant(
check: HostCheck,
): "success" | "warning" | "destructive" | "outline" {
if (check.status === "ok") return "success";
if (check.status === "inapplicable") return "outline";
return check.severity === "critical" ? "destructive" : "warning";
}
/**
* Worst-first. The host already sorts, but the console re-sorts because it also renders lists it
* filtered itself, and an ordering that depends on which rows were dropped is a bug waiting to
* happen.
*/
export function worstFirst(checks: HostCheck[]): HostCheck[] {
const severityRank = { critical: 0, warning: 1, info: 2 } as const;
const statusRank = { fail: 0, warn: 1, ok: 2, inapplicable: 3 } as const;
return [...checks].sort((a, b) => {
const attention = Number(!needsAttention(a)) - Number(!needsAttention(b));
if (attention !== 0) return attention;
const severity = severityRank[a.severity] - severityRank[b.severity];
if (severity !== 0) return severity;
const status = statusRank[a.status] - statusRank[b.status];
if (status !== 0) return status;
return a.id.localeCompare(b.id);
});
}
@@ -0,0 +1,94 @@
import { Link } from "@tanstack/react-router";
import { AlertTriangle, ArrowRight } from "lucide-react";
import type { FC } from "react";
import { useGetDiagnostics } from "@/api/gen/diagnostics/diagnostics";
import type { HostCheck } from "@/api/gen/model/hostCheck";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
checkTitle,
needsAttention,
statusLabel,
statusVariant,
worstFirst,
} from "@/lib/diagnostics";
import { m } from "@/paraglide/messages";
/**
* The dashboard's attention strip: "something about this host needs you".
*
* Follows the `ConflictsCard` rule **renders nothing at all when there is nothing**, so a healthy
* host sees zero extra chrome. It is deliberately a pointer, not a manual: severity, the check's
* name and the host's one-line summary, then a link. Remedies live on the troubleshooting page,
* because a dashboard that starts explaining how to fix things stops being a dashboard.
*/
/** Rows shown before deferring to the troubleshooting page. Three keeps it a strip. */
const MAX_ROWS = 3;
export const AttentionCard: FC = () => {
// The v1 checks are startup-static (a group membership, an installed udev rule), so this shares
// one generous-`staleTime` cache entry with the troubleshooting page rather than polling.
const diagnostics = useGetDiagnostics({
query: {
staleTime: 5 * 60_000,
// A host older than this console has no `/diagnostics` route and answers 404. That is a
// supported pairing, not a fault, so don't retry it and don't surface it here — the
// troubleshooting page is where "this host can't report checks" gets explained.
retry: false,
},
});
return <AttentionStrip checks={diagnostics.data?.checks ?? []} />;
};
/** The pure half — fed fixtures by the stories, so the empty state is provable. */
export const AttentionStrip: FC<{ checks: HostCheck[] }> = ({ checks }) => {
const problems = worstFirst(checks.filter(needsAttention));
if (problems.length === 0) return null;
const shown = problems.slice(0, MAX_ROWS);
const hidden = problems.length - shown.length;
return (
<Card className="border-amber-600/40 dark:border-amber-500/40">
<CardContent className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div className="min-w-0 flex-1 space-y-3">
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">
{m.diag_attention_title()}
</p>
<ul className="flex flex-col gap-2">
{shown.map((check) => (
<li
key={check.id}
className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm"
>
{/* Text, not colour alone the badge has to be readable to a screen
reader and to anyone who cannot tell the two tints apart. */}
<Badge variant={statusVariant(check)}>
{statusLabel(check)}
</Badge>
<span className="font-medium">{checkTitle(check)}</span>
<span className="min-w-0 text-muted-foreground">
{check.summary}
</span>
</li>
))}
</ul>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Link
to="/logs"
className="inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
{m.diag_attention_link()}
<ArrowRight className="size-3.5" />
</Link>
{hidden > 0 && (
<span className="text-xs text-muted-foreground">
{m.diag_attention_more({ count: hidden })}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
};
+2
View File
@@ -13,6 +13,7 @@ import { useDialogs } from "@/components/dialogs";
import { apiErrorMessage } from "@/lib/errors";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { AttentionCard } from "./AttentionCard";
import { DashboardView } from "./view";
export const SectionDashboard: FC = () => {
@@ -106,6 +107,7 @@ export const SectionDashboard: FC = () => {
<DashboardView
status={status}
library={library.data}
attention={<AttentionCard />}
onStopSession={async () => {
if (!(await confirmStopAll())) return;
stop.mutate(undefined, {
+6
View File
@@ -18,6 +18,10 @@ import { RunningGames } from "./RunningGames";
export const DashboardView: FC<{
status: Loadable<RuntimeStatus>;
library?: GameEntry[];
/** Host health warnings renders nothing when the host is healthy (see `AttentionCard.tsx`).
* Sits above the status query on purpose: a host whose `/status` is failing is exactly when
* its health checks are worth reading. */
attention?: ReactNode;
onStopSession: () => void;
onRequestIdr: () => void;
onEndGame: (game: ActiveGame) => void;
@@ -27,6 +31,7 @@ export const DashboardView: FC<{
}> = ({
status,
library,
attention,
onStopSession,
onRequestIdr,
onEndGame,
@@ -39,6 +44,7 @@ export const DashboardView: FC<{
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
{attention}
<QueryState
isLoading={status.isLoading}
error={status.error}
+257
View File
@@ -0,0 +1,257 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import {
CheckCircle2,
ChevronDown,
ChevronRight,
Copy,
RefreshCw,
} from "lucide-react";
import { type FC, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
getGetDiagnosticsQueryKey,
useGetDiagnostics,
useRefreshDiagnostics,
} from "@/api/gen/diagnostics/diagnostics";
import type { HostCheck } from "@/api/gen/model/hostCheck";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
checkTitle,
needsAttention,
statusLabel,
statusVariant,
worstFirst,
} from "@/lib/diagnostics";
import { apiErrorMessage } from "@/lib/errors";
import { m } from "@/paraglide/messages";
/**
* The troubleshooting page's checks list: everything the host knows about its own health.
*
* Unlike the dashboard strip, this shows the `ok` rows too "what is working" is the reassurance a
* dashboard deliberately omits, and it is most of the value when someone is hunting a problem that
* turns out to be elsewhere. `inapplicable` rows hide behind a toggle: they must not nag, but the
* page still has to be able to answer "why isn't this check relevant on my machine?".
*/
export const ChecksSection: FC = () => {
const qc = useQueryClient();
const diagnostics = useGetDiagnostics({
query: { staleTime: 5 * 60_000, retry: false },
});
const refresh = useRefreshDiagnostics();
const rerun = () =>
refresh.mutate(undefined, {
// The response IS the fresh report, so seed the shared cache entry with it instead of
// invalidating and making the host run every probe a second time.
onSuccess: (data) => qc.setQueryData(getGetDiagnosticsQueryKey(), data),
onError: (e) => toast.error(apiErrorMessage(e) ?? m.diag_rerun_failed()),
});
// A host that predates this console has no such route. Pairing N with N1 is supported, so this
// renders as a plain note rather than an error — nothing is broken, this host just can't answer.
const unsupported =
diagnostics.error instanceof ApiError && diagnostics.error.status === 404;
return (
<ChecksCard
checks={diagnostics.data?.checks ?? []}
unsupported={unsupported}
isLoading={diagnostics.isLoading}
error={unsupported ? undefined : diagnostics.error}
onRerun={rerun}
isRerunning={refresh.isPending}
/>
);
};
export const ChecksCard: FC<{
checks: HostCheck[];
/** The host has no diagnostics route (an older host paired with this console). */
unsupported?: boolean;
isLoading?: boolean;
error?: unknown;
onRerun: () => void;
isRerunning?: boolean;
}> = ({ checks, unsupported, isLoading, error, onRerun, isRerunning }) => {
const [showInapplicable, setShowInapplicable] = useState(false);
const applicable = worstFirst(
checks.filter((c) => c.status !== "inapplicable"),
);
const inapplicable = worstFirst(
checks.filter((c) => c.status === "inapplicable"),
);
const problems = applicable.filter(needsAttention).length;
return (
<Card>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-medium">{m.diag_checks_title()}</h2>
<Button
variant="outline"
size="sm"
onClick={onRerun}
disabled={isRerunning || unsupported}
>
<RefreshCw className="size-3.5" />
{isRerunning ? m.diag_rerunning() : m.diag_rerun()}
</Button>
</div>
{unsupported ? (
<p className="text-sm text-muted-foreground">
{m.diag_unavailable()}
</p>
) : error ? (
<p className="text-sm text-destructive">
{apiErrorMessage(error) ?? m.diag_rerun_failed()}
</p>
) : isLoading ? (
<p className="text-sm text-muted-foreground">{m.diag_loading()}</p>
) : (
<>
{problems === 0 && applicable.length > 0 && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-[var(--success)]" />
{m.diag_all_ok()}
</p>
)}
<ul className="flex flex-col gap-2">
{applicable.map((check) => (
<CheckRow key={check.id} check={check} />
))}
</ul>
{inapplicable.length > 0 && (
<div className="flex flex-col gap-2">
<button
type="button"
className="flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-foreground"
aria-expanded={showInapplicable}
onClick={() => setShowInapplicable((v) => !v)}
>
{showInapplicable ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
{showInapplicable
? m.diag_hide_inapplicable()
: m.diag_show_inapplicable({
count: inapplicable.length,
})}
</button>
{showInapplicable && (
<ul className="flex flex-col gap-2">
{inapplicable.map((check) => (
<CheckRow key={check.id} check={check} />
))}
</ul>
)}
</div>
)}
</>
)}
</CardContent>
</Card>
);
};
/**
* One check. Healthy and inapplicable rows are a single line there is nothing to act on, and
* making them expandable would imply otherwise. A row worth acting on opens to the impact and the
* remedy.
*/
const CheckRow: FC<{ check: HostCheck }> = ({ check }) => {
const [open, setOpen] = useState(false);
const expandable = needsAttention(check);
const title = checkTitle(check);
const header = (
<>
<Badge variant={statusVariant(check)}>{statusLabel(check)}</Badge>
<span className="font-medium">{title}</span>
<span className="min-w-0 text-muted-foreground">{check.summary}</span>
</>
);
return (
<li className="rounded-md border bg-card/40 px-3 py-2">
{expandable ? (
<button
type="button"
className="flex w-full flex-wrap items-baseline gap-x-2 gap-y-1 text-left text-sm"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{open ? (
<ChevronDown className="size-3 self-center" />
) : (
<ChevronRight className="size-3 self-center" />
)}
{header}
</button>
) : (
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
{header}
</div>
)}
{expandable && open && (
<div className="mt-3 flex flex-col gap-3 text-sm">
{check.impact && (
<div>
<p className="text-xs text-muted-foreground">
{m.diag_impact_label()}
</p>
<p className="mt-0.5 max-w-prose">{check.impact}</p>
</div>
)}
{check.remedy && (
<div>
<p className="text-xs text-muted-foreground">
{m.diag_remedy_label()}
</p>
<p className="mt-0.5 max-w-prose">{check.remedy.text}</p>
{check.remedy.command && (
<CopyableCommand command={check.remedy.command} />
)}
{check.remedy.relogin_required && (
<Badge variant="outline" className="mt-2">
{m.diag_relogin_required()}
</Badge>
)}
</div>
)}
</div>
)}
</li>
);
};
const CopyableCommand: FC<{ command: string }> = ({ command }) => (
<div className="mt-2 flex items-start gap-2">
<code className="min-w-0 flex-1 overflow-x-auto rounded-md bg-muted px-3 py-1.5 font-mono text-xs text-muted-foreground">
{command}
</code>
<Button
variant="ghost"
size="icon"
title={m.diag_copy()}
aria-label={m.diag_copy()}
onClick={() => {
navigator.clipboard
.writeText(command)
.then(() => toast.success(m.diag_copied()))
.catch(() => toast.error(m.diag_copy_failed()));
}}
>
<Copy className="size-3.5" />
</Button>
</div>
);
+3
View File
@@ -242,6 +242,9 @@ export const LogsCard: FC<{
unless something precedes it. This card used to restore it by hand at both
breakpoints. */}
<CardContent className="flex flex-col gap-3">
{/* The page heading says "Troubleshooting" now, so this card names itself otherwise
the log stream is the only section on the page with no label. */}
<h2 className="text-lg font-medium">{m.logs_title()}</h2>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
{LEVELS.map((l) => (
+6 -3
View File
@@ -1,16 +1,19 @@
import type { FC } from "react";
import { useLocale } from "@/lib/i18n";
import { ChecksSection } from "./ChecksCard";
import { ClientLogsSection } from "./ClientLogsCard";
import { LogsSection } from "./LogsCard";
import { LogsView } from "./view";
// Logs = one self-contained viewer card owning its polling; this container only binds the layout.
// Client-uploaded bundles ("Send logs to host") render beneath the live host log — same page a
// reporter already exports the host log from, so both halves of a report live in one place.
// Troubleshooting = the host's health checks over one self-contained viewer card owning its
// polling; this container only binds the layout. Client-uploaded bundles ("Send logs to host")
// render beneath the live host log — same page a reporter already exports the host log from, so
// both halves of a report live in one place.
export const SectionLogs: FC = () => {
useLocale();
return (
<LogsView
checks={<ChecksSection />}
viewer={
<>
<LogsSection />
+19 -5
View File
@@ -3,17 +3,31 @@ import type { FC, ReactNode } from "react";
import { m } from "@/paraglide/messages";
/**
* The Logs page LAYOUT the live page (`index.tsx`) and the Storybook story fill the single
* `viewer` slot, so the arrangement can never drift between them (same pattern as StatsView).
* The Troubleshooting page LAYOUT the live page (`index.tsx`) and the Storybook stories fill the
* slots, so the arrangement can never drift between them (same pattern as StatsView).
*
* This page is the troubleshooting home: it is where someone already goes when something is wrong,
* so the host's health checks meet them here rather than on a nav entry that is empty on a healthy
* host. The route stays `/logs` bookmarks and deep links outlive a label.
*
* Order is deliberate: checks first (structured, actionable), the log stream underneath. When the
* checks are green and something is still broken, the log is the natural next step now one scroll
* away instead of a separate destination.
*/
export const LogsView: FC<{ viewer: ReactNode }> = ({ viewer }) => (
export const LogsView: FC<{ checks?: ReactNode; viewer: ReactNode }> = ({
checks,
viewer,
}) => (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">{m.logs_title()}</h1>
<p className="text-sm text-muted-foreground">{m.logs_subtitle()}</p>
<h1 className="text-2xl font-semibold">{m.troubleshooting_title()}</h1>
<p className="text-sm text-muted-foreground">
{m.troubleshooting_subtitle()}
</p>
</div>
{checks}
{viewer}
</div>
</Section>
+44
View File
@@ -1,4 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { HostCheck } from "@/api/gen/model/hostCheck";
import { AttentionStrip } from "@/sections/Dashboard/AttentionCard";
import { DashboardView } from "@/sections/Dashboard/view";
import { statusActive, statusGrace, statusIdle } from "./lib/fixtures";
@@ -30,3 +32,45 @@ export const Idle: Story = {
export const GameWaitingForItsClient: Story = {
args: { status: { data: statusGrace, isLoading: false, error: null } },
};
const PROBLEMS: HostCheck[] = [
{
id: "takeover_privilege",
status: "fail",
severity: "critical",
summary: "User “enrico” is not in the “punktfunk” group",
impact: "Every takeover degrades to mirroring this machine's own session.",
params: {},
source: "startup",
},
{
id: "virtual_deck_vhci",
status: "fail",
severity: "warning",
summary: "The group “punktfunk” was granted but this session predates it",
impact: "The virtual Steam Deck controller cannot attach.",
params: {},
source: "startup",
},
{
id: "uinput_access",
status: "ok",
severity: "info",
summary: "The input device nodes are reachable.",
impact: "",
params: {},
source: "startup",
},
];
/**
* The attention strip in place: worst-first, one line each, no remedies the dashboard points at
* the troubleshooting page rather than becoming a manual. The healthy counterpart is every OTHER
* story on this page: they pass no `attention` at all, which is exactly what a healthy host renders.
*/
export const HostNeedsAttention: Story = {
args: {
status: { data: statusIdle, isLoading: false, error: null },
attention: <AttentionStrip checks={PROBLEMS} />,
},
};
+123
View File
@@ -0,0 +1,123 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { HostCheck } from "@/api/gen/model/hostCheck";
import { ChecksCard } from "@/sections/Logs/ChecksCard";
/**
* The troubleshooting page's checks list.
*
* The states that matter here are the ones that are easy to get wrong and impossible to see in a
* diff: a healthy host must not grow chrome, an `inapplicable` row must stay out of the way without
* disappearing, and a check this console has never heard of must still render because host N and
* console N1 is a supported pairing, and the host always sends readable English text with it.
*/
const check = (
over: Partial<HostCheck> & Pick<HostCheck, "id">,
): HostCheck => ({
status: "ok",
severity: "info",
summary: "",
impact: "",
params: {},
source: "startup",
...over,
});
const TAKEOVER_FAIL = check({
id: "takeover_privilege",
status: "fail",
severity: "critical",
summary: "User “enrico” is not in the “punktfunk” group",
impact:
"Streams that need the managed takeover cannot stop sddm.service, so every one of them degrades to mirroring this machine's own session instead. With the panel off that looks like a black screen on every connect, and nothing else reports it.",
remedy: {
text: "Add the user to the “punktfunk” group, then log out and back in. The same group gates the virtual Steam Deck pad's usbip nodes, which can present arbitrary emulated USB devices — join it only on a machine you trust.",
command: "sudo usermod -aG punktfunk enrico",
relogin_required: true,
},
params: { user: "enrico", group: "punktfunk", dm: "sddm.service" },
since_unix: 1_750_000_000,
});
/** The "I already added myself!" state — a re-login, not another usermod. */
const VHCI_RELOGIN = check({
id: "virtual_deck_vhci",
status: "fail",
severity: "warning",
summary: "The group “punktfunk” was granted but this session predates it",
impact:
"The virtual Steam Deck controller cannot attach, so Steam Input never sees it — in Game Mode that means nothing can be navigated with a pad.",
remedy: {
text: "Log out and back in. The membership is already recorded — this session just started before it was granted, and a session keeps the group set it began with.",
relogin_required: true,
},
params: { group: "punktfunk", user: "enrico" },
});
const UINPUT_OK = check({
id: "uinput_access",
summary: "The input device nodes are reachable.",
});
const CONFLICT_OK = check({
id: "server_conflict",
summary: "No other game-streaming server is active on this machine.",
});
const TAKEOVER_NA = check({
id: "takeover_privilege",
status: "inapplicable",
summary:
"No display manager drives this machine's logins, so a takeover has nothing to stop.",
});
/** A host newer than this console: the id is unknown, so only the wire text can be shown. */
const UNKNOWN = check({
id: "thermal_throttling",
status: "warn",
severity: "warning",
summary: "The encoder GPU has been thermally throttled for 4 minutes",
impact:
"Frames are being dropped under load, which reads as stutter on the client.",
remedy: {
text: "Check this machine's airflow and fan curve.",
relogin_required: false,
},
});
const meta = {
title: "Pages/Troubleshooting",
component: ChecksCard,
args: { onRerun: () => {}, isRerunning: false },
} satisfies Meta<typeof ChecksCard>;
export default meta;
type Story = StoryObj<typeof meta>;
/** The common case: nothing is wrong, and the list says so without shouting. */
export const Healthy: Story = {
args: { checks: [UINPUT_OK, CONFLICT_OK] },
};
/** The `.181` defect this whole feature exists for. */
export const OneCritical: Story = {
args: { checks: [TAKEOVER_FAIL, UINPUT_OK, CONFLICT_OK] },
};
/** Two problems of different severity, plus a row that does not apply to this box. */
export const Mixed: Story = {
args: { checks: [TAKEOVER_FAIL, VHCI_RELOGIN, UINPUT_OK, TAKEOVER_NA] },
};
/**
* Console N paired with host N+1. The check has no localized name and no localized prose here, and
* it still has to be readable that is what the host's English fallback text is for.
*/
export const UnknownCheckFromANewerHost: Story = {
args: { checks: [UNKNOWN, UINPUT_OK] },
};
/** An older host has no diagnostics route at all. Not an error — just nothing to report. */
export const HostTooOld: Story = {
args: { checks: [], unsupported: true },
};