Compare commits

..
141 Commits
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 b220daf537 feat(client/windows): the settings screen edits profiles too
P1 on the WinUI shell. The same surface now edits either layer, switched by a combo above
the section title — one settings UI, not a second editor that drifts from the first.

The interesting difference from GTK is where the override gets recorded. This shell
commits PER CONTROL, so it cannot hand over a list of touched rows the way a
commit-on-close dialog can. Instead each control hands `SettingsOverlay::absorb` the
effective settings before and after it fired, and absorb records the field that moved.
The comparison is against what the control was SHOWING, not against the globals, so
setting a value to whatever the global happens to be still records an override — the pin
the design asks for — and nothing is ever removed by inference.

⚠️ The content key becomes `(scope, section)`. Keyed on section alone, switching scope
diffs one scope's controls into the other's, which re-sets each reused ComboBox's items
(clearing WinUI's selection) while skipping `selected_index` wherever the two scopes'
values compare equal — the combos then render blank until touched. That is the same trap
the section key already existed for, one axis wider.

Only profileable rows render in profile scope: auto-wake, the library toggle, the decoder
and GPU pickers and the forwarded-pad picker are facts about this device or this network,
not about "Game vs Work".

Two shapes follow from this toolkit rather than from the design. Creating a profile takes
an auto-numbered name and is renamed from the row below, because ContentDialog here is
text-only and cannot hold a text field — the same constraint that put the host editor in a
tile. And Delete arms a root state that renders the confirmation declaratively, since
dialogs here are elements with `is_open`, not calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:12:43 +02:00
enricobuehlerandClaude Opus 5 8299151a09 fix(client-core): drop nine GTK-client files a stray copy left in the crate
An `rsync` with the wrong destination dropped the Linux shell's sources into
`crates/pf-client-core/src/` and they rode along with the previous commit. Nothing
referenced them — `lib.rs` never declared the modules, so they were dead weight rather
than a build break — but a crate that says it is UI-agnostic must not have a GTK shell
sitting in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:04:57 +02:00
enricobuehlerandClaude Opus 5 ad99be2fb2 feat(client): the overlay learns absorb + clear, so per-control shells can edit profiles too
Groundwork for the Windows half of P1, and a dedup of the Linux one.

The two shells commit differently: GTK writes once when the dialog closes (so it can hand
over a list of touched rows), WinUI writes on every control change (so it can't). `absorb`
serves the second shape — give it the effective settings before and after one control
fired, and it records the field that moved.

That is deliberately NOT the diff-on-save the design rejects. The comparison is against
the EFFECTIVE settings, i.e. what the control was actually showing, not against the
globals — so setting a value back to whatever the global happens to be still records an
override, which is exactly the pin the design wants. It only ever adds; removing an
override stays an explicit, separate operation.

`clear` is that operation, keyed by the overlay's own field names, with `resolution` as
the one alias for the width/height/match-window tri-state a single control drives on every
client. The GTK reset path now calls it instead of carrying its own `match` — that second
list was already a place where the two could drift, and the next client would have made it
a third.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:04:26 +02:00
enricobuehlerandClaude Opus 5 22cb975659 feat(client/linux): "Create shortcut…" — a double-clickable entry for a host or a pinned profile
D1's last Linux piece (design/client-deep-links.md §5). The shortcut is a CONTAINER FOR A
URL, not a second launch mechanism: it invokes the client with a positional
`punktfunk://…`, the same door xdg-open and a browser prompt use. That is what makes it
survive things it has no control over — scheme registration being lost, the host moving to
a new address, the store being wiped — because the URL itself carries the stable id, the
address and the pin.

A pinned card writes its profile into the shortcut, so "Desktop · Work" on the app grid is
one double-click to that exact session. Two sanitisation points that are not cosmetic: the
filename is slugged to ASCII (a host called "Büro · Mac" must still produce a writable
name), and the `Name=` value is flattened to one line — desktop entries are line-oriented,
so a newline in a host name would end the key and turn the rest into another one.

Under flatpak the sandbox cannot write ~/.local/share/applications, so the user gets the
URL to place themselves — the fallback the design already sanctions. The
`org.freedesktop.portal.DynamicLauncher` route, which exists for exactly this and shows
its own confirmation, is the intended upgrade there and is not built yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:01:46 +02:00
enricobuehlerandClaude Opus 5 bd0103b1ac feat(client/linux): a host edit sheet, and with it the clipboard toggle Linux never had
Linux had "Rename" and nothing else, so two per-host settings that already existed in the
store and on the Apple and Windows clients had no surface here at all: the per-host
clipboard opt-in, and now the profile binding. A Linux user could not turn on a feature
they were already paying the storage for.

The sheet holds exactly the things that are properties of the HOST rather than of the
stream — its name, whether this machine shares its clipboard with it, and which settings
profile a plain click uses. A binding whose profile was deleted reads as "Default
settings" and is cleaned up on save, which is the same "dangling resolves as none" rule
the connect path follows.

The menu entry becomes "Edit…", and the function and message follow: a `rename_dialog`
that opens an "Edit Host" sheet is exactly the drift that confuses the next reader.
Pinning stays in the card menu rather than doubling as a list of toggles here — one place
to pin is enough, and it is the place you are already looking when you decide to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:54:50 +02:00
enricobuehlerandClaude Opus 5 b9a7163556 feat(client/linux): the speed test writes where the tested host reads, and cards can copy their link
Two WP3 pieces.

**Speed test (§5.3).** Measuring the slow retro box downstairs used to re-tune the
desktop upstairs: Apply always wrote the GLOBAL bitrate, which the design calls the
sharpest existing symptom of the missing feature. It now writes the layer that host
actually resolves bitrate from — the bound profile's override if it has one, the global
if the host is unbound. A bound host whose profile inherits bitrate could legitimately
mean either, so it gets both buttons instead of a guess. The target depends only on the
host, so it is resolved before the test finishes and the button says where it will write
("Set in “Game”"). A one-off (a pinned card's profile) wins over the binding, and a
dangling binding falls back to the global — the same precedence a connect uses.

**Copy link.** Host and pinned cards can copy their own `punktfunk://` URL — the
self-emitted form carrying the stable id *and* host+fp, so a link pasted into a Playnite
entry or a Stream Deck macro still resolves after the host re-addresses or the store is
wiped. A pinned card copies its profile with it, which is the whole point of pinning it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:53:20 +02:00
enricobuehlerandClaude Opus 5 08ec0f4239 feat(client/linux): pin a host+profile as its own card
§5.2a: a profile you reach for regularly shouldn't live behind a context menu. Pinning
one puts *Desktop · Work* in the grid next to *Desktop*, and clicking it is a plain
one-click connect with that profile.

The pin is presentation state on the host record — `pinned_profiles`, which has been in
the store since P0 and unused until now. Deliberately NOT a duplicated host entry: that
would fork pairing, WoL MACs and renames, and every host-level action would then need a
"which card owns this" rule. So a pinned card is the same record rendered again with a
profile attached: it shares the host's live status because it reads the same record, and
a pin whose profile was deleted simply doesn't render.

What each card offers follows from what it is. The primary card keeps the host actions
and gains "Pin as card ▸" (listing only profiles not already pinned; "Default settings"
isn't pinnable — the primary card is that). A pinned card offers the one-offs and its own
removal, and deliberately not pair/rename/forget — it is a shortcut, not a second host,
and offering destructive host actions there would blur the distinction. One action serves
both directions: which kind of card it sits on decides whether it pins or unpins.

Verified on .21: a host bound to "Game" renders its primary card with a neutral Game chip
and, right after it, the pinned "Work" card with an accent chip, both showing the same
online/paired state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:49:06 +02:00
enricobuehlerandClaude Opus 5 b485f91f52 feat(client/linux): overridden rows say so, and can go back to inheriting
The gap in what the scope switcher shipped: a profile was a one-way door. Overrides are
recorded on touch and never inferred from comparing values — which is the right model,
and exactly why "not overridden" needs an explicit way back.

Each overridden row now carries an accent dot in its prefix and a reset button in its
suffix, so which settings a profile actually changes is legible without reading every
value against the defaults. Reset queues the field, re-opens the dialog in the same scope
and lets the close handler commit everything else first — so one code path still builds
the rows, including the rebuilt ones showing the inherited value again.

Clears are applied after the touched fields for a reason: resetting a ComboRow can fire
its change handler on the way past, and "back to inheriting" is the later, explicit
intent. An unknown field in the cleared set warns rather than silently doing nothing —
this list and the write-back list have to stay in step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:34:42 +02:00
enricobuehlerandClaude Opus 5 fa430646ff fix(packaging): the Pulse shim is never the client's, and never the host's requirement
Same bug as the Arch package, in the other two packagings.

The .deb CLIENT recommended `pipewire-pulse`. Apt installs Recommends by
default and the shim Conflicts with `pulseaudio`, so this proposed pulling the
user's sound server out from under them in exchange for something the client
cannot use: audio.rs drives libpipewire-0.3 directly and opens no Pulse socket.
(SDL is initialized with zero subsystems — gamepads only, on demand — so it
brings no audio backend either.)

The RPM HOST hard-Required `pipewire-pulseaudio`, which conflicts with
`pulseaudio` on Fedora too — uninstallable for anyone running real PulseAudio.
The host doesn't speak Pulse either; the shim is for the GAMES, which commonly
emit through the PulseAudio API, and real PulseAudio serves those equally well.
Demoted to Recommends, so the default Fedora box is unchanged and the informed
choice stops being blocked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:14:56 +02:00
enricobuehlerandClaude Opus 5 7d18468477 fix(packaging/arch): a client that never speaks Pulse must not demand the Pulse shim
`punktfunk-client` hard-depended on `pipewire-pulse`, which CONFLICTS with
`pulseaudio` — so anyone keeping real PulseAudio could not install the client at
all ("unresolvable package conflicts detected"), even though the client has no
use for the shim: audio.rs drives libpipewire-0.3 directly for both playback and
the mic uplink, and never opens a Pulse socket. Dropped it from the client.

The host doesn't speak Pulse either — it captures the sink monitor natively — but
the shim is genuinely useful there, for the GAMES, which commonly emit through
the PulseAudio API. That's an optdepend, and one real `pulseaudio` satisfies just
as well, so the host stops blocking that choice too.

Both now match what the .deb and the RPM already said (Recommends / "degrade
gracefully without it"); only the Arch package had hardened it into a conflict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 23:14:56 +02:00
enricobuehler 6de325a6b6 fix(ci): the unsafe lint said warn while CI enforced it as deny, and main went red
`unsafe_op_in_unsafe_fn = "warn"` was adopted workspace-wide in 39513528 on the assumption that
`warn` is a soft setting you can clear at leisure. It is not: ci.yml runs `cargo clippy … -D
warnings`, which promotes it to a hard error, so main has failed on EVERY commit since — Linux
`rust` and `rust-arm64` both dying on `pf-client-core` with 70 E0133 errors, and windows-host.yml
alongside them. A lint level that understates its own severity is worse than a strict one, so this
states what CI already does — `deny` — and writes the exemptions down instead.

Fourteen GPU/FFI backend files take `#![allow(unsafe_op_in_unsafe_fn)]`, each with its reason and
the workspace Cargo.toml carrying the argument in full. They are not "not done yet": measured
across them, 64% of the sites are a single third-party FFI call (ash, pyrowave-sys, libav, the
NVENC/AMF entry tables), and of the 44 `unsafe fn`s only 4 have a body containing no unsafe
operation at all. Since pf-encode also denies `undocumented_unsafe_blocks`, narrowing them means a
hand-written SAFETY comment per line that could only restate the signature — the exact noise that
made `unsafe` stop meaning anything here before. Everything else stays at zero and enforced, and
each allow is removable on its own terms.

Two smaller things this had to clear, both invisible to the job that would have caught them:

- `service.rs`: `undocumented_unsafe_blocks` wants the proof on EACH block, and a comment covering
  a group of consecutive `unsafe` statements only credits the first — so the two `OwnedHandle`
  wraps became their own statements. Windows-gated, so only the .47 gate sees it. 37a42078 fixed
  the same class of failure in pf-frame/dxgi.rs concurrently; that fix is taken as-is here.
- `clients/linux/ui_settings.rs`: `show`'s deletion left its doc comment stranded on `show_scoped`,
  which silently merged two unrelated doc blocks onto one fn. Removed.

Verified by running the real CI commands, not proxies: on .21 fmt + `clippy --workspace
--all-targets -- -D warnings` + the feature-gated `-p pf-encode --features
nvenc,vulkan-encode,pyrowave` + build + test, all rc=0; on the Intel box .47 all four
windows-host.yml clippy invocations, all rc=0. The arm64 job lints pf-client-core and
punktfunk-client-linux, both covered here.
2026-07-28 22:32:30 +02:00
enricobuehler f40c90aa23 refactor(host/inject/win-display): the Win32 unsafe surface is the FFI call, not the function
`spawn_in_active_session` existed only to launder `spawn_inner`'s `unsafe fn` marker — and its
own comment said why that marker was empty: the callee "has no caller-side preconditions, it
validates the session/token itself and owns every handle it opens". A marker that documents the
absence of a contract is not a contract. The two collapse into one safe fn whose `unsafe` now sits
on the eight Win32 calls, each with the proof it actually needs.

Kept `unsafe fn` exactly where a caller CAN break something: `merged_env_block` (a `*const u16`
block it must trust), `spawn_host` (a borrowed job `HANDLE`), `desktop_name` (an `HDESK`),
`inject_following_desktop` (a live synthetic-pointer device). Those bodies gain explicit blocks
instead — `merged_env_block`'s pointer walk is the one place here where the proof is load-bearing
rather than restating a signature, and it now says why the scan cannot run off the block's end.

With those four files at zero, all three crates take `deny(unsafe_op_in_unsafe_fn)` at the root,
next to the `undocumented_unsafe_blocks` deny they already had. The pair matters: alone, the older
deny is satisfied by an `unsafe fn` body, which needs no blocks at all and so can hide an unproven
FFI call. The workspace lint stays `warn` — it is a ratchet over the encoder backends still to be
cleared, and a crate that reaches zero should not be able to drift back silently.

Windows E0133 214 -> 182 (the remainder is the ash/AMF-dense encode backends). Verified on the
Intel box .47 and on Linux .21: no errors, no `unused_unsafe`, no dead-code fallout.
2026-07-28 22:31:11 +02:00
enricobuehlerandClaude Opus 5 808eba7292 feat(client/linux): bind a host to a profile, or connect with one just this once
The other half of P1 on GTK (design/client-settings-profiles.md §5.2): until now a
profile could be created and edited but only *used* through `--profile` or a URL. Host
cards get two menus, and they are separate on purpose — the predictability rule is that
connecting with a profile must never change what the card does next time.

- **Connect with ▸** is a one-off. It rides `ConnectRequest.profile` into the session's
  `--profile` flag and touches no stored state. "Default settings" in that menu is
  `Some("")`, not `None`: on a bound host, asking for the defaults is a real choice and
  has to survive as a value rather than degrade into "use the binding".
- **Default profile ▸** is the explicit rebinding act. It writes straight onto the host
  record — the binding IS a field there, so there is no map to keep in step — matched by
  fingerprint, else by address, like every other per-host lookup in this client.
- The card carries a **chip** naming its bound profile, so what a plain click will do is
  visible without opening a menu. A binding whose profile was deleted shows nothing and
  connects with the defaults, which is exactly what the resolver does.

A URL's `profile=` now takes the same one-off route, since it is the same kind of thing.

Verified on .21: `punktfunk://connect/Bound%20Box?profile=Work` against a host bound to
"Game" spawned a session that logged `profile=Work id=bbbbbbbbbbbb` — the one-off won,
and the binding on disk was untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 22:24:56 +02:00
enricobuehler 37a42078a0 fix(xcheck): mirror [workspace.lints] too — and the two things that hid behind it
`scripts/xcheck.sh` stopped working the moment the workspace adopted its unsafe discipline
(`52191071`). That commit added `[lints] workspace = true` to every crate manifest, and a member
inheriting a lint table the workspace ROOT does not define does not merely lose the lint — cargo
refuses to parse the manifest at all:

    error inheriting `lints` from workspace root manifest's `workspace.lints`
    `workspace.lints` was not defined

The generated root mirrored `[workspace.package]` but nothing else. It now mirrors every
`[workspace.lints.*]` table generically, so a future table (clippy, rustdoc, …) is picked up
without touching the script again.

That mattered more than a broken helper, because xcheck is the ONLY thing that lints these crates
for Windows — the Windows CI job clippies `-p punktfunk-host -p punktfunk-tray`, which does not
lint a dependency. So while it was down, two real failures sat on main unseen:

* **pf-frame `dxgi.rs`, 8 × `clippy::undocumented_unsafe_blocks`.** `307ca88b`/`0a710fdf` split
  the D3DKMT/DXGI calls into one `unsafe` block per FFI call under a single shared SAFETY comment,
  but the lint wants a proof per block — a comment two lines up with an intervening statement does
  not count. Each site now carries its own. Not "as above", which this sweep already found to be a
  proof that rots when the block it points at moves.
* **pf-win-display `input_desktop.rs`, one E0133.** `GetUserObjectInformationW` is an unsafe
  operation in an `unsafe fn` body, which `unsafe_op_in_unsafe_fn` (now warn workspace-wide, and
  `-D warnings` here) requires to sit in its own block. The SAFETY proof was already written; only
  the block was missing.

The `&&` chain in `elevate_process_gpu_priority` keeps two separate blocks rather than one around
the chain — wrapping it would destroy the short-circuit and always issue the relative-priority
call. Same trap this sweep hit in `wait_mode_settled`.

Verified: both xcheck targets clean again.
2026-07-28 22:17:43 +02:00
enricobuehler 21eda37aa3 fix(win-display): punktfunk's own display is not one of the operator's panels
The IddCx driver declares `DISPLAYCONFIG_OUTPUT_TECHNOLOGY_HDMI` for its monitors
(`packaging/windows/drivers/pf-vdisplay/src/monitor.rs`, `IDDCX_MONITOR_INFO::MonitorType`),
and `output_tech_class`'s allowlist reads HDMI as a real external panel. So every consumer of
`target_inventory` counted OUR OWN virtual display among the operator's physical monitors.
Measured on .173: both targets came back `external_physical`, `[(4352, "LG TV SSCR2", HDMI),
(257, "punktfunk", HDMI)]`.

That is not cosmetic. `restore_displays_ccd` ends with the last-resort guarantee that the desk
is never left all-dark:

    let (connected, lit) = target_inventory().filter(external_physical) …
    if connected > 0 && lit == 0 { force_extend_topology() }

The restore runs BEFORE the virtual is REMOVEd, so our own still-active display kept `lit >= 1`
and the backstop could NEVER fire — in exactly the situation it was written for. It also made
our display a candidate "physical suspect" in the disturbance-attribution inventory, which
`TargetInventory`'s own doc rules out ("only indirect/virtual targets (our own IDD included)").

Targets carrying our EDID manufacturer id ("PNK", stamped by the driver's edid.rs) are now
classified `external_physical = false`, tech `punktfunk-virtual`. Matching on the monitor device
path rather than the friendly name — the path carries the manufacturer id the OS itself derived
— and on both spellings, since the CCD device-interface path uses `#`
(`\\?\DISPLAY#PNK0000#…`) while the PnP instance id for the same monitor uses `\`
(`DISPLAY\PNK0000\1&15ecd195&5&UID264`, observed on .173). Allowlist-shaped like
`output_tech_class`, so a third-party virtual display is never silently adopted.

ON GLASS, read-only, on .173 — and it caught the backstop's trigger live:

  target  264  active=true  external_physical=false tech=punktfunk-virtual "punktfunk"
  target 4352  active=false external_physical=true  tech=HDMI              "LG TV SSCR2"

The physical is dark while our virtual holds the desktop. Post-fix that is `connected=1, lit=0`
— the backstop fires. Pre-fix it was `connected=2, lit=1` — suppressed. The new hardware case is
deliberately READ-ONLY (creates and destroys nothing) so it is safe against a live host;
repeated IddCx create/destroy churn is what wedges the driver's slot pool.

Also adds `manager::FAIL_NEXT_ISOLATES`, a `#[cfg(test)]` seam that fails the next N
`isolate_displays_ccd` calls, with every call site in manager.rs routed through it. The Phase-3
recovery legs fire only on a failed isolate, which real hardware does not produce, so they
cannot otherwise be exercised. `#[cfg(test)]` rather than an env knob: this crate's live tests
compile under `cfg(test)`, so no production switch exists that could disable display isolation
on an operator's box. The §5 3.2 case built on it is included but is NOT yet a passing on-glass
result — see the plan for where it got to.

Verified on .173: clippy -p punktfunk-host -p pf-vdisplay --all-targets and -p pf-win-display
--all-targets clean; pf-vdisplay 56 pass, pf-win-display 2 pass + hardware cases; both xcheck
targets clean.
2026-07-28 22:17:26 +02:00
enricobuehler 2b2c6f045d test(vdisplay): pin the force-EXTEND backstop on glass, with a real panel attached
`force_extend_topology` does two jobs — stop a fresh IddCx monitor being CLONED onto the
existing panel, and serve as `restore_displays_ccd`'s last-resort "the desk is not left
dark" backstop. Probed directly on .173 with only the LG TV connected, its preset returns
rc=31 ERROR_GEN_FAILURE while SDC_USE_DATABASE_CURRENT returns 0, which reads like a
backstop that cannot back anything up.

On glass it is not, and this case is the measurement that settled it. Active paths went
1 -> (virtual up) 1 -> (after force-EXTEND) 2: with one connected display there is nothing
to extend across, hence rc=31; with the virtual present there are two and the preset
applies. Both real call sites run in exactly that state — the restore fires BEFORE the
REMOVE, so the virtual is still there. No defect. Recording it so nobody re-derives the
alarming half from the rc alone.

 It also caught the clone hazard live: the arriving virtual monitor did NOT get its own
active path (1 -> 1); only the forced EXTEND gave it one (-> 2). That is exactly the
"no distinct source -> no frames" case the function's own doc describes, observed rather
than assumed.

⚠️ Residual, noted not asserted: a restore that fails once the virtual is already gone is
back to one connected display, where EXTEND returns 31 and cannot re-light anything.

Context for the sweep's other on-glass claims: the earlier `live_create_drop` and
`live_inplace_resize` passes on this box were taken with the TV POWERED OFF (every monitor
devnode Code 45), so they proved the driver round trip but never exercised deactivating a
real panel. Re-run now with the TV attached, `live_inplace_resize` still reports
in_place=true (target 257 -> 257, 2356x1332), so Phase 6.2 holds with a physical present.

Verified on .173: clippy -p punktfunk-host -p pf-vdisplay --all-targets and
-p pf-win-display --all-targets clean, 56 pass + 3 ignored, no orphans.
2026-07-28 22:17:26 +02:00
enricobuehler ea9a25d3b1 fix(win-display): a host with nothing lit is an ANSWER, not a failed CCD query
`query_active_config` asked `QueryDisplayConfig` for the active topology even when the
sizing call had just answered `numPaths = 0`. Windows rejects a zero-count query rather
than handing back an empty set, so "nothing is active" came back as "the query failed".

Zero active paths is an ordinary state, not a broken one: every panel off or in standby, a
KVM switched away, a headless box between its adapter arriving and its first monitor. The
`None` propagated into the two places that matter — `count_other_active` reported failure
where the honest answer is `Some(0)`, and `isolate_displays_ccd` bailed at its first line,
which is exactly the teardown-gate condition whose recovery legs exist to stop the
operator's physical panels being left dark (sweep §5 3.1).

MEASURED on .173 (RTX 4090, Win11 26200) with the TV powered off — every monitor devnode
Code 45, `numPaths = 0` for `QDC_ALL_PATHS` too — in a live, logged-on CONSOLE session:
`GetDisplayConfigBufferSizes` returns 0 paths and `QueryDisplayConfig` then returns 0x57
ERROR_INVALID_PARAMETER (0x5 ERROR_ACCESS_DENIED from session 0). This is the mechanism
behind the plan's  note that `count_other_active(&[])` yields `None` on that box: not the
API refusing us, just a query nobody should have made.

Three changes, one defect:

* `query_active_config` short-circuits `numPaths == 0` to an empty-but-valid config.
* `isolate_displays_ccd` returns that empty snapshot straight away instead of entering its
  retry loop. Nothing is active, so nothing needs deactivating and nothing needs restoring;
  the loop's re-commit exists to drive the IddCx adapter's COMMIT_MODES, and with no path
  at all there is no config to commit — four attempts would each log a rejected apply and
  sleep 250 ms to reach the topology we already have. `restore_displays_ccd` already
  treated an empty config as the no-op it is, so the round trip stays consistent.
* `set_virtual_primary_ccd` goes through the shared query. It carried a verbatim copy of it
  — same flags, same shape — and was therefore the one CCD entry point that would not have
  inherited the fix. Same seam asymmetry this crate keeps producing, and two hand-written
  unsafe blocks go with it.

Also this file's first tests. The case is `#[ignore]`d hardware, so it reports `ignored`
rather than a vacuous `ok` when nobody runs it. On-glass A/B in the console session on
.173: it passes with the short-circuit and FAILS without it, naming the conflation. A box
that does have a display lit passes either way.

⚠️ The test is read-only and must stay that way — the obvious companion assertion,
`isolate_displays_ccd(&[])`, is deliberately not written: an empty keep set means "keep
nothing", so on a box with displays it would deactivate every one of them.

Verified on .173: `clippy -p punktfunk-host -p pf-vdisplay --all-targets` and
`clippy -p pf-win-display --all-targets` both clean, pf-vdisplay 56 pass, pf-win-display
1 pass + 1 ignored. Both xcheck targets clean.
2026-07-28 22:17:26 +02:00
enricobuehler 5a14d2b3f4 fix(vdisplay): the helper budget must end the tree, not just the process we spawned
`Child::kill` is one `TerminateProcess`: it ends exactly the process we launched. On Unix
that is the whole story here — `kscreen-doctor`, `systemctl`, `pw-dump` are single processes
we exec directly. On Windows there is no direct exec, so every helper is reached through a
shell (`cmd /c …`, `powershell -Command "… | pnputil …"`) and the process that actually
hangs is a GRANDCHILD. Killing the shell left it running, holding the stdio handles and the
working directory it inherited from us, which means the budget bounded nothing.

Not theoretical: it is why a fully green `cargo test -p pf-vdisplay` still failed its CI job
on `33a31427`. The suite's own hung-helper case orphaned a 60-second `ping.exe`; it kept the
build step's stdout pipe open past the runner's 10 s WaitDelay ("exec: WaitDelay expired
before I/O complete") and pinned `crates\pf-vdisplay` so the workspace could not be cleaned
up. The tests reported `55 passed; 0 failed` in the same log.

Spawned children are now enrolled in a Job object. Job membership is inherited across
`CreateProcess`, so one `TerminateJobObject` ends every descendant, and
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` makes that hold on paths that never reach the explicit
call — an early `?`, a panic — because closing the last handle is then itself the kill.
Best-effort: a box that cannot make a job object degrades to the single-process kill it did
before rather than failing the query, the same stance as the already-ignored `Child::kill`.

`output_within` ends the tree BEFORE `wait_with_output`. That read runs to EOF, and a
grandchild outliving the helper holds the write end — the one way this "bounded" helper
could still hang forever.

The new test is the permanent lever, and it is not vacuous: it asserts the grandchild
reached its first marker before asserting it never reached its second. Verified on .173
(Win11 26200): 56 pass, `clippy -p punktfunk-host -p pf-vdisplay --all-targets` clean, no
`ping.exe` survivors. A/B with the job disabled: the test fails with "a grandchild outlived
the budget" and one orphan survives. On the CI runner .133 the job APIs were also checked
directly, as Administrator (already inside a job — nesting works) and as SYSTEM: both kill
the tree.

Trap for anyone editing that test: a .cmd file is read in the OEM code page, so an absolute
path baked into it is mangled the moment the temp dir holds a non-ASCII character
(`C:\Users\Enrico Bühler\…` arrives as `B?hler`) and every redirect fails with "path not
found". It uses `%~dp0` instead, so the file stays pure ASCII.
2026-07-28 22:17:26 +02:00
enricobuehlerandClaude Opus 5 cf419d8b6f feat(client/linux): the settings dialog edits profiles, not just the defaults
P1's core on GTK (design/client-settings-profiles.md §5.1). The same surface now edits
either layer: a scope switcher at the top of General swaps between "Default settings" and
one profile's overrides. Deliberately not a second editor — a parallel one would drift
from this one field by field, which is exactly what the "one settings UI" principle is
there to prevent.

Three decisions carry the design's semantics into this file:

- Rows always show the EFFECTIVE value — the global underneath with the profile's
  overrides applied — so a row the profile doesn't touch reads as the live global. That is
  what "inherit by default" has to look like.
- Overrides are recorded ON TOUCH, never by diffing values at save time. A profile may
  therefore pin a value that happens to equal today's global and keep it when the global
  later moves. The handlers that record this are installed after the seed pass, or opening
  a profile would override every row in it.
- Only profileable rows render in profile scope. Auto-wake, the library toggle, the
  decoder and GPU pickers, the audio endpoints, the forwarded-pad picker and the detected
  pad list are facts about this device or this network, not about "Game vs Work" (§3's
  tier G/H), so they simply aren't there.

Switching scope closes the dialog — which commits the layer being edited — and asks the
app to re-open in the new one, so there is a single code path that builds the rows and no
ambiguity about commit ordering. Both scopes read the controls through one shared closure;
the tri-state resolution row is the obvious trap if they ever diverged.

Create / Rename / Duplicate / Delete live in the switcher's group. Names are unique
case-insensitively and the prompt refuses duplicates in place rather than after the dialog
is gone. Delete counts what actually breaks — hosts that fall back to the defaults, pinned
cards that disappear — and leaves bindings dangling on purpose: they resolve as "no
profile" everywhere, and rewriting every host record here would be a second, racier source
of truth.

`PUNKTFUNK_SHOT_SETTINGS_SCOPE` puts the screenshot scene into profile scope, so both
halves of the surface are capturable. Verified on .21 under Xvfb: the switcher, the
per-profile actions and the tier filtering render, and the Statistics row seeds to the
Game profile's "Detailed" rather than the global's value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 22:05:29 +02:00
enricobuehlerandClaude Opus 5 6cf5d4ab87 feat(client/linux): punktfunk:// links open the GTK client
D1's activation half (design/client-deep-links.md §4.1). The client already had a
`--connect` CLI, but nothing claimed the scheme, so nothing outside a terminal could
start a stream: no browser prompt, no xdg-open, no double-clickable shortcut.

The `.desktop` entries gain `MimeType=x-scheme-handler/punktfunk;` and `Exec=… %u`, and
the rpm scriptlet joins deb and arch in running `update-desktop-database` — an entry
nothing indexes claims nothing. (nix substitutes the Exec line by prefix, so `%u`
survives; flatpak's exported entry is renamed to the app id by the standard mechanism.)

In the app, `HANDLES_OPEN` plus a positional-URL argv is the whole mechanism, and the
second half is the interesting one: argv stays withheld from GApplication as before
EXCEPT when it carries a URL, and then GIO's own single-instance forwarding delivers it
to the running window over D-Bus. Clicking a link with Punktfunk already open reuses that
window instead of racing a second instance, and we wrote no IPC to get it. A URL that
lands during a cold start (before the model exists) is parked rather than dropped —
silently losing a link is the one outcome that would make the feature untrustworthy.

Routing is four lines of translation, because the decisions are the brain's: parse,
resolve the host, resolve the profile, refuse. A link that resolves becomes exactly the
message a card click raises — the same wake, trust gate, and error surfaces — so there is
no second connect path to keep in sync. A link to a host we don't know, or know but never
pinned, opens the ordinary PIN ceremony seeded with what the link claimed, which is how
"a URL may never pair or trust on its own" stays true while still being useful. Never
preempting a live session is checked here, since only this layer knows one is running.

Verified on .21 under Xvfb against hand-authored stores: `connect/Bound%20Box?launch=`
resolved the host by name, carried the launch id through the trust gate, and spawned the
session; `punktfunk://pair/1234` and an unknown `profile=` both refused before any dial.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:37:10 +02:00
enricobuehler c262bf43df refactor(host): three more Windows unsafe fns that had no caller contract
Continues the crate-by-crate `unsafe_op_in_unsafe_fn` work. Same finding as
pf-frame: the useful move is often deleting the `unsafe fn` marker, not decorating
the body.

`install_steam_audio_pair` (no arguments) and `try_install_steam_audio(&str)` are now
safe fns. The call sites had already worked this out — both carried a SAFETY comment
opening "`install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s
`newdev.dll`", i.e. the obligation was never the caller's. Making them safe deletes
14 lines of that prose from two call sites and moves the real reasoning next to the
`LoadLibraryExW`/`transmute`/call chain it actually describes. wasapi_mic.rs: 8 -> 0.

`make_job` (no arguments, and it wraps its handle in an `OwnedHandle` before the
first fallible step) and `open_log_handle(&Path)` are safe fns too. The latter
returns a raw `HANDLE`, but that is an ownership obligation, not a safety one —
nothing a caller does with it can cause UB, and it is inherited by the child and
closed there. service.rs: 19 -> 14.

`spawn_host` keeps `unsafe fn`: it takes a raw `HANDLE`, and an invalid one is UB.
That is a contract worth stating, so it keeps the marker.

Windows-only note: `LoadLibraryExW` here already passes
`LOAD_LIBRARY_SEARCH_SYSTEM32`, so the newdev.dll load cannot pick up a planted DLL
from the working directory — now recorded in the SAFETY note rather than left
implicit.

Verified on Windows .47: punktfunk-host clean at EXITCODE=0 — zero errors, zero
unused-unsafe. Crate's own files now 29 E0133 (interactive.rs 15, service.rs 14),
down from 42.
2026-07-28 21:31:42 +02:00
enricobuehler 307ca88ba7 refactor(frame): dxgi.rs to zero E0133, and two of its unsafe fns didn't need to be
First crate done under `unsafe_op_in_unsafe_fn`. 23 sites -> 0, and the file's
`unsafe fn` count drops 5 -> 2, because narrowing was the wrong answer for two of
them:

`enable_inc_base_priority` takes no arguments and touches only the current process's
own token. `hags_enabled` takes a `LUID`, a plain value with no pointer a caller
could get wrong, and builds every gdi32 argument struct locally. Neither leaves a
precondition for a caller to uphold, so neither should have been `unsafe fn` at all —
they are now safe fns with the unsafe confined inside, which deletes the obligation
from their callers rather than restating it. That is a strictly better outcome than
wrapping their bodies, and it is invisible if you treat the lint as a mechanical
find-and-wrap.

`d3dkmt_set_scheduling_priority_class` stays `unsafe fn`: it takes a `HANDLE`, and an
invalid one is UB. That contract is real, so it keeps the marker and gains narrow
blocks inside instead.

The rest is narrowing. The clearest win is `elevate_process_gpu_priority`, whose
`ONCE.call_once(|| unsafe { .. })` had 25 lines inside one block — a `match` on a
config enum, three `tracing!` arms and an early return — for exactly one call that
needed it. That block is gone; only the `d3dkmt_*` call carries `unsafe` now.

Every remaining block is scoped to the FFI call it covers and carries a SAFETY note
naming what makes it hold: which handle is live, which local the callee writes,
which size matches which variable.

Verified: Windows .47 pf-frame clean at EXITCODE=0 — zero errors, zero E0133, zero
unused-unsafe. Linux .21 pf-frame zero errors (dxgi.rs is Windows-gated, checked for
regressions in the shared parts).
2026-07-28 21:31:42 +02:00
enricobuehler 0a710fdfe5 refactor(frame): narrow make_device's unsafe to the FFI calls themselves
First slice of the `unsafe_op_in_unsafe_fn` work on the Windows-gated code, and a
deliberate demonstration of the shape the rest should take.

`cargo fix` is NOT the tool here. Its suggestion for this lint wraps the entire
function body:

    pub unsafe fn make_device(..) -> Result<..> { unsafe {
        ..40 lines..
    }}

One block spanning everything, `{ unsafe {` / `}}` bracing, and — the actual problem
— zero change in how much code runs unchecked. It makes the status quo explicit
rather than smaller, which is the opposite of the goal.

Narrowed by hand instead: four blocks, each around the FFI call that needs it
(`D3D11CreateDevice`, two `SetGPUThreadPriority`, `SetMaximumFrameLatency`), each
with its own SAFETY note. The `Option` handling, the `?`, the `if let Ok(..)` casts
and the `tracing!` calls are back outside, where the compiler checks them. Roughly 40
unchecked lines become 6.

Fixed two of my own mistakes in the same pass: `elevate_process_gpu_priority` and
`auto_priority_gate` are ALREADY safe fns, and I had wrapped their calls in `unsafe`
out of momentum. Unnecessary `unsafe` is not free — it is the same "this is scary"
noise the lint exists to remove, and the compiler only warns about it. That mistake
is a good argument against doing the remaining ~590 sites mechanically.

pf-frame drops 23 -> 19 E0133. The remaining three functions in this file need a
different answer, not just narrowing: `enable_inc_base_priority` takes no arguments
and touches only the current process's own token, so it has NO caller contract and
should become a SAFE fn with unsafe blocks inside — which removes an `unsafe fn`
rather than decorating it. Same likely for `hags_enabled(LUID)`, whose only argument
is a plain value.

Verified on Windows .47: pf-frame checks clean at EXITCODE=0, no errors, no
unused-unsafe warnings.
2026-07-28 21:31:42 +02:00
enricobuehler a3843b6996 test(encode): the VAAPI dmabuf path verified on AMD silicon, and its owner-only fields say so
Closes the last unverified leg of the AvBuffer work. `DmabufInner` was the heaviest
ownership change in the crate — four owned objects (DRM device, derived VAAPI device,
DRM-PRIME frames ctx, filter graph) whose eight failure branches each repeated the
same four-line unwind, once inside a macro, plus a ninth copy in `Drop` — and it had
no coverage at all. `vaapi_cpu_encode_smoke` does not reach it: that drives the
swscale/CPU-upload path, which uses `VaapiHw` and never builds a graph.

`dmabuf_inner_alloc_drop_cycles` loops construct/drop, which is the whole contract
now that every handle releases itself. **It passes on a Radeon 780M**, as do the two
pre-existing VAAPI tests, so `VaapiHw` and `DmabufInner` are both hardware-verified
rather than compile-only.

Also fixes three more owner-only fields the AMD box surfaced: `graph`,
`vaapi_device` and `drm_device` are `never read` since the hand-written `Drop` that
used to read them is gone. Same call as the decoders and the QSV pair — annotate
rather than delete (removing `graph` would free it while `src`/`sink` still point
into it) or underscore-rename (which hides what they hold). `drm_frames` is NOT
annotated: `submit` genuinely reads it per frame.

I had missed these twice: my Linux log greps searched "never constructed|never used",
which does not match "never read". The check now includes all three spellings.

Two environment notes worth keeping, both diagnosed by testing OUTSIDE our code
first (ffmpeg's own CLI reproduced each):
  * Inside a distrobox on an immutable host, VAAPI needs
    `LIBVA_DRIVERS_PATH=/run/host/usr/lib64/dri`. The container's mesa (25.3.6) is
    older than the host's (26.0.4) and every encoder open fails with a bare ENOSYS —
    "Function not implemented", naming nothing. The test doc says so.
  * `cargo check --workspace` in that container fails on `glib-sys` (no GTK dev
    headers). Unrelated to this branch; .21 checks the full workspace clean.

Verified: AMD .116 (Radeon 780M, Mesa 26.0.4) full pf-encode suite 33 passed / 0
failed plus all 3 ignored VAAPI hardware tests green. Linux .21 workspace check exit
0 / zero errors, pf-encode 33/0, pf-client-core 34/0, and zero dead-code warnings
across all three spellings.
2026-07-28 21:31:42 +02:00
enricobuehler 4241bc0383 fix(encode/windows): enable D3D11 multithread protection before libav sees the device
libav turns on `ID3D11Multithread::SetMultithreadProtected` in
`d3d11va_device_create` — the path where it creates the device itself. We take the
other path, `d3d11va_device_init`, because we hand it the capturer's existing
`ID3D11Device`, and that path does not. Nothing enabled it on our side either, so it
was simply off. Two consequences, one of them shipping since long before this branch:

QSV zero-copy could not open at all. `av_hwdevice_ctx_create_derived(QSV <- D3D11VA)`
ends in `MFXVideoCORE_SetHandle`, and MFX rejects a device without multithread
protection as `MFX_ERR_UNDEFINED_BEHAVIOR (-16)`. libav reports that as "Error
setting child device handle", which names neither the cause nor the cure.

AMF — the DEFAULT Windows zero-copy path — has been running with a lock that does
nothing. We deliberately leave `lock`/`unlock` null so libav installs its
`d3d11va_default_lock`, and that lock is `ID3D11Multithread::Enter`/`Leave`, which
are documented no-ops while protection is off. The lock libav installs to serialise
our capture thread against its encode thread has therefore never actually serialised
anything. That is the more serious half of this fix, and it is not QSV-specific.

Measured on the Intel VM (UHD 750, FFmpeg 7.1.5 + libvpl), sweeping device flags x
adapter x protection:

    BGRA            -> QSV derive FAILED (-16)
    BGRA +MT        -> QSV derive OK
    BGRA|VIDEO +MT  -> QSV derive OK

`D3D11_CREATE_DEVICE_VIDEO_SUPPORT` makes no difference in either direction, and the
same result holds on both Intel adapters the box enumerates. Protection is the only
variable that matters, and it read back `was=false` every time, confirming nothing
else had enabled it. The hypothesis came from ffmpeg's own CLI succeeding at the
identical derive (`-init_hw_device d3d11va=d3d -init_hw_device qsv=q@d3d`) — the only
difference being who created the device.

With this, `zerocopy_qsv_alloc_drop_cycles` passes on real Intel silicon, which also
makes the QSV arm of the AvBuffer refactor hardware-verified rather than
compile-only. Its doc comment now points back here, since a future regression will
present as that same opaque "child device handle" line.

NOT flipped: `zerocopy_active` still defaults QSV off. The derive works and the
handles construct and release cleanly, but that is not the same as a validated
streaming session, and the default should move on glass evidence, not on this.

Verified on .47 (Intel UHD 750): full pf-encode --features amf-qsv suite 39 passed /
0 failed, d3d11hw_alloc_drop_cycles green, zerocopy_qsv_alloc_drop_cycles green, and
the native VPL path (qsv_encode_live_smoke) still green. Linux .21: workspace check
exit 0 / zero errors, pf-encode 33/0, pf-client-core 34/0.
2026-07-28 21:31:42 +02:00
enricobuehler 2d78d2a514 test(encode): D3d11Hw verified on real silicon; the QSV derive is broken upstream of us
Ran the QSV probe on the Intel VM (UHD 750, FFmpeg 8). It fails — and not in the
code it was written to cover:

    [AVHWDeviceContext] Error setting child device handle: -16
    ZeroCopyInner::open(QSV) failed on iteration 0: derive QSV device from D3D11VA

That is `MFX_ERR_UNDEFINED_BEHAVIOR` out of `MFXVideoCORE_SetHandle`, raised inside
`av_hwdevice_ctx_create_derived` — libav's own code, called with the same arguments
as before this branch. Confirmed pre-existing by A/B: the same probe, written against
unmodified `origin/main` with the raw-pointer struct, fails byte-identically on the
same box at the same first iteration. The ownership change is not implicated.

It is also not the hardware. `qsv::tests::qsv_encode_live_smoke` — the native VPL
backend, untouched by this branch — encodes real H.264 on that box via VPL 2.15 with
its own D3D11 zero-copy. So QSV works; libav's QSV-from-D3D11VA derive is what does
not. That matches `zerocopy_active` already defaulting QSV **off** pending Intel
validation, and is now a measured fact rather than a suspicion. The test stays,
`#[ignore]`d, with the finding in its doc comment so the next Intel driver / FFmpeg
bump re-checks it instead of the question being quietly dropped.

What that leaves is real coverage of the half that IS reachable anywhere:
`d3d11hw_alloc_drop_cycles` loops construct/drop on `D3d11Hw`, the hwdevice +
frames-pool pair both Windows zero-copy vendors share, so it covers the AMF path's
ownership without needing AMD hardware. **It passes on the Intel box: 8 cycles, no
abort.** Adapter selection is factored into `test_hw_device`, which prefers a vendor
and skips the Microsoft Basic Render Driver — a punktfunk host enumerates our own
virtual-display adapter too, so `EnumAdapters1(0)` is not a safe assumption.

Verified: QSV VM .47 full `pf-encode --features amf-qsv` suite 39 passed / 0 failed
(EXITCODE=0), with `d3d11hw_alloc_drop_cycles` green on real silicon. Linux .21
workspace check exit 0 / zero errors, pf-encode 33/0, pf-client-core 34/0.
2026-07-28 21:31:41 +02:00
enricobuehler 564797a12a test(encode): a QSV construct/drop smoke test, and AvFilterGraph goes Linux-only
Two loose ends ahead of running this on real Intel silicon.

`zerocopy_qsv_alloc_drop_cycles` covers the one ownership question in the crate that
was genuinely ambiguous. `ZeroCopyInner::open` builds a `D3d11Hw` and then DERIVES a
QSV device + frames ctx from it, and the tuple it used to return handed those two
derived pointers out twice — once as the encoder's args, once as the pair moved into
`Self`. Free for raw pointers, two owners for `AvBuffer`. Looping construct/drop is
what separates the outcomes: a double-unref aborts in the CRT, a missed one leaks an
Intel device per session. Nothing else reaches this code — `zerocopy_enabled`
defaults QSV OFF, and the native VPL backend supersedes this whole file unless
`PUNKTFUNK_QSV_FFMPEG=1` — so the test calls `open` directly and sidesteps both
gates, the same shape as `cuda_hw_alloc_drop_cycles`.

`AvFilterGraph` is now `#[cfg(target_os = "linux")]`. The Windows gate had been
reporting `struct AvFilterGraph is never constructed` since a960dff8 and I had been
filtering it out of my own log greps (searching for "never read", which does not
match "never constructed"). It is true: the VAAPI dmabuf path is the only
filter-graph user, and the AMF/QSV backends build no graph. Cfg'd out rather than
`allow`ed, so it cannot outlive its last caller unnoticed.

Verified. Windows .133: `cargo test -p pf-encode --features amf-qsv --no-run` at
EXITCODE=0 with the AvFilterGraph warnings gone (107 -> 105), and the test binary
lists `ffmpeg_win::tests::zerocopy_qsv_alloc_drop_cycles`. Linux .21: workspace
check exit 0 / zero errors, pf-encode 33 passed / 0 failed, pf-client-core 34 passed
/ 0 failed, and zero dead-code warnings there (AvFilterGraph is live on Linux).

NOTE for running it: the test binary needs `C:\Users\Public\ffmpeg\bin` on PATH at
RUNTIME. `FFMPEG_DIR` is build-time only, and without the DLLs the harness exits
silently with no output at all — which reads exactly like a test that does not exist.
2026-07-28 21:31:41 +02:00
enricobuehler e60fee7da6 refactor(encode): the QSV derived pair stops being two owners of the same pointer
`ZeroCopyInner` held the QSV device + frames ctx as nullable raw pointers, where
null meant "AMF, which feeds D3D11 frames directly" — a convention documented in a
comment and enforced by three `is_null()` checks. `Option<AvBuffer>` says it in the
type, so the checks and the hand-written `Drop` both go away.

The reason this one was left out of the previous commit is the aliasing. `open`
built a five-element tuple whose QSV arm handed the SAME two pointers out twice:
once as the encoder's `dev_ref`/`frames_ref`, once as the pair moved into `Self`.
For raw pointers that is free; for an owning type it is two owners and a
double-unref. Ownership and borrowing are now separated — the pair is owned in
`qsv_frames`/`qsv_device`, and the encoder's arguments are `as_ptr()` views taken
from whichever owner applies. `open_win_encoder` takes its own refs of what it is
handed, so lending transfers nothing.

Net: all 13 `av_buffer_unref` calls in this file are gone (the last two hand-written
Drops with them), including the encoder-open failure arm, which collapses to `?`
now that every handle releases itself.

Field order pinned and commented, as with the others: QSV frames, QSV device, then
`enc`, then `hw` — reproducing the old `Drop`, which ran ahead of all fields and so
released the derived pair before the encoder's AddRef'd copies and the D3D11 refs.

`qsv_device` picked up an `#[allow(dead_code)]`: `qsv_frames` is still read by the
send path, but the device is now purely an owner (the frames ctx and the encoder
each hold their own ref). Same call as the decoders — deleting it would free the
device early, an underscore name would hide it.

Verified on BOTH: Windows .133 `cargo check -p pf-encode --features amf-qsv
--all-targets` EXITCODE=0, no dead-code warnings, and confirmed non-vacuous (log
shows `Removed 23 files, 36.2MiB` then a real `Checking pf-encode`). Linux .21
`cargo check --workspace --all-targets` exit 0 / zero errors, pf-encode 33 passed /
0 failed, pf-client-core 34 passed / 0 failed, and `cuda_hw_alloc_drop_cycles` still
passing on real CUDA. The QSV path itself still wants Intel silicon to exercise.
2026-07-28 21:31:41 +02:00
enricobuehler 5b142a7e85 refactor(encode): the Windows D3D11VA hwdevice owns its refs
`D3d11Hw::new` is the third instance of the same shape as `CudaHw` and `VaapiHw` —
alloc a hwdevice, deref to fill it, init, alloc a frames ctx, deref, init — with the
same hand-written unwind: three `av_buffer_unref` calls spread over the failure
branches plus a `Drop` repeating the pair. It gets the same treatment, so all three
libav hwdevice wrappers in the crate now share one release path.

Field order carries the semantics here as in the other two: frames declared before
device, so declaration-order dropping reproduces what the hand-written `Drop` did.

`ZeroCopyInner`'s `qsv_device`/`qsv_frames` are deliberately NOT converted in this
commit. They are nullable (null for AMF, which feeds D3D11 frames directly), and the
QSV branch hands the same two pointers out twice — once as the encoder's
`dev_ref`/`frames_ref` and once as the owned pair moved into the struct. Modelling
that needs `Option<AvBuffer>` plus a borrow/own split so the aliasing does not become
two owners, which is a different change from the mechanical one this commit makes.

Verified on the Windows runner .133: `cargo check -p pf-encode --features amf-qsv
--all-targets` at EXITCODE=0. Confirmed non-vacuous — the log shows `cargo clean -p
pf-encode` removing 23 files / 36.2 MiB followed by a real `Checking pf-encode`,
because a fast green on that box can otherwise just be cargo reusing artifacts whose
timestamps outrank the freshly-extracted sources.
2026-07-28 21:31:41 +02:00
enricobuehler 06a406adf2 refactor(client): the D3D11VA decoder owns its refs too, and the owner-only fields say so
Completes the decoder trio. `D3d11vaDecoder::new` had the same cascading unwind as
the VAAPI and Vulkan constructors — four branches each unref'ing the hwdevice by
hand — and `d3d11va_decode_supported` opened a frames context it released on one
exit path while relying on the null check for the other. Both are `AvBuffer` now.
The one surviving `av_buffer_unref` in the file is deliberate: it runs before
ownership is taken, on the `av_hwdevice_ctx_init` failure ahead of `from_raw`.

The Windows gate then caught something worth keeping: `field hw_device is never
read`, in all three decoders. It was true and it was new — the hand-written `Drop`
used to be the field's only reader, so moving the unref into the type left a field
that is *purely* an owner. The compiler cannot express that, so each one now
carries a doc comment saying what it is and an explicit `#[allow(dead_code)]`.
Deleting the field would free the device early; renaming it `_hw_device` would
silence the lint by hiding what it holds. Neither is what we mean.

Verified on BOTH platforms this time. Windows runner .133 (the toolchain env from
the runner notes, plus `PF_FFVK_VULKAN_INCLUDE` for pf-ffvk): `cargo check -p
pf-client-core --all-targets` at EXITCODE=0, clean of the dead-code warnings. Linux
.21: `cargo check --workspace --all-targets` exit 0 / zero errors, pf-client-core
34 passed / 0 failed, pf-encode 33 passed / 0 failed.
2026-07-28 21:31:41 +02:00
enricobuehler a42ab075a8 refactor(client): the hardware decoders own their hwdevice ref
`VaapiDecoder::new` and `VulkanDecoder::new` create a hwdevice and then do several
more fallible things with it — resolve `vkWaitSemaphores`, find the decoder, alloc
the context, open it — and every one of those branches unref'd the device by hand,
with a `Drop` doing it once more. Six hand-written unrefs between them, each one a
line somebody has to remember when adding a step.

`video_libav::AvBuffer` owns it instead, so an early `bail!` releases it and the
branches carry nothing. It is a deliberate second copy of `pf-encode`'s type: the
crates do not depend on each other (host encode and client decode share no code
path), and this one needs something the host's does not — see below — so hoisting
it to a shared crate would mean giving that crate an ffmpeg dependency and both
sets of semantics to save about twenty lines.

That extra piece is `into_raw`. `pick_vulkan` hands its frames context to the codec
(`(*ctx).hw_frames_ctx = fr` — the codec unrefs it when the context closes), so the
wrapper must give up ownership rather than drop. Making the transfer explicit is
the point: dropping an `AvBuffer` there too would be exactly the double-unref this
type exists to prevent.

Two `av_buffer_unref` calls survive in `video_vulkan.rs` on purpose. One runs before
ownership is taken (the `av_hwdevice_ctx_init` failure, ahead of `from_raw`); the
other releases the codec's OWN pre-existing frames ctx before we replace it, which
was never ours to model.

Field order preserved: `hw_device` stays declared after `ctx`, so it still releases
after each `Drop` frees packet/frame/context — the order the hand-written unref had.

Verified on .21 (CachyOS, FFmpeg 62): `cargo check --workspace --all-targets` clean
at exit 0 with zero errors — the workspace-wide check owed since the AvBuffer
commit — plus pf-client-core 34 passed / 0 failed and pf-encode 33 passed / 0
failed. The decoders themselves still need real VAAPI/Vulkan playback to exercise.
2026-07-28 21:31:41 +02:00
enricobuehler eb9c5be20d refactor(encode): the VAAPI dmabuf path stops unwinding by hand
`DmabufInner::open` builds four owned objects — a DRM device, a VAAPI device
derived from it, a DRM-PRIME frames context, and a filter graph — and every one of
its eight failure branches unwound them by hand. The same four-line block
(`avfilter_graph_free` + three `av_buffer_unref`s) appeared eight times, once
*inside a macro*, plus a ninth copy in `Drop`. Adding a step to that function meant
remembering to extend the unwind at exactly the right depth; getting it wrong leaks
a device per failed session (the persistent listener accumulates them) or frees one
twice.

All eight are gone. `AvBuffer` already owned the buffer refs; `AvFilterGraph` now
does the same for the graph, so each handle is owned the moment it exists and an
early `bail!` releases whatever was built so far. `open` lost ~40 lines of cleanup
and gained none.

Field order in `DmabufInner` is load-bearing and says so: graph, frames, VAAPI
device, DRM device, then `enc` LAST. Fields drop in declaration order, and that
sequence reproduces the old hand-written `Drop` exactly — including that it ran
ahead of every field, so all four were released before ffmpeg-next dropped the
encoder. Everything here holds its own reference, so refcounting makes any order
sound; the ordering is pinned so a future reorder cannot quietly change what ships.

One subtlety preserved deliberately: the buffersrc parameters take `drm_frames`
BORROWED, not ref'd (`av_buffersrc_parameters_set` takes its own ref). That is now
`drm_frames.as_ptr()` — same borrow, same single owned ref, no new leak.

Verified on .21 (CachyOS, RTX 5070 Ti, FFmpeg 62): `cargo check -p pf-encode
--all-targets` clean at exit 0, `cargo test -p pf-encode` 33 passed / 0 failed, and
`cuda_hw_alloc_drop_cycles` still passes against real CUDA. vaapi.rs now contains
zero `av_buffer_unref` and zero `avfilter_graph_free` calls, down from 40 and 8.
The dmabuf path itself still needs AMD/Intel silicon to exercise end to end.
2026-07-28 21:31:41 +02:00
enricobuehler 7adc1db672 refactor(encode): AVBufferRef ownership moves into an RAII handle
`CudaHw::new` and `VaapiHw::new` are the same shape — alloc a hwdevice, deref it
to fill fields, init, alloc a frames ctx, deref, init — and both unwound by hand:
`av_buffer_unref` on every failure branch (three in CUDA, two in VAAPI) plus a
hand-written `Drop` repeating the pair. That shape has two failure modes and the
compiler can see neither: add a branch and forget the cleanup (leak), or let two
cleanup paths run (double-unref, an abort inside glibc).

`libav::AvBuffer` owns the ref instead. It null-checks on the way in — the check
each caller open-coded — and unrefs exactly once on drop, so an early `?` releases
whatever was built so far and the failure branches carry no cleanup at all. Both
hand-written `Drop` impls are gone, and `linux/mod.rs` goes from seven
`av_buffer_unref` calls to zero.

The one subtlety, called out at both structs: these two fields must stay declared
frames-BEFORE-device. Fields drop in declaration order, and the code being replaced
deliberately unref'd frames first (a frames ctx holds its own reference on its
device). Refcounting makes either order sound, but a field reorder should not
silently change what ships, so the ordering is load-bearing and commented as such.

Also adds `cuda_hw_alloc_drop_cycles` — the RAII path had NO test coverage: the
NVENC smoke tests take the CPU path and never construct a `CudaHw`, and the VAAPI
twin's tests need AMD/Intel silicon. Looping construct/drop is what catches the
double-unref (abort) and the leak (allocator growth) this refactor is about.

Verified on Nobara (RTX 5070 Ti): `cargo check -p pf-encode --all-targets` clean at
exit 0, `cargo test -p pf-encode` 33 passed / 0 failed, and
`cuda_hw_alloc_drop_cycles` passes against a real CUDA device — eight
construct/drop cycles, no abort. `VaapiHw` is compile-verified only; it is the
identical shape but no AMD/Intel box was reachable to run its two ignored tests.
2026-07-28 21:31:41 +02:00
enricobuehler 5219107177 chore(unsafe): the workspace adopts the drivers' unsafe discipline
`packaging/windows/drivers/*` has run `deny(unsafe_op_in_unsafe_fn)` +
`deny(clippy::undocumented_unsafe_blocks)` for a while, with `forbid(unsafe_code)`
on the modules that need no unsafe at all. The main workspace had no lint config
whatsoever, so nothing stopped a clean crate from quietly growing an `unsafe`, and
nothing distinguished the handful of genuinely-unsafe lines inside a 600-line
`unsafe fn` from the safe ones surrounding them.

Three things, all mechanical:

* `#![forbid(unsafe_code)]` on the eight crates that already contain zero unsafe
  (`pf-driver-proto`, `pf-host-config`, `pf-paths`, the three clean clients, both
  tools). These were clean by accident, not by contract; now they are clean by
  contract.

* `unsafe_op_in_unsafe_fn = "warn"` workspace-wide. `unsafe fn` states a contract
  the CALLER must uphold — it was never meant to switch off checking for the whole
  body. Measured fallout is 300 sites on Linux, and they are concentrated: six
  files carry all of them, while `punktfunk-core`, `pf-frame`, `pf-clipboard` and
  `pf-vdisplay` are already at zero. `warn` (not `deny`) so the build stays green
  while those six are worked down; it flips to `deny` once they are. This is also
  the Rust 2024 default, so it pays off the edition migration early.

* `proc::current_uid()` replaces eight `unsafe { libc::getuid() }` blocks. Each
  site had copied out the same SAFETY note verbatim, which is the tell: `getuid()`
  is parameterless, always succeeds and touches no memory, so there is no contract
  for a caller to uphold and no reason for the unsafe to be visible eight times.
  One `unsafe` behind a safe wrapper, none at the call sites.

Verified: `pf-vdisplay` builds clean on Linux (Nobara) at zero E0133; the
macOS-buildable crates build clean locally. No behaviour change.
2026-07-28 21:31:41 +02:00
enricobuehlerandClaude Opus 5 37edb1cc1a refactor(clients): both shells wake through the shared state machine
The last of C0's duplication: GTK's `wake_and_connect` and the WinUI shell's ran their own
copies of the same 90 s / 6 s / 1 s loop, and the Windows one's comment said it "mirrors
the Apple HostWaker" — now it literally does, because both drive `WakeWait`. What is left
in each shell is the part that genuinely differs: its dialog or screen, its advert drain,
its re-key, and its route back into the trust gate.

Behavioural review, since a sleeping host to test against wasn't available:

- GTK polled every 500 ms and resent on a wall-clock timer; it now ticks once a second
  like every other implementation. Up to half a second slower to notice a host coming
  back, and exactly the reference cadence in exchange.
- GTK took the FIRST matching advert in a drain and returned mid-loop; it now drains fully
  and takes the last, i.e. the freshest address. Same connect, fresher re-key.
- Windows sent one packet before the loop AND one on its first pass; the machine owns the
  packets now, so that duplicate is gone. Resends still land at 6 s, 12 s, … and the
  budget still expires at 90 s.
- Both keep their own cancel path, their own re-key, and their own error surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:01:47 +02:00
enricobuehlerandClaude Opus 5 983ff8e78d fix(session/console): a profile must not advertise a cursor channel the presenter isn't drawing
Found reviewing the P0 wiring. The console builds its window and input models ONCE, from
the global defaults, and they live across every launch — but each launch now re-resolves
the host's profile. So a profile that sets `mouse_mode: desktop` on a host, while the
global stays `capture`, made the session advertise `CLIENT_CAP_CURSOR` ("this client
draws the host cursor itself") to a presenter latched in capture mode, which draws no
cursor: the host stops compositing the pointer and the stream has no visible cursor at
all — the exact failure the capability's own doc comment warns about.

The advertisement now follows the LATCHED mouse model, which is the one that is actually
true of the presenter. Everything the host is told about the stream itself — mode,
bitrate, codec, audio, pad — keeps honoring the binding, and the comment says which
fields are latched and why, so the console's presentation-tier gap is documented rather
than rediscovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:46:29 +02:00
enricobuehlerandClaude Opus 5 eab4829630 feat(client): the brain layer — one connect plan, one wake machine, one spawn
C0 of design/client-architecture-split.md. Wake-then-connect exists three times today —
GTK's `WakeConnect`/`wake_fallback`, the WinUI shell's `wake_and_connect` (whose comment
says it "mirrors the Apple HostWaker"), and Apple's `HostWaker` itself — and the
deep-link and profile work was about to make it five. `pf-client-core` already held the
ingredients (wol, discovery, trust, library, session); what was missing was the layer
that composes them, so every front-end composed them itself.

`ConnectPlan` is a resolved intent: which host, which pin, which launch id, which
profile, the effective settings, whether to wake. One constructor per door — a card
click, a CLI verb, a URL — one type out. `ConnectPlan::resolve` is pure (stores in,
plan out), which is what lets the URL router be tested without a config directory;
`for_host` is the convenience that loads them.

`plan_from_link` is where the deep-link security rules actually live, rather than in
each shell: a contradicted fingerprint refuses and says which host it was about, an
ambiguous name refuses instead of picking the first, a profile the catalog can't honor
refuses BEFORE anything is dialled, and an unknown — or known-but-never-pinned — host
becomes a confirmation sheet carrying the claimed name and expected pin, so the first
connect is verified rather than blind TOFU. Preempting a live session stays with the
caller: only the front-end knows one is running.

`WakeWait` is Apple's `HostWaker` cadence as a pure step function — packet at 0 s and
every 6 s, presence polled every second, 90 s budget, and a PARK (not an error) at the
end, because "it didn't wake in 90 s" is usually "give it 10 more". As a step function
each front-end drives it from its own loop and they still agree, and the whole cadence
is testable without waiting 90 seconds.

Session spawn, its argv and its stdout contract move here too, and the GTK shell now
spawns through them — so "which flags does a stream get" and "what does ready mean" have
one answer for the shells, the console and the coming CLI. `--profile` deliberately does
NOT ride a plain card click: the session resolves the host's binding through the same
helper, and passing it would be a second source of truth for one decision.

Not yet adopted: the two shells' wake paths. That swap changes the most-used path in the
product and the handoff wants it verified on glass with a genuinely sleeping host, which
this session couldn't do — so the state machine lands, the duplication stays until it
can be verified away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:45:07 +02:00
enricobuehlerandClaude Opus 5 3350d3cfbf feat(client): the punktfunk:// grammar — one parser, one emitter, one vector file
D0 of design/client-deep-links.md. Apple has shipped `punktfunk://connect/<uuid>` for a
while; this is the same grammar, generalised and given a Rust implementation the GTK and
WinUI shells, the session and the coming CLI all share:

    punktfunk://connect/<host-ref>[?fp=…][&host=addr[:port]][&launch=…][&profile=…][&name=…]

The rule the grammar exists to keep is that a URL may only ever do what a click on an
existing card could do, minus trust decisions. So it carries REFERENCES to things that
already exist on this device — never values: no resolution, no bitrate, no codec, so a
web page cannot shape a session beyond picking among the user's own configurations. And
`pair` is not a route: it parses, and refuses, so a link can never start a trust
ceremony. Everything hostile is rejected here, once, for every front-end — 2 KB total
cap, per-parameter caps, strict percent-decoding (a half-escape or invalid UTF-8 is an
error, not a shrug), control characters refused after decoding, launch ids held to the
charset the host and Decky already agree on, and `fp=` that must actually be 64 hex.

Resolution follows the same order everywhere: stable record id → unique host name →
`addr[:port]`, with `host=`+`fp=` as the reinstall recovery path, which is why
self-emitted links carry all three. An ambiguous name refuses rather than guessing; a
stale id with no recovery address refuses rather than dialling "11111111-…" as if it
were a hostname; an unknown-but-plausible address becomes the confirmation sheet, never
an auto-connect.

`pf://` parses as an input alias and is never emitted or registered — claiming a
two-letter scheme on MSIX/Apple is unconditional squatting, and a link that resolves on
one platform only is a trap.

The 44-case `clients/shared/deeplink-vectors.json` is the cross-language contract: this
module's tests run every case, and the Swift and Kotlin ports will run the same file, so
three parsers cannot drift into three different security postures. Refusal codes are
part of that contract, not just the happy path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:32:32 +02:00
enricobuehlerandClaude Opus 5 a2be3f4de9 feat(client): settings profiles — the override catalog, the one resolver, and the session flag
A client setting is global today: pick 4K@120 HDR and the retro box gets it too.
The one escape hatch (per-host `clipboard_sync`) proves the shape works but covers a
single field. This is P0 of design/client-settings-profiles.md — the headless core the
shells, Apple, Android and the deep-link grammar all build against. No UI yet.

A profile is a NAMED BUNDLE OF OVERRIDES, not a snapshot: `SettingsOverlay` is sparse
`Option`s, so a field the user never touched keeps following the global value live, and
fixing a global once fixes it everywhere. `Some(x)` equal to today's global is still
meaningful — it pins x against a later global change — which is why the UI will write
`Some` on touch and `None` on an explicit reset, never by diffing.

The catalog is its own `client-profiles.json`, deliberately not part of the settings
file: that file has five whole-file load-modify-save writers with no merge, so a profile
written by one would be dropped by the next. Which host uses which profile is a field on
the host record instead of a map keyed by host — that is what dissolves the client's
long-standing "what identifies a host record" question for this feature.

Resolution has exactly one implementation, `trust::effective_settings()`:

    effective = overlay(profile).apply(global)
    profile   = one-off pick ?? host binding ?? none

Both session construction sites go through it (`main.rs` AND `console.rs` — touching one
and not the other is a Windows-only build break), so the console, and therefore Decky,
honor host bindings with no work of their own. Nothing here can fail a connect: a
deleted binding resolves as "no profile", and a one-off that can't be honored falls back
to the DEFAULTS rather than to the host's own profile — "connect with Work" must never
quietly stream "Game". `--profile <id|name>` is the one-off door for the shells' coming
"Connect with ▸" and for `punktfunk://…&profile=`; `--profile ""` forces the defaults on
a bound host. The active profile's name closes the stats overlay's first line, so
"which profile am I on?" is answerable without leaving the stream.

Also here, because this effort touches every one of these anyway:

- `KnownHost` gains `profile_id`, `pinned_profiles` and a lazily minted stable `id`. The
  id is groundwork (design §4.5) — no lookup is re-keyed, `fp_hex`/`addr:port` stay.
- `upsert` now preserves the state the USER set on a record — the profile binding, its
  pins, the clipboard decision, the id — against refreshes that carry none of it.
  `clipboard_sync` survived that only because the function happened not to mention it.
- `KnownHost::default()` exists so construction sites read `..Default::default()`; five
  hand-written literals were exactly how a new field gets silently dropped on re-pair.
- All three client stores now write temp+rename. A torn settings file loads as `Default`
  — i.e. silently resets every setting the user has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:22:33 +02:00
enricobuehlerandClaude Opus 5 33a31427ae fix(vdisplay): locks held across seconds-long work, an AB/BA inversion, and a panic that poisons the manager
Phase 7's contained half. 7.1's writer split and 7.3's reservation are NOT here — see
the plan; both need more than a local edit.

**Lock-order inversion, gamescope.** `create_managed_session_steamos` held
MANAGED_SESSION while taking STEAMOS_TOOK_OVER (and `persist_takeover`'s locks), while
`do_restore_tv_session` takes STEAMOS_TOOK_OVER first and MANAGED_SESSION second —
AB/BA between a connect and the restore worker, which genuinely run concurrently
(`vd.create` runs off the registry lock; the worker is its own thread). The connect
path now drops its guard before the pair and re-acquires after `poll_managed_node`, the
shape `create_managed_session` already used. And `takeover_live` held all four statics
at once — in a `||` chain every `.lock()` temporary lives to the end of the statement —
for four reads; scoped bindings drop each guard before the next, taking it out of the
ordering graph entirely.

**`observe_session_instance` decided and acted under one lock.** It held LAST_INSTANCE
across `invalidate_backend` (which drops keep-alive displays through the registry lock)
and a `systemctl` shell-out on a 10 s budget, from three call sites including a
per-second watcher. It decides under the lock and acts outside now; the baseline is
advanced inside it, so a concurrent observer cannot run the action twice.

**`admit` held the live-session table across the budget checks** — `manager::snapshot()`
blocks on the manager `state` lock, which is itself held across DDC round trips and
three 3 s activation ladders. Every connect, disconnect and mgmt read queued behind one
slow display operation. The guard is scoped to `decide` alone.

**GameStream never registered**, so its display was invisible to both Windows budgets
(they gate on `!live.is_empty()`) and a native connect could be admitted past capacity
the box had already spent. It registers now, anonymously, for the session's lifetime.

**`ensure_exclusive_watch` panicked on a failed thread spawn** while holding BOTH
`exclusive_watch` and (via its caller) `state` — poisoning the two locks the whole
manager runs on, so every later acquire and mgmt read would panic on `.lock().unwrap()`.
It logs and degrades instead: no re-assert watchdog is a degradation, a wedged manager
is not.

Also fixes two things only a Windows build shows, both mine: Phase 2.3 left
`gamescope_route` unused there (every reader is Linux-gated), which `-D warnings` in
Windows CI would have rejected — I had only run the host clippy on Linux. And Phase
5.4's helper-safening reaches a FOURTH crate, pf-inject, which xcheck does not lint.

Verified on .173: `cargo clippy -p punktfunk-host -p pf-vdisplay --all-targets` clean
across the whole tree; local fmt, both xcheck targets and 53 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 093c66cb1a fix(vdisplay/windows): a failed resize left a phantom monitor, and a refresh-only one silently didn't happen
Three defects in the mid-stream resize path.

**A failed re-arrival put a DEAD monitor back in the slot.** `re_add` REMOVEs the old
driver monitor before it ADDs the new one, so its only fallible step past that point
means the old monitor is gone — yet the caller answered an `Err` by re-inserting the
`Monitor` struct it had handed in, complete with a dead `key`, a departed `target_id`
and a stale `gdi_name`. Every later `acquire` then joined a monitor that did not exist.

It now rolls back: on ADD failure it re-ADDs at the OLD mode, so the session keeps
streaming what it already had. The outcome is a three-way `ReAdd` enum because the
distinction matters and a `Result` cannot carry it — `Arrived` stores the new monitor,
`RolledBack` stores the RECOVERED one (a re-arrival mints a fresh `target_id`, so the
struct the caller handed in is stale either way), and `Lost` means both ADDs failed and
the slot must be left EMPTY so the next acquire creates a monitor rather than joining a
phantom.

**A refresh-only change reported success at the old refresh.** `wait_mode_advertised`
compared resolution only, so a same-WxH new-Hz request answered "already advertised":
the fast path skipped the `IOCTL_UPDATE_MODES` that would have taught the driver the new
rate, `set_active_mode` fell back to the best advertised rate <= requested, and the
resize returned Ok. It matches `dmDisplayFrequency` now, so an unadvertised refresh
routes to UPDATE_MODES / the re-arrival instead.

And `mon.mode = mode` recorded what was ASKED FOR. `set_active_mode` deliberately falls
back to a lower advertised refresh rather than lose the client's resolution, so the
field could claim a rate the display was not running — and it is what the next resize
diffs against and what `/display/state` reports. It records what actually committed now,
read back via the new `active_mode` (`active_resolution` delegates to it), and logs when
the two differ. Deliberately NOT done by tightening `wait_mode_settled`: that gates the
re-arrival fallback, so requiring an exact refresh there would force a full re-arrival
every time the OS legitimately picked a lower rate.

**A short IOCTL_ADD reply leaked an IddCx slot.** The IOCTL had SUCCEEDED — the driver
already created the monitor and took a slot; only the reply was short — and the bail
undid none of it. The slot pool is small enough that ~16 leaks wedge every later ADD at
0x80070490, which is the wedge the ghost-reap in this same function exists to recover
from. It now issues the compensating REMOVE with the session id already in scope, and
says so at error level if that fails too.

Windows runner: clippy --all-targets clean, pf-vdisplay 55 passed, pf-capture 18 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 50a153b72a refactor(win-display): the CCD helpers were unsafe fn for serialization, not soundness
`pf-win-display`'s 21 CCD/GDI helpers were `pub unsafe fn` without a memory-safety
obligation between them. Every one takes `Copy` scalars or borrowed Rust data, returns
owned values, and discharges its FFI preconditions internally —
`retry_set_display_config` even binds the input desktop itself, which is the one thing
a caller could plausibly get wrong. Their own `# Safety` sections say as much: one
reads "safe to call from any thread", and the rest say "call under the manager `state`
lock". That is a SERIALIZATION requirement — calling one unlocked races the topology
mutator and returns a stale answer, a correctness bug, not undefined behaviour.

Encoding it with `unsafe fn` cost 55 `unsafe` blocks across THREE crates whose proofs
could only restate "this takes a u32 and returns a String". That is how `unsafe` stops
meaning anything: the blocks that wrap a real FFI obligation read exactly like the ones
that wrap nothing. The requirement is now prose on the functions that have it, and
`unsafe` marks only the FFI calls inside the helpers.

win_display.rs 97 -> 63 `unsafe`, manager.rs 56 -> 30, pf_vdisplay.rs 34 -> 32, plus 10
sites in pf-capture's IDD-push paths. 55 blocks and 55 orphaned `// SAFETY:` proofs
gone; `#![allow(clippy::missing_safety_doc)]` with them, since nothing is `pub unsafe
fn` any more.

Two things the compiler handed back once the blocks went, both of which existed only to
carry a proof: the `let-else` parens in `force_mode_reenumeration` and
`apply_source_positions`, and the nested `if` in `wait_mode_settled` — which is `&&`
again, still short-circuiting, so the second CCD query on a 25 ms poll is unchanged.

Scope note: the sweep and the plan both scoped this as two crates. It is three —
pf-capture calls the same helpers from 10 sites, and `unused_unsafe` under `-D warnings`
makes that non-optional. Found by re-deriving the count, which the plan asked for
because the underlying finding had been REFUTED in verification; the refutation was
wrong about the magnitude (22 of manager.rs's 45 blocks, against its claimed 24).

Verified on the Windows runner: clippy --all-targets clean over pf-frame,
pf-win-display, pf-capture and pf-vdisplay; pf-vdisplay 55 passed, pf-capture 18 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 029979e824 fix(vdisplay/windows): own the device handle, prove the ladder once, and fix two proofs that were wrong
Three of Phase 5's four items; 5.4 is held for a decision (see below).

`open_device` is a SAFE fn returning an `OwnedHandle`. It never had a caller
obligation — it takes no arguments and every precondition is internal — so the
`unsafe fn` it used to be pushed a proof burden onto four call sites with nothing to
prove, and handed back a raw `HANDLE` that two of them re-owned by hand with
`CloseHandle`. That shape has already leaked once in this file (the wrap-IMMEDIATELY
comment in `open` records it). Ownership is now a `Drop`, `probe()` is
`open_device().map(|_| ())`, `is_available()` is `open_device().is_ok()`, and
`CloseHandle` is no longer imported here at all.

`resolve_target_gdi` ran the same 60 × 50 ms poll three times verbatim, and the 2nd and
3rd copies documented themselves as "SAFETY: as the resolve loop above" — a pointer to
a proof rather than a proof, which rots silently the moment the block it points at
moves. One `poll_gdi_name`, one proof, and the three-stage ladder now reads as the
three stages its doc describes.

Two proofs were simply wrong, both discharged everything except the one obligation
that mattered:

* The SetupAPI detail buffer was a `Vec<u8>` (align 1) written through as
  `SP_DEVICE_INTERFACE_DETAIL_DATA_W` (align 4). The SAFETY comment proved bounds and
  aliasing and was silent on alignment. Now a `Vec<u64>`. Its guard also compared the
  required size against `size_of::<u32>()` while stamping
  `size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>()` into `cbSize` — checking a different
  number than the one it promises the OS.
* `GetMonitorInfoW` was handed `&mut info.monitorInfo`: `cbSize` promises 104 bytes but
  that pointer's provenance covers the 40-byte `MONITORINFO` prefix, so everything the
  OS writes past byte 40 — which is `szDevice`, the only field the caller reads — is out
  of bounds for it. A compiler may assume those bytes were untouched and fold the zeroed
  initializer into the reads, i.e. return an EMPTY device name. That name is what
  `panel_off_except`'s exclusion compares against, so an empty one darkens the panel it
  is meant to spare. The pointer now carries the full struct's provenance.

pf_vdisplay.rs 42 -> 34 `unsafe`, manager.rs 58 -> 56. Windows runner: clippy
--all-targets clean, 48 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 f94191d927 fix(vdisplay/linux): stop destroying the user's portal config, and honour the /tmp promise the docs made
Both wlr-family backends need ONE key set in a config file the user also owns —
`chooser_cmd` in xdpw's `[screencast]`, `custom_picker_binary` in xdph's
`screencopy { … }`. Both did it by `fs::write`-ing a COMPLETE file over whatever was
there, so every other setting the user had in
`~/.config/xdg-desktop-portal-wlr/config` or `~/.config/hypr/xdph.conf` was destroyed
on first connect — silently, permanently, no backup.

They now set that one key in place and keep every other line byte-for-byte, with a
one-time `create_new` backup the first time we touch a file we did not write (once, so
a later edit cannot overwrite the user's ORIGINAL with our own earlier output).

The merge lives in its own module because a merge is only worth doing if it cannot
corrupt what it merges into: seven tests cover the three cases per grammar — block
absent, key present, key absent — plus idempotence (which is what keeps the caller
from restarting the portal on every connect) and the empty-file shape. Four of them
FAIL against the old whole-file write, so they are not vacuous. The module is declared
unconditionally though only Linux calls it: the logic is pure string handling, and
tests that guard a user's real config should run on every platform's CI, not only
where the callers compile.

Separately, seven call sites resolved the runtime dir as
`env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into())` while their own doc
comments promised the opposite — "per-user, 0700 — NOT a world-writable /tmp path
another local user could pre-create or rewrite between our write and xdph's read". One
of those paths is an executable xdph then RUNS. `Ok("")` was the same defect's other
half: it yielded a path relative to the process CWD rather than any runtime dir. All
seven now go through one resolver that applies the rule `session.rs` already had
(non-empty, else `/run/user/<uid>`, never /tmp). The picker shim also gets its mode at
CREATION rather than a chmod afterwards, so it is never briefly world-readable while
being a file something else executes.

And identity.rs was the only writer in the workspace creating
`pf_paths::config_dir()` — which holds the host private key, the pairing allow-list and
the mgmt token — with a plain `create_dir_all` instead of the 0700 `create_private_dir`
every other writer uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 2c015bba30 fix(vdisplay/windows): three teardown gates that each ended with the operator's panels dark
All in the same ~120 lines, all the same shape: a recovery leg gated on the wrong
flag, so the path that turned something off had no path that turned it back on.

**The DDC wake was nested inside the CCD-restore arm.** Panels are commanded dark over
DDC/CI *before* the isolate, but `panel_on_all()` sat inside
`if let Some(saved) = ccd_saved.take()` — and `isolate_displays_ccd` returns `None`
whenever its `query_active_config` fails. So a failed isolate meant the panels were
never woken, and because the link stays live there is no returning signal to trigger a
DPMS wake either: dark for the rest of the host's life. Hoisted out of that arm, next
to the `pnp_disabled` restore which was already outside it for exactly this reason.

**A later member's isolate could deactivate the physicals with nothing able to restore
them.** If the FIRST member's isolate failed, `ccd_saved` stayed `None` and
`ccd_exclusive` `false`; a second member's isolate then succeeded, blanked the
physicals, and discarded its snapshot as "the group restores the first member's" —
except there wasn't one. Teardown's restore is gated on `ccd_saved`, and the re-assert
watchdog never started. The group now adopts the first SUCCESSFUL isolate's snapshot.

**A non-last-member teardown ran the exclusive isolate on a Primary group.** The gate
was `ccd_saved.is_some()`, but `Topology::Primary` stores a snapshot too (from
`set_virtual_primary_ccd`) — so a shrink cleared `DISPLAYCONFIG_PATH_ACTIVE` on every
non-kept path, blanking precisely the physical displays `Primary` exists to keep lit.
It keys off `ccd_exclusive` now (what the re-assert watchdog already checks), and a
Primary shrink re-promotes a survivor instead, since the departing member may have
been the one holding primary.

Also: `apply_group_layout` had only ever run from `acquire`, so a member LEAVING never
re-arranged the survivors — they kept the origins computed while the departing monitor
was still between them, leaving a dead gap in the desktop until the next acquire. Now
called at the end of `teardown_removed`, which is where the slot map has already shrunk
and after the REMOVE, so it covers all six teardown paths at once.

The shrink gate is extracted as `shrink_action` so it can be pinned without a driver or
a desktop — manager.rs had no tests at all, which is how a gate keyed on the wrong flag
survived. Verified on the Windows runner: 48 passed (was 46), and both new tests FAIL
against the old gate, so they are not vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 8b076f5cc9 fix(vdisplay): the gamescope sub-mode is a per-session value, not a process-global env write
Completes what the env-lock fix started. `apply_input_env` published its decision into
PUNKTFUNK_GAMESCOPE_NODE/_SESSION and `GamescopeDisplay::create` read it back out —
with the lock released in between, and with the GameStream plane, the mid-stream
switch watcher and the capture-loss rebuild all re-running the writer. So session B's
routing decision could retarget session A's `create`: A asks for a bare spawn, B
connects, A attaches to B's session instead.

The decision now travels as a `GamescopeRoute` return value and is carried on the
backend INSTANCE via `set_gamescope_route` — the discipline `set_launch_command`
already documents two lines above it ("Carried on the backend instance — NOT a
process-global env var — so concurrent sessions can't stomp each other's launch
target"). Nothing writes the two keys any more; they are read once, as operator
overrides, and `create`/`poolable_now`/`launch_is_nested` all answer from the
session's own route.

The route carries its payload rather than just the verdict, so the managed session
flavour and the attach node id stop being separate env reads too.

Threaded through both planes: handshake -> SessionContext -> `vd`, and the GameStream
`open_source` -> `run`. Three re-resolution points needed the same treatment and each
would have silently downgraded a session to a bare spawn without it — the switch
watcher's rebuilt backend, the capture-loss rebuild (which can swap the whole
instance), and the operator-pinned path, which deliberately skips the input-backend
write but still needs a route. That last one is why the ladder is split out as
`resolve_gamescope_route`: a `PUNKTFUNK_COMPOSITOR` pin must not silently mean "bare
spawn" on a box pinned to the managed session.

`#[must_use]` on both entry points found the capture-loss site, which I had missed.

Verified on 192.168.1.21: whole workspace `cargo check -p punktfunk-host`, clippy
--all-targets clean over the host and pf-vdisplay, 100 pf-vdisplay tests, and the live
GNOME create/drop still green. Windows + Linux xcheck clean on the Mac.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:15 +02:00
enricobuehlerandClaude Opus 5 80e57c70be fix(vdisplay): the env lock covered its writers and not its readers, and the gamescope ladder read back its own output
Two halves of the same problem — process env used as shared mutable state.

`apply_session_env` set XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS / WAYLAND_DISPLAY /
HYPRLAND_INSTANCE_SIGNATURE / SWAYSOCK under ENV_LOCK, while detection read those same
five keys with no lock at all, from five points spread across a `/proc` scan. That is
exactly the hazard ENV_LOCK's own doc names: glibc `setenv` can realloc `environ` and
free the old value string, so a concurrent `getenv` reads freed memory — UB documented
there as "could crash the host". It is reachable today: the host's session watcher
calls `detect_active_session` every second for a stream's lifetime while a second
client's connect runs `apply_session_env` on a spawn_blocking thread.

Detection now samples all five into an `EnvProbe` in ONE locked read and scans from
that snapshot. Taking the lock around the whole of `detect_active_session` would have
been simpler and wrong: it walks `/proc` every second, and holding a process-wide lock
across a directory walk trades this problem for the one Phase 7 is about. The readers
become pure functions of their inputs, which is also what let the tests stop mutating
process env to steer them — `without_inherited_swaysock` is gone, replaced by handing
in a probe. Empty values are now treated as absent, so `XDG_RUNTIME_DIR=""` falls back
to /run/user/<uid> instead of yielding a relative path.

The gamescope sub-mode ladder had a nastier version: `apply_input_env` WRITES
PUNKTFUNK_GAMESCOPE_NODE/_SESSION to publish the sub-mode it chose, and read the same
keys back as operator overrides. The Attach arm sets `_NODE=auto`, and `node_env` sits
at rung 2 of the ladder — above `dedicated_launch` at rung 3 — so one Attach decision
latched Attach for the rest of the host's life and silently overrode
`game_session=dedicated`. Only rung 1 could escape it, because the Spawn arm that
would have cleared the keys sits below the rung that by then always fired. The
overrides are now sampled once, before this module has written anything, which keeps
their actual meaning: "the operator set this before we ran". The live reads that
remain (`launch_is_nested`, `poolable_now`) are deliberate — those consume the
published decision.

And gamescope's `create` read both keys unguarded, racing the writer and skipping the
lock its two siblings take for the same keys; it now takes one guarded snapshot.

Verified on Linux (.25): 100 passed, 1 ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:36 +02:00
enricobuehlerandClaude Opus 5 787756e68b test(vdisplay/mutter): the on-glass lever for the GNOME backend, and what it took to see the leak
`live_mutter_create_drop`, `#[ignore]`d in the same convention as the Windows
backend's live tests: the real four-step handshake, hold, then drop the keepalive and
let the RAII teardown revert it. Verified against gnome-shell on 192.168.1.21 —
node_id came back in ~5 ms, preferred_mode as asked, and the topology reverted.

Two things that would otherwise be re-derived by whoever validates this next, both of
which cost a wrong answer here first.

The virtual monitor does NOT appear in DisplayConfig's GetCurrentState just because
`create` succeeded — its size follows the PipeWire negotiation, so without a consumer
attached there is no monitor to census. A before/after monitor diff therefore proves
nothing about this backend, which is the trap the first attempt fell into.

And a short-lived process cannot exhibit the abandoned-thread leak at all: exiting
drops the D-Bus connection, which makes Mutter tear the session down no matter which
code is running. Reproducing it needs the process held alive past the failed create,
and the observable is the live RemoteDesktop session object, not the monitor:
`gdbus introspect -d org.gnome.Mutter.RemoteDesktop -o /org/gnome/Mutter/RemoteDesktop`
lists a `node Session` child per live session.

With a 1 ms create timeout forced and the process held 20 s, that probe reads 0 with
b89a36a6 and 1 without it — the orphan thread parked on a live session. Both read 0
after exit, which is precisely why this only ever bit a long-running host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 69bc92afed fix(vdisplay/mutter): a create timeout no longer abandons the session thread with the desktop dark
`create` gave up after 45 s and dropped its `stop` flag WITHOUT setting it — the
`StopGuard` that owns the only `store(true)` was built on the success path only. The
thread meanwhile discarded its own send failure with `let _ =`, so it sailed past the
dead channel, made the virtual output PRIMARY, and parked in `while !stop` for the
rest of the process's life holding the D-Bus connection that IS the monitor's
lifetime. Its teardown was unreachable.

Under the default topology that orphan applies a SOLE-monitor APPLY_TEMPORARY config.
Every physical head goes dark, and Mutter reverts such a config only once the virtual
monitor disappears — which the parked thread prevents. There is no in-process
recovery: each retry adds another orphan and another virtual monitor.

The other three portal/D-Bus backends never had this — kwin.rs, hyprland.rs and
wlroots.rs all `?` on that exact send. Mutter is now the same shape, via a shared
`report_node` that stops the session when the opener has gone. Two more layers behind
it, because the send can also LAND in the moment `recv_timeout` gives up: `create`
and `stream_existing_output` build their guards BEFORE the wait, so every error arm
signals the thread on its way out, and the thread re-checks the flag before it touches
topology — so a doomed session never applies a sole-monitor config at all, rather than
applying one and reverting it a tick later.

The fix lands once because the duplication that caused it is gone first: `connect` and
`connect_monitor` shared ~72 verbatim lines, and the mirror path repeating the session
path's `let _ = send` is precisely what that copy bought. Both now share `open_rd_sc`
(steps 1-2) and `start_and_await_node` (step 4).

Also stops a physical hotplug stealing the virtual monitor's identity.
`wait_virtual_connector` took "any connector absent from the pre-snapshot" across a
window spanning a ~10 s stream wait plus 6 s of polling; TOPOLOGY_LOCK keeps sibling
sessions out of it but a hotplug is not ours to serialise, and the session's topology
apply and scale persistence would then be aimed at the operator's new panel. It now
prefers the connector advertising the client's exact WxH — the discriminator
`make_virtual_primary` already matches on — falls back to the old behaviour when that
singles nothing out, and warns when more than one candidate appears.

Tests: `pick_virtual` split out of the polling loop so the choice is testable without
a session bus. Verified on Linux (192.168.1.25): 99 passed, and the hotplug case FAILS
against the old first-new-connector logic, so it is not vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 e7dc7e9d18 fix(win-display): every CCD/GDI operation now carries a proof, not just the blocks that needed one
Companion to c16f2850, which closed the same hole in pf-vdisplay. The file header
claims "Every `unsafe` block in this file carries a `// SAFETY:` proof", and that
was true — it just did not cover the 84 unsafe OPERATIONS sitting directly in
`unsafe fn` bodies, which under edition 2021 need no block and so carry no proof.
That is the whole CCD surface: every QueryDisplayConfig, every SetDisplayConfig,
every DISPLAYCONFIG union read.

Rather than 84 restatements of the same argument, the shared contract is stated ONCE
at the top — how the buffer-size/query pair keeps pointer and count in agreement, and
why the two obligations the compiler cannot see (arrays only ever come from the OS or
from a SavedConfig this module built; SetDisplayConfig serialised under the manager
`state` lock and bound to the input desktop) hold at every site. Per-site comments
then name the site-specific fact instead of repeating the invariant.

The union reads justify differently and the header says so: `modeInfoIdx` and
`ADVANCED_COLOR_INFO.value` overlay same-sized POD, so no discriminant can be got
wrong, while `MODE_INFO.Anonymous.sourceMode` IS discriminated by `infoType` — and
every read of it is guarded by that check. Writing that down found the one place the
guard does NOT gate the read: the `then_some` in set_virtual_primary_ccd evaluates
eagerly, so POD-ness is what carries it there, not the check. The comment says so
rather than implying a guard that is not doing the work.

Two shape changes, both deliberate. `wait_mode_settled`'s two calls are nested rather
than `&&`-joined so the second CCD query stays short-circuited — it polls every 25 ms
and must not run while the target has no active path. And the eager union read in
`set_virtual_primary_ccd` is hoisted to a `let` so its proof has somewhere to attach;
`then_some` already evaluated it eagerly, so nothing changes.

Union field ASSIGNMENTS are safe in Rust — only reads are — so two of them keep no
block. Verified on the Windows CI runner: clippy -D warnings clean across pf-frame,
pf-win-display, pf-capture and pf-vdisplay, and both crates' tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 6782aa0054 fix(vdisplay): stop the suite reporting ok for tests that never ran, and let clippy see the KWin backends
Three coverage holes, all of the same shape — a gate that looks green because
nothing is behind it.

The Windows half (~3,400 lines) had no executed tests. Its only two `#[test]`s
opened with `if env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() { return; }`, so
without the hardware they passed without running, and no CI job compiled the
crate's test targets at all — `-p punktfunk-host` builds pf-vdisplay as a
dependency, which never reaches a `#[cfg(test)]` module. Both live tests are
`#[ignore]`d now (an unrun hardware test should say `ignored`), and windows-host.yml
gains the `--all-targets` lint plus a real test step. The link question that keeps
the host and pf-encode on clippy-only does not apply: pf-encode is `default = []`
and nothing here turns on nvenc/amf-qsv/qsv. Settled on the runner to the same
standard as pf-capture's step — 46 passed, 2 ignored.

`#![allow(clippy::all, ...)]` at the top of kwin.rs and kwin_output_mgmt.rs was
meant for wayland-scanner output, but the generated modules already carry their own
copy of that attribute — so the file-level one was only ever silencing 2,366 lines
of hand-written backend, with `clippy::correctness` (deny-by-default) among the
casualties. Dropping it found a dead match arm in the `kde_output_configuration_v2`
dispatch; that one is now exhaustive on purpose, so a re-vendored XML that adds an
event fails the build instead of dropping it silently. (The sibling `_ => {}` in the
DeviceMode dispatch is NOT dead — `ModeEvent::Removed` really does reach it and get
swallowed, which is a separate defect.)

`dead_code` is now enforced on Linux, where ~10k of the ~17k lines live, instead of
allowed crate-wide behind a rationale that had stopped being true. It turned up no
genuinely dead code: four items, every one live from tests or another platform, now
annotated with which. One deserved the comment most — `Entry::keepalive` is "never
read" because it is an RAII guard whose `Drop` releases the compositor output, so
deleting it as unused would release every pooled display the moment it was created.

Two tests were not testing what they claimed. `another_uids_socket_is_ignored`
asserted the sway-socket ownership guard but never reached it — `find_sway_socket`
filters on the `sway-ipc.<uid>.` filename prefix first, so the foreign-uid file was
rejected by name and the assertion held even with `md.uid() != uid` deleted. It is
renamed to what it does cover, and the real leg is a new `#[ignore]`d root test that
chowns a correctly-named socket (querying with a non-matching pid, or the exact-path
shortcut would return before the guard and make it vacuous all over again). And
proc.rs's tests were ungated while the module is compiled everywhere, so they would
have gone red on Windows the first time anyone ran them: `#[cfg(all(test, unix))]`
now, with a `cmd /c` twin that does run there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 995cac5d03 fix(vdisplay): the three hardest FFI calls were exempt from the crate's own unsafe-proof rule
`#![deny(clippy::undocumented_unsafe_blocks)]` cannot see an unsafe operation that
sits directly in an `unsafe fn` body — in edition 2021 `unsafe_op_in_unsafe_fn` is
allow-by-default, so a bare call inside such a body needs no block and therefore
carries no proof. The file headers claim "every unsafe block carries a SAFETY
proof", and that was true; it just did not cover the calls that needed it most.

Turning the lint on named exactly three: `DeviceIoControl` and the `ioctl` wrapper
in pf_vdisplay.rs — the whole host<->driver control channel — and
`restore_displays_ccd` in manager.rs, the call the teardown path depends on to give
the operator their physical panels back. Each now carries a real proof, and `ioctl`
and `set_render_adapter` grew the `# Safety` heading their callers were owed.

Also teaches scripts/wincheck.sh to cover pf-vdisplay, which is what let the above
be compile-verified rather than reasoned: the crate's Windows half is ~3,400 lines
that Linux CI never compiles. That needed a stub pf-encode (the real one drags
ffmpeg-sys, and the admission gate calls exactly one predicate from it) and
CompositorPref on the stub core. Verified non-vacuous with a planted type error.

pf-win-display's half of the same lint is a separate change — it flags 62 further
operations, and they want proofs written one at a time, not in bulk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:11 +02:00
enricobuehlerandClaude Opus 5 9b38b6a22d feat(stats): a capture records which encoder and GPU produced it
The stage split is the thing an fps-shortfall report is diagnosed from, and
it cannot be read without knowing the backend behind it: a p50 `submit` of
10 ms means "the GPU's CSC+encode throughput is the ceiling" on one backend
and something else entirely on another. The capture's meta carried the mode,
codec and client but neither the encoder nor the GPU, so every report so far
cost a round-trip asking which one it was.

Both come from `pf_gpu::active()` — the record the encoder open itself
writes — so they name the branch that really opened rather than a re-derived
guess that goes stale the moment a backend gains an internal fallback. Read
once per capture, not per frame.

The console shows them next to the mode in the recording's header. Both
fields are `serde(default)`, so a recording made before this still loads and
simply omits them.

Refs #9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:14:10 +02:00
enricobuehlerandClaude Opus 5 eb4ab91627 feat(host): a frame limiter for the game, not for the stream
PUNKTFUNK_MAX_FPS caps how fast the compositor lets the game render. It
deliberately does NOT touch the session: the client still negotiates and
receives its full rate, because the encode loop re-encodes the held frame
whenever the compositor produced no new one, and a repeat of an unchanged
picture is an almost-empty P-frame. So a 60-capped game on a 120 Hz session
still puts 120 frames a second on the wire — and the GPU time the game gives
up goes to capture and encode instead, which is the point (on a laptop or a
handheld, it goes to heat and battery too).

Capping the stream would be a different and mostly unwanted feature: it
hands the client fewer frames than it asked for and saves the game's GPU
nothing.

gamescope is the compositor that has the lever, so it is the one that gets
it: the rate becomes --nested-refresh, which is what gamescope clamps the
game to. All three sessions we own take it — the bare spawn's -r, the
managed gamescope-session-plus wrapper's PF_HZ, and the SteamOS PATH shim's.
In the managed path the limit lands on PF_HZ alone and NOT on
CUSTOM_REFRESH_RATES, so the mode the session advertises stays the client's
— that is what makes games see the real refresh rather than the box's EDID.

Two scoping notes. Under gamescope the cap is the nested output's rate, so
everything it composites moves at it, not the game alone; there is only the
one output. And the attach path (PUNKTFUNK_GAMESCOPE_NODE) mirrors a
gamescope we do not own, so it has no lever to pull and is untouched.

Closes #13

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:14:10 +02:00
enricobuehlerandClaude Opus 5 86b605f01f feat(host): run the virtual display faster than the stream needs
PUNKTFUNK_VDISPLAY_HZ_MULT runs the virtual display at a multiple of the
session's rate without putting one extra frame on the wire. A compositor
paints on its own vblank, so a frame finished just after the capture sampled
waits nearly a full interval to be picked up — the jittery part of the
latency budget, not the steady part; at 2 that worst case halves. It costs
the compositor and GPU the extra composites, so it stays opt-in at 1.

The pacing rate was previously just "whatever the backend achieved". It is
now the session's rate floored by the achieved one — the same value as
before whenever the knob is unset, and the only correct answer when it is
set: never above what the client negotiated, never above what the display
actually produces. Both build sites get it, the initial build and the
in-place resize, so a mid-stream resize can't silently drop back to 1x.

A backend that won't give the multiplied rate now says so at info rather
than warn, since that is the expected outcome of an opt-in knob; falling
short of the SESSION's own rate still warns, because that one costs the
client frames.

Closes #14

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:13:19 +02:00
enricobuehlerandClaude Opus 5 a4aa89be31 fix(decky): drive a native client install, not only the flatpak
Every headless call went through `flatpak run io.unom.Punktfunk`, and the
launch wrapper exec'd the same. On a Deck whose client came from a sysext, a
.deb/rpm, an AUR build or a nix profile there is no such app: discovery
worked (it is mDNS), and everything that needed the client — pairing, the
library fetch, the host store, the stream launch itself — failed.

Resolve the client once. The flatpak still wins when it is actually
installed, so an existing Deck is byte-for-byte unchanged, and a native
`punktfunk-client` is the fallback; `PF_DECKY_CLIENT` forces one on a machine
with both. Both kinds share ~/.config/punktfunk — the flatpak's sandbox HOME
is the real home — so identity, known-hosts and settings need no divergence.

The wrapper takes the resolved binary as PF_CLIENT_BIN and execs it in place
of `flatpak run`; kill_stream sends a name-matched SIGTERM where there is no
flatpak instance to kill; and the client-update check, which reads the
flatpak's tracked remote, simply reports nothing for a native install, whose
updates belong to whatever installed it.

Installed-ness is now decided by the app's exported directory rather than by
the presence of the `flatpak` binary, so a machine that has flatpak but not
this app no longer resolves to a client that cannot run.

Closes #11

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:13:19 +02:00
enricobuehlerandClaude Opus 5 f98547c41f fix(web-console): the paired-clients card counts both pairing planes
GameStream and native (punktfunk/1) clients pair into separate stores, and
`/status` only ever reported the GameStream certificate count. Native is the
DEFAULT plane, so a host whose clients had all paired normally — and which
Moonlight had never touched — showed "0 paired" on the dashboard.

Report the native count alongside it, the way the tray's own summary route
already does, and sum the two in the card.

Closes #10

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:13:19 +02:00
enricobuehlerandClaude Opus 5 198b1d5e80 fix(apple): iPad mouse buttons, held-key repeat, and the scroll direction
Three separate things an iPad's keyboard and mouse got wrong.

Every mouse button past the first two clicked LEFT on the host. UIKit's
button mask is 1-based over the HID order, and only `.secondary` was read —
middle, back and forward all fell into the `else` and became button 1. Map
3/4/5 through `.button(_:)`; only middle and right swap places, because the
wire numbers them the other way round.

Holding a key deleted exactly one character. GameController reports a key
once on press and once on release — it has no repeat channel — and the host
injects exactly what it is told, so nothing downstream ever turned a held key
into a stream of presses. Generate the repeat client-side at the usual
delay-then-rate, newest key only, with modifiers and lock keys excluded.
macOS was never affected: NSEvent hands it the window server's own repeats.

Scrolling ignored the system's Natural Scrolling switch. UIKit has already
applied that preference by the time it hands over a pan translation — it is
what makes every UIScrollView on the device turn the right way — so negating
y pinned the stream to traditional scrolling and inverted the setting for
everyone on the default. Pass the sign through, exactly as the macOS path
passes `NSEvent.scrollingDeltaY` through, and let that one recognizer own
scroll under pointer lock too: it is the only way trackpad two-finger
scrolling ever arrives, so gating it on the lock had been dropping it
entirely there, and the raw GameController axis it deferred to carries no
such preference. iOS installs no GCMouse scroll handler now, so nothing
double-sends; tvOS, which has no scroll pan, keeps it.

Closes #7, closes #15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:13:19 +02:00
enricobuehlerandClaude Opus 5 580ea0d338 fix(android): a mouse's side buttons work even when they arrive as keys
Not every mouse reports back/forward the same way. One that carries them on
the HID button page (BTN_SIDE/BTN_EXTRA) gets BUTTON_BACK/BUTTON_FORWARD in
the motion button state, which is the only shape the forwarder listened for.
A mouse that carries them on the consumer page (AC Back / AC Forward — the
common Bluetooth shape, and what Android TV boxes tend to see) produces only
synthesized KEYCODE_BACK/KEYCODE_FORWARD, and those were swallowed outright
to stop them doubling as Android navigation. On that hardware both side
buttons were simply dead.

Route the key shape into the same wire buttons, deciding by the DEVICE (it
has to be able to be a mouse) rather than by the event's source, since the
consumer-page collection is a separate sub-device that may be stamped
SOURCE_KEYBOARD. A D-pad-capable device is excluded so an air-mouse remote's
BACK stays the couch user's way out of the stream.

Both paths now funnel through one press/release helper whose held-set guard
collapses a device that reports BOTH into a single wire press.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:13:19 +02:00
enricobuehlerandClaude Opus 5 2f39e15e88 fix(ci): the shader drift gate never ran — dash has no process substitution
`diff <(spirv-dis …) <(spirv-dis …)` is bash syntax, and Gitea's runner executes
a step's `run:` under `sh -e`. dash rejected the script at PARSE time, so the
gate compared nothing — and, being step 2 of the `rust` job, it took Format,
Clippy, Build, Test, the feature-gated encode backends, the C ABI harness and
the committed-header check down with it. Every one of those has been skipped on
the 35 commits between the gate landing (143a707f) and this one.

A gate that cannot run looks exactly like a gate that passes, which is the
precise failure mode it was written to prevent.

Disassemble to files instead. That is dash-clean without depending on whether
act_runner honours a `shell: bash` override, which is the other way to fix it
and the more fragile one.

Verified in CI's own container (ubuntu:resolute, glslang 16.2.0, spirv-tools
2026.1) by running the step body extracted from this YAML under `sh -e`: it
passes on the current tree, and — the half worth checking — it still FAILS on a
deliberately corrupted blob, so it is not passing vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:28:29 +02:00
enricobuehlerandClaude Opus 5 4065b5bd03 docs(gamescope): the missing cursor was never the whole story
"The mouse cursor isn't included in the captured image" sat in Known Limits
contradicting the section above it, which already explained that the host draws
the pointer back in. Both are half-true and the difference matters to a reader
choosing whether to install anything: gamescope does leave the pointer out, you
do still see one, and what it costs is a full pass over every frame — plus, on
the fastest encode paths, the pointer itself, because a fixed-function front
end has nowhere to blend it.

Which is the real argument for `punktfunk-gamescope` on a box that will never
turn HDR on, and the page never made it.

Release notes gain the spawn-flag verification and the packaging wiring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:03:30 +02:00
enricobuehlerandClaude Opus 5 c801465469 fix(packaging): every channel that ships punktfunk-gamescope now builds it
The docs already told users where to get it — the Bazzite sysext, an Arch
package, a NixOS option — and none of the three were true. `rpm.yml` built the
sysext without ever passing `--gamescope`, `arch.yml` did not know the PKGBUILD
existed, and the nix derivation had never been evaluated once.

Evaluating it found its central assumption wrong: `gamescope.unwrapped` does
not exist on current nixpkgs, where `gamescope` IS the buildable derivation, so
the override threw on every build. It now prefers `.unwrapped` where a future
nixpkgs wraps it and checks the RESULT is patchable — `overrideAttrs` on a
symlinkJoin succeeds and does nothing, which would install an UNPATCHED
gamescope under a name the host reads as a promise of HDR.

Both it and the PKGBUILD had also drifted to a stale patch list: two patches
named where three exist, one of them under the level-1 filename retired when
the cursor patch landed. Both read the patch directory now, so the list cannot
go stale again. The PKGBUILD additionally delegates the whole build to
`build-punktfunk-gamescope.sh` rather than re-deriving the meson invocation —
its copy had already lost `force_fallback_for=wlroots`, and unlike Fedora 43,
Arch ships wlroots, so that package would have linked it shared and shipped a
binary that starts only on machines carrying the dev library. It asserts its
own pinned rev matches the script's, the one thing makepkg needs statically.

Both CI builds are cached on `packaging/gamescope/**`, which is the only reason
this is affordable: that tree depends on nothing else in the repo, so a normal
push restores a binary instead of spending ten minutes on someone else's C++.
And both are best-effort. punktfunk works without this binary — SDR on the
gamescope backend, which is what every release before this one did — so a
hiccup building gamescope must not cost the packages those workflows exist to
publish. A failure warns and is never cached, so the next run retries.

`rpm.yml` also installs Fedora's own gamescope for its runtime libraries: the
sysext verifies our binary by executing `--version`, and on a cache hit nothing
else in the job would have pulled libavif/luajit/seatd in.

Verified by evaluating and patch-phase-building the nix derivation against
nixos-unstable: all three patches apply to nixpkgs' already-patched 3.16.25
tree, and the banner stamps +pfhdr2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:03:30 +02:00
enricobuehlerandClaude Opus 5 0c4e582b29 fix(gamescope): a managed session now proves our flags reached it
The bare spawn builds gamescope's argv itself. The two managed modes hand the
flags over indirectly — `gamescope-session-plus` through `GAMESCOPE_BIN` +
`PF_HDR_ARGS`, SteamOS through a PATH shim — and a session free to ignore
either would exec the distro's gamescope carrying none of them.

Half of that failure was already loud: HDR dies on the bit depth the Welcome
fixed before the display existed. The other half was silent. The host had been
told the compositor would paint the pointer, so it stopped painting one, and
nobody did — a stream that is correct in every respect except that it has no
cursor, which nothing in the logs would have said.

So both managed paths now read the running compositor's `/proc/<pid>/cmdline`
once its node appears, and refuse the session when a flag we passed is missing.
The plan is fixed by then (`cursor_blend` feeds the encoder open, which
precedes the display), so this session cannot be corrected in place; instead
the capability is latched off for the process and the spawn fails, and the
retry resolves a correct SDR host-composited session. One rejected attempt per
boot, then it converges.

Fails OPEN at every ambiguity: no flags expected, or no readable gamescope in
`/proc`, says nothing. And it accepts ANY running gamescope carrying the flags,
because a box commonly has a second one — the Nobara test box runs its own
game-mode `/usr/bin/gamescope --steam` beside ours. That leaves only false
PASSES possible, and the flag that matters cannot produce one:
`--pipewire-composite-cursor` exists nowhere but our patch set.

Two things fell out of writing it. `gamescope_argvs` matches argv[0]'s basename
with `ends_with`, not `==` — the old equality test would have missed our own
`punktfunk-gamescope`, which is what `current_gamescope_output_size` was
already scanning for. And `hdr-probe` gained a line for who paints the cursor,
the one capability with no symptom of its own until you compare two streams.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:03:30 +02:00
enricobuehlerandClaude Opus 5 ee716d0137 fix(encode): pf-encode did not build on Linux without vulkan-encode
`vulkan_encode_available_at` and `vulkan_encode_caps` were gated on bare
`target_os = "linux"`, but the second returns `vulkan_video::VulkanEncodeCaps`
and both reference that module — which only exists under the feature. Every
CALL SITE was already correctly gated, so nothing pointed at them; the two
definitions alone were enough to fail the build.

That is CI's default-feature line (`cargo clippy --workspace --all-targets`),
so this was going to be caught — it was caught on a Fedora 44 box first.

`cursor_blend_capable`'s `ten_bit` goes unused in the featureless arm for the
same reason, which `-D warnings` also rejects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:03:30 +02:00
enricobuehler 0d2cc65f17 fix(gamescope): force the VENDORED wlroots — the binary's portability was luck
Built the patches on a second distro (Nobara 44 / Fedora 44, for the NVIDIA
leg) and the resulting binary would not start on its own host:

    libwlroots-0.19.so: cannot open shared object file

gamescope vendors wlroots as a submodule, but meson prefers a SYSTEM one when
the build host has `wlroots-devel` — and links it shared. Fedora 43 has no such
package, so the `.116` build fell back to the submodule and linked it
statically; Fedora 44's `dnf builddep gamescope` pulls one in, so the same
script produced a binary bound to a library that exists only inside the build
container.

Confirmed rather than assumed: `ldd` on the f43 binary lists no wlroots at all
and its log says `Subproject wlroots finished`, while the f44 log says
`Dependency wlroots-0.19 found` from the system.

The consequence, had Fedora 43 ever shipped `wlroots-devel`: the sysext payload
would have carried a gamescope that cannot start, and it would have surfaced to
users as "HDR just doesn't work" rather than as a build failure. `-Dforce_
fallback_for=libliftoff,vkroots,wlroots` makes every distro produce the same
self-contained binary. All three entries go together — gamescope's own
meson.build hard-errors if the first two are dropped from that list.

Verified after the fix: `ldd` shows no wlroots/liftoff/vkroots and no missing
deps, the binary starts on the Nobara host, and its node offers the same four
formats with the colorimetry props — same result as Bazzite f43.
2026-07-28 18:03:30 +02:00
enricobuehler 3d3ecf1e82 test(encode/nvenc): the NVIDIA HDR leg, verified on an RTX 5070 Ti
The NVIDIA half of "zero-copy, no host CSC, 10-bit HDR" had never met a GPU.
Two `#[ignore]`d smokes now drive it, run on `.41` (Bazzite f43, RTX 5070 Ti,
driver 595.58.03):

* `nvenc_cuda_hdr10_packed_rgb` — a packed 2:10:10:10 CUDA payload straight
  into NVENC as `ARGB10`, HEVC **and** AV1. Asserts what would catch a
  mislabelled stream: the encoder DERIVED 10-bit and HDR from the input format
  rather than being told, and picked `ARGB10` for `X2Rgb10`. That derivation is
  what selects Main10 / AV1-at-10 and the BT.2020 PQ signalling.
* `nvenc_cuda_hdr10_cursor_blend` — `cursor_blend.comp` MODE 3/4, the 10-bit
  channel-unpacking twin. Asserts the blend targets `SlotFormat::X2Rgb10` and
  not the 8-bit layout, which would tint the pointer and shift its channels.

Both green, and the dumped bitstreams decode 0-error reporting `Main 10` /
AV1 `Main`, `yuv420p10le`, `bt2020nc` / `smpte2084` / `bt2020`.

There is no host colour conversion anywhere on this path: the frame arrives as
a LINEAR dmabuf, crosses to CUDA through the Vulkan bridge, and NVENC's ASIC
does the BT.2020 conversion following the VUI the session configured. The
"no CSC" property AMD gets from the EFC, NVIDIA gets from the encoder itself.

The whole pre-existing `nvenc_` suite was re-run alongside them — 16/16, no
regression from the depth-derivation and buffer-format changes underneath it.

Capture half checked too: the patched gamescope binary built on the AMD box
runs unmodified on the NVIDIA one (same Fedora 43 base) and its node offers the
same four formats with the colorimetry props — so headless gamescope + 10-bit
PQ is not AMD-specific.
2026-07-28 18:03:30 +02:00
enricobuehler 576bf7e294 test(encode/vulkan): 10-bit smokes — and they pass on a real AMD GPU
The Vulkan Video 10-bit path was the least-exercised code on this branch:
nothing had ever created a Main10 video session, so the profile query, the
`G10X6…3PACK16` picture allocation, the scratch→plane copy, the hand-packed
AV1 sequence header and the colour signalling were all first-run-on-glass.
Three `#[ignore]`d smokes now drive them, and they were run.

`.116` (Bazzite f43, AMD 780M, RADV / Mesa 26.0.4), all three green:

* `vulkan_smoke_10bit` — HEVC Main10 through the compute CSC;
* `vulkan_smoke_10bit_av1` — AV1 at 10 bits;
* `vulkan_smoke_rgb_10bit` — HDR with NO host CSC, the EFC converting BT.2020
  off the packed 10-bit source. It did **not** soft-skip, which answers the one
  capability in this whole feature I had only ever read in a registry: RADV's
  VCN EFC really does advertise `MODEL_YCBCR_2020` and accept a 10-bit
  packed-RGB encode source.

Every stream decodes 0-error and reports `yuv420p10le` + `bt2020nc` /
`smpte2084` / `bt2020`. The AV1 one parsing at all is the load-bearing result
there: `high_bitdepth` precedes the CICP bytes in `color_config()`, so a wrong
bit would have thrown every later field out of phase rather than merely
mislabelling the depth.

Round-trip on solid frames, fed (160,160,800) as 10-bit codes:

    compute CSC  -> (159, 158, 796)
    EFC          -> (159, 158, 796)
    AV1          -> (158, 159, 794)

Under 0.5%, all of it limited-range quantisation and lossy encode. The compute
and EFC results being IDENTICAL is the strongest check available: two
independent BT.2020 NCL implementations — my shader and AMD's fixed-function
block — agreeing to within rounding.

The smokes deliberately assert structure (submits encode, AU count, the depth
the encoder settled on), not colour: a shader writing the 10 bits into the
wrong end of the word still produces a decodable stream. Colour is the dump +
ffmpeg round-trip above, which is what actually caught nothing this time.

Also corrects a comment: I claimed a wrong store factor was a "~1.6% luminance
error". It is not. The `<< 6` PLACEMENT is the load-bearing part (dropping it
is 64x too dark); `1/1023` vs `64/65535` is ~0.1% and harmless.
2026-07-28 18:03:30 +02:00
enricobuehler 9eb9f74893 fix(gamescope): the patch did not compile on Fedora/Bazzite — three real defects
Built it for the first time, on the AMD Bazzite box (`.116`, RADV 780M,
Fedora 43 toolbox). The patches applied cleanly to the pinned rev and the
configure got all the way through wlroots — then found three things no amount
of reading would have:

**1. `SPA_VIDEO_TRANSFER_SMPTE2084` does not exist on Fedora 43.** PipeWire
1.4.11's transfer-function enum stops at `ADOBERGB`: SPA grew
BT2020_10 / SMPTE2084 / ARIB_STD_B67 as one later block. So the patch simply
did not compile on the distro it is primarily FOR. This is the same trap
punktfunk's own consumer documents and works around — `pf-capture`'s
`pw_pods.rs` spells the value out as `14` for exactly this reason — and the
fix is the same: spell out both colorimetry values, because the enum is wire
ABI mirroring GStreamer's, not a private detail. `SPA_VIDEO_COLOR_PRIMARIES_BT2020`
is present here but gets the same treatment, since its own header comment says
`\since 1.6` and there is no reason to depend on that.

The `PW_CHECK_VERSION(1, 0, 0)` guard was also just wrong: it tests the library
version, which says nothing about which enum members a header names. It now
guards only the FORMAT constants, which are what it was actually right about.

**2. The build pulled in three subprojects we never ship.** gamescope's own
unit tests want Catch2 **v3** (`catch2-with-main`) and Fedora ships v2, so the
configure died on a test suite that is not ours to build. Also disabled: the
OpenVR integration (a code path a headless capture session never enters) and
the WSI layer — which would have been WORSE than wasted work, since the distro
gamescope package installs that same layer and ours would have collided with
it file-for-file.

**3. `dnf builddep gamescope` is not sufficient.** It resolves Fedora's
*packaged* gamescope, which is older than the master we pin, and misses
`xorg-x11-server-Xwayland-devel` — without which wlroots fails several minutes
in with a `xserver.wrap` error that names nothing useful. Documented with the
symptom, so the next person recognises it in one line instead of ten minutes.
2026-07-28 18:03:30 +02:00
enricobuehler fd648aa776 feat(gamescope): put the cursor in the node, so a session can be zero-copy
gamescope keeps the pointer out of its PipeWire node — it lives on a hardware
plane for scanout, and `paint_pipewire()` composites a separate, reduced frame
that never includes it. So punktfunk has always reconstructed it from XFixes
and blended it in host-side.

That blend is what has been blocking the zero-CSC encode path, and the cost is
larger than it sounds: `VulkanVideoEncoder::open` refuses the RGB-direct (EFC)
source for any session with `cursor_blend`, because that front end is
fixed-function and has no blend stage. `cursor_blend_for` sets it
unconditionally for gamescope. Net effect: a gamescope session paid a
full-frame colour-conversion pass per frame, forever, for a pointer.

So put the cursor where it belongs. A second carried gamescope patch adds
`--pipewire-composite-cursor` (off by default — the node has never carried it,
and a consumer that draws its own would get two), painting it with the same
`MouseCursor::paint` call the scanout composite uses. It scales for free:
paint_pipewire has already set `currentOutputWidth/Height` to the capture size,
which is what that function scales against. The repaint test grows the cursor's
state beside the commit ids — a pointer-only move produces no commit, so
without it the composited cursor would freeze on a static screen while the real
one moved, and a cursor that became hidden would never be erased.

Host side, the `+pfhdr` marker becomes a monotonic PATCH LEVEL, so one probe
answers every capability the session must know before it is planned (level 1 =
HDR formats, level 2 = the cursor flag). `cursor_blend_for` and the
`gamescope_cursor` resolver both consult it through one helper, because they
have to agree: the reader without the blend is a wasted X11 connection, the
blend without the reader is a stream with no pointer, and both together with a
gamescope that paints its own would draw two.

⚠ The two indirect spawn modes carry the flag through `PF_HDR_ARGS`, so this
shares a dependency with the HDR flags: a session that ignores
`GAMESCOPE_BIN`/`PATH` and execs the distro's gamescope gets neither. HDR fails
loudly there (negotiation timeout + SDR latch); a missing cursor would be
silent. Noted in packaging/gamescope/README.md as worth a post-spawn
`/proc/<pid>/cmdline` check if it ever bites.
2026-07-28 18:03:30 +02:00
enricobuehler 2a13bc5252 fix(gamestream): advertise AV1 Main10, and honour HDR per negotiated codec
`ServerCodecModeSupport` layered `SCM_HEVC_MAIN10` and never `SCM_AV1_MAIN10`,
on the stated theory that "the GameStream AV1 path is left off until
live-confirmed". But the SDR baseline has always offered AV1 **Main8** to every
client, so that path is either live or it is not — the DEPTH was never the
uncertain part, and the omission only cost AV1-preferring clients their HDR.

Now that the encoders probe 10-bit per codec, each bit is gated on that codec's
own `can_encode_10bit` AND the SDR baseline already advertising it. A box that
does HEVC Main10 but not 10-bit AV1 — or the reverse — advertises the truth
instead of one bit standing in for both.

Two gates follow from that:

* `host_hdr_capable` becomes codec-agnostic (ANY 10-bit-capable codec makes the
  host HDR-capable). It was asking about HEVC alone, which would have hidden
  HDR entirely on a hypothetical AV1-only-10-bit box;
* the RTSP honor gains the per-session half: a client that negotiated the codec
  this host CANNOT do 10-bit with degrades to 8-bit SDR there, rather than
  being handed a PQ label over an 8-bit stream. H.264 always lands there —
  there is no 10-bit H.264 encode anywhere.

The unit test now pins each bit independently, including the two one-without-
the-other cases a single shared flag got wrong in both directions.
2026-07-28 18:03:30 +02:00
enricobuehler cb4690f216 feat(encode/vulkan): probe the device instead of guessing — AV1 10-bit + zero-CSC HDR
Three fixes to the same mistake: deciding what the Vulkan Video backend can do
from a table in our heads rather than from the driver, and routing everything
that didn't fit to libav VAAPI — where a session loses real RFI recovery and
the cursor blend for no reason the hardware asked for.

**Capability probe, per codec AND depth.** `probe_encode_support`'s "is there
an encode queue" boolean becomes `VulkanEncodeCaps { supported, eight_bit,
ten_bit }`, answered by `vkGetPhysicalDeviceVideoCapabilitiesKHR` against the
very profile chain the session open builds. So the dispatcher's prediction
cannot disagree with reality: a capable device keeps the Vulkan path, an
incapable one routes to VAAPI BEFORE burning a failed open, and the
cursor-blend mirror stays honest for free. This is the shape the direct-SDK
NVENC path already uses for its codec GUIDs.

**AV1 10-bit.** It was excluded on a guess about driver coverage; now the
device answers. `color_config()` carries `high_bitdepth` + the BT.2020/PQ CICP
triplet in both the `StdVideoAV1ColorConfig` and the sequence-header OBU we
bit-pack ourselves — they must stay identical or the driver's frame OBUs parse
against a header we didn't write. `high_bitdepth` sits BEFORE the CICP bytes,
so getting it wrong doesn't just mislabel the depth, it puts every following
field one bit out of phase; the new test reads the packed bits back.

**Zero-CSC RGB-direct in HDR.** The EFC probe assumed BT.709 and BGRA. It now
asks for the model this session's colourimetry needs (`MODEL_YCBCR_2020` for
10-bit — the extension has always had it) and for the CAPTURED format as an
encode-source format, and the session create-info selects the matching model.
An HDR session with no pointer to composite therefore hands the captured
buffer straight to the fixed-function front end and runs no host CSC at all.
Sessions that DO composite a pointer keep the compute CSC, unchanged: the EFC
cannot blend, and that rule outranks everything.

Also: `can_encode_10bit` on AMD/Intel now reports the union of VAAPI's and
Vulkan Video's answers instead of VAAPI's alone. `open_amd_intel` tries Vulkan
first and falls back, so either one being able to encode Main10 makes the
session 10-bit-capable — answering `false` because only one of them said yes
stranded encodable HDR sessions at 8 bits.
2026-07-28 18:03:30 +02:00
enricobuehler 479f0965ee feat(encode/vulkan): Vulkan Video encodes 10-bit, so AMD/Intel HDR keeps the good path
The Vulkan Video backend was 8-bit for no structural reason — the API has
`VK_VIDEO_COMPONENT_BIT_DEPTH_10_BIT` and `PROFILE_IDC_MAIN_10` in the very
fields this pinned to 8 and MAIN, and AMD VCN and Intel both encode Main10.
It was six hardcoded sites, and the cost of leaving them was paid twice over:
an HDR session had to take libav VAAPI, losing real RFI loss recovery AND the
compute CSC's cursor blend — which on gamescope is the only way the pointer
reaches the stream at all, since gamescope has no embedded-cursor mode.

An HDR session now opens a Main10 profile with 10-bit component depths, a
`G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture + DPB, and an SPS carrying
`bit_depth_*_minus8 = 2` with the BT.2020/PQ CICP triplet instead of BT.709.

`rgb2yuv10.comp` is the CSC's twin, and the two interesting parts of it are:

* it is a PURE 3x3 matrix. The samples arrive already PQ-encoded (gamescope
  composites into the PQ container), so BT.2020 NCL applies to the code values
  as they are — there is no transfer function to apply here and applying one
  would be wrong;
* the scratch planes are `R16`/`RG16`, not the picture's plane formats. The
  10-bit ycbcr plane formats are not storage-image formats, so the shader
  writes the value into the HIGH bits by hand (`code10 << 6`, hence the
  `64/65535` factor and not `1/1023`) into planes that are merely
  SIZE-compatible with the picture's — which is all `vkCmdCopyImage` requires.

Scope and safety:

* HEVC only. AV1 10-bit encode has far thinner driver coverage, and a session
  open is not the place to gamble on it — those stay on VAAPI, as does a device
  that fails the Main10 profile query inside the open (the pre-existing "failed
  Vulkan open falls back to VAAPI" net, no new probe needed).
* HDR pins the compute-CSC arm over the EFC RGB-direct one, which the EFC could
  not serve anyway: its fixed-function conversion is 8-bit BT.709 narrow with
  no knob for BT.2020.
* `open_inner` binds `hdr` to the parameter-set HEADER bytes, so the depth flag
  is `ten_bit` there — the one name collision this change had to route around.
2026-07-28 18:03:30 +02:00
enricobuehler 86f4f950ba docs: the gamescope path is no longer 8-bit-only
Five pages asserted "gamescope's capture output is 8-bit" as a flat fact. It is
a fact about the STOCK binary, so say that instead, and say what to install to
change it: a new "HDR on gamescope" section covering the extra package, the two
knobs, what has to line up for a session to go HDR at all, and the two things
that surprise people — SDR content rides the same PQ stream at
`--hdr-sdr-content-nits`, and AMD/Intel HDR sessions currently lose the
composited pointer (the encode path that carries 10-bit is the one that cannot
blend it).

The roadmap's "parked / blocked" entry keeps the half that is still blocked
(Mutter's `RecordVirtual` is SDR-only through the GNOME 51 dev branch) and
drops the half that no longer is.
2026-07-28 18:03:30 +02:00
enricobuehler 17f824c3e9 feat(encode/nvenc): an HDR capture stays zero-copy on NVIDIA
AMD/Intel needed no new encoder code for HDR — the VAAPI path already ingests
an XR30 dmabuf into `format=p010:out_color_matrix=bt2020`. NVIDIA did: the
10-bit formats were excluded from the GPU import outright, so an HDR session
fell back to a CPU readback plus swscale, which is the one thing the capture
path is not allowed to ship.

It turns out no CSC kernel is needed. NVENC ingests packed 10-bit RGB natively
as `ARGB10`/`ABGR10` and does the conversion itself following the configured
VUI matrix — which `apply_low_latency_config` already sets to BT.2020 NCL for
an HDR session. So the frame travels LINEAR dmabuf → Vulkan bridge → CUDA →
NVENC unconverted: no host CSC pass, no depth loss, no extra work on a
contended SM.

* invariant 1 is restated rather than dropped: HDR must never take the TILED
  EGL de-tile blit (it renders into an 8-bit `GL_RGBA8` texture). The HDR pods
  are LINEAR-only by construction, so the plan may build the importer; the
  per-frame gate — which sees the negotiated modifier the plan cannot — is what
  enforces the tiled half, and falls back to the CPU path if a producer ever
  ignores our offer.
* …but only where the encoder can actually take the payload
  (`linux_hdr_cuda_ok`). libav's HDR route builds a P010 hardware frames
  context and swscales into it, so on a host without the direct-SDK backend a
  packed-2:10:10:10 CUDA buffer would land in a P010 surface as garbage. Those
  keep the CPU path.
* `nvenc_cuda` stops pinning 8-bit/SDR. Depth and HDR now follow the INPUT
  format, like the Windows backend: a 10-bit session whose capture came back
  8-bit encodes AND labels 8-bit rather than mislabelling.
* the cursor-blend compute shader gains two 10-bit modes, so the pointer
  gamescope leaves out of its node survives the HDR path. Same display-referred
  blend the CPU path's `composite_cursor_rgb10` already does — the samples are
  PQ, and a real sRGB→PQ cursor LUT is polish, not correctness for a pointer.
2026-07-28 18:03:30 +02:00
enricobuehler 689297c86e feat(hdr): a gamescope session streams true HDR10, decided before the Welcome
The Linux native plane has been 8-bit for a reason that stopped being true:
`capturer_supports_hdr()` returned a flat `false` because Mutter's virtual
monitors are SDR-only upstream. That is still right for Mutter, KWin and
wlroots — and wrong for gamescope, whose node can now offer 10-bit BT.2020 PQ
(packaging/gamescope). Open the three gates that held it off, together:

* the capture-side gate becomes SOURCE-AWARE (`capturer_supports_hdr_for`):
  Windows keeps its platform answer, gamescope asks whether the resolved binary
  offers the formats, everything else stays false. It must be truthful rather
  than optimistic — the Welcome is irrevocable, and PQ frames on an 8-bit
  encoder are a deliberate hard error — so every term is a static fact resolved
  before anything is spawned, including "do we spawn gamescope at all" (an
  attach inherits someone else's flags and can promise nothing);
* `want_hdr` reaches the capturer: `open_virtual_output` grew the parameter it
  never had, and the host arm stopped dropping `want.hdr` on the floor;
* the spawn gains `--hdr-enabled --hdr-debug-force-support` on all three
  sub-modes (bare args, the `GAMESCOPE_BIN` wrapper, the SteamOS PATH shim),
  from one shared `hdr_args` — the force flag is what makes it work headless,
  and forgetting it looks exactly like a negotiation failure.

Two things that would have gone wrong quietly:

* the keep-alive reuse key gains `hdr`. gamescope cannot turn HDR on live, so a
  kept SDR display handed to an HDR session would give the game no HDR surfaces
  while the stream negotiated PQ over an SDR composite — wrong, and not
  obviously broken. Same for the managed session-plus/SteamOS reuse check.
* the HDR-negotiation-failure latch becomes PER SOURCE. It was one
  process-wide flag, so a monitor that left HDR mode would have disabled
  gamescope HDR until the host restarted, and vice versa — two facts with
  nothing in common but the word HDR.

GameStream follows the same shape: `host_hdr_capable` gains the gamescope arm,
and the RTSP gate's live BT.2100 monitor probe is scoped to the portal source —
a headless box has no monitor to be in HDR mode, so running it there would
hard-refuse every gamescope HDR session.

Off by default for one release (`PUNKTFUNK_GAMESCOPE_HDR=1`), and
`punktfunk-host hdr-probe` now answers for both Linux HDR sources.
2026-07-28 18:02:13 +02:00
enricobuehler d6818263ce feat(gamescope): a build of gamescope whose capture output can be HDR
gamescope's built-in PipeWire node has always been SDR-only: it offers BGRx
and NV12, and `paint_pipewire()` hardcodes a Gamma-2.2 composite with the SDR
screenshot LUT set. So an HDR game reaches punktfunk already tone-mapped down,
and the whole gamescope backend streams 8-bit no matter what the client can
decode. Games CAN render HDR on a headless gamescope today — only the capture
half is missing.

Carry the two patches that close it (packaging/gamescope/patches): the node
additionally offers `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 +
BT.2020 props, mapped to the same 10-bit capture texture the HDR AVIF
screenshot path already allocates, and `paint_pipewire()` composites into them
with the HDR screenshot LUTs + `EOTF_PQ` — which is exactly the in-tree
`bHDRScreenshot` branch. The new formats are listed LAST, so every existing
consumer keeps negotiating the 8-bit stream bit-for-bit. Offered upstream
against ValveSoftware/gamescope#2126.

Ship it as `punktfunk-gamescope`, beside the distro's own binary rather than
replacing it: a Bazzite sysext payload (`build-sysext.sh --gamescope`, which
refuses a binary without the marker), an Arch package, a nix derivation +
`services.punktfunk.host.gamescopeHdr`, and one distro-agnostic build script
the rest call.

The second patch stamps `+pfhdr1` into the `--version` banner. That is not
cosmetic: punktfunk fixes a session's bit depth in the Welcome, before the
display exists and with no way to take it back, so its capability answer has to
be a static property of the binary it will spawn.
2026-07-28 18:01:20 +02:00
enricobuehler 28c50d1c5b fix(encode): every backend signals its colour, so no decoder has to guess
Three encode paths shipped a bitstream with no colour description at all,
leaving primaries/transfer/matrix/range "unspecified":

- Vulkan Video HEVC (`vk_build.rs`) built an SPS with no VUI whatsoever.
  This is the DEFAULT backend for AMD/Intel Linux hosts on HEVC/AV1.
- Vulkan Video AV1 packed `color_description_present_flag = 0`.
- The openh264 software path wrote nothing (it converts BT.709 limited and
  relied on decoders defaulting to that).
- The libav-NVENC Linux path excluded packed-RGB 4:2:0, on the belief that
  "NVENC's internal CSC writes its own VUI". It doesn't: libavcodec derives
  `colourDescriptionPresentFlag` from the AVCodecContext colour fields, so
  leaving them unspecified emits none. Reachable on a CPU/dmabuf capture, a
  build without `--features nvenc`, or PUNKTFUNK_NVENC_DIRECT=0.

Unsignalled looks fine on every punktfunk client — `csc_rows` falls back to
BT.709 on "unspecified" — which is why this survived. Vendor TV decoders do
not: they guess colorimetry from RESOLUTION, and an LG webOS panel reads a
4K SDR stream as BT.2020 and renders it visibly washed out.

All four now signal BT.709 limited, which is what every host CSC actually
produces (`rgb2yuv.comp`, `convert_bt709`, the swscale paths) and what the
Welcome's `ColorInfo::SDR_BT709` already advertises out-of-band. NVENC,
VAAPI, QSV, AMF and the Windows libav path were already correct.

Two tests, both parsing the REAL emitted bitstream rather than re-asserting
the constants: an independent bit-walk of the AV1 sequence header (the
packed OBU must stay identical to the `StdVideoAV1ColorConfig` handed to the
driver), and an H.264 SPS/VUI parse proving openh264 honours the request
instead of dropping it.

Not yet verified on hardware: the HEVC VUI depends on the driver's SPS
writer emitting `vui_parameters()`. PUNKTFUNK_VULKAN_ENCODE=0 falls back to
VAAPI if a driver mishandles it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3c56ff5717b2c9a0871953127da3dadd6a84220d)
2026-07-28 17:01:59 +02:00
enricobuehler 1db8f7631b fix(nix): move the bun packages to bun2nix — no more hand-bumped deps hash
`nix build .#punktfunk-web` has been broken since 1e9957d9 re-resolved
web/bun.lock: the console's node_modules came from a fixed-output derivation
whose single aggregate `outputHash` was last refreshed in 4094f620, so every
lockfile change silently invalidated it and the fix required a round-trip on a
Linux nix box (build, read the `got:` hash, paste it back). The runner
(sdk/bun.lock) had the same latent trap.

Replace both FODs with bun2nix (github:nix-community/bun2nix, pinned to 2.1.2).
`fetchBunDeps` turns a generated, committed `bun.nix` into bun's global install
cache — ONE `fetchurl` per package, keyed by the integrity hash already in the
lockfile — and the setup hook then runs a fully offline `bun install` in
`bunRoot`. There is no aggregate hash left to go stale. The `@unom` scope needs
no special handling: bun.lock records those tarballs' full git.unom.io URLs and
the registry is read-public.

`bun.nix` keeps itself in step: `bun2nix` is now a devDependency of both
packages and regenerates the file on every `bun install` — web via
`postinstall`, the SDK via `prepare`, because sdk/ is the published
@punktfunk/host package and a postinstall would fire on consumers' installs.
Both the flake input and the npm devDependency are pinned to the same exact
version; `bun.nix` has no schema stability guarantee across bun2nix releases, so
they move together (README documents this).

Dropped along the way: the manual `cp -R ${deps}/node_modules` + `chmod -R u+w`
+ `patchShebangs web/node_modules` dance, since bun2nix patches shebangs inside
the cache. `dontUseBunPatch` keeps the hook from running `patchShebangs .` over
the whole repo checkout (it would rewrite scripts/web-init.sh, which we ship
verbatim); `dontRunLifecycleScripts` preserves the old `--ignore-scripts`
behaviour, so playwright still never tries to download browsers.

Verified on a Linux nix box (Determinate Nix 3.21.5): `.#punktfunk-web` and
`.#punktfunk-scripting` both build green, offline; the i18n guard reports its
421 compiled messages, the `Bun.serve` bundle guard passes, and
`nix run .#punktfunk-scripting -- --list` discovers an installed plugin.
`nix flake show --all-systems` evaluates every output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4cfe7f05ee608868857be9e7eec079044448a965)
2026-07-28 17:01:59 +02:00
enricobuehler 0d8862457b style(nix): run the repo's own nix fmt (nixfmt-rfc-style) over the flake
flake.nix, packaging/nix/packages.nix and packaging/nix/nixos-module.nix had
drifted from the formatter the flake itself declares (`formatter =
nixfmt-rfc-style`). Pure reformat — no expression changes — split out so the
bun2nix migration that follows is reviewable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 927b27c4fade6d646e3ef822678b6738f1e2f28a)
2026-07-28 17:01:59 +02:00
enricobuehler 347c106498 fix(packaging/tray): the .deb must build the tray in its own cargo invocation
punktfunk-tray panicked at every launch on Debian/Ubuntu:

  zbus-5.16.0/src/abstractions/executor.rs:190
  there is no reactor running, must be called from the context of a Tokio 1.x runtime

Not a code bug — cargo feature unification. zbus picks its executor from
its own feature flags; the host's ashpd enables zbus/tokio while the tray
runs ksni's async-io executor with no tokio runtime by design. Features
are additive across everything built in ONE invocation, and deb.yml built
`-p punktfunk-host -p punktfunk-tray` together, handing the tray a
tokio-flavoured zbus with no runtime anywhere near it.

The invariant is already established and explained inline in the RPM spec,
the Arch PKGBUILD and the Nix packages.nix — all three build the tray
alone, and their comments even claim "(Same split the .deb does.)" The
.deb did not, in two places at once: the workflow co-built it, and
build-deb.sh's own correct standalone build was skipped by an
`if [ ! -x "$TRAY_BIN" ]` guard, so it packaged the poisoned artifact.
That is also why only Debian/Ubuntu users saw it.

Drops the tray from the workflow's host build and makes build-deb.sh's
tray build unconditional — cargo no-ops when the artifact is already
async-io-resolved and rebuilds it when it is not, so a stale co-built
binary can no longer be shipped. Corrects the Nix README note that had
recorded deb/rpm/arch as sharing a "latent" crash, and its nix develop
recipe, which co-built all four.

Verified on a Linux box: co-built reproduces the panic exactly; built
alone, the tray reaches the session bus and exits with the intended
"no StatusNotifier tray available".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f91983a84c56464bd2c18d537963cb7fb3a6e059)
2026-07-28 17:01:59 +02:00
enricobuehler 0868b6a364 fix(vdisplay): compositor availability follows the live session, not the env
GNOME/Mutter reported "Unavailable" on a host sitting in a live Mutter
session — and, on the same request, "Default", because the two columns had
different sources. available() asked each backend, and those probes read
the process env (XDG_CURRENT_DESKTOP for Mutter, WAYLAND_DISPLAY for
KWin's registry handshake, SWAYSOCK for sway) — env a host started outside
the session (systemd --user, a TTY, ssh) never inherited. It is only
retargeted at the live session on the connect path, so the answer also
flipped depending on whether anyone had connected yet.

Both columns now come from the same /proc scan detect() already used: the
live session's compositor is usable by definition, as is an explicit
operator pin, and the per-backend probe stays as the fallback for backends
that are not the live session (gamescope, which spawns its own). A live
KWin without the zkde_screencast grant now surfaces as available and fails
at create with that probe's precise message, which beats "no usable
compositor" on a box visibly running KDE.

Mutter's env sniff stays deliberately narrow — one var, not three.
XDG_CURRENT_DESKTOP is the one apply_session_env owns end to end (written
per connect, scrubbed when nothing is live); sniffing DESKTOP_SESSION
alongside would resurrect the bug that scrub exists to prevent, where a
stale value after a gnome-shell crash routes the next client into a dead
session.

Non-Linux hosts now report no compositors at all rather than five Linux
backends flagged unavailable with no default — on Windows the pf-vdisplay
driver is the only backend and vdisplay::open ignores the argument, so the
old list read as broken detection instead of "not applicable here". The
console says so explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit eb7ba3d6177552f5c3ed6a15439404084869c636)
2026-07-28 17:01:59 +02:00
enricobuehler 3b11288c97 fix(web): unsaved custom display settings are visible and recoverable
Everything else on the Displays page auto-applies — a preset click, the
game-session choice, the experimental toggles — but the Custom block does
not, and its Save button sat at the bottom of a block taller than most
viewports. People edited, never scrolled far enough to find it, navigated
away, and silently lost the lot.

The draft is now compared against the last-seeded server value, and that
one boolean drives the whole affordance: a badge in the card header
(visible without scrolling), an amber ring plus a title on the block, and
a sticky action bar pinned to the viewport for as long as any part of the
block is on screen. The bar states which of the two states you are in
rather than leaving it implied, Save disables when there is nothing to
save, and Discard changes puts the stored policy back.

Two ways edits could still vanish are closed: a preset click now confirms
before overwriting pending edits, and a reload/close warns. Re-picking
Custom while already on Custom is a no-op instead of re-seeding, which
would otherwise fill in defaults for unset fields and report "unsaved
changes" for a click that changed nothing.

Adds a `warning` badge variant + --warning token for the pending state —
attention, not failure, and distinct from the destructive red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c4318609c0da5ae1313081c5e488f685504f70d1)
2026-07-28 17:01:59 +02:00
enricobuehler 5ea087ca47 feat(session): the stats overlay scales with the display's DPI
The stream chrome — stats OSD, capture hint, start banner, resize label —
was hardcoded at 14 px with 12/10/8 px insets. The overlay composites into
the swapchain 1:1 in PHYSICAL pixels, so on a 4K panel at 200 % all of it
rendered at half its intended physical size: fine on a 1080p monitor, a
squint on a HiDPI laptop.

FrameCtx now carries a scale — SDL's window display scale (DPI × the
display's content scale) times a PUNKTFUNK_OSD_SCALE preference — and every
metric moved into a `base` module that is multiplied by it. Re-read per
frame and quantized into the damage key, so dragging the window to a
differently-scaled monitor re-renders at the new size rather than keeping
the stale one. Sanitized because SDL returns 0.0 when it cannot resolve the
window's display, which would collapse the panel to nothing.

Two details worth keeping: the face is re-derived at the scaled size rather
than the canvas transformed, because Skia rasterizes glyphs at the
requested size where a magnified 14 px bitmap would be mush; and the long
capture hint is fit-clamped to the window — at 2× it is wider than a 1080p
screen, so scaling it naively would have traded a small OSD for a truncated
one. Linux and Windows share this path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 744467d13a28b91ba88a23d6038da70263e9b502)
2026-07-28 17:01:59 +02:00
enricobuehler b444308592 feat(host): PUNKTFUNK_HOST_NAME names the host in Moonlight and the clients
A box called `bazzite-htpc` had no way to present itself as "Living Room"
short of renaming the machine. The new knob overrides the name everywhere
a human sees it: the GameStream serverinfo <hostname> element and the
mDNS service instance name both adverts carry. Unset (the default) is the
machine's own hostname, exactly as before.

Free text is the point, so the DNS-level name is now a separate concern
from the display name. The instance label may contain spaces and accents;
an A-record target may not, and mdns-sd rejects the whole ServiceInfo if
the target is not a legal name — which would take discovery down rather
than merely look wrong. dns_label() sanitizes the target and passes an
already-legal name through byte-for-byte, so hosts without the override
advertise precisely what they always did. The display name loses `.` (it
would split the label, and clients derive the name as the first label of
the fullname, so "Ben's PC v1.2" would arrive as "Ben's PC v1") and is
capped at the 63-byte DNS-SD ceiling on a char boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit bbf72261a12e0e67601f549d40db65ae61979268)
2026-07-28 17:01:59 +02:00
enricobuehler 40714317a8 fix(web): empty-state cards get their top padding back
"No games found yet." sat flush against the top edge of its card on any
screen ≥640px. The call sites overrode CardContent's padding with a bare
`p-8`, but tailwind-merge only resolves conflicts within a variant: `p-8`
cancels `p-4` and leaves `sm:p-6 sm:pt-0` standing, so the desktop
breakpoint kept the zero top inset that exists for cards WITH a header.
These three have none.

Uses `flush` — the escape hatch CardContent documents for exactly this —
so the padding is owned outright at every breakpoint instead of fought
from the outside. The library grid is the one that was reported; the
store catalogue and the recordings list are the same idiom with the same
bug. PairedDevices deliberately keeps its `p-6`: it is a table under a
header, where pt-0 is the intended look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 71fc47f32af7c74327a26425035298f8e6464c96)
2026-07-28 17:01:59 +02:00
enricobuehler 188f55d3b1 fix(encode/nvenc): the host advertises what the driver lists, not a superset
Every NVIDIA host advertised a static H.264|HEVC|AV1 superset, so a 1st-gen
Maxwell (GTX 960M, no HEVC/AV1 encode) offered HEVC — a client that believed
it got ~15 s of blank video and a disconnect instead of a stream. Both OSes
now ask the driver itself (nvEncGetEncodeGUIDs) on one throwaway direct-SDK
session: Linux on the shared CUDA context, Windows on the selected render
adapter, wired into host_wire_caps AND the GameStream serverinfo mask (which
had been left on the superset for NVIDIA on both OSes). Fails open — an
unanswerable probe keeps the historical superset, so it can only ever narrow
the advertisement to codecs the GPU really encodes.

The HEVC 4:4:4 answer rides the same session on Linux instead of opening a
libav hevc_nvenc FREXT probe: that open is the prime suspect for the field
bug where one probe wedges NVENC process-wide (NV_ENC_ERR_INVALID_VERSION on
every later session until a host restart), and the direct backend re-checks
the same caps bit at session open anyway. The ffmpeg probe remains only for
hosts that really stream over libav (PUNKTFUNK_NVENC_DIRECT=0 or a build
without the nvenc feature), where ffmpeg's NVENC client runs regardless. The
10-bit probe deliberately stays libav — Linux HDR rides the libav P010 path.

On-hardware: .136 (RTX 5070 Ti) 14/14 nvenc tests in one process incl. the
probe followed by real sessions and dirty teardown; .173 (Windows RTX) probe
+ 47 release lib tests. The Windows probe test documents the pre-existing
MSVC debug-link failure (LNK2019 via the sdk crate's unused lazy loader) —
run it with --release, the same reason windows-host.yml gates with clippy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 0346ec8090568eb499e8cb7d735305b28471185e)
2026-07-28 17:01:59 +02:00
enricobuehlerandClaude Opus 5 560e663aef fix(drivers): the pad channel asks the devnode who to trust, not the mailbox
A LocalService principal could take over a virtual pad's shared input section and
forge HID input into the interactive desktop.

The host duplicates each pad's unnamed DATA section into the driver's WUDFHost, and
through gamepad proto v2 it learned that process from `driver_pid` in the named
bootstrap mailbox. That mailbox has to be LocalService-writable — that is what the
driver's own WUDFHost runs as — and the delivery gate, verify_is_wudfhost, only checks
that the target's IMAGE is %SystemRoot%\System32\WUDFHost.exe. That image is
world-executable. So anything running as LocalService — notably the deliberately
de-privileged plugin runner — could spawn its own WUDFHost (CREATE_SUSPENDED parks it
indefinitely with the right image path), publish that pid, and be handed
SECTION_MAP_READ|WRITE on a live section. For pf-mouse that section drives a real
absolute pointer, so it was desktop control; for the pads it was forged gamepad input
plus a read of the remote user's controller state.

The module docs claimed mailbox tampering "yields at worst a gamepad DoS, never a read
or an injection". That was wrong, and the reasoning behind it — that a LocalService
token is DACL-denied OpenProcess on a UMDF WUDFHost — only covers the REAL host, not
one the attacker spawned itself.

The pid now comes from the device stack (ChannelProof, proto 2 -> 3). The host asks the
devnode it SwDeviceCreate'd who is serving it, looked up by the instance id PnP handed
back, so a planted look-alike devnode is not a candidate and the kernel — not anything
the attacker supplies — does the routing. Only the driver PnP actually bound to that
device can answer. `driver_pid` survives as a liveness hint; a tamperer can still deny a
pad, which squatting the name always allowed, but can no longer choose the recipient.
Two rules keep the state machine honest around it: a delivery stands until its target
process EXITS (judged on a retained SYNCHRONIZE handle, so a recycled pid cannot fake
it, and UMDF's restart-after-driver-crash still re-attaches), and a pad with no
SwDeviceCreate devnode refuses to deliver rather than fall back — unless an operator
sets PUNKTFUNK_PAD_CHANNEL_TRUST_MAILBOX, which says so loudly.

Three transports, because Windows carries different things to different driver shapes,
and the obvious two did not survive contact with hidclass. Measured on .173 (Win11
26200): HidD_GetIndexedString is NOT forwarded to a UMDF HID minidriver at all — it
failed for every index including ones the driver demonstrably serves through the named
wrappers; and a private device interface registers and enumerates but cannot be OPENED
(ERROR_GEN_FAILURE), because hidclass owns IRP_MJ_CREATE on a devnode it is the FDO for.
That is exactly why pf-xusb was never affected: it is not a HID minidriver, so nothing
sits above it. What works:

  * pf-xusb   — a private IOCTL on its own GUID_DEVINTERFACE_XUSB.
  * pf-mouse  — the HID serial string. Verified: PFCP:3:0:7296, and 7296 was a genuine
                service-spawned WUDFHost.exe. Safe here alone: nothing reads the virtual
                mouse's serial, whereas a pad's is SDL/Steam dedup material.
  * pf-gamepad — a HID feature report, and it cost NO report-descriptor change. The
                captured descriptors already declare far more Feature ids than the driver
                ever served: 0x85 is declared on DualSense, DualShock 4 and Edge alike and
                used to fail with STATUS_INVALID_PARAMETER, so hidclass lets it through and
                nothing can have depended on the old failure. The Deck's one feature report
                is unnumbered and Steam drives it command->response, so its proof rides that
                existing contract via a private two-byte command. Verified: feature 0x85
                returned magic "PFCP", proto 3, pad_index 0, wudf_pid 18456 — and 18456 was
                a WUDFHost — with the product string still 'DualSense Wireless Controller'.

Also renamed pf-dualsense -> pf-gamepad. One driver has always served four identities, so
the old name read as if the other three lived elsewhere. ONLY the package identity moved
(crate, INF/CAT/DLL, UMDF service, build script, CI lines, log file, env var). The four
HARDWARE IDS are deliberately unchanged — they bind every devnode the host creates and
every installed system — as are the Global\pfds-boot-<i> mailbox and PAD_MAGIC, which are
wire contract. `driver install --gamepad` now retires the pre-rename store package first,
matched on pf_dualsense.dll because that string appears only in the OLD inf; matching on
the hardware ids would delete what we are about to install. On .173 that separated 14
stale packages from the 1 new one with 0 ambiguous, and the renamed package binds the old
hwid (devgen root\pf_dualsense -> oem143.inf = pf_gamepad.inf).

The repo's own pre-commit/pre-push rustfmt hooks named the old crate, so they caught the
rename before the commit did — they now check pf-gamepad, and pf-mouse alongside it, which
they had been missing relative to the CI line.

Host and drivers MUST ship together: v2<->v3 fails closed in both directions by design,
with the existing "update host + drivers together" diagnostic.

The rename moved files that also carry the security change, so splitting this into two
commits would mean reconstructing an intermediate state that was never gated. It is one
commit on purpose.

Gated on the windows-amd64 runner with cargo clean first (the box's clock lags, so stale
artifacts would read as a vacuous green): clippy -D warnings clean for pf-inject,
pf-capture and pf-driver-proto, drivers workspace build + the CI clippy line clean,
cargo check --release -p punktfunk-host clean, 19 + 58 tests green. Also fixes pf-mouse
still writing its debug log to world-writable C:\Users\Public, which the 2026-07-17
review moved for the other three drivers and missed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:54:40 +02:00
enricobuehlerandClaude Fable 5 9b3ec9204c fix(capture): a refused EGL→CUDA dmabuf offer stops being asked
The raw-dmabuf passthrough has had a negotiation-timeout latch since the
hybrid-Intel case: one conclusive timeout and later captures negotiate the
CPU path instead of re-paying it. The EGL→CUDA dmabuf-only offer had no
equivalent — a compositor that accepts none of the importer's modifiers
timed out the same 10 s negotiation on every session, forever, under the
generic 'format negotiation never completed' diagnosis.

Now the symmetric latch (note_gpu_dmabuf_negotiation_failed) gates
build_importer in the one negotiation resolver, scoped to this offer alone
(raw passthrough, worker-death latch, encoder untouched), with the same
operator escape as the raw arm: an explicit PUNKTFUNK_ZEROCOPY=1 keeps
erroring loudly instead of downgrading.

One correctness detail beyond the handoff's sketch: whether the GPU offer
was ACTUALLY advertised is a runtime fact of the PipeWire thread (the
importer may fail to construct — no CUDA — in which case no dmabuf was
offered and a timeout must not latch it off). plan.build_importer alone
cannot answer that, so the thread records the made-offer on CaptureSignals
(gpu_dmabuf_offer) and the timeout diagnosis branches on the offer that
really happened, not the plan's intent.

Also renames vaapi_dmabuf_forced → zerocopy_forced (it now guards both
timeout arms; single caller). New plan invariant pinned in tests: the
latch gates only the importer, never the raw passthrough.

(V3 from design/pf-zerocopy-sweep-handoff.md — the deferred design call,
now decided in favour of the symmetric latch.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:15:36 +02:00
enricobuehlerandClaude Fable 5 e92a0aaa00 fix(zerocopy/vkslot): a timed-out blend can't corrupt its slot, and NV12 cursor chroma sits on the grid
Two [GPU]-class defects from design/pf-zerocopy-sweep-handoff.md; the code
is compile- and unit-verified, the visual halves still owe on-glass time.

- blend_ref reused a fence and command buffer after a wait timeout: on
  TIMEOUT it reset a fence that still had a pending signal and re-recorded
  a command buffer the GPU might still be executing — reached exactly in
  the contended case the CPU-synced path exists for (no timeline export +
  visible cursor + a >1 s GPU stall). A failed wait now drains the device
  before the reset (the VkBridge::import_linear precedent), and free_slots
  quiesces unconditionally instead of only when a timeline exists, so
  teardown after a timed-out sync blend no longer frees objects an
  outstanding submission references. (C1)

- cursor_blend.comp anchored NV12 chroma blocks to the cursor's oy, not
  the surface chroma grid: for odd oy every UV sample averaged luma rows
  one below the rows it covers (a one-row colour fringe on the cursor's
  edges, ~half of all cursor positions) and the cursor's last row's chroma
  was never written. Block rows now anchor to the chroma grid the same way
  spans anchor to the word grid — y0 = floor(oy/2)*2, per-row cy guards
  for the straddle block — and blend_geometry counts blocks from the same
  anchor (one extra block when oy is odd; covered by new tests, including
  negative oy). cursor_blend.spv rebuilt (glslangValidator, spirv-val
  clean, drift gate passes). (C6)

Owed on-glass: a stalled-GPU cursor session for C1; an odd-oy cursor
colour check for C6 (subtle fringe on the cursor's top/bottom edge).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:01:46 +02:00
enricobuehlerandClaude Fable 5 fa083f50d3 fix(zerocopy): unsafe fn bodies join the unsafe-proof program
#![deny(clippy::undocumented_unsafe_blocks)] never inspected the body of an
unsafe fn — operations there are not "unsafe blocks" — so roughly 20
functions' worth of raw GL/CUDA/Vulkan driver calls sat outside the
invariant the crate advertises. Concretely, that blind spot is why the
constructor-leak and teardown-ordering shapes fixed earlier in this series
could ship without ever prompting a reviewer.

#![deny(unsafe_op_in_unsafe_fn)] now closes the gap: every unsafe fn body
is an explicit unsafe block carrying a SAFETY comment that names the
caller contract it relies on (the dlopen'd CUDA wrapper table gets a
uniform forward-to-live-table proof). Mechanical; no behavior change.

(V2 from design/pf-zerocopy-sweep-handoff.md.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:59:15 +02:00
enricobuehlerandClaude Fable 5 c677732c60 fix(zerocopy/client): a wedged worker can't park the reaper, and no worker outlives the host
Three related reaper problems (R5 from design/pf-zerocopy-sweep-handoff.md):

- sweep_reaper called kill() + blocking wait() while holding the global
  REAPER mutex. A worker wedged in a driver ioctl is in D state and ignores
  SIGKILL, so wait() never returned — parking every later spawn() and every
  importer drop() behind the lock. Expired entries are now drained under the
  lock and killed outside it, with a bounded (~100 ms) try_wait poll; a
  worker that still won't die is parked again for a later sweep (re-killing
  is harmless) instead of blocking anyone.
- No PR_SET_PDEATHSIG: if the host died, the worker survived holding its
  CUcontext + BufferPool — by the code's own comment, hundreds of MB of
  VRAM. The pre_exec closure (async-signal-safe: prctl/getppid/dup2/fcntl
  only, no allocation) now arms SIGKILL-on-parent-death with the standard
  getppid race guard.

Not taken: arming the 20 s kill deadline from a timer instead of the next
spawn/drop (the handoff's optional third leg). PDEATHSIG closes the
worst-case orphan; a wedged worker after the LAST capture of a session
still waits for the next spawn to be swept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:53:43 +02:00
enricobuehlerandClaude Fable 5 3c62da3b8e fix(zerocopy/client): renegotiation retires the old generation's IPC mappings
Shared::mappings only ever grew: clear_cache reset sent_keys and told the
worker to drop its fd cache, but nothing closed the host-side CUDA IPC
mappings for the previous pool generation. A session that renegotiates
repeatedly (mode changes, HDR toggles, client reconnects) accumulated a
pool's worth of stale mappings each time, each pinning a host VA
reservation to peer memory the worker had already freed.

Mappings now carry a refcount and a retired flag. clear_cache closes every
unreferenced mapping immediately and marks the rest retired; a retired
mapping closes when its last in-flight DeviceBuffer releases. The worker's
half of the contract: ClearCache also forgets its VA→id map, so anything
delivered after the boundary gets a fresh id WITH its descriptor — without
that, a same-shape renegotiation (worker keeps its pool) would re-deliver
an old id whose host mapping was just closed, and the host would misread
it as a desync. Ids never repeat (next_id only counts up), so fresh ids
cannot collide with the graveyard.

(R6 from design/pf-zerocopy-sweep-handoff.md.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:52:42 +02:00
enricobuehlerandClaude Fable 5 a2033d6c82 fix(zerocopy/vulkan): VkBridge bring-up and the CSC build unwind instead of leaking
VkBridge::new leaked its instance (and past device creation, the device and
command pool too) on every error path — reached repeatedly, because a box
whose Vulkan device refuses the external-memory extensions retries the
bridge on every LINEAR frame. Pre-device failures now destroy the instance
explicitly (the VkSlotBlend::new shape); after device creation the
remaining objects build into an incrementally-filled struct whose existing
Drop tolerates the nulls a partial init leaves (Vulkan destroy calls are
defined no-ops on VK_NULL_HANDLE).

ensure_csc had the same hole across its six-object pipeline build; the
fallible half now fills the Csc front to back and a mid-build failure
destroys exactly what was created, leaving self.csc None for a clean retry.

(R3 from design/pf-zerocopy-sweep-handoff.md.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:49:42 +02:00
enricobuehlerandClaude Fable 5 8d02255703 fix(zerocopy/egl): construction can fail late, so teardown must be structural
Three unwind holes in the EGL side, all of the same shape — fallible
construction over raw handles with no Drop to unwind through — and all
retried per frame, so a sustained failure (VRAM pressure is the realistic
one) leaked unboundedly:

- The GlBlit/Nv12Blit/Yuv444Blit constructors interleave GL-object creation
  with fallible steps (FBO completeness, CUDA registration, pool
  allocation). A GlNameGuard now owns every bare GL name until the final
  struct exists; on unwind it deletes them AFTER the RegisteredTexture
  locals unregister (declaration order), preserving the
  unregister-before-delete invariant. gl.rs gets the same treatment for its
  compile paths: the vertex shader on a fragment-compile failure, the
  program on a link failure. (R1)
- EglImporter::new leaked the DRM render-node fd and the whole gbm_device on
  every '?' after their creation (most realistically cuda::context() failing
  on a host where EGL comes up but CUDA does not). Both now live in a
  GbmDevice with its own Drop: destroy the device, then close the fd. (R2)
- EglImporter's manual Drop destroyed the gbm device and closed the fd
  BEFORE field drops ran the blit destructors, which then called
  cuGraphicsUnregisterResource/glDeleteTextures against a dead native
  display — the stale-driver-state class this path once crashed on. The
  Drop impl is gone; teardown is now purely field order (blits and bridge
  first, GbmDevice last), and the blit SAFETY comments cite that order
  instead of the previously-false claim. (R4)

(R1/R2/R4 from design/pf-zerocopy-sweep-handoff.md.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:47:54 +02:00
enricobuehlerandClaude Fable 5 8de5ba4092 test(zerocopy): the fd must actually cross the socket, not just claim to
SCM_RIGHTS descriptor passing is the mechanism the whole worker isolation
design rests on, and no test verified it end to end: both suites asserted
only the has_fd boolean parsed from the JSON body. Setting the send-site's
descriptor to None — zero-copy broken in production — stayed green; so did
dropping the received fd in the worker's dispatch loop.

Now the client-side scripted peer records the st_ino of every descriptor
that arrives and the tests assert the sequence against dmabuf_key() of the
sent plane (SCM_RIGHTS re-numbers the fd but preserves the open file
description, so the inode is the identity the worker keys its cache on);
the worker dispatch test sends one import with a live fd and asserts the
backend received a descriptor with the sender's identity. (T1 from
design/pf-zerocopy-sweep-handoff.md)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:43:39 +02:00
enricobuehlerandClaude Fable 5 143a707f76 fix(zerocopy): the mechanical sweep batch — truthful fence waits, forgiving env flags, no leaked planes
From the pf-zerocopy review sweep (design/pf-zerocopy-sweep-handoff.md), the
compile-verifiable batch:

- dmabuf_fence: the blocking poll's result was discarded — EINTR silently
  skipped the wait (reopening the stale-frame race a SIGCHLD away) and a
  timeout reported as waited. Now EINTR retries with the remaining budget
  and the caller gets Signaled/TimedOut/NoFence, so the one diagnostic
  operators have about implicit fencing stops lying. (C2)
- env flags: PUNKTFUNK_ZEROCOPY=TRUE meant *off* — values are case-folded
  now, and an unrecognised spelling falls back to the flag's default with a
  one-shot warning instead of silently inverting the operator's intent. (C3)
- cuda: alloc_pitched_nv12 leaked the Y plane when the UV allocation failed
  (per-frame under VRAM pressure — the worst possible time to leak); a failed
  async-copy enqueue now drains the stream before returning, so a recycled
  pool buffer can't race an orphaned in-flight copy. (C4, C5)
- worker: --fd is validated (>= 3, fstat + S_ISSOCK) before OwnedFd adoption
  — 'zerocopy-worker --fd -1' was constructing OwnedFd's niche value. (V1)
- docs: all 15 rustdoc warnings fixed, including the link to the renamed
  note_raw_dmabuf_negotiation_failed. (D1)
- CI: a SPIR-V drift gate — the committed .spv blobs are include_bytes!'d and
  rebuilt by hand; the gate diffs disassembly (filtering only the
  shaderc/glslang generator difference) so a forgotten rebuild fails CI
  instead of shipping the old kernel. Both blobs verified in sync. (S1)
- tests: bt709_limited pinned to external BT.709 anchors (it is the sole
  oracle for the GPU colour self-test); blend_geometry gets its first tests —
  empty rect, CURSOR_MAX clamp, per-format group counts, and negative-ox
  floor alignment. (T2, T3)

Verified on .25: clippy -D warnings clean, 27/27 tests, cargo doc 0 warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:40:37 +02:00
enricobuehlerandClaude Sonnet 5 c4e80fd455 docs(release): v0.21.0 notes should only cover its own delta
Duplicated the entire v0.20.1 fix bundle verbatim instead of
following house convention (each vX.Y.Z.md covers only what changed
since the immediately preceding release file — see v0.19.1 -> v0.19.2,
neither restates the other). v0.20.1 is a real, already-tagged
release with its own notes file; 0.21.0 only needed to add the
monitor-streaming feature, the KDE registry fix found while building
it, and the CI-only winget fix, with a pointer back to v0.20.1 for
the rest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 09:08:47 +02:00
enricobuehlerandClaude Opus 5 d08893383a chore(release): bump workspace version to 0.21.0
42 commits since v0.20.0 (v0.20.1 was tagged but never announced;
superseded by this release rather than repointed, since real new
functionality landed after it was cut). Minor, not patch: the
headline is streaming one of the machine's own physical monitors
instead of always creating a virtual display — pick it from a new
console card or pin it in host.env, on KWin, Mutter, sway and
Hyprland, in both the Punktfunk app and Moonlight.

Also folds in the full v0.20.1 fix bundle: three Windows install
blockers found within hours of 0.20.0, GameStream/Moonlight compat
back to opt-in on fresh installs, three gamescope Game Mode takeover
faults, a laptop-panel stall misdiagnosis, and the PyroWave
high-bitrate latency-creep bundle — plus a KWin 6.7 regression found
while building the monitor feature (silent kscreen-doctor fallback
on every session) and a winget release-verification CI fix.

Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows
virtual-display driver protocol at 6. GET /display/monitors is a new
additive endpoint; the display policy gains an optional
capture_monitor field. No embedder rebuild required.

Notes authored ahead of the tag per docs/releases/README.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:03:44 +02:00
enricobuehlerandClaude Sonnet 5 74179c4c2e fix(ci/winget): envs: must live under with:, not as a step sibling
24d2f97e added envs: GITHUB_REF_NAME to fix "Verify the served
catalogue" failing on every tag with `GITHUB_REF_NAME: unbound
variable` — but placed it as a sibling of with:/env: on the step,
which is not part of the step schema and is silently ignored by
Gitea's runner. appleboy/ssh-action never received it as an input,
so the step kept failing identically on v0.20.1's first tag run.

docker.yml and deploy-services.yml already forward REGISTRY_TOKEN
this way correctly: envs: nested inside with:. Moved to match.
Confirmed by parsing the workflow and checking envs lands in the
step's with: dict, not as a bare step key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:01:45 +02:00
enricobuehlerandClaude Opus 5 8fe71be424 fix(web): applying a saved preset kept switching the streamed screen off
`applyCustomPreset` builds a FRESH policy object instead of spreading the
draft, so every field it doesn't name is dropped. `capture_monitor` was
not named — so picking one of your own saved presets silently took a host
that was mirroring a real monitor and put it back on a virtual display.
The same omission in the Custom switch would have done it there too.

Found on-glass against a running serve on .136: the PUT is whole-object,
so the console is the only thing keeping the orthogonal axes alive, and
these two sites were the ones building a policy from scratch rather than
from what was already in force.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 a98174ebfa feat(host): anchor-test, and libei says which output absolute input landed on
The absolute-region ladder existed for one case a unit test can only
simulate — two heads of the SAME size, where matching a libei region by
the streamed mode is a coin flip — and that case had never been run
against a real compositor. It was also unobservable: the log said which
regions the EIS server offered, never which one a coordinate went into,
so "the pointer is on the wrong monitor" could only be discovered by
looking at a monitor.

libei now reports the chosen region once per distinct answer, beside the
existing miss warning: one covers an anchor that named nothing, this one
covers an anchor that matched something, by saying which.

`anchor-test` is the gate that reads it, in the shape mirror-test
established: it enumerates the heads, SAYS whether the rig actually has
two of the same size (a green run on a single-head box proves nothing),
anchors at a named one (or `--none` for the A/B that makes the anchored
run mean anything), and walks the corners. It refuses to run off libei
rather than emitting a green result about a ladder that isn't there.

On-glass on KWin 6.7.3 with `kwin_wayland --virtual --output-count 2`
(regions 1920x1080+0+0 and +1920+0): unanchored picks +0+0, anchored at
Virtual-1 picks +1920+0, anchored at Virtual-0 picks +0+0. That is open
item 3 of design/per-monitor-portal-capture.md §8b, closed.

Clearing the pin now logs too — setting one always did, and the streamed
screen going back to virtual is the same size of change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 ea0c61fb0f docs: streaming a real monitor, and the CLI that names one
The console grew a "Streamed screen" card and the host grew a
PUNKTFUNK_CAPTURE_MONITOR knob and a list-monitors command, and the docs
knew about none of it — a control with no explanation anywhere.

virtual-displays.md gets the feature section: what it is for, that the
monitor is never touched, that its resolution wins and a client scales,
that a bad name is a hard error rather than a different screen, and that
there is no chooser dialog on any of the four backends (which is what
makes it work unattended). Plus the three troubleshooting entries the
shape of the feature predicts: settings that do nothing while mirroring,
a console card the env var has locked, and a pin that names no head.

host-cli.md documents list-monitors and mirror-test; configuration.md
gets the knob, including that it outranks the console on purpose;
running-as-a-service.md gets the desktop-session drop-in and states that
the host needs nothing exported to find its session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 19deac75fe feat(packaging): a desktop-login host restarts with its desktop
The shipped unit's PartOf=punktfunk-kde-session.service covers the
APPLIANCE route, where we start the compositor ourselves. A host on a
machine somebody logs into has no such unit: when Plasma or GNOME
restarts, the daemon keeps running while holding a Wayland socket and a
portal D-Bus connection that both died with the old compositor, and it
cannot recover either in-process. It fails quietly — the host still
listens, still answers, and every session it then serves dies at capture.
A host that mirrors a monitor idles for days between sessions, which is
the shape that finds this at the worst moment.

The drop-in binds the host to graphical-session.target: PartOf takes it
down with the session, WantedBy brings it back with the new one. Shipped
under /usr/share rather than as an active drop-in, because it is wrong
for the appliance route (which may never reach that target at all, and
would then leave the host permanently stopped) — the operator opts in.

Closes the restart half of design/per-monitor-portal-capture.md §6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 45f04f50fe test(mgmt): gamescope reports no monitors, and that is not a missing explanation
/display/monitors must never answer empty AND silent — the console reads
that as "the host is broken". But gamescope is nested: it owns no
physical heads by construction, so empty-and-silent is the correct answer
there, not an unexplained one.

Any dev box that has ever been in game mode keeps gamescope-0 sockets in
its runtime dir, so detect() resolves gamescope and the test failed on
the machine rather than on the code (found running the suite on .136).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 6afc05155b feat(vdisplay/session): derive SWAYSOCK — the last session var a --user host lacked
Session detection already hands every backend the live session's env:
WAYLAND_DISPLAY from a socket scan, XDG_RUNTIME_DIR from the uid, the
session bus, XDG_CURRENT_DESKTOP, and Hyprland's instance signature. sway
was the exception — SWAYSOCK had to be INHERITED from the login shell, so
a systemd --user host that never saw one had no sway IPC at all: no
output enumeration, no capture chooser, no wlroots backend (its
is_available() keys off that very variable).

It is derivable, and by identity rather than guess: sway names its socket
sway-ipc.<uid>.<pid>.sock, and detection already knows the compositor
PID. Ladder: a valid inherited value, then the exact PID's socket, then
the newest one we own (a sway re-exec can leave the name pointing at a
PID we didn't see). None on river — the other wlroots desktop ships no
sway IPC, and saying so is what stops apply_session_env exporting a
SWAYSOCK that points at nothing.

Closes the session-env half of design/per-monitor-portal-capture.md §6
for a desktop-login host: nothing has to be exported for the host to find
its session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 829bcb7962 fix(gamestream): the streamed-screen pin applies to the portal source too
PUNKTFUNK_CAPTURE_MONITOR is documented as "instead of creating a virtual
display OR taking whichever head the portal hands back", and the console
presents it as a host-wide setting. The compat plane's portal source did
neither: it went straight to the chooser, so a Moonlight client on a
pinned host silently got whatever screen the portal picked. That is the
one outcome the whole feature exists to prevent — showing the wrong
monitor is worse than showing none.

The portal arm now mirrors the pinned head instead, through the same
MirrorDisplay the virtual source reaches via vdisplay::open. It launches
nothing and creates no virtual output, so it needs neither the
game-lifetime machinery nor the registry (External ownership passes
through it anyway).

Which screen a pooled capturer is showing is now the third reuse key
beside HDR-ness and cursor mode. The pin is a LIVE setting — the console
can re-aim the host between two connects — so without it in the key the
second connect would keep streaming the previous screen, which is the
same silent-wrong-monitor failure by another route (§7.3, open item 5).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 de3ef50434 fix(native): a mirrored monitor has a fixed mode — refuse to resize it
A physical head runs at the mode its owner set, and MirrorDisplay::create
ignores the requested one on purpose. So a mid-stream Reconfigure against
a mirror could only ever tear the cast down and rebuild the identical one
at the identical size — a hitch that buys nothing, and an invitation to
the worse reflex of reconfiguring the display someone is sitting at.

reconfig_allowed grows a third gate beside gamescope and per-client-mode
identity, and the session captures it at bring-up like the others: this
session opened its display under whatever the pin said then, so a console
change mid-session must not retroactively change the answer it gives.

Linux-only, because vdisplay::open only routes to the mirror there — a
pin left in a Windows host's settings streams nothing different and must
not disable resize as a side effect.

design/per-monitor-portal-capture.md §7.3, open item 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 b08226dbf1 feat(vdisplay): mirror a pinned monitor on sway and Hyprland
P5 of design/per-monitor-portal-capture.md, and the end of a hole worth
naming: the pin was accepted, persisted and offered in the console while
`create` bailed on these two backends, so a sway or Hyprland host with a
monitor selected failed EVERY session.

Both already ship the mechanism — a managed portal-chooser config plus a
per-session selection file (xdpw's `Monitor: NAME`, xdph's
`[SELECTION]screen:NAME`) — so mirroring is that same chooser pointed at a
physical connector instead of a headless output we created. The keepalive
stops the cast and nothing else; the monitor is the compositor's.

The selection write is now serialized against the handshake that reads it
(SELECTION_LOCK, applied to the virtual-output paths too). That file is one
per-user path: whoever writes last before the portal reads wins, and the
loser does not fail — it silently captures the other session's output.

`managed` is no longer grounds for refusal everywhere. It is conclusive
only where the name is ours by construction (KWin's `Virtual-punktfunk`,
Hyprland's `PF-N`); sway names EVERY headless output `HEADLESS-N`, its own
included, so the old rule would have refused to mirror any output on a
headless sway box — exactly the remote setup this feature serves. Found
on-glass, and now a test.

Verified on a two-output headless sway: asking for HEADLESS-1 yields
1280x720 frames and HEADLESS-2 yields 1920x1080, so the chooser really does
steer per request. Hyprland is compile-only — no Hyprland box exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 d461d889c3 feat: the streamed screen is a persisted setting, pickable from the console
P4 of design/per-monitor-portal-capture.md. The pin was an env var read
once at startup, which a console picker can never write — so it becomes a
field of the display policy (orthogonal to presets, like game_session), and
`vdisplay::capture_monitor()` resolves env-over-policy: an appliance that
pinned in its unit's host.env stays pinned there, and a console click
cannot re-aim a machine whose operator already declared the answer.

Read per `open` rather than cached, so a picker change takes effect on the
next session instead of the next host restart. The host re-resolves the
input anchor on every policy write — including clearing it when the pin is
cleared, or a later virtual-display session inherits an anchor aimed at a
monitor it is not showing.

`with_manual_layout` carries the pin through like the other orthogonal
axes: without that, saving a display ARRANGEMENT would silently swap the
streamed screen back to virtual.

Console: a "Streamed screen" card listing the host's real monitors beside
"Virtual screen (default)", saving on selection. A disabled head is listed
but not selectable (so "why isn't it here?" has an answer), and an
env-pinned host renders read-only with the reason rather than offering
controls that silently lose to the environment.

Verified on GNOME/Mutter: pinning via the policy file alone (no env) routes
sessions to the mirror and anchors input; setting the env to a different
connector overrides it, exactly as documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 358cfa4be4 feat(host): mirror-test — the on-glass gate for per-monitor capture
Opens the display backend exactly as a session would, attaches a capturer
and reports the frames a named head actually produces, with no client
involved. `--monitor` names the head explicitly (it cannot go through
PUNKTFUNK_CAPTURE_MONITOR: pf_host_config parses the environment once and
startup already read it, so a tool setting it for itself would still see
the old snapshot — hence vdisplay::open_mirror); with no argument it
exercises the production routing through the pin.

A frame timeout is NOT treated as failure. Compositor capture is
damage-driven — the host's own capture diag logs new_fps=0 for virtual
outputs on an idle desktop for exactly this reason — so the first version
of this tool ended its measurement on the first idle gap and made a working
mirror look like a 2-frame stall.

Verified on KWin 6.7.3 (home-nobara-1), mirroring HDMI-A-1: 346 frames in
20s at 1920x1080, arriving while input was injected and pausing when the
desktop went quiet, on both the CPU (BGRx/MemFd) and zero-copy (NV12
dmabuf) paths, ownership=External.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 045deaf77a fix(vdisplay/kwin): bind the output-device REGISTRY — KWin 6.7 stopped
advertising per-output globals

Found on-glass while testing monitor enumeration: KWin 6.7.3 advertises
kde_output_device_registry_v2 and NOT one kde_output_device_v2 global per
output. This module only ever bound the globals, so on a current KWin it
saw zero devices.

That is not just an enumeration gap — it silently disabled the whole
in-process output-management path, which exists precisely because
kscreen-doctor wedges. The live 0.19.2 host on the test box logs
"kde_output_management unavailable — kscreen-doctor fallback" on EVERY
session, so every topology apply there has been going through the tool this
module was written to stop using.

Both models are supported now: the per-output globals older KWin sends, and
the registry's `output` events. Devices from the registry arrive a round
later than globals do, so the handshake takes one more barrier — skipped
when nothing is still awaiting its `done`, so the classic path costs
nothing.

Verified on KWin 6.7.3: enumeration now reports HDMI-A-1 1920x1080@60 at
+0,+0 scale 1.35 primary, matching kscreen-doctor exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 bf2f54a5fe feat(vdisplay): mirror a pinned monitor on Mutter, and scope the HDR probe
P3 of design/per-monitor-portal-capture.md. Mutter's mirror is the same
private ScreenCast session the virtual path already drives, one call
different — RecordMonitor instead of RecordVirtual — so it inherits what
makes that path work headlessly: a direct D-Bus API that needs no
interactive approval, unlike the xdg portal a background service could
never answer.

Deliberately not under TOPOLOGY_LOCK: that serializes operations which add
or remove a monitor or apply a monitor configuration, and mirroring does
neither. Nothing is created, no layout changes, and teardown is just Stop —
the SIGSEGV-adjacent ordering the virtual path must observe cannot arise
when no monitor is being removed.

The GNOME HDR probe now honors the pin. Asking "is ANY monitor in BT.2100
mode" was fair while capture took whatever head it was handed; once the
operator names one, an HDR TV on the next connector must not talk the host
into offering PQ formats for the SDR panel it is actually streaming. A pin
that names no live head reports SDR rather than falling back to "any" — the
session is about to fail on that same missing monitor, and an over-claimed
HDR offer would be a second, quieter wrong answer.

⚠️ RecordMonitor's exact signature is read from the interface, not yet
confirmed against a live gnome-shell; no GNOME box was reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 53c772702f feat(host): list-monitors CLI, and two bugs it caught on-glass
An operator configuring an unattended host has to learn the connector
names from somewhere, and "curl the management API before the host is
configured" is not it. `punktfunk-host list-monitors` prints them with
geometry and flags the pinned one.

Running it under a real sway immediately caught two defects the unit tests
could not:

  - the query used the `swaymsg` command helper, which inserts `--`, so
    `-t get_outputs` came back as "Unknown/invalid command '-t'". Queries
    now go through a `swaymsg_query` helper that cannot make that mistake.
  - compositors write the literal "Unknown" rather than leaving make/model
    empty, so the picker label read "Unknown Unknown". One `describe`
    helper now treats that as absent, for all four backends.

Verified on a headless two-output sway: names, modes, positions, scale and
the case-insensitive pin all resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 93c2765db7 feat(vdisplay): mirror a pinned physical monitor on KWin
P2 of design/per-monitor-portal-capture.md. zkde_screencast's
stream_output records an output KWin already has — the connector name IS
the selection, so there is no dialog, no portal and no chooser for a
background service to answer.

It arrives as a VirtualDisplay reporting DisplayOwnership::External, which
already means "someone else's display, merely mirrored: no keep-alive, no
topology, no reuse". So the rule that we must never disable, move or
"restore" a monitor the user is sitting in front of holds by construction
rather than by everyone remembering it — and create() ignores the client's
requested mode for the same reason: a panel runs at the mode its owner set.

Routing lives in vdisplay::open, the one place every session opens a
display, so the pin cannot be honored on one plane and ignored on another.
The host sets the libei anchor from the same pin (pf-vdisplay must not
depend on pf-inject), which is what makes absolute input land on the
mirrored head instead of a same-sized neighbour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 2eeee650b9 fix(inject/libei): absolute coordinates resolve by identity, not by size
libei hands the injector one region per logical monitor with no output
name attached, so picking one meant matching the streamed mode's SIZE —
a coin flip the moment two heads share a mode, and it already resolved
wrong on-glass once (GNOME, a dummy HDMI beside the virtual primary: the
seat cursor never entered the streamed monitor, so neither cursor path
could see it).

The ladder is now mapping_id -> origin -> size -> first. Two outputs can
share a size but never a top-left, and a mirrored head's region is not the
client's size at all, so the size rung cannot find it — this is what makes
per-monitor capture land its input on the monitor it is showing.

An anchor that matches nothing falls back down the ladder rather than
stranding input: the region set is the truth and the anchor is our belief
about it. It warns once per distinct anchor, because the failure this
exists to prevent is a pointer that silently lands elsewhere.

P1 of design/per-monitor-portal-capture.md — mechanism and tests only.
Nothing sets an anchor yet; the pin wires it in P2, and the doc comment
records why it is host-level and not per-session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 95e3314d9a feat(vdisplay): enumerate the host's physical monitors
P0 of per-monitor capture (design/per-monitor-portal-capture.md): nothing
in the tree could answer "what heads does this host have?", which both a
monitor pin and a console picker need first.

One read per compositor, each beside the code that already speaks that
dialect: KWin's kde_output_device_v2 (the topology session now also records
geometry + scale), Mutter's DisplayConfig.GetCurrentState, swaymsg
get_outputs, hyprctl monitors. Logical geometry throughout, because x/y is
what identifies a head — two monitors can share a size but never an origin.

PUNKTFUNK_CAPTURE_MONITOR parses here and is reported at startup, loudly
including that it is not yet enforced: a knob that is read but inert has to
say so, or "it didn't work" reads as a bug. GET /display/monitors always
answers 200 with an explained empty list, so a compositor-less host renders
as "nothing to pick", not as a broken console.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Sonnet 5 3721b6816d chore(release): bump workspace version to 0.20.1
24 commits since v0.20.0. Patch, not minor: entirely fixes and
hardening on top of last release's session⇄game work, nothing new to
opt into. Headline fixes: three Windows install blockers reported
within hours of 0.20.0 (a false conflicting-host detection, a broken
winget log-path switch, missing package-source instructions),
GameStream/Moonlight compat flipped back to opt-in on fresh installs,
three gamescope Game Mode takeover faults that could black out a
Linux machine or strand it on a host restart, a deactivated laptop
panel misdiagnosed as a blind spot instead of a named stall cause,
and the PyroWave high-bitrate latency-creep bundle (clock re-sync
starvation, wire bitrate overshoot, backlog buildup, ABR probe
false-congestion).

Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows
virtual-display driver protocol at 6, unchanged from 0.20.0 — no
embedder rebuild required, 0.18/0.19/0.20 hosts and clients mix
freely.

Notes authored ahead of the tag per docs/releases/README.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 19:25:47 +02:00
enricobuehler 0e0d5b8b4d chore(api): regenerate openapi.json — the stats description drifted
`2c69cbda` rewrote StatsSample::mbps's doc comment (goodput → attempted
sealed wire bytes) without regenerating the snapshot, so
`openapi_document_is_complete_and_checked_in` fails on main today. Pure
regeneration, no API surface change: the description and the version stamp
are the only differences.
2026-07-27 19:04:20 +02:00
enricobuehler 6be865f33f fix(host): a host that is stopped hands the box's session back first
Until now the host had no signal handling at all: SIGTERM killed it
outright. That is fine for a host that owns nothing — but a managed
gamescope takeover owns the box's session, and on a mask-fragile display
manager (Nobara's plasmalogin) it has STOPPED that display manager for the
length of the stream. Killed there, the host leaves a box with no graphical
session and nothing left to restart it: the crash-restore state lives in
$XDG_RUNTIME_DIR, which logind removes along with the user manager, so not
even the next host start can heal it. `systemctl --user restart
punktfunk-host` mid-stream — or a package update doing it for you — was
enough to strand a box until someone reached a VT.

So SIGTERM and SIGINT now restore first and exit after. The restore runs on
a blocking thread (it shells out) under a 20s grace, well inside systemd's
TimeoutStopSec, and a host that took nothing over exits immediately.

`restore_takeover_now` is the synchronous sibling of the debounced
disconnect restore, and deliberately ignores the keep-alive policy the
latter honors: `keep_alive=forever` pins a session for the NEXT client,
which means nothing once the host that would serve them is exiting. Both
paths now share one `takeover_live()` predicate instead of repeating the
four-way "is anything ours" test.

Checked on Linux: clippy -D warnings on punktfunk-host + pf-vdisplay, tests,
rustfmt.
2026-07-27 19:04:20 +02:00
enricobuehler f3615f83a5 fix(gamescope): the takeover no longer kills the host it is running in
Field report, Nobara + 0.20.0: switching into Game Mode mid-stream
disconnects the client and the box never lights up again. His journal has
the whole mechanism, ten seconds apart:

  12:34:18.9  freed Steam: stopped the display manager for this stream
  12:34:29.0  systemd[1685]: Stopping punktfunk-host.service

Stopping the display manager ends the user's last login session. The
packaged host is a `systemd --user` unit, so logind then stops
user@1000.service once UserStopDelaySec elapses — 10s by default on Nobara
— and takes the host with it. The stream dies, the scheduled restore never
runs, and the display manager stays down with nothing left to restart it:
a black box that needs a VT to recover. Exactly the two symptoms reported,
and the intermittency is just whether an active Gaming Mode session was
there to trigger the takeover at all.

It never showed on the repro VM because lingering was enabled there for the
earlier sessionless tests (`Linger=yes` — confirmed on the VM today), which
is precisely the thing that breaks the dependency: logind keeps the user
manager alive with no session. Our KDE/GNOME/Arch setup guides already ask
for it; his box, following the KDE route, did not have it.

So the takeover now ensures lingering BEFORE it touches the display
manager: `loginctl enable-linger` directly (allow_active in logind's own
policy — verified working unprivileged on Nobara), else the packaged helper
gains a `linger` verb that enables it for PKEXEC_UID alone, never a
caller-named user. If neither works the takeover is refused with an
actionable error and managed degrades to attach — mirroring the box's own
session is strictly better than a screen that needs a VT. Hosts that cannot
be reached this way (root, a system unit) skip the check: the cgroup test
is a pure `cgroup_under_user_manager` with a unit test, matched against the
real shapes on a Nobara box (`user@1000.service/app.slice/...` vs a
`session-N.scope`).

Also documents the requirement in the gamescope guide's Nobara section.

Checked on Linux: clippy -D warnings, 78 tests, rustfmt; helper verified
with `sh -n`, and the linger enable/disable round-trip run live on the
Nobara VM.
2026-07-27 19:04:20 +02:00
enricobuehler eae1837a3a fix(gamescope): a managed takeover never waits on a dialog nobody can see, nor acts on a months-old sentinel
Two ways the Nobara DM-stop takeover could end a session and leave the box
dark, both found while triaging a 0.20.0 field report (managed round-trip:
"my client disconnects and my monitor doesn't turn back on", intermittent).
0.20.0 is the first release where that box runs the takeover for real —
before it, no package shipped the polkit privilege, so managed always
degraded to attach and never touched the display manager.

Privileged verbs are non-interactive now. `try_stop_display_manager` and
`restore_display_manager` try a plain system-bus `systemctl` first, and
without `--no-ask-password` that asks polkit for INTERACTIVE authorization.
On a box where the host runs inside the user's graphical session, polkit
hands that to the session's agent: a password dialog on the box's own
screen, which during a takeover is off or mid-switch. Nobody sees it,
nobody answers it, and the call blocks on the stream's own thread — the
capture-loss rebuild, or the restore worker — while the rebuild budget
burns. The takeover then lands after the session it was for already ended.
With the flag it fails immediately ("interactive authentication required"),
which is what both callers are already written for: the packaged pkexec
helper (allow_any, no agent needed) takes it from there.

A sentinel with no baseline is not a request. `session_select_requested`
read "no baseline recorded" as "the sentinel appeared during the session" —
but `~/.config/steamos-session-select` is a permanent file, so every box
whose user has ever switched sessions has one. The baseline was recorded
only after a SUCCESSFUL launch, leaving a real window: takeover succeeds,
launch fails, the client retries inside the 5s restore debounce (which the
retry cancels), and the honor gate fires on a months-old write — pushing
the box to the desktop the user never asked for, plus a 120s grace refusing
to relaunch game mode. Now the baseline is taken at takeover too (setting
STOPPED_DM is what arms the gate), and "never baselined" reads as no
request. `SESSION_SELECT_BASELINE` carries both states explicitly, and the
decision is a pure `sentinel_advanced` with a unit test for all six cases.

Checked on Linux: cargo check --all-targets, clippy -D warnings, 77 tests,
rustfmt.
2026-07-27 19:04:20 +02:00
enricobuehlerandClaude Fable 5 ba89b9fcd0 feat(tools): display-disturb learns the bench moves the field sessions needed
Three upgrades from the on-glass rounds on .173/.221:

- `ddc-open` timing: the handle-ACQUISITION cost per monitor is reported even
  when it yields no physical handles — the poller's first contact with a
  virtual display, exactly what the DDC-fail-fast driver work changes; the
  old code silently skipped handle-less monitors, which on a streaming host
  in exclusive topology is every monitor there is.
- Monitors are labeled by GDI device name (\\.\DISPLAYn) so a virtual
  display and a panel are tellable apart in the correlation log.
- `extend` mode: one SDC_TOPOLOGY_EXTEND poke — re-activates every
  attachable display, i.e. the exact 'non-managed display re-activated'
  event the exclusive watchdog evicts. One shot, not a loop: trigger a
  reassert round, observe the recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:52:31 +02:00
enricobuehlerandClaude Fable 5 622817954a fix(capture/win-display): a deactivated laptop panel is a named stall suspect, not a blind spot
Field A/B (reporter, Legion 5 Pro hybrid, 2026-07-27): the ~2 s capture-stall
metronome IS the exclusive isolate's own doing — deactivating the laptop panel
leaves a dark-but-connected eDP head whose driver-level servicing disturbs the
capture path (~1.7-2.8 s period); `topology: primary` (panel stays active)
stopped it outright (16.3 stalls/min → 0). Our own .221 logged the identical
signature the day before. The detector then steered AWAY from the cause:
connected_inactive filtered to EXTERNAL physicals only, so the one display
that mattered — on a hybrid laptop, the default config — reported as `none`.

- TargetInventory: new `internal_panel` class bit (eDP/LVDS/embedded).
- connected_inactive_externals → connected_inactive_physicals: internal
  panels join the suspect list (virtual/indirect stay excluded); a nameless
  panel renders as "laptop panel".
- The below-OS METRONOMIC warn names the dark-panel case and its actual
  cures (`topology: primary`, `pnp_disable_monitors`) — the old text's
  "unplug it" prescriptions are impossible for an internal panel.
- ddc_power_off: a zero-ack sweep now says at INFO that the axis did nothing
  (internal panels have no DDC/CI) instead of silently no-op'ing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:04:08 +02:00
enricobuehlerandClaude Fable 5 ab58fd2f0e feat(vdisplay/driver): DDC/CI against the virtual monitor fails fast
In exclusive topology the virtual display is the ONLY monitor on the desktop,
so monitor-control software (the Twinkle Tray / PowerToys PowerDisplay /
Monitorian class) aims its entire DDC traffic — brightness polls,
capabilities-string requests — at OUR monitor. There is no bus and no sink;
the only wrong answer is a slow one (a timeout-shaped failure occupies
win32k's physical-monitor path, serialized per monitor, for its full
duration). Register EvtIddCxMonitorI2CTransmit/Receive and answer every probe
with an immediate STATUS_NOT_SUPPORTED, making the virtual monitor the
cheapest thing on the "bus" to poll. (The receive DDI has no out-arg at the
callback — refusing synchronously refuses the whole transaction.) EDID needs
no equivalent: the OS serves descriptor queries from the blob supplied at
monitor creation without calling the driver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:37:06 +02:00
enricobuehlerandClaude Fable 5 1945052e66 fix(capture/vdisplay): descriptor-following defers while a topology reassert is in flight
The exclusive-topology reassert's forced re-commit transiently bounces the
virtual display's mode; the descriptor poller can sample that EVICTION state
and recreate the ring at a mode the recovery chain is about to undo (field
log 2026-07-27 10:30:44Z: hdr=true → false → recreate → recovery restores
true → second recreate). New pf-win-display::topology_churn latch (deadline
semantics — self-expiring, so a lost release can never wedge descriptor-
following off): the watchdog holds it for interval+3s each fighting round and
releases on "stable again"; poll_display_hdr consumes samples but acts on
nothing while held — which also keeps the negotiated-depth pin-back from
issuing a CCD write mid-eviction, where it would fight the reassert itself.
The deliberate recreate_ring_in_place recovery path is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:37:05 +02:00
enricobuehlerandClaude Fable 5 9e1a686795 feat(tools): display-disturb — deterministic display-stack disturbance generator
The stall-immunity bench needs both stall classes on demand, without a standby
TV or a monitor-tool storm: `ddc` replays the Twinkle-Tray/PowerDisplay-class
DDC/CI traffic (VCP reads, optional capabilities-string requests) through the
win32k I2C path; `modeset` re-commits the current mode with CDS_RESET — the
Level-Two modeset entry that idles the whole adapter with nothing Win32-visible
changing. Every op prints an epoch-stamped took_ms line so host.log stall
reports correlate line-for-line, and the op duration itself measures the
driver's service time per disturbance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:24:20 +02:00
enricobuehlerandClaude Fable 5 19d6f79d2d feat(vdisplay/driver): the frame pump survives MMCSS refusal and outranks GPU contention
Two stall-immunity hardenings for the swap-chain drain thread (branch-2 of the
disturbance-immunity program — failures in OUR delivery leg, as opposed to
adapter-wide display servicing, which no priority survives):

- MMCSS registration fails under the restricted WUDFHost token on some boxes;
  the drain thread then ran UNPRIORITIZED — the whole display's frame pump at
  normal priority, starvable by DDC/HPD-servicing DPC pressure into
  multi-hundred-ms delivery holes. Fall back to TIME_CRITICAL.

- IddCxSetRealtimeGPUPriority (IddCx 1.9) raises the processing device above
  every regular application's GPU priority. Slot availability comes from
  raising the exported IddMinimumVersionRequired 4 → 10, which was overdue
  independently: the drain loop already calls ReleaseAndAcquireBuffer2 (1.10)
  unconditionally, so binding a pre-1.10 framework would dispatch past the
  populated IddFunctions table — with 10 an old framework fails the bind
  cleanly instead. The product floor is already Win11 22H2 (installer
  MinVersion=10.0.22621, whose framework is 1.10), so no supported system
  changes behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:24:10 +02:00
enricobuehlerandClaude Fable 5 94f0207cbd feat(windows/client): logs persist — %LOCALAPPDATA%\punktfunk\logs\client.log
The shell is a windows_subsystem binary and spawns punktfunk-session with
CREATE_NO_WINDOW + inherited stderr: a normal GUI/MSIX launch has no
console, so every log line — the shell's and the session's whole
receive/decode/present forensic trail — evaporated exactly when a user
hit something worth reporting. The 2026-07 PyroWave latency-sawtooth
report had to be triaged from host logs alone; the deciding evidence
(clock re-sync vs backlog-flush lines) lives client-side.

- New logfile module: %LOCALAPPDATA%\punktfunk\logs\client.log,
  the host's rotation convention (10 MB cap -> .old at next start, one
  generation), best-effort (no dir -> plain stderr, never a startup
  failure).
- The tracing subscriber tees to stderr AND the file (ANSI off — the
  file is what users send); startup logs the path.
- The spawned session's stderr is piped through the same tee,
  line-buffered — dev-terminal runs keep their interleaved output,
  GUI runs finally keep anything at all.
- The --console path and the punktfunk-console.exe MSIX shim forward
  the same way (a couch launch has no console either).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 2c69cbdab9 docs(host/stats): say what the stats actually measure — three field-triage traps
Three lines that each sent the 2026-07 PyroWave field triage down a wrong
path, made honest:

- The 'client accepts streamed AUs … will stream per-slice' log fires
  before the encoder exists and regardless of backend support (only Linux
  direct-NVENC implements chunked polling) — it now states the client
  capability only.
- StatsSample::mbps was documented as 'transmit goodput'; it is attempted
  sealed wire bytes at seal time — AU bytes + shard framing + FEC parity
  (+ PyroWave window padding), not reduced by socket send drops.
- The per-stage split's 'encode' stage is the poll() duration, which is
  ~0 by construction for synchronous backends (PyroWave encodes inside
  submit → its time shows as 'submit') — documented at the split's
  definition so 'encode 0.00' stops reading as an instrumentation hole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 d8d8c6c43d fix(core/abr): the capacity probe's own overload no longer reads as session congestion
The startup speed-test probe deliberately overloads the link (2 Gb/s for
800 ms) to measure capacity. The report tick is suppressed while it runs
and the window anchors are rebased when it ends — but two residues leak
past the rebase: probe frames still pending in the reassembler age out as
frames_dropped for another ~120 ms (the loss-window fuse), and the burst
can latch flush_in_window (a jump-to-live it caused itself). Either one
reads as a SEVERE window: the controller backs off ×0.7 and — worse —
slow start ends at the first congestion signal, permanently.

That is precisely the 2026-07 field report's Automatic session: 20→14
Mb/s exactly one report tick after the probe, then a purely additive
+6 %/4.5 s crawl that took the whole 4.5-minute match to reach 308 Mb/s
on a link the probe had just measured at ~1.2 Gb/s. Slow start (doubling
per clean window to the probed ceiling, 'seconds instead of minutes')
never got to run.

The first post-probe report window is now discarded outright: no
LossReport (probe residue would also spike the host's adaptive FEC), the
standing-latency detector closes it as not-loss-free (its clean-run just
resets), and the ABR is fed nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 b8b8ac336c feat(core/client): all-intra streams drain the frame channel to the newest AU
The pre-decode FrameChannel is strictly FIFO because H.26x reference
chains forbid mid-stream drops — falling behind is only recoverable via
the coarse jump-to-live thresholds (depth ≥6 sustained, or 400 ms behind
the capture clock) at the cost of a keyframe round-trip.

PyroWave has no reference chains: every AU decodes independently, so a
slow consumer can skip straight to the newest queued AU with zero
recovery cost. The pump now flags the channel all-intra for PyroWave
sessions and pop() drains to the newest, which caps any standing
pre-decode queue at ~1 frame structurally — the 2026-07 field report's
780M client could otherwise ratchet a real multi-frame backlog (and with
it the OSD latency) between those coarse thresholds.

Skips are counted separately from losses (they were delivered, just
superseded) and surfaced at debug on the report tick — the OSD 'lost'
line keeps meaning wire loss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 6d1baa0add fix(pf-encode/pyrowave): the bitrate pin holds on the WIRE, not the raw bitstream
A datagram-aligned PyroWave session inflates the codec bitstream ×1.2–1.3
on its way to the wire — greedy packing of few-hundred-byte atomic block
packets into 1408 B windows zero-pads most window tails, plus the 4-byte
prefixes and FRAG chains. The 2026-07 field report's 1440p60 10-bit
"Automatic" pin of 407 Mb/s put a measured 550 Mb/s on a 1 GbE link;
nothing enforced the pin past the rate controller.

New shared WireBudget (pyrowave_wire.rs, both backends): tracks the real
per-frame AU/bitstream ratio as a ×1024 fixed-point EMA (prior ×1.25,
weight 1/8, clamped ×1.0–×2.0) and deflates the budget handed to
pyrowave's rate control by it, so the windowed AU lands on the configured
rate. Sealed-datagram framing (+4.5%) and FEC parity stay uncompensated —
H.26x sessions carry those on top of the configured bitrate too, and the
pin must mean the same thing for every codec.

Dense (non-chunked) sessions are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 53640b8754 fix(core/clock): re-sync survives loaded links — floor baseline, spaced rounds, bounded staleness
The mid-stream clock re-sync starved on high-bitrate LAN sessions (2026-07
PyroWave-sawtooth field report, RX 9070 XT -> 780M @ 550 Mb/s): every batch
was judged against the CONNECT-TIME RTT, measured before the video data
plane existed, with a 2 ms floor — mid-stream control RTTs on a loaded GbE
link sit above that almost permanently, so batches were rejected for
minutes while the wall clocks drifted apart and the OSD e2e figure ramped
19->150 ms before snapping back on a lucky batch.

Three changes:
- Rounds are spaced 7 ms apart (stamped at send time, so the spacing never
  lands in the RTT). An 8-round batch used to complete inside ONE ~6 ms
  video burst — all rounds sampled the same congestion state; spacing walks
  them across the frame cycle so the min-RTT round finds a quiet gap.
- ResyncGuard replaces the static baseline: the guard band follows the best
  RTT the session has evidenced (connect RTT, then min over every completed
  batch — rejected ones included).
- Rejection streaks are bounded: after 3 consecutive rejections the best
  (min-RTT) batch of the streak is applied anyway. Its queueing bias is at
  most ~half its RTT; unbounded wall-clock drift costs more per minute.

Rejections now log at warn with the streak and floor — the starvation
signature is grep-able in exactly the logs users send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehler 94b7a834cb fix(installer/windows): GameStream is opt-in, not on by default
A user found this in their log and had never been offered a choice:

  WARN GameStream/Moonlight compat ENABLED (--gamestream): its pairing runs over
  plain HTTP and its legacy control encryption can reuse GCM nonces
  (security-review #5/#9) — an on-path LAN attacker could MITM pairing or
  recover input. Enable only on a TRUSTED network.

They were right. The `gamestream` task carried no `Flags: unchecked`, so it was
pre-ticked; and since a silent install takes the wizard's own task defaults
(1839d756), a winget install turned it on with no checkbox ever shown. The host
warns that this plane is a security downgrade on every single start, so having
the installer enable it by default cannot be squared with our own advice — least
of all on the path where nobody sees a wizard.

Now unchecked, matching `allowpublicfw`, the other task with a security
consequence. A default install therefore lands `PUNKTFUNK_HOST_CMD=serve`, the
native-only host; Punktfunk's own clients are unaffected, since the native
punktfunk/1 plane is always on. Unattended opt-in is `/MERGETASKS=gamestream`.

Scope, deliberately: this changes FRESH installs only. `GamestreamParam` already
omits the flag entirely unless `FreshHostInstall`, so no existing host has its
choice touched by an upgrade, and anyone relying on Moonlight today keeps it.
`DEFAULT_HOST_CMD` (the service's fallback when host.env carries no
PUNKTFUNK_HOST_CMD line at all) is deliberately NOT flipped here: the installer
always writes the line explicitly, so it does not affect a normal install, and
inverting it would silently disable Moonlight for anyone whose host.env lost that
line — a runtime regression that belongs in its own change, if at all.

Docs corrected everywhere they stated the old default, since three of them now
told users the opposite of what ships: the winget Agreement shown BEFORE install
(it said "enabled by default" and gave the opt-OUT override), the Windows
packaging README, and host.env.example. The `--override` example in the installer
manifest is inverted with it (`/MERGETASKS=gamestream` to add, `!` to remove).

Verified: [Tasks] + [Code] compile with ISCC 6.7.1 on the windows-amd64 runner
(all optional features defined), and the winget catalogue rebuilds and passes
29/29 with the edited manifests.
2026-07-27 12:26:47 +02:00
enricobuehler 4114dfeff7 fix(winget): the Log switch used |LOGPATH|, which winget never substitutes
Second field report on 0.20.0's winget path, and independent of the conflict
abort in 37781a61: an INTERACTIVE install through UniGetUI dies before the
wizard with Inno's "Error creating log file: The filename, directory name, or
volume label syntax is incorrect."

`<LOGPATH>` is winget's own token and the only spelling it replaces — the
schema this manifest declares says so verbatim ("<LOGPATH> token can be included
in the switch value so that winget will replace the token with user provided
path"). We shipped `|LOGPATH|`, which is not a token, so winget passed the
literal string to Inno; `|` is not legal in a Windows filename, and Inno refuses
before doing anything else.

Worse than the conflict abort in two ways: it is not confined to silent installs
— it fires in EVERY mode for any caller that requests a log — and UniGetUI
requests one by default, so a GUI user cannot install at all. Confirmed against
the live source, which is serving the broken switch to everyone right now:
  curl -s https://winget.punktfunk.unom.io/packageManifests/unom.PunktfunkHost
  → "Log": "/LOG=\"|LOGPATH|\""

Guarded by the suite that exists for exactly this class of defect (a wrong
response shape does not fail loudly — winget just says "no package found"). The
new check accepts an absent Log switch, requires <LOGPATH> when present, and
rejects any |TOKEN| spelling. Mutation-verified: restoring |LOGPATH| fails the
check naming the offending value, and the corrected manifest passes 29/29.

⚠ Fixing the template does NOT fix the live catalogue: it is derived from the
manifest trio attached to each release, so the corrected switch only reaches
users when a release carries it.
2026-07-27 10:28:59 +02:00
enricobuehler 37781a610f fix(installer/windows): only a host that will actually RUN is a conflict
Field report within hours of 0.20.0 (HitFrostbite): `winget install
unom.PunktfunkHost` fails with "Installer failed with exit code: 1",
0x8A150006, after the hash verifies and the install starts.

That is Setup aborting on purpose. `StreamHostPresent` counted a conflicting
host as present if its SCM service key existed at ANY start type, OR if a bare
Program Files\<Name> directory existed — so a disabled service, or a folder left
behind by an uninstall, read as a live conflict. 3d893013 then made the warning
a SuppressibleMsgBox defaulting to IDNO so unattended installs could not hang on
it; under winget's /VERYSILENT /SUPPRESSMSGBOXES that default aborts
InitializeSetup, and Inno exits "failed to initialize" = code 1. Net effect:
anyone with Sunshine on disk — the exact audience most likely to migrate — could
not install via winget at all, with no visible reason.

The other candidate for exit 1 is excluded rather than assumed: the manifest
declares MinimumOSVersion 10.0.22621.0 matching the script's MinVersion, so
winget refuses an unsupported OS BEFORE downloading, and the report shows the
hash verified and the install starting. The conflict check is also the only
`Result := False` in InitializeSetup.

Detection now keys on the service's `Start` value (0 boot / 1 system /
2 automatic / 3 manual / 4 disabled), counting only 0-2 — a host that comes up
on its own is the only kind that can take the GameStream ports or load a second
virtual-display driver. Directories no longer count at all: they were the
noisiest signal and the one that survives an uninstall. This makes the installer
agree with the tray, which dropped its always-on warning over a merely-INSTALLED
Sunshine in this same release (3e782852) for exactly the same reason. The
message now says "installed and set to start automatically" and offers disabling
the service as a remedy, both of which are what is actually detected.

A conflicting host running as a plain user process is deliberately not detected:
pure Pascal cannot enumerate processes, it is a runtime rather than install-time
condition, and the host already reports it via the `punktfunk::detect` startup
warning, `detect-conflicts`, and /api/v1/local/summary.

Verified by compiling the [Code] section with ISCC 6.7.1 on the windows-amd64
runner (WithWeb defined, as CI does). That caught two defects in my own first
draft that review would not have: a continuation line STARTING with #13#10, which
ISPP reads as a preprocessor directive ("Unknown preprocessor directive", compile
aborted), and `{commonpf}` written inside a `{ }` comment, which closes it early
since Inno comments do not nest. Both are noted at the sites so they are not
reintroduced.
2026-07-27 09:00:34 +02:00
enricobuehler ec9aa415f6 chore(web): override brace-expansion to 5.0.8 — clears GHSA-mh99-v99m-4gvg
bun-audit has been red since the advisory published, so it was already failing
before v0.20.0 was cut (same failure on 0323158a). High-severity DoS: unbounded
expansion length causing an OOM crash. All three paths are dev tooling —
orval→typedoc→minimatch, @storybook/react-vite→glob→minimatch, and
nitro-v2-vite-plugin→nitropack→archiver→archiver-utils→glob→minimatch — so
nothing shipped to a user was exposed, but the leg gates every lockfile change.

Done as an `overrides` entry, joining the eight already there for exactly this
purpose. `bun update brace-expansion` is NOT the fix and was reverted: for a
package that is only transitive it adds a spurious DIRECT dependency, and it
does not even resolve the advisory — it hoists 5.0.8 for the new direct dep and
leaves `minimatch/brace-expansion` pinned at the vulnerable 5.0.7.

The advisory's affected range is `<= 5.0.7` across ALL majors with 5.0.8 the
only patched version, so the two nested 2.1.2 copies had to move too — a major
bump for consumers declaring ^2.0.1/^2.0.2. Checked rather than assumed: 5.0.8
is dual ESM/CJS with a real `require` branch in its exports map (2.1.2 was
plain CJS), so those consumers still resolve. Proven by building: the nitro
build is itself one of the forced consumers.

Verified: `bun audit` reports no vulnerabilities, both nested entries are gone
from the lock, and the full CI web sequence is green —
`bun install --frozen-lockfile --ignore-scripts`, `bun run build`, `bun run lint`.
2026-07-27 07:32:08 +02:00
enricobuehler 24d2f97eae fix(ci/winget): forward GITHUB_REF_NAME into the remote shell
The catalogue-verify step has failed on every stable tag since it landed,
v0.20.0 included: `bash: line 6: GITHUB_REF_NAME: unbound variable`, twice,
then exit 1.

`env:` populates the RUNNER's environment. appleboy/ssh-action executes its
`script` on the REMOTE host over SSH, which inherits none of it — the action
has a separate `envs:` input naming the variables to forward. Without it the
`set -u` at the top of the script turns the first expansion into a fatal, and
the two `curl`s either side report only the collateral: `Failed writing body`
is curl losing its pipe when the `grep` it was feeding died.

The failure is maximally misleading, because everything it guards ALREADY
WORKED: the catalogue is built, tested and shipped in the preceding steps, and
only the proof-of-serving fails. Confirmed by hand against the live source
during the v0.20.0 release — it serves unom.PunktfunkHost 0.20.0 correctly.

The pattern was already right everywhere else: both "Pull and start docs" steps
(deploy-services.yml, docker.yml) pair `env:` with `envs:`. A sweep of the
other three ssh-action steps found no further instance — the remaining two pass
no variables at all.
2026-07-27 07:31:55 +02:00
enricobuehler c9b8f666cd docs(release): winget needs its source added first — say so
Reported within an hour of the announcement: `winget install unom.PunktfunkHost`
answers "no package found". That is correct behaviour, not a broken package —
Punktfunk is served from its own REST source, and winget only searches sources
it has been told about. The notes said "after adding Punktfunk's package source
once" without ever giving the command, so there was no way to act on it.

Both commands are now spelled out, the `winget source add` one flagged as
needing an Administrator terminal (it does), and the failure mode is stated
explicitly so someone who hits it recognises it rather than filing it as a bug.

The lead-in is untouched, so the Discord embed for v0.20.0 stays accurate and
this needs no re-announcement — the release body is re-synced from this file.
2026-07-27 07:31:37 +02:00
enricobuehler 35b6c835fd docs: on a gamescope session the display outranks the end-game setting
On-glass on .41: with `game_on_session_end: keep` — "leave it running, nothing
is ever closed" — a deliberate stop ended the game, and a drop ended it ~14 s
later. Both times the LEASE did the right thing (no `ending the launched game`
line); the game died because the virtual display was torn down, and a nested
launch runs inside that gamescope.

So on a dedicated gamescope session the game's lifetime IS the display's, and
keep-alive decides it — not this setting:

  * a deliberate stop skips the linger by design, so the game goes at once,
  * a drop lingers the keep-alive window, then takes the game with it,
  * only keep-alive Forever actually keeps it — and `always` still ends it at
    the reconnect window, which is the documented precedence and was verified
    (a `forever`-pinned display released at grace expiry, 60.006 s).

That makes the console's promise untrue on the most common Linux setup, so the
card now says so for every option rather than only warning about `always`, and
the docs get the three cases as a table. Worded so a non-gamescope host reads it
and moves on — a desktop-session launch is an ordinary process and none of it
applies.

Not changing behavior here: making `keep` pin the display would pit it against
"a stop must not leave a ghost display", and which wins is a product call rather
than something to decide silently mid-test.

web: codegen + tsc + biome clean, en/de complete. docs-site: build + tsc clean.
2026-07-27 01:43:40 +02:00
enricobuehler 39a9f09f04 fix(mgmt/web): the running-game cover slot says "game" when there is no art
Rendered the Dashboard stories headlessly and looked at them, which is how this
turned up: with no cover the slot was an empty grey rectangle, reading as
something that failed to load rather than something absent.

Plenty of rows will never have art — an operator-typed command has no catalog
entry behind it, a custom entry may carry none, and nothing does until
`/library` has loaded. The slot keeps its fixed size so rows stay aligned when
only some titles have covers; it just holds a gamepad glyph instead of nothing.
2026-07-27 00:47:05 +02:00
292 changed files with 29689 additions and 4978 deletions
+38
View File
@@ -112,10 +112,48 @@ jobs:
makepkg -f -d --holdver
ls -lh "$GITHUB_WORKSPACE/dist"
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
# completely different dependency set, published into the same repo so `pacman -S
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
#
# CACHED on `packaging/gamescope/**`: it depends on nothing else in this repo, so a normal
# push restores the built package instead of spending ~10 minutes on someone else's C++ tree.
# Arch is rolling, so the cache is invalidated by our own patch changes only — a stale binary
# against newer system libs is the same risk the distro's own package carries between rebuilds.
- uses: actions/cache@v4
id: gamescope
with:
path: dist-gamescope
key: punktfunk-gamescope-arch-${{ hashFiles('packaging/gamescope/**') }}
- name: Build punktfunk-gamescope (makepkg)
if: steps.gamescope.outputs.cache-hit != 'true'
# Best-effort: punktfunk-host works without it (SDR on the gamescope backend), and a
# failure building gamescope must not cost the packages this workflow exists to publish.
run: |
set -x
pacman -S --noconfirm --needed \
glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \
libxkbcommon libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \
hwdata luajit pipewire seatd sdl2-compat vulkan-icd-loader wayland \
xcb-util-errors xcb-util-wm xorg-xwayland \
meson cmake glm wayland-protocols benchmark libxcursor || true
mkdir -p dist-gamescope && chown builder: dist-gamescope
chown -R builder: packaging/gamescope
if sudo -u builder env PKGDEST="$GITHUB_WORKSPACE/dist-gamescope" \
bash -c 'cd packaging/gamescope && makepkg -f -d --holdver'; then
ls -lh dist-gamescope
else
echo "::warning::punktfunk-gamescope failed to build — Arch boxes stay SDR on the gamescope backend this run"
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
fi
- name: Publish to the Gitea Arch registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
# The gamescope companion rides the same loop (same repo, same channel).
cp -f dist-gamescope/*.pkg.tar.zst dist/ 2>/dev/null || true
for pkg in dist/*.pkg.tar.zst; do
echo "uploading $pkg"
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
+24
View File
@@ -26,6 +26,30 @@ jobs:
apt-get update
apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libsdl3-dev
# The committed pf-zerocopy SPIR-V blobs are pulled in with include_bytes! and rebuilt only
# by hand — edit a .comp, forget the rebuild, and the OLD kernel ships with no compile error
# or failing test. Recompile each shader and diff the disassembly. Filtering OpSourceExtension
# (+ --no-header) is exactly what absorbs the shaderc-vs-glslang generator difference; every
# instruction, ID and constant must match.
#
# Disassemble to FILES rather than `diff <(…) <(…)`: Gitea's runner executes a step's `run:`
# under `sh -e`, not bash, and dash has no process substitution — the shell rejected the
# script at PARSE time, so the gate never compared anything. Worse, it took the whole `rust`
# job with it: Format, Clippy, Build, Test and every gate below were skipped on each of the
# 35 commits between the gate landing (143a707f) and this fix. A gate that cannot run is
# indistinguishable from one that passes, which is exactly the failure it exists to prevent.
- name: Shader SPIR-V drift gate (pf-zerocopy)
run: |
apt-get install -y --no-install-recommends glslang-tools spirv-tools
for s in rgb2nv12_buf cursor_blend; do
d=crates/pf-zerocopy/src/imp
glslangValidator -V "$d/$s.comp" -o "/tmp/$s.spv" >/dev/null
spirv-dis --no-header "$d/$s.spv" | grep -v OpSourceExtension > "/tmp/$s.committed"
spirv-dis --no-header "/tmp/$s.spv" | grep -v OpSourceExtension > "/tmp/$s.rebuilt"
diff "/tmp/$s.committed" "/tmp/$s.rebuilt" \
|| { echo "::error::$d/$s.spv is stale — rebuild it from $s.comp"; exit 1; }
done
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
# registry/git are download caches, target/ the incremental build. The target key
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
+11 -4
View File
@@ -234,11 +234,18 @@ jobs:
git config --global --add safe.directory "$PWD"
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8
# via PKG_CONFIG_PATH (set in rust-ci-noble).
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). ffmpeg-sys-next links the image's
# bundled FFmpeg 8 via PKG_CONFIG_PATH (set in rust-ci-noble).
#
# punktfunk-tray is deliberately NOT in this invocation — build-deb.sh builds it separately,
# and that split is load-bearing (see the identical note in the RPM spec / Arch PKGBUILD):
# cargo unifies features across one build, so co-building the tray with the host pulls the
# host's ashpd -> zbus/tokio onto the tray's shared zbus and the tray panics at every launch
# with "there is no reactor running, must be called from the context of a Tokio 1.x runtime".
# It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and
# Arch packages — which already split it — were fine.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-tray
-p punktfunk-host
- name: Build host .deb (FFmpeg bundled)
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
+54
View File
@@ -59,6 +59,11 @@ jobs:
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
# sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and
# on a cache hit (the common case) nothing else in this job would have pulled libavif /
# luajit / seatd / SDL2 in. Cheap, and it tracks gamescope's dep list for us.
dnf -y install gamescope || true
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
command -v bun >/dev/null || {
@@ -124,13 +129,62 @@ jobs:
done
echo "published to $OWNER/rpm/$GROUP"
# The HDR-capable gamescope the sysext carries (packaging/gamescope) — what lets the
# gamescope backend stream 10-bit BT.2020 PQ instead of 8-bit SDR.
#
# CACHED, and that is the whole reason this is affordable: it is a ~10-minute C++ meson build
# of an entirely separate tree that depends on NOTHING in this repo except
# `packaging/gamescope/**` (the patches and the upstream pin, which lives in the build
# script). So the key is that directory's hash and a normal push restores a binary instead of
# building one. Per-Fedora-major, because the binary is soname-coupled to its base exactly
# like the RPM is — an f43 build does not start on f44 (libavutil.so.59 vs .60).
- uses: actions/cache@v4
id: gamescope
with:
path: gs-cache
key: punktfunk-gamescope-f${{ matrix.fedver }}-${{ hashFiles('packaging/gamescope/**') }}
- name: Build the HDR gamescope
if: steps.gamescope.outputs.cache-hit != 'true'
# Best-effort ON PURPOSE. The sysext is the primary Bazzite delivery path and works without
# this binary (the host just stays SDR on the gamescope backend, which is what every
# release before this one did) — so a hiccup building someone else's tree must not cost the
# whole image. It is loud, though: the warning below, and `--gamescope` silently absent
# downstream is impossible because build-sysext.sh verifies the +pfhdr marker itself.
run: |
set -x
# `dnf builddep` resolves Fedora's PACKAGED gamescope, which is older than the master we
# pin, so it can come up short — xorg-x11-server-Xwayland-devel is the one that actually
# bites (wlroots' configure dies on a missing xserver.wrap several minutes in).
dnf -y install dnf-plugins-core meson ninja-build glslc || true
dnf builddep -y gamescope || true
dnf -y install xorg-x11-server-Xwayland-devel || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
- name: Build the sysext image
run: |
# Execute it here rather than only handing it over: build-sysext.sh treats an unusable
# --version as fatal (rightly — it is how the +pfhdr marker is read), and a cached binary
# whose runtime libs are missing from this container must cost the image its HDR, not the
# image itself.
gs=()
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
gs=(--gamescope gs-cache/punktfunk-gamescope)
echo "folding in $(gs-cache/punktfunk-gamescope --version 2>&1 | head -1)"
else
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
"${gs[@]}" \
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
+2 -2
View File
@@ -153,9 +153,9 @@ jobs:
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
# toolchain-only probe crate and is excluded.)
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
run: |
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
+33 -2
View File
@@ -172,8 +172,8 @@ jobs:
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
# pf-vkhdr-layer's clippy below runs --release.
#
# pf-encode and pf-capture are linted SEPARATELY with --all-targets so their Windows
# `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions
# pf-encode, pf-capture and pf-vdisplay are linted SEPARATELY with --all-targets so their
# Windows `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions
# (`variant_layout_matches_c` and friends, which are the only guard on a hand-mirrored
# vtable ABI), the QSV tests, the PyroWave-Windows smoke test; pf-capture's `StallWatch`
# tests, the DXGI HDR self-tests and the cursor-conversion tables. The host lint above
@@ -199,6 +199,7 @@ jobs:
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
cargo clippy --release -p pf-capture --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-capture clippy" }
cargo clippy --release -p pf-vdisplay --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-vdisplay clippy" }
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
- name: Test (pf-capture, Windows)
@@ -223,6 +224,28 @@ jobs:
run: |
cargo test --release -p pf-capture; if ($LASTEXITCODE) { throw "pf-capture tests" }
- name: Test (pf-vdisplay, Windows)
shell: pwsh
# pf-vdisplay's Windows half is ~3,400 lines (manager.rs, pf_vdisplay.rs, ddc.rs, the three
# manager/ submodules) that NO other job compiles — the host lint above builds the crate as
# a dependency, so its test targets were reaching no compiler anywhere. Worse, the only two
# Windows `#[test]`s were `if env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() { return; }`
# early-returns, so an unrun hardware test reported `ok`. They are `#[ignore]`d now and this
# step reports them as `ignored`, which is the truth.
#
# The link objection recorded for the host and pf-encode above does NOT apply here, for the
# same reason it does not apply to pf-capture: `pf-encode` is `default = []`, and nothing in
# `-p pf-vdisplay`'s graph turns on `nvenc`/`amf-qsv`/`qsv`, so no nvidia/ffmpeg/libvpl
# import libs are ever asked for. Settled empirically before this step was added, to the
# same standard as pf-capture's: the exact two commands were run on this runner against a
# checkout at C:\temp\pf-vd-check — clippy clean, then 46 passed / 2 ignored in 0.19 s.
#
# --release to reuse C:\t\release rather than spawning a debug tree (the C1069 trigger).
# Note this DOES build a second, featureless pf-encode; it is small precisely because none
# of the encoder features are on.
run: |
cargo test --release -p pf-vdisplay; if ($LASTEXITCODE) { throw "pf-vdisplay tests" }
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
shell: pwsh
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
@@ -436,5 +459,13 @@ jobs:
| grep -q "${GITHUB_REF_NAME#v}" \
|| { echo "served catalogue does not contain ${GITHUB_REF_NAME#v}"; exit 1; }
echo "winget source serving ${GITHUB_REF_NAME#v}"
# `env:` below populates the RUNNER's environment; this action runs `script` on the
# REMOTE host, which inherits nothing from it. `envs:` is the action's OWN input —
# it must live under `with:` (matching docker.yml/deploy-services.yml's REGISTRY_TOKEN
# forwarding) — naming the variables to forward into the remote shell. A prior fix put
# it as a step-level sibling of `with:`/`env:` instead: that key is not part of the
# step schema, so appleboy/ssh-action never received it as an input and the step kept
# failing ("GITHUB_REF_NAME: unbound variable") on every tag after 24d2f97e too.
envs: GITHUB_REF_NAME
env:
GITHUB_REF_NAME: ${{ gitea.ref_name }}
Generated
+35 -27
View File
@@ -1020,6 +1020,13 @@ dependencies = [
"subtle",
]
[[package]]
name = "display-disturb"
version = "0.21.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
@@ -2194,7 +2201,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.20.0"
version = "0.21.0"
[[package]]
name = "lazy_static"
@@ -2299,7 +2306,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"bindgen",
"cmake",
@@ -2334,7 +2341,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"punktfunk-core",
]
@@ -2823,7 +2830,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2844,7 +2851,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -2856,6 +2863,7 @@ dependencies = [
"pipewire",
"punktfunk-core",
"pyrowave-sys",
"rand 0.9.4",
"rustls",
"sdl3",
"serde",
@@ -2868,7 +2876,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2886,7 +2894,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -2907,7 +2915,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -2931,7 +2939,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"ash",
"bindgen",
@@ -2940,7 +2948,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"libc",
@@ -2952,7 +2960,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2966,11 +2974,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.20.0"
version = "0.21.0"
[[package]]
name = "pf-inject"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2999,14 +3007,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -3021,7 +3029,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3053,7 +3061,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3065,7 +3073,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -3273,7 +3281,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"android_logger",
"jni",
@@ -3289,7 +3297,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3305,7 +3313,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3320,7 +3328,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3339,7 +3347,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3371,7 +3379,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3455,7 +3463,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3469,7 +3477,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"anyhow",
"ksni",
@@ -3492,7 +3500,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"bindgen",
"cmake",
+31 -1
View File
@@ -28,6 +28,7 @@ members = [
"clients/session",
"clients/windows",
"clients/android/native",
"tools/display-disturb",
"tools/latency-probe",
"tools/loss-harness",
]
@@ -48,13 +49,42 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.20.0"
version = "0.21.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
authors = ["unom"]
repository = "https://git.unom.io/unom/punktfunk"
# The `unsafe` discipline the `packaging/windows/drivers/*` crates already run, extended to the
# workspace. `unsafe fn` marks a CONTRACT the caller must uphold; it is not a licence for the whole
# body to skip checking. Without this lint an `unsafe fn` body is unchecked end to end, so a 600-line
# function hides which handful of lines are actually the unsafe ones — exactly the reviewer-hostile
# shape we are working down. (This is the Rust 2024 default; adopting it early also pays off the
# edition migration.)
#
# `deny`, not `warn`. `warn` was never actually a softer setting: CI runs `cargo clippy … -D
# warnings`, which promotes it to a hard error anyway — that is how adopting this lint turned main
# red on every platform for a day without the level in this file ever saying `deny`. A level that
# lies about its own severity is worse than a strict one, so this now states what CI already does,
# and the exemptions are written down per file instead of hiding in a 689-warning wall nobody reads.
#
# THE EXEMPTIONS. Fourteen GPU/FFI backend files carry `#![allow(unsafe_op_in_unsafe_fn)]` with a
# one-line reason each. They are not "not done yet" — they are where this lint stops paying:
# their bodies are ash/CUDA/AMF/libav calls almost line for line (measured: 64% of the sites are a
# single third-party FFI call, and of the 44 `unsafe fn`s in them only 4 have a body containing no
# unsafe operation at all). Narrowing them means one `unsafe {}` per line plus, since pf-encode also
# denies `clippy::undocumented_unsafe_blocks`, one hand-written SAFETY comment per line that could
# only ever restate "an ash call on a live device" — the precise noise that made `unsafe` stop
# meaning anything here before (see the header of `pf-win-display/src/win_display.rs`).
#
# Everything else in the workspace is at zero and enforced. Removing one of those allows, file by
# file, is real work with a real payoff; blanket-narrowing all fourteen is not. Prefer DELETING an
# `unsafe fn` marker over wrapping its body: keep the marker only where a caller can actually break
# something (a raw pointer, a borrowed HANDLE, a GPU object that must not be in flight).
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
[profile.release]
opt-level = 3
lto = "thin"
+154 -3
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.19.2"
"version": "0.20.0"
},
"paths": {
"/api/v1/clients": {
@@ -190,6 +190,38 @@
}
}
},
"/api/v1/display/monitors": {
"get": {
"tags": [
"display"
],
"summary": "Physical monitors",
"description": "The heads this host actually has — for pinning capture at one (`PUNKTFUNK_CAPTURE_MONITOR`) and\nfor rendering a picker. Read-only: this never creates, moves or disables anything. Note these\nare *not* the managed virtual displays — those are `/display/state`. See\n`design/per-monitor-portal-capture.md` §5.1.",
"operationId": "getDisplayMonitors",
"responses": {
"200": {
"description": "The host's physical monitors",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MonitorsResponse"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/presets": {
"get": {
"tags": [
@@ -3646,6 +3678,66 @@
}
}
},
"ApiMonitorInfo": {
"type": "object",
"description": "One physical monitor this host has, as the compositor reports it.",
"required": [
"connector",
"description",
"mode",
"x",
"y",
"scale",
"primary",
"enabled",
"managed",
"selected"
],
"properties": {
"connector": {
"type": "string",
"description": "Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes."
},
"description": {
"type": "string",
"description": "Human label for a picker (`make model`, else the connector)."
},
"enabled": {
"type": "boolean",
"description": "Driven right now. A disabled head is still listed, so it can be explained rather than missing."
},
"managed": {
"type": "boolean",
"description": "Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only)."
},
"mode": {
"type": "string",
"description": "`WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown)."
},
"primary": {
"type": "boolean",
"description": "The compositor's primary/focused head."
},
"scale": {
"type": "number",
"format": "double",
"description": "Logical scale factor."
},
"selected": {
"type": "boolean",
"description": "True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor."
},
"x": {
"type": "integer",
"format": "int32",
"description": "Desktop-space top-left — what makes a head identifiable when two share a size."
},
"y": {
"type": "integer",
"format": "int32"
}
}
},
"ApiSelectedGpu": {
"type": "object",
"description": "The GPU the **next** session's pipeline will be created on, and why. (A preference change\napplies to the next session; a running session keeps the GPU it opened on.)",
@@ -3820,11 +3912,19 @@
"format": "int64",
"minimum": 0
},
"encoder_backend": {
"type": "string",
"description": "The encode backend that ACTUALLY opened for this session — `\"nvenc\"`, `\"vaapi\"`,\n`\"vulkan\"`, `\"amf\"`, `\"qsv\"`, `\"software\"`, … — and the GPU it runs on.\n\nRecorded because the stage split alone can't be read without them. A p50 `submit` of 10 ms\nmeans \"the GPU's CSC+encode throughput is the ceiling\" on one backend and something else\nentirely on another, and every fps-shortfall report so far has cost a round-trip asking\nwhich one it was. Both come from `pf_gpu::active()`, the record the encoder open itself\nwrites, so they name the branch that really opened rather than a re-derived guess.\n\n`\"\"` when nothing was streaming at registration (or on a build without the record)."
},
"fps": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"gpu": {
"type": "string",
"description": "Human-readable GPU name (`\"NVIDIA GeForce RTX 4090\"`, `\"CPU (openh264)\"`), or `\"\"`."
},
"height": {
"type": "integer",
"format": "int32",
@@ -4251,6 +4351,13 @@
"type": "object",
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
"properties": {
"capture_monitor": {
"type": [
"string",
"null"
],
"description": "**Mirror a physical monitor instead of creating a virtual display**: the connector name\n(`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.\n\nOrthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and\n`#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a\n**host-wide** setting, not per-client — the host-pinned decision of record in\n`design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see\n[`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it."
},
"ddc_power_off": {
"type": "boolean",
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
@@ -5720,6 +5827,43 @@
"reject"
]
},
"MonitorsResponse": {
"type": "object",
"description": "The host's physical monitors + which one capture is pinned to.",
"required": [
"monitors"
],
"properties": {
"compositor": {
"type": [
"string",
"null"
],
"description": "Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved."
},
"error": {
"type": [
"string",
"null"
],
"description": "Why the list is empty, when enumeration failed (compositor unreachable, unsupported\nplatform). `None` with an empty list means \"asked, and there are none\"."
},
"monitors": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApiMonitorInfo"
},
"description": "The heads, ordered left-to-right by desktop position."
},
"pinned": {
"type": [
"string",
"null"
],
"description": "The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,\nso the console can show \"pinned to DP-2, which this host doesn't have\"."
}
}
},
"NativeClient": {
"type": "object",
"description": "A paired native (punktfunk/1) client.",
@@ -6219,6 +6363,7 @@
"audio_streaming",
"pin_pending",
"paired_clients",
"native_paired_clients",
"active_sessions",
"games"
],
@@ -6240,10 +6385,16 @@
},
"description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game."
},
"native_paired_clients": {
"type": "integer",
"format": "int32",
"description": "Number of paired native (punktfunk/1) devices — the default plane, so on a host that has\nnever been touched by Moonlight this is the only non-zero one of the pair.",
"minimum": 0
},
"paired_clients": {
"type": "integer",
"format": "int32",
"description": "Number of pinned (paired) client certificates.",
"description": "Number of pinned (paired) GameStream client certificates. Native (punktfunk/1) devices pair\nagainst a separate store and are counted in `native_paired_clients` — sum the two for\n\"how many clients are paired with this host\".",
"minimum": 0
},
"pin_pending": {
@@ -6633,7 +6784,7 @@
"mbps": {
"type": "number",
"format": "float",
"description": "Transmit goodput (Mb/s)."
"description": "Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes\nplus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned\nmode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops."
},
"packets_dropped": {
"type": "integer",
@@ -339,6 +339,17 @@ class MainActivity : ComponentActivity() {
return true // consumed
}
}
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
// Resolved before the remote-pointer hook so pointer mode can't eat them as its own
// BACK. See [mouseSideButton] for how a mouse's BACK is told from a remote's.
mouseSideButton(event)?.let { back ->
when (event.action) {
KeyEvent.ACTION_DOWN ->
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
}
return true
}
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
@@ -355,13 +366,12 @@ class MainActivity : ComponentActivity() {
return true
}
when (event.keyCode) {
// A mouse's back/forward buttons already go over the wire as X1/X2 via their
// BUTTON_* motion edges — but Android ALSO delivers them as key events: the input
// reader synthesizes KEYCODE_BACK/FORWARD (stamped SOURCE_MOUSE) unconditionally,
// and a view-level FALLBACK BACK appears when the BUTTON_* press goes unconsumed.
// Swallow every such duplicate or it doubles as Android navigation and yanks the
// user out of the stream. A remote/keyboard BACK is never mouse-sourced, so it
// still falls through to the BackHandler and exits.
// Whatever [mouseSideButton] didn't claim. A view-level FALLBACK BACK appears when
// a BUTTON_* press goes unconsumed, and an air-mouse remote stamps its own BACK
// SOURCE_MOUSE; both are duplicates of something already handled, and letting
// either through doubles as Android navigation and yanks the user out of the
// stream. A remote/keyboard BACK is never mouse-sourced, so it still falls through
// to the BackHandler and exits.
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD ->
if (event.isFromSource(InputDevice.SOURCE_MOUSE) ||
event.flags and KeyEvent.FLAG_FALLBACK != 0
@@ -431,6 +441,34 @@ class MainActivity : ComponentActivity() {
return super.dispatchKeyEvent(event)
}
/**
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
* anything else — including a remote's or keyboard's BACK, which must keep exiting the stream.
*
* A mouse that carries its side buttons on the HID consumer page (AC Back / AC Forward) reaches
* us only as `KEYCODE_BACK`/`KEYCODE_FORWARD`, with no `BUTTON_BACK`/`BUTTON_FORWARD` motion
* edge behind it — on those, the motion path alone leaves the side buttons dead. The event may
* even be stamped SOURCE_KEYBOARD rather than SOURCE_MOUSE, because the consumer-page collection
* is a separate sub-device, so the DEVICE is what we ask: it has to be able to be a mouse.
*
* A D-pad-capable device is excluded even when it also reports a pointer: that is an air-mouse
* remote, whose BACK is the couch user's way out of the stream and must stay navigation.
* FLAG_FALLBACK events are excluded too — those are a duplicate the framework raises after an
* unconsumed BUTTON_* press, i.e. one the motion path already forwarded.
*/
private fun mouseSideButton(event: KeyEvent): Boolean? {
val back = when (event.keyCode) {
KeyEvent.KEYCODE_BACK -> true
KeyEvent.KEYCODE_FORWARD -> false
else -> return null
}
if (event.flags and KeyEvent.FLAG_FALLBACK != 0) return null
val device = event.device ?: return null
if (!device.supportsSource(InputDevice.SOURCE_MOUSE)) return null
if (device.supportsSource(InputDevice.SOURCE_DPAD)) return null
return back
}
/** Last D-pad direction synthesised from a stick/HAT — edge detection (one focus move per push). */
private var lastNavDir = 0
@@ -180,6 +180,22 @@ class MouseForwarder(
}
}
/**
* A mouse side button that arrived as a KEY event rather than a BUTTON_* motion edge.
*
* Not every mouse reports its side buttons the same way. One that puts them on the HID button
* page (BTN_SIDE/BTN_EXTRA) gets `BUTTON_BACK`/`BUTTON_FORWARD` in the motion button state and
* lands in [button]. One that puts them on the consumer page (AC Back / AC Forward — common on
* Bluetooth mice, and the shape Android TV boxes tend to see) produces ONLY synthesized
* `KEYCODE_BACK`/`KEYCODE_FORWARD` key events, so [button] never fires and the side buttons are
* dead on the wire. This is the key-shaped entry point for those.
*
* Devices that report BOTH send the key first and the motion edge second (that is the order the
* input reader synthesizes them in), so both paths funnel into the same held-set and the
* add/remove guard collapses the pair into a single wire press.
*/
fun sideButtonKey(back: Boolean, down: Boolean) = press(if (back) 4 else 5, down)
private fun button(actionButton: Int, down: Boolean) {
val b = when (actionButton) {
MotionEvent.BUTTON_PRIMARY -> 1
@@ -189,9 +205,15 @@ class MouseForwarder(
MotionEvent.BUTTON_FORWARD -> 5
else -> return
}
press(b, down)
}
private fun press(b: Int, down: Boolean) {
if (down) {
heldButtons.add(b)
NativeBridge.nativeSendPointerButton(handle, b, true)
// add() is false when the button is already held — the second delivery of a button
// this device reports on two paths at once. Sending the down again would double-press
// it on the host.
if (heldButtons.add(b)) NativeBridge.nativeSendPointerButton(handle, b, true)
} else if (heldButtons.remove(b)) {
// Only release what we pressed — drops the release of a swallowed engaging click
// and anything that raced a capture transition.
+3
View File
@@ -64,3 +64,6 @@ libc = "0.2"
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
opus = "0.3"
[lints]
workspace = true
@@ -86,6 +86,13 @@ public final class InputCapture {
/// its Esc suppression need it in both states).
private var cmdKeysDown: Set<UInt32> = []
#if !os(macOS)
/// The key currently auto-repeating, and the timer driving it. iOS/tvOS only see
/// `startAutoRepeat`. Main-queue only, like every other field here.
private var autoRepeatVK: UInt32?
private var autoRepeatTimer: Timer?
#endif
/// Physical Control/Option/Shift keys currently held (Windows VKs, both L/R sides). iPad only:
/// the Q release chord is recognized from the HID stream here (iOS has no NSEvent monitor,
/// like the toggle), so it needs the live modifier state tracked in both forwarding states,
@@ -322,6 +329,9 @@ public final class InputCapture {
}
mice.removeAll()
keyboards.removeAll()
#if !os(macOS)
stopAutoRepeat()
#endif
}
deinit { stop() }
@@ -330,6 +340,9 @@ public final class InputCapture {
/// and modifier/latch tracking (GC delivers nothing while inactive, so a released
/// in another app would otherwise stay "held" here forever hijacking Esc).
private func releaseAll() {
#if !os(macOS)
stopAutoRepeat() // before the releases below, so the ticker can't outlive the key-up
#endif
cmdKeysDown.removeAll()
chordModifiersDown.removeAll()
suppressedVK = nil
@@ -347,6 +360,58 @@ public final class InputCapture {
residualScrollY = 0
}
#if !os(macOS)
/// Windows VKs that must never auto-repeat: the modifiers (a held Shift is a state, not a
/// stream of presses) and the lock keys (each repeat would toggle the light again).
private static let noAutoRepeatVKs: Set<UInt32> = [
0x10, 0xA0, 0xA1, // Shift, LShift, RShift
0x11, 0xA2, 0xA3, // Control, LControl, RControl
0x12, 0xA4, 0xA5, // Alt, LAlt, RAlt
0x5B, 0x5C, // LWin, RWin
0x14, 0x90, 0x91, // CapsLock, NumLock, ScrollLock
]
/// Start (or hand over) the held-key auto-repeat, matching a real keyboard's delay-then-rate.
///
/// GameController reports a key ONCE on press and once on release it has no repeat channel
/// and the host injects exactly what it is told, so nothing downstream ever turns a held key
/// into a stream of presses. Holding Backspace deleted a single character. macOS is unaffected:
/// its NSEvent path already receives the window server's own repeats and forwards them.
///
/// Only the newest key repeats, which is what a hardware keyboard does pressing a second key
/// takes the repeat over from the first. Timings are the iOS/macOS defaults (~0.5 s delay,
/// ~25 Hz); `.common` keeps it running while a scroll or gesture is tracking.
private func startAutoRepeat(_ vk: UInt32) {
guard !Self.noAutoRepeatVKs.contains(vk) else { return }
stopAutoRepeat()
autoRepeatVK = vk
let timer = Timer(timeInterval: 0.5, repeats: false) { [weak self] _ in
guard let self, self.autoRepeatVK == vk else { return }
let ticker = Timer(timeInterval: 0.04, repeats: true) { [weak self] _ in
// Stop the moment the key is no longer held a release that raced the timer, or a
// releaseAll from a blur, must not keep typing into the host.
guard let self, self.autoRepeatVK == vk, self.forwarding,
self.pressedVKs.contains(vk)
else {
self?.stopAutoRepeat()
return
}
self.emitKey(vk, down: true)
}
self.autoRepeatTimer = ticker
RunLoop.main.add(ticker, forMode: .common)
}
autoRepeatTimer = timer
RunLoop.main.add(timer, forMode: .common)
}
private func stopAutoRepeat() {
autoRepeatTimer?.invalidate()
autoRepeatTimer = nil
autoRepeatVK = nil
}
#endif
/// The single wire boundary for a key event. Every `.key` send funnels through here so the
/// active location-based modifier layout is applied in exactly one place while all internal
/// press/release bookkeeping (`pressedVKs`, `cmdKeysDown`, `resolveModifier`'s `isDown`) stays on
@@ -534,16 +599,24 @@ public final class InputCapture {
}
}
}
// Scroll WHEEL (plain HID mice) while pointer-locked: GCMouse's scroll dpad reports
// wheel deltas here, +y up / +x right already the host's WHEEL convention, one unit
// per notch ×120 (WHEEL_DELTA), residual-accumulated by sendScroll. (Trackpad
// two-finger scrolling is gesture-based and does NOT reach GameController that
// arrives via the stream view's scroll pan recognizer; on macOS, via scrollWheel.)
// Scroll WHEEL, tvOS only. GCMouse's scroll dpad reports raw device deltas, +y up / +x
// right the host's WHEEL convention already, one unit per notch ×120 (WHEEL_DELTA),
// residual-accumulated by sendScroll.
//
// iOS deliberately installs no handler here and takes ALL scroll from the stream view's
// pan recognizer instead the same one that carries trackpad two-finger scrolling, which
// is gesture-based and never reaches GameController. That recognizer sees a plain wheel
// too, and unlike this raw axis its deltas already carry the system's Natural Scrolling
// preference, so routing everything through it is what makes the setting apply under
// pointer lock. Installing both would double-send every wheel notch. (macOS has its own
// path: StreamLayerView.scrollWheel.)
#if os(tvOS)
input.scroll.valueChangedHandler = { [weak self] _, dx, dy in
guard let self, self.forwarding, self.gcMouseForwarding else { return }
self.sendScroll(dx: dx * 120, dy: dy * 120)
}
#endif
#endif
}
/// Forward relative mouse motion (macOS). Fed by StreamLayerView's NSEvent monitor
@@ -676,6 +749,14 @@ public final class InputCapture {
self.pressedVKs.remove(vk)
}
self.emitKey(vk, down: pressed)
// GC has no repeat channel, so a held key becomes a repeat here (see startAutoRepeat).
// A release only cancels the repeat if it is THIS key's releasing an older key while
// a newer one is still held must leave the newer one repeating.
if pressed {
self.startAutoRepeat(vk)
} else if self.autoRepeatVK == vk {
self.stopAutoRepeat()
}
}
#endif
}
@@ -368,9 +368,14 @@ public final class StreamViewController: StreamViewControllerBase {
guard self.inputCapture?.gcMouseForwarding == false else { return }
self.inputCapture?.sendMouseButton(button, pressed: down)
}
// Scroll is the ONE indirect channel that is NOT gated on the lock. The scroll pan keeps
// firing while the scene is pointer-locked (it is the only way trackpad two-finger scrolling
// ever arrives GameController has no gesture channel), so gating it here dropped trackpad
// scrolling entirely under lock. Nothing double-sends because iOS installs no GCMouse scroll
// handler at all: this recognizer sees the wheel too, already carrying the system's Natural
// Scrolling preference, which the raw GameController axis does not.
streamView.onScroll = { [weak self] dx, dy in
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
self.inputCapture?.sendScroll(dx: dx, dy: dy)
self?.inputCapture?.sendScroll(dx: dx, dy: dy)
}
let capture = InputCapture(connection: connection)
@@ -920,14 +925,21 @@ final class StreamLayerUIView: UIView {
}
}
/// Trackpad / wheel scroll (no lock) host scroll deltas. The translation is consumed
/// each callback so the next is a fresh delta. Sign/scale are tunable ( one notch per
/// ~10 pt): finger up scrolls up (host +y), x passes through the host WHEEL convention.
/// Trackpad / wheel scroll host scroll deltas. The translation is consumed each callback so
/// the next is a fresh delta, and scales at one WHEEL notch per 10 pt of pan.
///
/// Both axes pass through with their sign intact, which is what makes the stream follow the
/// system's Natural Scrolling switch: UIKit has already applied that preference by the time it
/// hands us a translation (it is what makes every UIScrollView on the device turn the right
/// way), so the sign we get IS the user's choice, and the host's WHEEL convention agrees with
/// it +y is a wheel-forward notch, the one that moves content down. Negating y here, as this
/// did, pinned the stream to traditional scrolling and inverted the setting for everyone on the
/// default. macOS passes `NSEvent.scrollingDeltaY` through for exactly the same reason.
@objc private func handleScroll(_ g: UIPanGestureRecognizer) {
guard g.state == .began || g.state == .changed else { return }
let t = g.translation(in: self)
g.setTranslation(.zero, in: self)
onScroll?(Float(t.x) * 12, Float(-t.y) * 12)
onScroll?(Float(t.x) * 12, Float(t.y) * 12)
}
/// Map a view-space point through the aspect-fit letterbox into host-mode pixels; points
@@ -944,9 +956,21 @@ final class StreamLayerUIView: UIView {
return HostPoint(x: x, y: y, w: UInt32(hostMode.width), h: UInt32(hostMode.height))
}
/// `.secondary` (right button / two-finger click) GameStream right (3); else left (1).
/// UIKit's button mask the wire's GameStream button number.
///
/// The mask is 1-based over the HID button order 1 primary, 2 secondary, 3 middle, 4/5 the
/// side buttons while the wire numbers middle and right the other way round (1 left,
/// 2 middle, 3 right, 4 X1/back, 5 X2/forward), so only those two swap. Without the 35 arms
/// every button past the first two fell into the `else` and clicked LEFT on the host.
///
/// `.primary`/`.secondary` are spelled out because they are the only two named cases; the rest
/// come from `.button(_:)`, which takes the same 1-based number.
private static func gsButton(for mask: UIEvent.ButtonMask) -> UInt32 {
mask.contains(.secondary) ? 3 : 1
if mask.contains(.secondary) { return 3 }
if mask.contains(.button(3)) { return 2 }
if mask.contains(.button(4)) { return 4 }
if mask.contains(.button(5)) { return 5 }
return 1
}
private func nextFreeID() -> UInt32 {
+29 -11
View File
@@ -20,6 +20,9 @@
# firing Wake-on-LAN so the connect survives the host's resume)
# PF_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
# PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it
# resolved a non-flatpak install — then the client is exec'd directly and
# PF_APPID/PF_FLATPAK are unused)
#
# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before
# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores
@@ -38,8 +41,23 @@ set -u
APPID="${PF_APPID:-io.unom.Punktfunk}"
FLATPAK="${PF_FLATPAK:-flatpak}"
# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and
# Gaming Mode reclaims focus automatically (no manual refocus needed).
# The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile
# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that
# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only
# difference is the prefix in front of it.
#
# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode
# reclaims focus automatically (no manual refocus needed).
run_client() {
if [ -n "${PF_CLIENT_BIN:-}" ]; then
exec "$PF_CLIENT_BIN" "$@"
fi
exec "$FLATPAK" run --arch=x86_64 "$APPID" "$@"
}
# What we are about to run, for the log line each branch prints.
CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}"
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
if [ -n "${PF_BROWSE:-}" ]; then
@@ -48,14 +66,14 @@ if [ -n "${PF_BROWSE:-}" ]; then
# library shortcut launches. `--browse <host>` opens straight into that host's library (the
# per-host "open on screen" action). A streams a game, session end returns here, B quits.
if [ -z "${PF_HOST:-}" ]; then
echo "punktfunkrun: gamepad UI $APPID --browse (console home)" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse --fullscreen
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
run_client --browse --fullscreen
fi
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2
if [ -n "${PF_MGMT:-}" ]; then
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
fi
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
run_client --browse "$PF_HOST" --fullscreen
fi
# Streaming modes need a host (browse above is the only host-less path).
@@ -72,8 +90,8 @@ if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
fi
if [ -n "${PF_LAUNCH:-}" ]; then
# A pinned game: the id rides the session Hello and the host launches that title.
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2
run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
fi
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" "$@"
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2
run_client --connect "$PF_HOST" "$@"
+112 -24
View File
@@ -328,10 +328,75 @@ def _flatpak() -> str | None:
)
# --- which client is installed -------------------------------------------------------------
#
# The flatpak is the Deck's usual client, but it is not the only one: a sysext, a .deb/.rpm, an
# AUR build, a nix profile and a hand-built binary all install a NATIVE `punktfunk-client`, and
# on those the plugin used to be dead in the water — every headless call went through
# `flatpak run io.unom.Punktfunk` and simply failed. Both kinds keep identity, known-hosts and
# settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real
# home), so nothing else in this file has to care which one answered.
NATIVE_BIN = "punktfunk-client"
# Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and
# SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix.
_NATIVE_PREFIXES = (
"/usr/bin",
"/usr/local/bin",
"/run/host/usr/bin",
"/var/lib/extensions/punktfunk/usr/bin",
)
def _native_client() -> str | None:
"""Absolute path of a native (non-flatpak) client binary, or None."""
found = shutil.which(NATIVE_BIN, path=os.environ.get("PATH", "") + ":" + ":".join(_NATIVE_PREFIXES))
if found:
return found
for prefix in (str(Path(decky.DECKY_USER_HOME) / ".local" / "bin"),):
candidate = Path(prefix) / NATIVE_BIN
if candidate.exists():
return str(candidate)
return None
def _flatpak_installed() -> bool:
"""True when the flatpak APP is actually installed — not merely that `flatpak` exists.
Checked by the app's own exported directory rather than by shelling out to `flatpak info`,
because this is on the path of every headless call and a subprocess per call would be absurd.
Both scopes count: the Deck installs --user, a distro image may ship it system-wide.
"""
if not _flatpak():
return False
user = Path(decky.DECKY_USER_HOME) / ".local" / "share" / "flatpak" / "app" / APP_ID
return user.exists() or Path("/var/lib/flatpak/app", APP_ID).exists()
def _client_argv() -> list[str] | None:
"""The argv PREFIX that runs the client headlessly, or None when no client is installed.
Flatpak wins when it is installed: it is the tested Deck path, so an existing install keeps
behaving exactly as it did. A native binary is the fallback — and on a machine with no
flatpak client, the thing that makes the plugin work at all. `PF_DECKY_CLIENT=native|flatpak`
forces one when a machine has both.
"""
forced = os.environ.get("PF_DECKY_CLIENT", "").strip().lower()
native = _native_client()
if forced == "native":
return [native] if native else None
if forced != "flatpak" and not _flatpak_installed() and native:
return [native]
if _flatpak_installed():
return [_flatpak(), "run", "--arch=x86_64", APP_ID]
return [native] if native else None
def _flatpak_env() -> dict:
"""Environment for a headless ``flatpak run`` from the backend (no display needed for
pairing). Reconstruct the user-session bits flatpak wants; the backend may not inherit
them. Harmless if some are already set."""
"""Environment for a headless client run from the backend (no display needed for pairing).
Reconstruct the user-session bits flatpak wants; the backend may not inherit them. Harmless
if some are already set — and correct for a NATIVE client too, which needs the same HOME and
the same LD_LIBRARY_PATH repair below."""
env = dict(os.environ)
# Decky Loader is a PyInstaller binary: it prepends its bundled libs (an older libssl) to
# LD_LIBRARY_PATH (its /tmp/_MEI* unpack dir), and that env leaks into our subprocess. The
@@ -388,17 +453,19 @@ async def _flatpak_capture(args: list[str], timeout: float = 20.0) -> tuple[int,
async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
"""Run the flatpak CLIENT headlessly (``flatpak run … io.unom.Punktfunk <client_args>``) with
the user-session env, returning ``(returncode, stdout, stderr)`` with SEPARATE pipes so a JSON
payload on stdout stays clean of the client's log lines on stderr. ``(-1, "", "")`` when
flatpak is missing or the call errors/times out. This is the single entry point for the
headless host-store modes (``--list-hosts`` / ``--add-host`` / ``--set-host`` /
``--forget-host`` / ``--reset`` / ``--reachable``), which mutate the SAME
``client-known-hosts.json`` the desktop client reads — so state is shared, not duplicated."""
flatpak = _flatpak()
if not flatpak:
"""Run the CLIENT headlessly with the user-session env, returning ``(returncode, stdout,
stderr)`` with SEPARATE pipes so a JSON payload on stdout stays clean of the client's log
lines on stderr. ``(-1, "", "")`` when no client is installed or the call errors/times out.
Whether that client is the flatpak or a native install is [_client_argv]'s business; both
read and write the SAME ``client-known-hosts.json`` the desktop client uses. This is the
single entry point for the headless host-store modes (``--list-hosts`` / ``--add-host`` /
``--set-host`` / ``--forget-host`` / ``--reset`` / ``--reachable``), so state is shared, not
duplicated."""
prefix = _client_argv()
if not prefix:
return -1, "", ""
argv = [flatpak, "run", "--arch=x86_64", APP_ID, *client_args]
argv = [*prefix, *client_args]
proc = None
try:
proc = await asyncio.create_subprocess_exec(
@@ -885,9 +952,22 @@ class Plugin:
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
exec bit is needed — Decky's zip extraction drops it, and the root-owned plugins dir
means this unprivileged backend couldn't chmod it back on anyway."""
means this unprivileged backend couldn't chmod it back on anyway.
``client_bin`` is set only when the resolved client is a NATIVE install; the frontend
passes it to the wrapper as ``PF_CLIENT_BIN`` so the launch execs the binary instead of
``flatpak run``. Absent = the wrapper's flatpak default, i.e. every existing Deck
install is unaffected."""
path = _runner_path()
return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()}
prefix = _client_argv()
native = bool(prefix) and prefix[0] != _flatpak()
return {
"runner": path,
"app_id": APP_ID,
"exists": Path(path).exists(),
"client_kind": "native" if native else ("flatpak" if prefix else "none"),
"client_bin": prefix[0] if native else "",
}
async def get_settings(self) -> dict:
"""Read the flatpak client's stream settings (resolution/bitrate/gamepad…)."""
@@ -1032,28 +1112,36 @@ class Plugin:
}
async def kill_stream(self) -> dict:
"""Force-stop a wedged stream client (``flatpak kill``)."""
flatpak = _flatpak()
if not flatpak:
return {"ok": False, "error": "flatpak-not-found"}
"""Force-stop a wedged stream client ``flatpak kill`` for the sandboxed one, a plain
SIGTERM by name for a native install (which has no flatpak instance to kill)."""
prefix = _client_argv()
if not prefix:
return {"ok": False, "error": "client-not-found"}
if prefix[0] == _flatpak():
argv = [prefix[0], "kill", APP_ID]
else:
# -x: whole-name match, so this can only ever hit the client itself.
killer = shutil.which("pkill") or "/usr/bin/pkill"
argv = [killer, "-x", NATIVE_BIN]
try:
proc = await asyncio.create_subprocess_exec(
flatpak, "kill", APP_ID,
*argv,
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
env=_flatpak_env(),
)
await asyncio.wait_for(proc.wait(), timeout=10.0)
except Exception: # noqa: BLE001
decky.logger.exception("flatpak kill failed")
decky.logger.exception("kill_stream (%s) failed", argv[0])
return {"ok": False}
return {"ok": True}
async def update_client(self) -> dict:
"""Update the flatpak **client** (io.unom.Punktfunk) in the USER installation — the scope a
Steam Deck install lives in, which ``sudo flatpak update`` (system-scope) never reaches.
Returns whether a new commit was actually pulled. Best-effort; non-fatal."""
flatpak = _flatpak()
if not flatpak:
Returns whether a new commit was actually pulled. Best-effort; non-fatal. A NATIVE client
is updated by whatever installed it (distro package manager, sysext, nix), never here —
`check_update` reports no client update for one, so the UI never offers this."""
if not _flatpak_installed():
return {"ok": False, "updated": False, "error": "flatpak-not-found"}
_, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
before_commit = _field_from(before, "Commit")
+5
View File
@@ -90,6 +90,11 @@ export interface RunnerInfo {
runner: string; // absolute path to bin/punktfunkrun.sh
app_id: string; // flatpak app id
exists: boolean;
// Which client the backend resolved: the flatpak, a native install (.deb/rpm/sysext/AUR/nix),
// or none at all. Older backends send neither field — hence optional.
client_kind?: "flatpak" | "native" | "none";
// Absolute path of the native binary; "" for flatpak. Passed to the wrapper as PF_CLIENT_BIN.
client_bin?: string;
}
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
+13 -5
View File
@@ -214,7 +214,7 @@ async function ensureControllerConfig(): Promise<void> {
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
* across reinstalls, and pre-two-shortcut installs had this one visible).
*/
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }> {
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
const info = await runnerInfo();
if (!info.exists) {
throw new Error(`launch wrapper missing at ${info.runner}`);
@@ -231,7 +231,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
void applyArtwork(remembered);
return { appId: remembered, runner: info.runner };
return { appId: remembered, runner: info.runner, clientBin: info.client_bin ?? "" };
}
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
@@ -239,7 +239,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }
setShortcutHidden(appId, true);
void applyArtwork(appId);
remember(STORAGE_KEY_STREAM, appId);
return { appId, runner: info.runner };
return { appId, runner: info.runner, clientBin: info.client_bin ?? "" };
}
/**
@@ -259,7 +259,10 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
void ensureControllerConfig();
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
// PF_CLIENT_BIN only when the backend resolved a NATIVE client — else the wrapper's flatpak
// default stands and this shortcut is exactly what it always was.
const clientBin = info.client_bin ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
@@ -357,9 +360,14 @@ export async function launchStream(
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
const waking = wake(host, port).catch(() => ({ ok: false }));
const [{ appId, runner }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
const [{ appId, runner, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
const target = port && port !== 9777 ? `${host}:${port}` : host;
const env = [`PF_HOST=${target}`];
// Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every
// existing Deck install produces byte-identical launch options to before.
if (clientBin) {
env.push(`PF_CLIENT_BIN=${clientBin}`);
}
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
+3
View File
@@ -32,3 +32,6 @@ anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
relm4 = { version = "0.11", features = ["libadwaita"] }
[lints]
workspace = true
+271 -10
View File
@@ -35,6 +35,11 @@ const CSS: &str = "
.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
background: alpha(currentColor, 0.35); }
.pf-pip.pf-online { background: @success_color; }
/* An overridden row in profile scope: an accent dot in the prefix, so which settings this
profile changes is legible at a glance without reading every value. (Plain string literal
-- a quote in here would end it.) */
.pf-override-dot { min-width: 8px; min-height: 8px; border-radius: 999px;
background: @accent_color; }
/* Most-recent host: a full accent ring drawn as an inset outline so it follows the card's
rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves
the card's own elevation shadow intact. */
@@ -76,6 +81,9 @@ pub struct AppModel {
#[derive(Debug)]
pub enum AppMsg {
/// A `punktfunk://` URL arrived (scheme handler, a shortcut, or a second invocation
/// forwarded to this instance by GApplication) — design/client-deep-links.md §4.1.
DeepLink(String),
/// The trust gate in front of every connect (rules 13, see `update`).
Connect(ConnectRequest),
/// Connect to a saved host that isn't advertising but has a known MAC: fire a wake
@@ -115,6 +123,9 @@ pub enum AppMsg {
/// The speed-test dialog resolved (either way) — release `busy`.
SpeedTestDone,
ShowPreferences,
/// Re-open Preferences editing a specific layer — the settings scope switcher's
/// destination (design/client-settings-profiles.md §5.1).
ShowPreferencesScoped(crate::ui_settings::Scope),
ShowShortcuts,
ShowAbout,
ShowAddHost,
@@ -219,6 +230,7 @@ impl SimpleComponent for AppModel {
HostsOutput::Pair(req) => AppMsg::Pair(req),
HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req),
HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt),
HostsOutput::Toast(msg) => AppMsg::Toast(msg),
});
let nav = adw::NavigationView::new();
@@ -269,6 +281,13 @@ impl SimpleComponent for AppModel {
}
window.present();
// The deep-link seam is live from here: anything GApplication delivered during a cold
// start has been parked, and everything from now on arrives as a message.
LINK_TX.with_borrow_mut(|tx| *tx = Some(sender.input_sender().clone()));
for url in PENDING_LINKS.with_borrow_mut(std::mem::take) {
sender.input(AppMsg::DeepLink(url));
}
ComponentParts {
model,
widgets: AppWidgets {},
@@ -277,6 +296,7 @@ impl SimpleComponent for AppModel {
fn update(&mut self, msg: AppMsg, sender: ComponentSender<Self>) {
match msg {
AppMsg::DeepLink(url) => self.open_deep_link(&url, &sender),
// The trust gate (the host is the policy authority — it advertises
// `pair=optional` only when it accepts unpaired clients):
// 1. PINNED RECONNECT — a stored fingerprint connects silently.
@@ -478,15 +498,25 @@ impl SimpleComponent for AppModel {
self.hosts.emit(HostsMsg::SetConnecting(None));
self.toast("Cancelled — the request may still be pending on the host.");
}
AppMsg::ShowPreferences => {
AppMsg::ShowPreferences => sender.input(AppMsg::ShowPreferencesScoped(
crate::ui_settings::Scope::Defaults,
)),
AppMsg::ShowPreferencesScoped(scope) => {
let hosts = self.hosts.sender().clone();
crate::ui_settings::show(
let reopen = sender.clone();
crate::ui_settings::show_scoped(
&self.window,
self.settings.clone(),
&self.gamepad,
&self.probes.borrow(),
scope,
// The switcher closes the dialog to commit the layer it was editing, then
// asks for it back in the new scope — so the app owns the re-open and the
// dialog stays a pure view.
move |next| reopen.input(AppMsg::ShowPreferencesScoped(next)),
move || {
// The library toggle changes the saved cards' menu — re-render.
// The library toggle changes the saved cards' menu, and a profile edit
// changes the chips — re-render either way.
let _ = hosts.send(HostsMsg::Refresh);
},
);
@@ -504,6 +534,86 @@ impl AppModel {
self.toasts.add_toast(adw::Toast::new(msg));
}
/// Route a `punktfunk://` URL (design/client-deep-links.md §4.1). Parsing, host/profile
/// resolution and every refusal rule live in the shared brain (`plan_from_link`); this is
/// only the GTK end of it — turn the outcome into the same messages a card click raises,
/// so a link gets the identical wake, trust and error surfaces and NOT a second connect
/// path of its own.
fn open_deep_link(&mut self, url: &str, sender: &ComponentSender<AppModel>) {
use pf_client_core::deeplink;
use pf_client_core::orchestrate::{plan_from_link, PlanOutcome};
use pf_client_core::profiles::ProfilesFile;
tracing::debug!(%url, "deep link");
let link = match deeplink::parse(url) {
Ok(l) => l,
Err(e) => return self.toast(&e.message()),
};
let known = trust::KnownHosts::load();
let outcome = plan_from_link(
&link,
&known,
&ProfilesFile::load(),
&self.settings.borrow(),
);
match outcome {
Ok(PlanOutcome::Connect(plan)) => {
// Rule 2 of §3: never preempt a live session. Only this layer knows one is
// running, which is why the brain leaves the check here.
if self.busy {
return self.toast("A session is already running — end it first.");
}
let req = ConnectRequest {
name: plan.host.name.clone(),
addr: plan.host.addr.clone(),
port: plan.host.port,
fp_hex: plan.host.fp_hex.clone(),
pair_optional: false,
launch: plan.launch.clone().map(|id| (id.clone(), id)),
mac: plan.host.mac.clone(),
// `profile=` in a URL is a one-off, exactly like "Connect with ▸": it
// shapes this session and leaves the host's binding alone.
profile: plan.profile_override.clone(),
};
// A link is a launch like any other: with a MAC it takes the dial-first wake
// path, so a sleeping host wakes instead of erroring.
sender.input(if plan.wake {
AppMsg::WakeConnect(req)
} else {
AppMsg::Connect(req)
});
}
Ok(PlanOutcome::ConfirmUnknown(unknown)) => {
// Known-but-unpinned, or not known at all: the link may not pair and may not
// trust on its own, so it opens the ordinary ceremony under the user's eyes —
// the PIN dialog, seeded with what the link claimed.
if self.busy {
return self.toast("A session is already running — end it first.");
}
let req = ConnectRequest {
name: unknown.name.clone().unwrap_or_else(|| unknown.addr.clone()),
addr: unknown.addr.clone(),
port: unknown.port,
fp_hex: unknown.fp.clone(),
pair_optional: false,
launch: unknown.launch.clone().map(|id| (id.clone(), id)),
mac: Vec::new(),
profile: None,
};
self.toast(&format!(
"{} isn't paired with this device yet — pair it to continue.",
req.name
));
crate::ui_trust::pin_dialog(&self.window, sender, self.identity.clone(), req);
}
Ok(PlanOutcome::Unsupported(route)) => self.toast(&format!(
"Punktfunk can't open “{}” links yet.",
route.as_str()
)),
Err(e) => self.toast(&e.message()),
}
}
fn close_waiting(&mut self) {
if let Some(w) = self.waiting.borrow_mut().take() {
w.close();
@@ -520,7 +630,33 @@ impl AppModel {
let status = gtk::Label::new(Some("Connecting…"));
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
dialog.set_extra_child(Some(&status));
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
// Where a measured bitrate belongs is "the layer this host actually resolves bitrate
// from" (design/client-settings-profiles.md §5.3) — the long-standing wrong answer was
// always the global, so measuring the slow retro box downstairs re-tuned the desktop
// too. The target depends only on the host, so it is known before the result lands and
// the button can say where it will write.
let target = SpeedTestTarget::resolve(&req);
match &target {
SpeedTestTarget::Global => {
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
}
SpeedTestTarget::Profile(p) => {
dialog.add_responses(&[
("close", "Close"),
("apply", &format!("Apply to “{}", p.name)),
]);
}
// A bound host whose profile doesn't override bitrate could legitimately mean
// either: the user gets both, rather than us guessing which layer they meant.
SpeedTestTarget::Ask(p) => {
dialog.add_responses(&[
("close", "Close"),
("apply-global", "Set as default"),
("apply", &format!("Set in “{}", p.name)),
]);
dialog.set_response_enabled("apply-global", false);
}
}
dialog.set_response_enabled("apply", false);
dialog.set_close_response("close");
dialog.present(Some(&self.window));
@@ -590,13 +726,36 @@ impl AppModel {
));
dialog.set_response_enabled("apply", true);
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
dialog.connect_response(Some("apply"), move |_, _| {
if matches!(target, SpeedTestTarget::Ask(_)) {
dialog.set_response_enabled("apply-global", true);
}
let mbit = f64::from(recommended_kbps) / 1000.0;
{
let (settings, toasts) = (settings.clone(), toasts.clone());
dialog.connect_response(Some("apply"), move |_, _| {
let where_to = match &target {
SpeedTestTarget::Global => {
let mut s = settings.borrow_mut();
s.bitrate_kbps = recommended_kbps;
s.save();
"the default bitrate".to_string()
}
SpeedTestTarget::Profile(p) | SpeedTestTarget::Ask(p) => {
write_profile_bitrate(&p.id, recommended_kbps);
format!("{}", p.name)
}
};
toasts.add_toast(adw::Toast::new(&format!(
"{mbit:.0} Mbit/s set in {where_to}"
)));
});
}
dialog.connect_response(Some("apply-global"), move |_, _| {
let mut s = settings.borrow_mut();
s.bitrate_kbps = recommended_kbps;
s.save();
toasts.add_toast(adw::Toast::new(&format!(
"Bitrate set to {:.0} Mbit/s",
f64::from(recommended_kbps) / 1000.0
"{mbit:.0} Mbit/s set in the default bitrate"
)));
});
}
@@ -607,6 +766,81 @@ impl AppModel {
}
}
/// Which layer a measured bitrate should land in for the host that was tested
/// (design/client-settings-profiles.md §5.3).
enum SpeedTestTarget {
/// No profile bound — the global default, i.e. what has always happened.
Global,
/// The bound profile already overrides bitrate, so that override is what this host reads.
Profile(pf_client_core::profiles::StreamProfile),
/// Bound, but the profile inherits bitrate: writing either layer is defensible, so ask.
Ask(pf_client_core::profiles::StreamProfile),
}
impl SpeedTestTarget {
fn resolve(req: &crate::ui_hosts::ConnectRequest) -> SpeedTestTarget {
// Resolved exactly the way a connect resolves it: the one-off pick this test was
// started with (a pinned card carries one), else the host's binding.
let bound = trust::KnownHosts::load()
.hosts
.iter()
.find(|h| h.addr == req.addr && h.port == req.port)
.and_then(|h| h.profile_id.clone());
let reference = match req.profile.as_deref() {
Some("") => return SpeedTestTarget::Global,
Some(id) => Some(id.to_string()),
None => bound,
};
let Some(reference) = reference else {
return SpeedTestTarget::Global;
};
let catalog = pf_client_core::profiles::ProfilesFile::load();
match catalog.resolve(&reference).0 {
Some(p) if p.overrides.bitrate_kbps.is_some() => SpeedTestTarget::Profile(p.clone()),
Some(p) => SpeedTestTarget::Ask(p.clone()),
// A dangling binding resolves as no profile everywhere else; here too.
None => SpeedTestTarget::Global,
}
}
}
/// Write a measured bitrate into one profile's overlay, leaving everything else alone.
fn write_profile_bitrate(id: &str, kbps: u32) {
let mut catalog = pf_client_core::profiles::ProfilesFile::load();
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) else {
return; // deleted while the test ran — the toast still tells the truth about the test
};
p.overrides.bitrate_kbps = Some(kbps);
if let Err(e) = catalog.save() {
tracing::warn!(error = %format!("{e:#}"), "saving the measured bitrate");
}
}
thread_local! {
/// Where a delivered URL goes once the window exists. Both ends of this live on the GTK
/// main thread: `connect_open` fires there, and so does the model's `init`.
static LINK_TX: std::cell::RefCell<Option<relm4::Sender<AppMsg>>> =
const { std::cell::RefCell::new(None) };
/// URLs that arrived before the model existed — the cold-start case, where GApplication
/// runs `open` before `activate` builds the window. A dropped URL is the one outcome a
/// link must never have, so they wait here instead.
static PENDING_LINKS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// Hand a URL to the running app, or park it until there is one.
fn deliver_deep_link(url: String) {
let queued = LINK_TX.with_borrow(|tx| match tx {
Some(tx) => {
let _ = tx.send(AppMsg::DeepLink(url.clone()));
false
}
None => true,
});
if queued {
PENDING_LINKS.with_borrow_mut(|q| q.push(url));
}
}
pub fn run() -> glib::ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
@@ -649,16 +883,43 @@ pub fn run() -> glib::ExitCode {
return crate::cli::exec_session();
}
let mut builder = adw::Application::builder().application_id(APP_ID);
// HANDLES_OPEN is what makes `Exec=punktfunk-client %u` work: GApplication turns the URI
// into an `open` call, and — this is the part that matters — a SECOND invocation forwards
// its URI to the already-running instance over D-Bus and exits, so clicking a link with
// Punktfunk open reuses the window instead of racing a new one.
let mut builder = adw::Application::builder()
.application_id(APP_ID)
.flags(gio::ApplicationFlags::HANDLES_OPEN);
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps
// each launch its own primary instance.
if crate::cli::shot_scene().is_some() {
builder = builder.flags(gio::ApplicationFlags::NON_UNIQUE);
builder =
builder.flags(gio::ApplicationFlags::NON_UNIQUE | gio::ApplicationFlags::HANDLES_OPEN);
}
let adw_app = builder.build();
adw_app.connect_open(|app, files, _hint| {
for f in files {
deliver_deep_link(f.uri().to_string());
}
// `open` does not raise a window on its own; the model's activate handler does.
app.activate();
});
// One SDL context for the whole process, started while single-threaded.
let gamepad = crate::gamepad::GamepadService::start();
let app = relm4::RelmApp::from_app(adw_app).with_args(Vec::new());
// argv stays withheld from GApplication — except for a positional URL, which is exactly
// what GIO's single-instance forwarding is for. Passing it through means the FIRST
// instance's `open` fires locally and a later one's is delivered to the primary, with no
// IPC of our own.
let args: Vec<String> = match crate::cli::deep_link_arg() {
Some(url) => vec![
std::env::args()
.next()
.unwrap_or_else(|| "punktfunk-client".into()),
url,
],
None => Vec::new(),
};
let app = relm4::RelmApp::from_app(adw_app).with_args(args);
app.run::<AppModel>(AppInit { gamepad });
glib::ExitCode::SUCCESS
}
+30 -6
View File
@@ -38,6 +38,18 @@ pub fn arg_flag(flag: &str) -> bool {
std::env::args().any(|a| a == flag)
}
/// A positional `punktfunk://` (or the `pf://` input alias) anywhere in argv — the deep-link
/// door (design/client-deep-links.md §4.1). It is positional, not a flag, because that is what
/// `Exec=punktfunk-client %u` hands us, what a `.desktop` shortcut embeds, and what a browser's
/// "Open Punktfunk?" prompt ends up invoking. Validation happens later, in the shared parser —
/// this only decides whether argv contains something addressed to us.
pub fn deep_link_arg() -> Option<String> {
std::env::args().skip(1).find(|a| {
let lower = a.to_ascii_lowercase();
lower.starts_with("punktfunk://") || lower.starts_with("pf://")
})
}
/// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the
/// console library exec the session binary, which handles its own fullscreen).
pub fn fullscreen_mode() -> bool {
@@ -336,11 +348,7 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
name,
addr: addr.clone(),
port,
fp_hex: String::new(),
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
..Default::default()
});
}
match known.save() {
@@ -487,6 +495,7 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) {
pair_optional: true,
launch: None,
mac: Vec::new(),
profile: None,
};
let mock_advert =
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
@@ -536,11 +545,26 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) {
speakers: vec![dev("alsa_output.mock-hdmi", "HDMI / DisplayPort Audio")],
mics: vec![dev("alsa_input.mock-usb", "USB Microphone Analog Stereo")],
};
let dialog = crate::ui_settings::show(
// `PUNKTFUNK_SHOT_SETTINGS_SCOPE=<profile id|name>` captures the dialog in
// PROFILE scope — the second half of the settings surface (design
// client-settings-profiles.md §5.1), where only profileable rows render.
let scope = std::env::var("PUNKTFUNK_SHOT_SETTINGS_SCOPE")
.ok()
.filter(|v| !v.is_empty())
.and_then(|reference| {
pf_client_core::profiles::ProfilesFile::load()
.resolve(&reference)
.0
.map(|p| crate::ui_settings::Scope::Profile(p.id.clone()))
})
.unwrap_or(crate::ui_settings::Scope::Defaults);
let dialog = crate::ui_settings::show_scoped(
&ctx.window,
ctx.settings.clone(),
&ctx.gamepad,
&probes,
scope,
|_| {},
|| {},
);
// Optional page for the capture (general/display/input/audio/controllers);
+4
View File
@@ -3,6 +3,7 @@
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
#![forbid(unsafe_code)]
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
// Root re-exports keep every `crate::trust`-style path resolving unchanged.
@@ -13,6 +14,9 @@ pub use pf_client_core::{discovery, gamepad, library, trust, video, wol};
mod app;
#[cfg(target_os = "linux")]
mod cli;
// "Create shortcut…" — the desktop-entry writer (design/client-deep-links.md §5).
#[cfg(target_os = "linux")]
mod shortcuts;
#[cfg(target_os = "linux")]
mod spawn;
#[cfg(target_os = "linux")]
+102
View File
@@ -0,0 +1,102 @@
//! "Create shortcut…" — a desktop entry that boots straight into one host (optionally with a
//! profile or a game), design/client-deep-links.md §5.
//!
//! The shortcut is a **container for a URL**, not a second launch mechanism: it invokes the
//! client with a positional `punktfunk://…`, which is the same door xdg-open and a browser
//! prompt use. That is deliberate — it keeps working if scheme registration is lost or the
//! host store changes, because the URL itself carries the stable id, the address and the pin.
//!
//! Under flatpak the sandbox cannot write `~/.local/share/applications`, so this offers the
//! URL to copy instead. The `org.freedesktop.portal.DynamicLauncher` route (which exists for
//! exactly this, with its own confirmation) is the intended upgrade there.
use std::path::PathBuf;
/// Are we inside the flatpak sandbox? `/.flatpak-info` is present in every flatpak run and
/// nowhere else — the standard check, and the one the portal docs use.
pub fn sandboxed() -> bool {
std::path::Path::new("/.flatpak-info").exists()
}
/// Write `~/.local/share/applications/punktfunk-<slug>.desktop` for this URL and return the
/// path. Best-effort `update-desktop-database` afterwards: an entry nothing indexes still
/// works from a file manager, it just won't show up in search straight away.
pub fn write_desktop_entry(label: &str, url: &str) -> Result<PathBuf, String> {
let home = std::env::var("HOME").map_err(|_| "HOME isn't set".to_string())?;
let dir = PathBuf::from(home).join(".local/share/applications");
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let path = dir.join(format!("punktfunk-{}.desktop", file_slug(label)));
// Desktop-entry values are line-oriented: a newline in a host name would end the Name
// key and turn the rest into an unparsable line (or, worse, another key).
let name = one_line(label);
let entry = format!(
"[Desktop Entry]\n\
Type=Application\n\
Name={name}\n\
Comment=Stream from this Punktfunk host\n\
Exec=punktfunk-client \"{url}\"\n\
Icon=io.unom.Punktfunk\n\
Terminal=false\n\
Categories=Game;Network;\n\
StartupNotify=true\n"
);
std::fs::write(&path, entry).map_err(|e| format!("{}: {e}", path.display()))?;
// Some desktops only offer a `.desktop` as a launchable icon when it is executable.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755));
}
let _ = std::process::Command::new("update-desktop-database")
.arg(&dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
Ok(path)
}
/// A filename-safe slug: ASCII alphanumerics and `-`, everything else collapsed to one `-`,
/// capped so a long host+profile pair can't produce a name the filesystem rejects.
fn file_slug(label: &str) -> String {
let mut out = String::new();
for c in label.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-');
let capped: String = trimmed.chars().take(48).collect();
if capped.is_empty() {
"host".to_string()
} else {
capped
}
}
/// Flatten to one line — see [`write_desktop_entry`] on why a newline here is not cosmetic.
fn one_line(s: &str) -> String {
s.replace(['\n', '\r'], " ").trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
/// Names become safe filenames, and a name that survives to `Name=` can't break the file.
#[test]
fn labels_are_sanitised_both_ways() {
assert_eq!(file_slug("Living Room PC"), "living-room-pc");
assert_eq!(file_slug("Büro · Mac"), "b-ro-mac");
assert_eq!(file_slug("Desk · Work"), "desk-work");
assert_eq!(file_slug("////"), "host");
assert_eq!(file_slug(""), "host");
assert!(file_slug(&"x".repeat(200)).len() <= 48);
// The classic injection: a newline would end the Name key and start a new one.
assert_eq!(
one_line("Desk\nExec=rm -rf ~"),
"Desk Exec=rm -rf ~".to_string()
);
}
}
+66 -159
View File
@@ -1,15 +1,20 @@
//! The shell↔session handoff: every stream runs in the spawned `punktfunk-session`
//! Vulkan binary (the legacy in-process presenter is gone — phase 5 of
//! punktfunk-planning `linux-client-rearchitecture.md`). This module owns the child's
//! lifecycle plumbing — its stdout contract parsed into typed [`AppMsg`]s the relm4 app
//! consumes: spinner until `{"ready":true}`, banner from the `{"error"|"ended": …}`
//! line, exit code 3 + `trust_rejected` routed to the re-pair PIN ceremony.
//! punktfunk-planning `linux-client-rearchitecture.md`). What is left here is the
//! TRANSLATION: a [`ConnectRequest`] becomes a `ConnectPlan`, and the session's typed
//! lifecycle events become the [`AppMsg`]s the relm4 app consumes — spinner until
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, exit code 3 +
//! `trust_rejected` routed to the re-pair PIN ceremony.
//!
//! Spawning, the argv, the stdout contract and the child handle live in
//! `pf_client_core::orchestrate` (design/client-architecture-split.md §3) — the WinUI shell
//! and the coming CLI spawn through the same code, so "what flags does a stream get" and
//! "what does ready mean" have exactly one answer.
use crate::app::AppMsg;
use crate::ui_hosts::ConnectRequest;
use std::io::BufRead as _;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use pf_client_core::orchestrate::{self, ConnectPlan, HostTarget, SessionEvent};
use pf_client_core::trust::Settings;
/// Spawn tunables beyond a plain connect.
#[derive(Debug, Default)]
@@ -25,55 +30,7 @@ pub struct SpawnOpts {
pub cancel: Option<CancelHandle>,
}
/// Kills the spawned session child (the request-access Cancel button). Safe to call
/// any time; a child that already exited is a no-op.
#[derive(Clone, Debug, Default)]
pub struct CancelHandle(Arc<Mutex<Option<Child>>>);
impl CancelHandle {
pub fn kill(&self) {
if let Some(child) = self.0.lock().unwrap().as_mut() {
let _ = child.kill();
}
}
}
/// One parsed stdout line from the session child's contract.
enum ChildEvent {
Ready,
Error { msg: String, trust_rejected: bool },
Ended(String),
}
/// Parse one stdout line of the session contract; `None` for anything else (stats…).
fn parse_line(line: &str) -> Option<ChildEvent> {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
if v.get("ready").and_then(|r| r.as_bool()) == Some(true) {
return Some(ChildEvent::Ready);
}
if let Some(msg) = v.get("error").and_then(|m| m.as_str()) {
return Some(ChildEvent::Error {
msg: msg.to_string(),
trust_rejected: v.get("trust_rejected").and_then(|t| t.as_bool()) == Some(true),
});
}
if let Some(msg) = v.get("ended").and_then(|m| m.as_str()) {
return Some(ChildEvent::Ended(msg.to_string()));
}
None
}
/// The session binary: installed next to the shell, else `$PATH` (dev runs from
/// `target/…` land on the sibling).
pub fn session_binary() -> std::path::PathBuf {
if let Ok(exe) = std::env::current_exe() {
let sibling = exe.with_file_name("punktfunk-session");
if sibling.exists() {
return sibling;
}
}
"punktfunk-session".into()
}
pub use orchestrate::{session_binary, CancelHandle};
/// Spawn the session binary for a connect with `fp_hex` pinned and translate its
/// lifecycle into [`AppMsg`]s. `tofu` = the fingerprint came from the host's advert
@@ -90,114 +47,64 @@ pub fn spawn_session(
fullscreen_on_stream: bool,
opts: SpawnOpts,
) -> Result<(), String> {
let mut cmd = Command::new(session_binary());
cmd.arg("--connect")
.arg(format!("{}:{}", req.addr, req.port))
.arg("--fp")
.arg(&fp_hex)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit()); // session logs interleave with the shell's
if let Some((id, _)) = &req.launch {
cmd.arg("--launch").arg(id);
}
if let Some(secs) = opts.connect_timeout_secs {
cmd.arg("--connect-timeout").arg(secs.to_string());
}
if fullscreen_on_stream {
cmd.arg("--fullscreen");
}
let mut child = cmd
.spawn()
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
tracing::info!(host = %req.addr, port = req.port, "session binary spawned");
let stdout = child.stdout.take().expect("piped stdout");
// Park the child where the cancel handle (and the reader, for the final reap) can
// reach it.
let slot = opts.cancel.clone().unwrap_or_default();
*slot.0.lock().unwrap() = Some(child);
// The plan this connect resolves to. A plain card click carries no `--profile`: it honors
// the host's own binding, which the session resolves through the same helper this shell
// would have used (design/client-settings-profiles.md §4.6) — passing it here would be a
// second source of truth for one decision. Only a "Connect with ▸" pick (or a URL's
// `profile=`) sets one, and it applies to this session alone.
//
// Two fields are deliberately not the shell's state yet, because nothing in the spawn path
// reads them: `settings` carries only what the argv needs (the fullscreen flag), and `wake`
// is false because this shell still runs its own dial-first wake fallback in `app.rs`. Both
// become real when the GTK connect path moves onto `ConnectOrchestrator` (arch-split C0).
let plan = ConnectPlan {
host: HostTarget {
name: req.name.clone(),
addr: req.addr.clone(),
port: req.port,
fp_hex: Some(fp_hex.clone()),
mac: req.mac.clone(),
id: None,
},
launch: req.launch.as_ref().map(|(id, _)| id.clone()),
profile: None,
// A one-off pick rides the flag; without one the session resolves the host's own
// binding through the same helper this shell would have used.
profile_override: req.profile.clone(),
settings: Settings {
fullscreen_on_stream,
..Default::default()
},
wake: false,
connect_timeout_secs: opts.connect_timeout_secs,
tofu,
};
let persist_paired = opts.persist_paired;
std::thread::Builder::new()
.name("punktfunk-session-io".into())
.spawn(move || {
let mut error: Option<(String, bool)> = None;
let mut ended: Option<String> = None;
for line in std::io::BufReader::new(stdout).lines() {
let Ok(line) = line else { break };
match parse_line(&line) {
Some(ChildEvent::Ready) => {
let _ = sender.send(AppMsg::SessionReady {
req: req.clone(),
fp_hex: fp_hex.clone(),
tofu,
persist_paired,
});
}
Some(ChildEvent::Error {
msg,
trust_rejected,
}) => {
error = Some((msg, trust_rejected));
}
Some(ChildEvent::Ended(msg)) => ended = Some(msg),
None => {}
}
}
// EOF — reap the child (killed-by-cancel lands here too; -1 = signal).
let code = slot
.0
.lock()
.unwrap()
.take()
.and_then(|mut c| c.wait().ok())
.and_then(|s| s.code())
.unwrap_or(-1);
tracing::info!(code, "session binary exited");
let (mut error, mut ended) = (None::<(String, bool)>, None::<String>);
orchestrate::spawn_session(&plan, opts.cancel, move |ev| match ev {
SessionEvent::Ready => {
let _ = sender.send(AppMsg::SessionReady {
req: req.clone(),
fp_hex: fp_hex.clone(),
tofu,
persist_paired,
});
}
SessionEvent::Error {
msg,
trust_rejected,
} => error = Some((msg, trust_rejected)),
SessionEvent::Ended(msg) => ended = Some(msg),
SessionEvent::Exited(code) => {
let _ = sender.send(AppMsg::SessionExited {
req,
req: req.clone(),
code,
error,
ended,
error: error.take(),
ended: ended.take(),
tofu,
});
})
.map_err(|e| format!("session reader thread: {e}"))?;
}
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_stdout_contract() {
assert!(matches!(
parse_line("{\"ready\":true}"),
Some(ChildEvent::Ready)
));
match parse_line("{\"error\":\"no route\",\"trust_rejected\":false}") {
Some(ChildEvent::Error {
msg,
trust_rejected,
}) => {
assert_eq!(msg, "no route");
assert!(!trust_rejected);
}
_ => panic!("error line"),
}
match parse_line("{\"error\":\"pin\",\"trust_rejected\":true}") {
Some(ChildEvent::Error { trust_rejected, .. }) => assert!(trust_rejected),
_ => panic!("trust line"),
}
match parse_line("{\"ended\":\"Host ended the session\"}") {
Some(ChildEvent::Ended(m)) => assert_eq!(m, "Host ended the session"),
_ => panic!("ended line"),
}
// Stats and stray output never become events.
assert!(parse_line("stats: 1280×800@60 · 60 fps").is_none());
assert!(parse_line("").is_none());
assert!(parse_line("{\"other\":1}").is_none());
}
}
+456 -33
View File
@@ -32,6 +32,11 @@ pub struct ConnectRequest {
pub launch: Option<(String, String)>,
/// Wake-on-LAN MAC(s) for this host. Empty when none is known.
pub mac: Vec<String>,
/// A ONE-OFF settings profile for this connect ("Connect with ▸ X"): `Some(id)` overrides
/// the host's binding for this launch, `Some("")` forces the global defaults on a bound
/// host, `None` honors the binding. It never rebinds anything — the host's default changes
/// only through an explicit "Default profile" pick (design/client-settings-profiles.md §5.2).
pub profile: Option<String>,
}
impl ConnectRequest {
@@ -62,6 +67,14 @@ enum CardKind {
online: bool,
recent: bool,
library_enabled: bool,
/// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per
/// refresh rather than re-read per card.
profiles: Rc<Vec<(String, String)>>,
/// `Some((id, name))` when this card is a PINNED host+profile pair rather than the
/// host's primary card (design §5.2a): a one-click shortcut for a profile the user
/// reaches for often. It is presentation state on the host record — never a second
/// host entry, which would fork pairing, WoL and renames.
pinned: Option<(String, String)>,
},
Discovered(DiscoveredHost),
}
@@ -69,19 +82,55 @@ enum CardKind {
#[derive(Debug)]
pub enum CardOutput {
Connect(ConnectRequest),
/// Set (or clear, with `None`) this host's DEFAULT settings profile — the explicit
/// rebinding act; a one-off connect never does this.
BindProfile {
fp_hex: String,
addr: String,
port: u16,
profile_id: Option<String>,
},
WakeConnect(ConnectRequest),
Pair(ConnectRequest),
SpeedTest(ConnectRequest),
Library(ConnectRequest),
Rename { fp_hex: String, name: String },
Forget { fp_hex: String, name: String },
Wake { mac: Vec<String>, addr: String },
/// Open the host edit sheet (name, profile binding, clipboard).
Edit {
fp_hex: String,
name: String,
},
Forget {
fp_hex: String,
name: String,
},
Wake {
mac: Vec<String>,
addr: String,
},
/// Put this card's `punktfunk://` URL on the clipboard.
CopyLink(String),
/// Write a desktop entry that launches this card's URL.
CreateShortcut {
label: String,
url: String,
},
/// Add or remove a pinned host+profile card (design §5.2a). Presentation only — it never
/// changes the host's default profile, and unpinning never touches the profile itself.
TogglePin {
fp_hex: String,
addr: String,
port: u16,
profile_id: String,
pin: bool,
},
}
impl HostCard {
fn request(&self) -> ConnectRequest {
match &self.kind {
CardKind::Saved { host: k, .. } => ConnectRequest {
CardKind::Saved {
host: k, pinned, ..
} => ConnectRequest {
name: k.name.clone(),
addr: k.addr.clone(),
port: k.port,
@@ -90,6 +139,9 @@ impl HostCard {
pair_optional: false,
launch: None,
mac: k.mac.clone(),
// A pinned card IS its profile: clicking it connects with that one, without
// touching the host's default.
profile: pinned.as_ref().map(|(id, _)| id.clone()),
},
CardKind::Discovered(a) => ConnectRequest {
name: a.name.clone(),
@@ -100,6 +152,7 @@ impl HostCard {
pair_optional: a.pair == "optional",
launch: None,
mac: a.mac.clone(),
profile: None,
},
}
}
@@ -171,7 +224,11 @@ impl relm4::factory::FactoryComponent for HostCard {
};
match &self.kind {
CardKind::Saved {
host: k, online, ..
host: k,
online,
profiles,
pinned,
..
} => {
// Presence pip + spelled-out state, then the trust pill.
let pip = gtk::Box::new(gtk::Orientation::Horizontal, 0);
@@ -190,6 +247,26 @@ impl relm4::factory::FactoryComponent for HostCard {
} else {
pill("Trusted", "pf-accent")
});
// The chip says what a plain click on THIS card will do: its own profile on a
// pinned card, the host's binding on the primary one. A binding whose profile
// was deleted shows nothing and resolves as the defaults, which is exactly
// what will happen on connect (design §6).
let chip = pinned.as_ref().map(|(_, name)| name.as_str()).or_else(|| {
k.profile_id
.as_ref()
.and_then(|id| profiles.iter().find(|(pid, _)| pid == id))
.map(|(_, name)| name.as_str())
});
if let Some(name) = chip {
status.append(&pill(
name,
if pinned.is_some() {
"pf-accent"
} else {
"pf-neutral"
},
));
}
}
CardKind::Discovered(_) => {
status.append(&if req.pair_optional {
@@ -214,6 +291,8 @@ impl relm4::factory::FactoryComponent for HostCard {
online,
recent,
library_enabled,
profiles,
pinned,
} => {
if *recent {
overlay.add_css_class("pf-recent");
@@ -250,7 +329,7 @@ impl relm4::factory::FactoryComponent for HostCard {
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
add(
"rename",
Box::new(move || CardOutput::Rename {
Box::new(move || CardOutput::Edit {
fp_hex: fp.clone(),
name: name.clone(),
}),
@@ -276,21 +355,185 @@ impl relm4::factory::FactoryComponent for HostCard {
}),
);
}
// Profiles: a ONE-OFF connect ("Connect with"), and the explicit rebinding
// act ("Default profile"). They are separate menus on purpose — the whole
// predictability rule is that connecting with a profile never changes what
// the card will do next time (design §5.2).
{
let profile_action =
|name: &str, out: Box<dyn Fn(Option<String>) -> CardOutput>| {
let a = gio::SimpleAction::new(name, Some(glib::VariantTy::STRING));
let sender = sender.clone();
a.connect_activate(move |_, param| {
// The empty string is "Default settings" — a real choice, not
// an absent one, so it has to survive as a value.
let id = param.and_then(|p| p.str()).unwrap_or("").to_string();
let _ = sender.output(out(Some(id).filter(|s| !s.is_empty())));
});
actions.add_action(&a);
};
let req_for_connect = req.clone();
profile_action(
"connect-with",
Box::new(move |id| {
let mut req = req_for_connect.clone();
// `Some("")` — not `None` — so a bound host really does connect
// with the defaults when the user asks for them.
req.profile = Some(id.unwrap_or_default());
CardOutput::Connect(req)
}),
);
let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port);
profile_action(
"bind-profile",
Box::new(move |id| CardOutput::BindProfile {
fp_hex: fp.clone(),
addr: addr.clone(),
port,
profile_id: id,
}),
);
// "Copy link": the self-emitted URL for this card, which is the pairing
// an external tool (a Playnite entry, a Stream Deck macro) is configured
// with. It carries the stable id AND host+fp, so it still resolves after a
// re-address or a reinstall (design/client-deep-links.md §2/§5).
{
let (host, profile) = (k.clone(), pinned.clone());
let a = gio::SimpleAction::new("copy-link", None);
let sender = sender.clone();
a.connect_activate(move |_, _| {
let url = pf_client_core::deeplink::DeepLink::for_host(
&host,
None,
profile.as_ref().map(|(id, _)| id.as_str()),
)
.to_url();
let _ = sender.output(CardOutput::CopyLink(url));
});
actions.add_action(&a);
}
// "Create shortcut…": the same URL as Copy link, wrapped in a desktop
// entry so it is double-clickable from the app grid.
{
let (host, profile) = (k.clone(), pinned.clone());
let a = gio::SimpleAction::new("shortcut", None);
let sender = sender.clone();
a.connect_activate(move |_, _| {
let url = pf_client_core::deeplink::DeepLink::for_host(
&host,
None,
profile.as_ref().map(|(id, _)| id.as_str()),
)
.to_url();
let label = match &profile {
Some((_, name)) => format!("{} \u{00b7} {name}", host.name),
None => host.name.clone(),
};
let _ = sender.output(CardOutput::CreateShortcut { label, url });
});
actions.add_action(&a);
}
// The same action pins from a primary card and unpins from a pinned one —
// which of the two this card is decides the direction.
let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port);
let pinning = pinned.is_none();
profile_action(
"toggle-pin",
Box::new(move |id| CardOutput::TogglePin {
fp_hex: fp.clone(),
addr: addr.clone(),
port,
profile_id: id.unwrap_or_default(),
pin: pinning,
}),
);
}
overlay.insert_action_group("card", Some(&actions));
let menu = gio::Menu::new();
menu.append(Some("Pair with PIN…"), Some("card.pair"));
menu.append(Some("Test network speed…"), Some("card.speed"));
// An explicit wake only when offline and a MAC is known.
if !online && !k.mac.is_empty() {
menu.append(Some("Wake host"), Some("card.wake"));
if let Some((pin_id, pin_name)) = pinned {
// A pinned card is a shortcut, not a second host: it offers the one-offs
// and its own removal, and deliberately NOT pair/rename/forget — those
// belong to the host, and offering them here would blur what the card is.
let with = gio::Menu::new();
for (label, target) in std::iter::once(("Default settings", "")).chain(
profiles
.iter()
.map(|(id, name)| (name.as_str(), id.as_str())),
) {
let item = gio::MenuItem::new(Some(label), None);
item.set_action_and_target_value(
Some("card.connect-with"),
Some(&target.to_variant()),
);
with.append_item(&item);
}
menu.append_submenu(Some("Connect with"), &with);
let unpin = gio::MenuItem::new(
Some(&format!("Unpin \u{201c}{pin_name}\u{201d}")),
None,
);
unpin.set_action_and_target_value(
Some("card.toggle-pin"),
Some(&pin_id.as_str().to_variant()),
);
menu.append_item(&unpin);
menu.append(Some("Copy link"), Some("card.copy-link"));
menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut"));
} else {
if !profiles.is_empty() {
let with = gio::Menu::new();
let bind = gio::Menu::new();
let pin = gio::Menu::new();
for (label, id) in std::iter::once(("Default settings", "")).chain(
profiles
.iter()
.map(|(id, name)| (name.as_str(), id.as_str())),
) {
// A checkmark would be the natural cue for the current binding, but
// a GMenu radio needs shared state per card; the bound profile is
// named on the card's chip instead, visible without opening a menu.
for (menu, action) in
[(&with, "card.connect-with"), (&bind, "card.bind-profile")]
{
let item = gio::MenuItem::new(Some(label), None);
item.set_action_and_target_value(
Some(action),
Some(&id.to_variant()),
);
menu.append_item(&item);
}
// "Default settings" is not pinnable — the primary card is that.
if !id.is_empty() && !k.pinned_profiles.iter().any(|p| p == id) {
let item = gio::MenuItem::new(Some(label), None);
item.set_action_and_target_value(
Some("card.toggle-pin"),
Some(&id.to_variant()),
);
pin.append_item(&item);
}
}
menu.append_submenu(Some("Connect with"), &with);
menu.append_submenu(Some("Default profile"), &bind);
if pin.n_items() > 0 {
menu.append_submenu(Some("Pin as card"), &pin);
}
}
menu.append(Some("Pair with PIN\u{2026}"), Some("card.pair"));
menu.append(Some("Test network speed\u{2026}"), Some("card.speed"));
// An explicit wake only when offline and a MAC is known.
if !online && !k.mac.is_empty() {
menu.append(Some("Wake host"), Some("card.wake"));
}
// Experimental (Preferences gate): browse the host's game library.
if *library_enabled {
menu.append(Some("Browse library\u{2026}"), Some("card.library"));
}
menu.append(Some("Copy link"), Some("card.copy-link"));
menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut"));
menu.append(Some("Edit\u{2026}"), Some("card.rename"));
menu.append(Some("Forget"), Some("card.forget"));
}
// Experimental (Preferences gate): browse the host's game library.
if *library_enabled {
menu.append(Some("Browse library…"), Some("card.library"));
}
menu.append(Some("Rename…"), Some("card.rename"));
menu.append(Some("Forget"), Some("card.forget"));
let menu_btn = gtk::MenuButton::builder()
.icon_name("view-more-symbolic")
.menu_model(&menu)
@@ -392,6 +635,8 @@ pub enum HostsMsg {
#[derive(Debug)]
pub enum HostsOutput {
Connect(ConnectRequest),
/// A one-line confirmation for the window's toast overlay.
Toast(String),
WakeConnect(ConnectRequest),
Pair(ConnectRequest),
SpeedTest(ConnectRequest),
@@ -668,9 +913,61 @@ impl SimpleComponent for HostsPage {
let mgmt = self.mgmt_port_for(&req);
let _ = sender.output(HostsOutput::Library(req, mgmt));
}
CardOutput::Rename { fp_hex, name } => self.rename_dialog(&sender, &fp_hex, &name),
CardOutput::Edit { fp_hex, name } => self.edit_host_dialog(&sender, &fp_hex, &name),
CardOutput::Forget { fp_hex, name } => self.forget_dialog(&sender, &fp_hex, &name),
CardOutput::Wake { mac, addr } => crate::wol::wake(&mac, addr.parse().ok()),
CardOutput::BindProfile {
fp_hex,
addr,
port,
profile_id,
} => {
// Written straight onto the host record — the binding IS a field there, so
// there is no map to keep in step (design §4.1). Matched by fingerprint
// when there is one, else by address, like every other per-host lookup.
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| {
(!fp_hex.is_empty() && h.fp_hex == fp_hex)
|| (h.addr == addr && h.port == port)
}) {
h.profile_id = profile_id;
if let Err(e) = known.save() {
tracing::warn!(error = %format!("{e:#}"), "saving the profile binding");
}
}
self.rebuild(); // the chip follows immediately
}
CardOutput::CopyLink(url) => {
if let Some(display) = gtk::gdk::Display::default() {
display.clipboard().set_text(&url);
}
let _ = sender.output(HostsOutput::Toast("Link copied".into()));
}
CardOutput::CreateShortcut { label, url } => {
self.shortcut_result(&sender, &label, &url);
}
CardOutput::TogglePin {
fp_hex,
addr,
port,
profile_id,
pin,
} => {
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| {
(!fp_hex.is_empty() && h.fp_hex == fp_hex)
|| (h.addr == addr && h.port == port)
}) {
h.pinned_profiles.retain(|p| p != &profile_id);
if pin {
h.pinned_profiles.push(profile_id);
}
if let Err(e) = known.save() {
tracing::warn!(error = %format!("{e:#}"), "saving the pinned cards");
}
}
self.rebuild();
}
},
}
}
@@ -694,6 +991,14 @@ impl HostsPage {
.max_by_key(|&(_, t)| t)
.map(|(fp, _)| fp);
let library_enabled = self.settings.borrow().library_enabled;
// One catalog read per refresh, shared by every card's menus and chip.
let profiles: Rc<Vec<(String, String)>> = Rc::new(
pf_client_core::profiles::ProfilesFile::load()
.profiles
.into_iter()
.map(|p| (p.id, p.name))
.collect(),
);
{
let mut saved = self.saved.guard();
@@ -715,10 +1020,33 @@ impl HostsPage {
kind: CardKind::Saved {
host: k.clone(),
online,
profiles: profiles.clone(),
recent: most_recent.as_deref() == Some(k.fp_hex.as_str()),
library_enabled,
pinned: None,
},
});
// …then its pinned host+profile cards, in the order the user pinned them.
// They share the host's live status because they read the same record, and a
// pin whose profile is gone simply doesn't render (design §5.2a).
for id in &k.pinned_profiles {
let Some((id, name)) = profiles.iter().find(|(pid, _)| pid == id) else {
continue;
};
saved.push_back(HostCard {
// The spinner belongs to whichever card was clicked; a pin has its own
// key so two cards for one host don't both spin.
connecting: false,
kind: CardKind::Saved {
host: k.clone(),
online,
profiles: profiles.clone(),
recent: false,
library_enabled,
pinned: Some((id.clone(), name.clone())),
},
});
}
}
}
@@ -776,28 +1104,122 @@ impl HostsPage {
}
/// Rename a saved host — an entry in an alert, then upsert + refresh.
fn rename_dialog(&self, sender: &ComponentSender<Self>, fp_hex: &str, current: &str) {
let entry = gtk::Entry::builder()
.text(current)
.activates_default(true)
/// Write the shortcut, or — inside the flatpak sandbox, which cannot reach
/// `~/.local/share/applications` — hand the user the URL to place themselves. The
/// DynamicLauncher portal is the intended upgrade for that case (design §5); until then
/// the fallback is the one the design already sanctions, not a dead end.
fn shortcut_result(&self, sender: &ComponentSender<Self>, label: &str, url: &str) {
if crate::shortcuts::sandboxed() {
let dialog = adw::AlertDialog::new(
Some("Create Shortcut"),
Some(
"Punktfunk is sandboxed here, so it can't add the shortcut itself. Copy \
this link and make a launcher for it \u{2014} it opens the same stream.",
),
);
let entry = gtk::Entry::builder().text(url).editable(false).build();
dialog.set_extra_child(Some(&entry));
dialog.add_responses(&[("close", "Close"), ("copy", "Copy link")]);
dialog.set_response_appearance("copy", adw::ResponseAppearance::Suggested);
dialog.set_close_response("close");
{
let url = url.to_string();
dialog.connect_response(Some("copy"), move |_, _| {
if let Some(display) = gtk::gdk::Display::default() {
display.clipboard().set_text(&url);
}
});
}
dialog.present(Some(&self.widgets.stack));
return;
}
let msg = match crate::shortcuts::write_desktop_entry(label, url) {
Ok(_) => format!("Shortcut for \u{201c}{label}\u{201d} added to your applications"),
Err(e) => {
tracing::warn!(error = %e, "writing the shortcut");
format!("Couldn't create the shortcut \u{2014} {e}")
}
};
let _ = sender.output(HostsOutput::Toast(msg));
}
/// The host edit sheet — the per-host settings that are properties of the HOST, not of
/// the stream: its name, whether this machine shares its clipboard with it, and which
/// settings profile it defaults to.
///
/// Linux had only "Rename" until now; the clipboard toggle in particular existed in the
/// store and on the Apple and Windows clients but had no Linux surface at all, so a Linux
/// user could not turn on a feature they were already paying the storage for.
fn edit_host_dialog(&self, sender: &ComponentSender<Self>, fp_hex: &str, current: &str) {
let stored = KnownHosts::load()
.hosts
.iter()
.find(|h| h.fp_hex == fp_hex)
.cloned();
let name_row = adw::EntryRow::builder().title("Name").build();
name_row.set_text(current);
let clipboard_row = adw::SwitchRow::builder()
.title("Share clipboard")
.subtitle(
"Copy and paste between this machine and that host. Per host \u{2014} handing a \
host your clipboard is a decision about that host.",
)
.build();
let dialog = adw::AlertDialog::new(Some("Rename Host"), None);
dialog.set_extra_child(Some(&entry));
dialog.add_responses(&[("cancel", "Cancel"), ("rename", "Rename")]);
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("rename"));
clipboard_row.set_active(stored.as_ref().is_some_and(|h| h.clipboard_sync));
// Profile picker: "Default settings" plus the catalog, seeded to the current binding.
let catalog = pf_client_core::profiles::ProfilesFile::load();
let mut labels = vec!["Default settings".to_string()];
let mut ids: Vec<String> = vec![String::new()];
for p in &catalog.profiles {
labels.push(p.name.clone());
ids.push(p.id.clone());
}
let bound = stored.as_ref().and_then(|h| h.profile_id.clone());
// A binding whose profile is gone reads as Default settings and is cleaned up on save
// — the same "dangling resolves as none" rule the connect path follows.
let selected = bound
.as_ref()
.and_then(|id| ids.iter().position(|i| i == id))
.unwrap_or(0);
let profile_row = adw::ComboRow::builder()
.title("Profile")
.subtitle("The settings a plain click uses for this host")
.model(&gtk::StringList::new(
&labels.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
profile_row.set_selected(selected as u32);
let list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
list.append(&name_row);
list.append(&profile_row);
list.append(&clipboard_row);
let dialog = adw::AlertDialog::new(Some("Edit Host"), None);
dialog.set_extra_child(Some(&list));
dialog.add_responses(&[("cancel", "Cancel"), ("save", "Save")]);
dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("save"));
dialog.set_close_response("cancel");
{
let sender = sender.clone();
let fp = fp_hex.to_string();
dialog.connect_response(Some("rename"), move |_, _| {
let name = entry.text().trim().to_string();
if name.is_empty() {
return;
}
dialog.connect_response(Some("save"), move |_, _| {
let name = name_row.text().trim().to_string();
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.name = name;
if !name.is_empty() {
h.name = name;
}
h.clipboard_sync = clipboard_row.is_active();
h.profile_id = ids
.get(profile_row.selected() as usize)
.filter(|id| !id.is_empty())
.cloned();
let _ = known.save();
}
sender.input(HostsMsg::Refresh);
@@ -886,6 +1308,7 @@ impl HostsPage {
pair_optional: false,
launch: None,
mac: Vec::new(),
profile: None,
}));
});
}
+675 -82
View File
@@ -4,13 +4,49 @@
//! dynamic where the meaning depends on the selection (touch mode). Written back to
//! disk when the dialog closes. About stays in the primary menu (GNOME convention)
//! rather than as a page.
//!
//! The same surface edits SETTINGS PROFILES (design/client-settings-profiles.md §5.1): a
//! scope switcher at the top swaps the whole dialog between the global defaults and one
//! profile's overrides. It is deliberately not a second editor — a parallel one would drift
//! from this one field by field. In profile scope only profileable ("tier P") rows render,
//! every row shows the EFFECTIVE value (the inherited global until you touch it), and the
//! override is recorded on touch rather than by comparing values, so a profile can pin a
//! value that happens to equal today's global and keep it when the global later moves.
use crate::trust::Settings;
use adw::prelude::*;
use pf_client_core::profiles::{ProfilesFile, SettingsOverlay, StreamProfile};
use pf_client_core::trust::StatsVerbosity;
use std::cell::{Cell, RefCell};
use std::collections::HashSet;
use std::rc::Rc;
/// Which layer the dialog is editing.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Scope {
/// The global defaults every profile inherits from — the only scope before this feature.
Defaults,
/// One profile's overrides, by id.
Profile(String),
}
/// Which rows the user actually touched this session. The override model is explicit, not
/// diffed: touching a control creates the override, and only an explicit reset removes it
/// (design §4.1). Keys are the overlay's field names, with `resolution` covering the
/// width/height/match-window tri-state that one row drives.
#[derive(Clone, Default)]
struct Touched(Rc<RefCell<HashSet<&'static str>>>);
impl Touched {
fn mark(&self, key: &'static str) {
self.0.borrow_mut().insert(key);
}
fn has(&self, key: &str) -> bool {
self.0.borrow().contains(key)
}
}
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
const RESOLUTIONS: &[(u32, u32)] = &[
(0, 0),
@@ -25,6 +61,358 @@ const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
/// `1.0` = Native. Applied at connect and each match-window resize.
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
/// The scope switcher, plus (in profile scope) that profile's management actions.
///
/// Switching scope does not swap the rows in place: it closes the dialog — which commits the
/// layer being edited — and asks the app to re-open in the new scope. One code path builds
/// the rows, and the commit ordering is unambiguous.
#[allow(clippy::too_many_arguments)]
fn scope_group(
dialog: &adw::PreferencesDialog,
inline: bool,
scope: &Scope,
catalog: &ProfilesFile,
active: Option<&StreamProfile>,
next_scope: &Rc<RefCell<Option<Scope>>>,
parent: &impl IsA<gtk::Widget>,
) -> adw::PreferencesGroup {
let g = group(
"",
"A profile overrides only what you change here; everything else follows Default \
settings.",
);
let mut labels: Vec<String> = vec!["Default settings".into()];
labels.extend(catalog.profiles.iter().map(|p| p.name.clone()));
labels.push("New profile…".into());
let new_index = (labels.len() - 1) as u32;
let current = match scope {
Scope::Defaults => 0,
Scope::Profile(id) => catalog
.profiles
.iter()
.position(|p| &p.id == id)
.map_or(0, |i| i as u32 + 1),
};
let row = ChoiceRow::new(
dialog,
inline,
"Editing",
"Which layer these settings belong to",
&labels.iter().map(String::as_str).collect::<Vec<_>>(),
);
row.set_selected(current);
{
let (dialog, next, parent) = (dialog.clone(), next_scope.clone(), parent.as_ref().clone());
let ids: Vec<String> = catalog.profiles.iter().map(|p| p.id.clone()).collect();
row.connect_changed(move |i| {
if i == new_index {
// Creation is the one branch that has to ask a question first; the switch
// happens in its callback, so a cancelled prompt leaves the dialog put.
let (dialog, next) = (dialog.clone(), next.clone());
prompt_name(&parent, "New profile", "Create", "", move |name| {
let mut catalog = ProfilesFile::load();
if catalog.name_taken(&name, None) {
return; // the prompt already refuses these; belt and braces
}
let profile = StreamProfile::new(name);
let id = profile.id.clone();
catalog.profiles.push(profile);
if catalog.save().is_ok() {
*next.borrow_mut() = Some(Scope::Profile(id));
dialog.close();
}
});
return;
}
*next.borrow_mut() = Some(match i {
0 => Scope::Defaults,
n => match ids.get(n as usize - 1) {
Some(id) => Scope::Profile(id.clone()),
None => Scope::Defaults,
},
});
dialog.close();
});
}
g.add(row.widget());
// Leaking the row keeps its handler alive for the dialog's lifetime — the ChoiceRow owns
// the closure, and the widget alone doesn't keep it.
std::mem::forget(row);
if let Some(active) = active {
let actions = adw::ActionRow::builder()
.title(&active.name)
.subtitle("This profile")
.use_markup(false)
.build();
let buttons = gtk::Box::builder()
.spacing(6)
.valign(gtk::Align::Center)
.build();
for (label, action) in [
("Rename…", ProfileAction::Rename),
("Duplicate", ProfileAction::Duplicate),
("Delete…", ProfileAction::Delete),
] {
let b = gtk::Button::builder().label(label).build();
if matches!(action, ProfileAction::Delete) {
b.add_css_class("destructive-action");
}
let (dialog, next, parent, id, name) = (
dialog.clone(),
next_scope.clone(),
parent.as_ref().clone(),
active.id.clone(),
active.name.clone(),
);
b.connect_clicked(move |_| {
run_profile_action(action, &parent, &dialog, &next, &id, &name)
});
buttons.append(&b);
}
actions.add_suffix(&buttons);
g.add(&actions);
}
g
}
#[derive(Clone, Copy)]
enum ProfileAction {
Rename,
Duplicate,
Delete,
}
/// Rename / duplicate / delete for the profile in scope. Each ends by closing the dialog so
/// the edit and the re-render can't disagree about what the catalog holds.
fn run_profile_action(
action: ProfileAction,
parent: &gtk::Widget,
dialog: &adw::PreferencesDialog,
next: &Rc<RefCell<Option<Scope>>>,
id: &str,
name: &str,
) {
let (dialog, next, id) = (dialog.clone(), next.clone(), id.to_string());
match action {
ProfileAction::Rename => {
let keep = id.clone();
prompt_name(parent, "Rename profile", "Rename", name, move |new_name| {
let mut catalog = ProfilesFile::load();
if catalog.name_taken(&new_name, Some(&keep)) {
return;
}
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == keep) {
p.name = new_name;
}
if catalog.save().is_ok() {
*next.borrow_mut() = Some(Scope::Profile(keep.clone()));
dialog.close();
}
});
}
ProfileAction::Duplicate => {
let mut catalog = ProfilesFile::load();
let Some(source) = catalog.find_by_id(&id).cloned() else {
return;
};
// "Work 2", "Work 3", … — the first name the catalog doesn't already hold.
let copy_name = (2..)
.map(|n| format!("{} {n}", source.name))
.find(|n| !catalog.name_taken(n, None))
.unwrap_or_else(|| source.name.clone());
let mut copy = StreamProfile::new(copy_name);
copy.overrides = source.overrides.clone();
copy.accent = source.accent.clone();
let new_id = copy.id.clone();
catalog.profiles.push(copy);
if catalog.save().is_ok() {
*next.borrow_mut() = Some(Scope::Profile(new_id));
dialog.close();
}
}
ProfileAction::Delete => {
// The warning counts what actually breaks: hosts that fall back to the defaults,
// and pinned cards that disappear (design §6).
let known = crate::trust::KnownHosts::load();
let bound = known
.hosts
.iter()
.filter(|h| h.profile_id.as_deref() == Some(id.as_str()))
.count();
let pinned = known
.hosts
.iter()
.filter(|h| h.pinned_profiles.iter().any(|p| p == &id))
.count();
let mut body = format!("{name}” will be removed.");
if bound > 0 {
body.push_str(&format!(
"\n\n{bound} host{} will fall back to Default settings.",
if bound == 1 { "" } else { "s" }
));
}
if pinned > 0 {
body.push_str(&format!(
"\n{pinned} pinned card{} will disappear.",
if pinned == 1 { "" } else { "s" }
));
}
let confirm = adw::AlertDialog::new(Some("Delete profile?"), Some(&body));
confirm.add_responses(&[("cancel", "Cancel"), ("delete", "Delete")]);
confirm.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
confirm.set_close_response("cancel");
confirm.connect_response(Some("delete"), move |_, _| {
let mut catalog = ProfilesFile::load();
catalog.profiles.retain(|p| p.id != id);
if catalog.save().is_ok() {
// Bindings and pins are left dangling on purpose: they resolve as "no
// profile" everywhere, and rewriting every host record here would be a
// second, racier source of truth.
*next.borrow_mut() = Some(Scope::Defaults);
dialog.close();
}
});
confirm.present(Some(parent));
}
}
}
/// A one-line name prompt (create/rename). Refuses empty and duplicate names in place —
/// menus keyed by name are ambiguous otherwise (design §6) — rather than failing after the
/// dialog is gone.
fn prompt_name(
parent: &impl IsA<gtk::Widget>,
heading: &str,
accept: &str,
initial: &str,
on_ok: impl Fn(String) + 'static,
) {
let dialog = adw::AlertDialog::new(Some(heading), None);
let entry = adw::EntryRow::builder().title("Name").build();
entry.set_text(initial);
let list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
list.append(&entry);
dialog.set_extra_child(Some(&list));
dialog.add_responses(&[("cancel", "Cancel"), ("ok", accept)]);
dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("ok"));
dialog.set_close_response("cancel");
let taken_against = initial.to_string();
let e = entry.clone();
let d = dialog.clone();
let validate = move || {
let name = e.text().trim().to_string();
let catalog = ProfilesFile::load();
let dup = !name.eq_ignore_ascii_case(&taken_against) && catalog.name_taken(&name, None);
d.set_response_enabled("ok", !name.is_empty() && !dup);
e.set_title(if dup {
"Name — already used by another profile"
} else {
"Name"
});
};
validate();
{
let validate = validate.clone();
entry.connect_changed(move |_| validate());
}
let e = entry.clone();
dialog.connect_response(Some("ok"), move |_, _| {
let name = e.text().trim().to_string();
if !name.is_empty() {
on_ok(name);
}
});
dialog.present(Some(parent));
}
/// Write the rows the user touched into this profile's overlay and persist the catalog.
///
/// Only touched fields move: an untouched row leaves whatever the profile already had
/// (an inherited `None`, or an existing override this build might not even render), which is
/// what keeps an older client from erasing a newer one's values just by opening the dialog.
/// The catalog is re-read here rather than reused, so a profile renamed in another window
/// between opening and closing this one survives.
fn commit_profile(
active: &StreamProfile,
touched: &Touched,
cleared: &HashSet<&'static str>,
values: &Settings,
) {
let mut catalog = ProfilesFile::load();
let Some(slot) = catalog.profiles.iter_mut().find(|p| p.id == active.id) else {
return; // deleted from under us — nothing to write to, and nothing to complain about
};
let o: &mut SettingsOverlay = &mut slot.overrides;
if touched.has("resolution") {
// One row drives the tri-state, so all three fields move together.
o.match_window = Some(values.match_window);
o.width = Some(values.width);
o.height = Some(values.height);
}
if touched.has("refresh_hz") {
o.refresh_hz = Some(values.refresh_hz);
}
if touched.has("render_scale") {
o.render_scale = Some(values.render_scale);
}
if touched.has("bitrate_kbps") {
o.bitrate_kbps = Some(values.bitrate_kbps);
}
if touched.has("codec") {
o.codec = Some(values.codec.clone());
}
if touched.has("hdr_enabled") {
o.hdr_enabled = Some(values.hdr_enabled);
}
if touched.has("compositor") {
o.compositor = Some(values.compositor.clone());
}
if touched.has("audio_channels") {
o.audio_channels = Some(values.audio_channels);
}
if touched.has("mic_enabled") {
o.mic_enabled = Some(values.mic_enabled);
}
if touched.has("touch_mode") {
o.touch_mode = Some(values.touch_mode.clone());
}
if touched.has("mouse_mode") {
o.mouse_mode = Some(values.mouse_mode.clone());
}
if touched.has("invert_scroll") {
o.invert_scroll = Some(values.invert_scroll);
}
if touched.has("inhibit_shortcuts") {
o.inhibit_shortcuts = Some(values.inhibit_shortcuts);
}
if touched.has("gamepad") {
o.gamepad = Some(values.gamepad.clone());
}
if touched.has("stats_verbosity") {
o.stats_verbosity = Some(values.stats_verbosity());
}
if touched.has("fullscreen_on_stream") {
o.fullscreen_on_stream = Some(values.fullscreen_on_stream);
}
// Resets last: a row the user reset may also have fired its change handler on the way
// (a ComboRow re-seeds), and "back to inheriting" is the later, explicit intent. The field
// names are the overlay's own, so the list of what can be cleared lives in ONE place —
// this used to be a second `match` here, which is exactly how the two drift.
for key in cleared {
if !o.clear(key) {
tracing::warn!(field = key, "reset of an unknown overlay field");
}
}
if let Err(e) = catalog.save() {
tracing::warn!(error = %format!("{e:#}"), "saving the profile catalog");
}
}
/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)".
fn render_scale_label(scale: f64) -> String {
if scale == 1.0 {
@@ -130,7 +518,7 @@ fn gamescope_session() -> bool {
|| std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok()
}
type ChangedFn = Rc<RefCell<Option<Box<dyn Fn(u32)>>>>;
type ChangedFn = Rc<RefCell<Vec<Box<dyn Fn(u32)>>>>;
/// A titled single-choice preference row. On a desktop this is a stock popover
/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable
@@ -138,8 +526,9 @@ type ChangedFn = Rc<RefCell<Option<Box<dyn Fn(u32)>>>>;
struct ChoiceRow {
row: adw::PreferencesRow,
selected: Rc<Cell<u32>>,
/// Fires on user changes only — [`connect_changed`](Self::connect_changed) is installed
/// after seeding, so programmatic `set_selected` during setup never fires it.
/// Fires on user changes only — handlers are installed after seeding, so programmatic
/// `set_selected` during setup never fires them. A list, not one: a row can carry both a
/// dynamic caption and (in profile scope) the override mark.
changed: ChangedFn,
/// Subpage mode only: the current value rendered as the row's suffix.
value_label: Option<gtk::Label>,
@@ -158,7 +547,7 @@ impl ChoiceRow {
) -> ChoiceRow {
let options: Rc<Vec<String>> = Rc::new(options.iter().map(|s| s.to_string()).collect());
let selected = Rc::new(Cell::new(0u32));
let changed: ChangedFn = Rc::new(RefCell::new(None));
let changed: ChangedFn = Rc::new(RefCell::new(Vec::new()));
if !inline {
let row = adw::ComboRow::builder()
@@ -171,7 +560,7 @@ impl ChoiceRow {
let (sel, chg) = (selected.clone(), changed.clone());
row.connect_selected_notify(move |r| {
if sel.replace(r.selected()) != r.selected() {
if let Some(f) = chg.borrow().as_ref() {
for f in chg.borrow().iter() {
f(r.selected());
}
}
@@ -227,7 +616,7 @@ impl ChoiceRow {
let user_change = sel.replace(idx) != idx;
value.set_text(&label);
if user_change {
if let Some(f) = chg.borrow().as_ref() {
for f in chg.borrow().iter() {
f(idx);
}
}
@@ -291,7 +680,7 @@ impl ChoiceRow {
}
fn connect_changed(&self, f: impl Fn(u32) + 'static) {
*self.changed.borrow_mut() = Some(Box::new(f));
self.changed.borrow_mut().push(Box::new(f));
}
}
@@ -357,17 +746,40 @@ fn group(title: &str, description: &str) -> adw::PreferencesGroup {
g
}
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
/// there so the library toggle takes effect without a nav round-trip). `probes` is the
/// shell's startup device probe (`AppModel::probes`) — may still be empty. Returns the
/// presented dialog so the screenshot harness can select a page; callers ignore it.
pub fn show(
/// The dialog in a given [`Scope`]. `on_scope` asks the app to re-open it in another one:
/// switching scope closes this dialog first, so the layer being edited is committed before
/// the next one is loaded, and there is exactly one place that builds the rows.
pub fn show_scoped(
parent: &impl IsA<gtk::Widget>,
settings: Rc<RefCell<Settings>>,
gamepads: &crate::gamepad::GamepadService,
probes: &DeviceProbes,
scope: Scope,
on_scope: impl Fn(Scope) + 'static,
on_closed: impl Fn() + 'static,
) -> adw::PreferencesDialog {
let catalog = ProfilesFile::load();
// A scope pointing at a deleted profile degrades to the defaults rather than erroring —
// the same rule a dangling host binding follows.
let active: Option<StreamProfile> = match &scope {
Scope::Profile(id) => catalog.find_by_id(id).cloned(),
Scope::Defaults => None,
};
let profile_mode = active.is_some();
// Rows always show the EFFECTIVE value: the global underneath, with this profile's
// overrides on top. A row the profile doesn't override therefore reads as the live
// global, which is what "inherit by default" has to look like.
let seed: Settings = match &active {
Some(p) => p.overrides.apply(&settings.borrow()),
None => settings.borrow().clone(),
};
let touched = Touched::default();
// Fields whose override a per-row reset dropped — applied at commit, after the touched
// ones, so "reset" wins over a value the same row happens to be showing.
let cleared: Rc<RefCell<HashSet<&'static str>>> = Rc::default();
// Where a scope switch wants to go once this dialog has committed and closed.
let next_scope: Rc<RefCell<Option<Scope>>> = Rc::default();
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
// subpage onto it.
let dialog = adw::PreferencesDialog::new();
@@ -465,7 +877,7 @@ pub fn show(
// GPU picker (multi-GPU boxes): the adapter name feeds the session's device pick
// via `Settings::adapter` → PUNKTFUNK_VK_ADAPTER. Hidden when there's nothing to
// pick; a saved adapter that's gone (eGPU unplugged) keeps a revertable entry.
let saved_adapter = settings.borrow().adapter.clone();
let saved_adapter = seed.adapter.clone();
let mut gpu_names = vec!["Automatic".to_string()];
let mut gpu_keys: Vec<String> = vec![String::new()];
for a in &probes.adapters {
@@ -709,9 +1121,9 @@ pub fn show(
],
);
// ---- Seed from the current settings ----
// ---- Seed from the effective settings for this scope ----
{
let s = settings.borrow();
let s = &seed;
let res_i = if s.match_window {
1
} else {
@@ -775,18 +1187,162 @@ pub fn show(
set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32));
}
// ---- Override tracking (profile scope) ----
// Installed after the seed block on purpose: `set_selected`/`set_active` during setup must
// not look like the user touching the control, or opening a profile would override every
// row in it.
if profile_mode {
macro_rules! on_change {
($row:expr, $key:literal) => {{
let t = touched.clone();
$row.connect_changed(move |_| t.mark($key));
}};
}
macro_rules! on_toggle {
($row:expr, $key:literal) => {{
let t = touched.clone();
$row.connect_active_notify(move |_| t.mark($key));
}};
}
on_change!(res_row, "resolution");
on_change!(hz_row, "refresh_hz");
on_change!(scale_row, "render_scale");
on_change!(codec_row, "codec");
on_change!(compositor_row, "compositor");
on_change!(stats_row, "stats_verbosity");
on_change!(touch_row, "touch_mode");
on_change!(mouse_row, "mouse_mode");
on_change!(surround_row, "audio_channels");
on_change!(pad_row, "gamepad");
on_toggle!(hdr_row, "hdr_enabled");
on_toggle!(fullscreen_row, "fullscreen_on_stream");
on_toggle!(inhibit_row, "inhibit_shortcuts");
on_toggle!(invert_row, "invert_scroll");
on_toggle!(mic_row, "mic_enabled");
let t = touched.clone();
bitrate_row.connect_value_notify(move |_| t.mark("bitrate_kbps"));
}
// ---- Override markers + per-row reset (profile scope) ----
// Every overridden row says so — an accent dot — and carries the only way back to
// inheriting: an explicit reset. Without this a profile is a one-way door, since the
// override model deliberately never infers "not overridden" from a value comparison.
if let Some(active) = &active {
let o = &active.overrides;
let mark = |row: &adw::PreferencesRow, key: &'static str, overridden: bool| {
if !overridden {
return;
}
let Some(row) = row.downcast_ref::<adw::ActionRow>() else {
return;
};
let dot = gtk::Box::builder()
.css_classes(["pf-override-dot"])
.valign(gtk::Align::Center)
.build();
row.add_prefix(&dot);
let reset = gtk::Button::builder()
.icon_name("edit-undo-symbolic")
.tooltip_text("Reset to Default settings")
.valign(gtk::Align::Center)
.css_classes(["flat"])
.build();
let (dialog, next, cleared, scope) = (
dialog.clone(),
next_scope.clone(),
cleared.clone(),
scope.clone(),
);
reset.connect_clicked(move |_| {
// Queue the clear and re-open in the same scope: the close handler commits
// whatever else was edited first, then drops this field, and the rebuilt rows
// show the inherited value. One code path builds rows — including this one.
cleared.borrow_mut().insert(key);
*next.borrow_mut() = Some(scope.clone());
dialog.close();
});
row.add_suffix(&reset);
};
mark(
res_row.widget(),
"resolution",
o.width.is_some() || o.height.is_some() || o.match_window.is_some(),
);
mark(hz_row.widget(), "refresh_hz", o.refresh_hz.is_some());
mark(scale_row.widget(), "render_scale", o.render_scale.is_some());
mark(
bitrate_row.upcast_ref(),
"bitrate_kbps",
o.bitrate_kbps.is_some(),
);
mark(codec_row.widget(), "codec", o.codec.is_some());
mark(hdr_row.upcast_ref(), "hdr_enabled", o.hdr_enabled.is_some());
mark(
compositor_row.widget(),
"compositor",
o.compositor.is_some(),
);
mark(
surround_row.widget(),
"audio_channels",
o.audio_channels.is_some(),
);
mark(mic_row.upcast_ref(), "mic_enabled", o.mic_enabled.is_some());
mark(touch_row.widget(), "touch_mode", o.touch_mode.is_some());
mark(mouse_row.widget(), "mouse_mode", o.mouse_mode.is_some());
mark(
invert_row.upcast_ref(),
"invert_scroll",
o.invert_scroll.is_some(),
);
mark(
inhibit_row.upcast_ref(),
"inhibit_shortcuts",
o.inhibit_shortcuts.is_some(),
);
mark(pad_row.widget(), "gamepad", o.gamepad.is_some());
mark(
stats_row.widget(),
"stats_verbosity",
o.stats_verbosity.is_some(),
);
mark(
fullscreen_row.upcast_ref(),
"fullscreen_on_stream",
o.fullscreen_on_stream.is_some(),
);
}
// ---- Assemble the category pages (the Apple revamp's map) ----
let general = page("General", "preferences-system-symbolic");
// The scope switcher heads the first page — the one row that is always about which layer
// you are editing, not about the stream.
general.add(&scope_group(
&dialog,
inline,
&scope,
&catalog,
active.as_ref(),
&next_scope,
parent,
));
let session_group = group("Session", "");
session_group.add(&fullscreen_row);
session_group.add(&wake_row);
// Auto-wake is a property of the host and this network, not of "Game vs Work" — it stays
// global in v1 (design §3, tier H/G).
if !profile_mode {
session_group.add(&wake_row);
}
let stats_group = group("Statistics", "");
stats_group.add(stats_row.widget());
let library_group = group("Library", "");
library_group.add(&library_row);
general.add(&session_group);
general.add(&stats_group);
general.add(&library_group);
// The library browser is an app-level toggle for this device, not a per-profile one.
if !profile_mode {
let library_group = group("Library", "");
library_group.add(&library_row);
general.add(&library_group);
}
let display = page("Display", "video-display-symbolic");
let resolution_group = group("Resolution", "");
@@ -797,8 +1353,11 @@ pub fn show(
quality_group.add(&bitrate_row);
quality_group.add(codec_row.widget());
quality_group.add(&hdr_row);
quality_group.add(decoder_row.widget());
if let Some(r) = &gpu_row {
// Decoder and GPU are facts about THIS device's hardware — never per profile (tier G).
if !profile_mode {
quality_group.add(decoder_row.widget());
}
if let (Some(r), false) = (&gpu_row, profile_mode) {
quality_group.add(r.widget());
}
// The one form-level note (deliberately not repeated on every row).
@@ -825,11 +1384,14 @@ pub fn show(
let audio = page("Audio", "audio-volume-high-symbolic");
let audio_group = group("", "Applies from the next session.");
audio_group.add(surround_row.widget());
if let Some(r) = &speaker_row {
// The speaker/mic endpoint pickers below are this device's audio routing (tier G) — they
// render only in the defaults scope; the surround + mic-uplink rows above are profileable.
if let (Some(r), false) = (&speaker_row, profile_mode) {
audio_group.add(r.widget());
}
audio_group.add(&mic_row);
if let Some(r) = &micdev_row {
if let (Some(r), false) = (&micdev_row, profile_mode) {
audio_group.add(r.widget());
}
audio.add(&audio_group);
@@ -837,8 +1399,12 @@ pub fn show(
let controllers = page("Controllers", "input-gaming-symbolic");
let controllers_group = group("", "");
// The detected-pad list (mirrors the Apple Controllers section): informational rows
// above the pickers, from the same snapshot that feeds the forwarding picker.
if pads.is_empty() {
// above the pickers, from the same snapshot that feeds the forwarding picker. It is
// about the hardware plugged into THIS device, so profile scope shows only the
// emulated-type picker below it.
if profile_mode {
// nothing — the pad inventory belongs to the device, not the profile
} else if pads.is_empty() {
let none = adw::ActionRow::builder()
.title("No controllers detected")
.css_classes(["dim-label"])
@@ -861,7 +1427,9 @@ pub fn show(
controllers_group.add(&row);
}
}
controllers_group.add(forward_row.widget());
if !profile_mode {
controllers_group.add(forward_row.widget());
}
controllers_group.add(pad_row.widget());
controllers.add(&controllers_group);
@@ -872,64 +1440,89 @@ pub fn show(
dialog.add(&controllers);
dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut();
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len());
s.match_window = res_i == 1;
(s.width, s.height) = if res_i <= 1 {
(0, 0)
} else {
RESOLUTIONS[res_i - 1]
};
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.render_scale =
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
// Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the
// session, hand-edited or written by another client): it displays as "Automatic", and
// writing that back would silently erase it just by opening + closing the dialog.
// Persist the row only when the user picked a non-Auto entry or the stored value was
// a listed one to begin with.
let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1);
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
s.gamepad = GAMEPADS[pad_sel].to_string();
}
s.touch_mode =
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
s.mouse_mode =
MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_MODES.len() - 1)].to_string();
s.forward_pad = chosen_pin.borrow().clone();
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
// One reader for the rows, two destinations: the globals, or a profile's overrides.
// Sharing it is what keeps the two scopes from interpreting the same controls
// differently (the tri-state resolution row is the obvious trap).
let apply_rows = |s: &mut Settings| {
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len());
s.match_window = res_i == 1;
(s.width, s.height) = if res_i <= 1 {
(0, 0)
} else {
RESOLUTIONS[res_i - 1]
};
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.render_scale =
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
// Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the
// session, hand-edited or written by another client): it displays as "Automatic", and
// writing that back would silently erase it just by opening + closing the dialog.
// Persist the row only when the user picked a non-Auto entry or the stored value was
// a listed one to begin with.
let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1);
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
s.gamepad = GAMEPADS[pad_sel].to_string();
}
s.touch_mode =
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
s.mouse_mode =
MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_MODES.len() - 1)].to_string();
s.forward_pad = chosen_pin.borrow().clone();
s.compositor = COMPOSITORS
[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string();
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
if let Some(r) = &gpu_row {
s.adapter = gpu_keys[(r.selected() as usize).min(gpu_keys.len() - 1)].clone();
}
if let Some(r) = &speaker_row {
s.speaker_device =
speaker_keys[(r.selected() as usize).min(speaker_keys.len() - 1)].clone();
}
if let Some(r) = &micdev_row {
s.mic_device = micdev_keys[(r.selected() as usize).min(micdev_keys.len() - 1)].clone();
}
s.set_stats_verbosity(
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
);
s.fullscreen_on_stream = fullscreen_row.is_active();
s.auto_wake = wake_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active();
s.invert_scroll = invert_row.is_active();
s.mic_enabled = mic_row.is_active();
s.hdr_enabled = hdr_row.is_active();
s.audio_channels = match surround_row.selected() {
1 => 6,
2 => 8,
_ => 2,
s.decoder =
DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
if let Some(r) = &gpu_row {
s.adapter = gpu_keys[(r.selected() as usize).min(gpu_keys.len() - 1)].clone();
}
if let Some(r) = &speaker_row {
s.speaker_device =
speaker_keys[(r.selected() as usize).min(speaker_keys.len() - 1)].clone();
}
if let Some(r) = &micdev_row {
s.mic_device =
micdev_keys[(r.selected() as usize).min(micdev_keys.len() - 1)].clone();
}
s.set_stats_verbosity(
StatsVerbosity::ALL
[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
);
s.fullscreen_on_stream = fullscreen_row.is_active();
s.auto_wake = wake_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active();
s.invert_scroll = invert_row.is_active();
s.mic_enabled = mic_row.is_active();
s.hdr_enabled = hdr_row.is_active();
s.audio_channels = match surround_row.selected() {
1 => 6,
2 => 8,
_ => 2,
};
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
s.library_enabled = library_row.is_active();
};
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
s.library_enabled = library_row.is_active();
s.save();
drop(s);
match &active {
// Profile scope writes the touched rows into the catalog and leaves the globals
// exactly as they were — the point of the whole feature.
Some(active) => {
let mut values = seed.clone();
apply_rows(&mut values);
commit_profile(active, &touched, &cleared.borrow(), &values);
}
None => {
let mut s = settings.borrow_mut();
apply_rows(&mut s);
s.save();
}
}
// A scope switch closed this dialog to commit first; now re-open in the new scope.
if let Some(next) = next_scope.borrow_mut().take() {
on_scope(next);
}
on_closed();
});
dialog.present(Some(parent));
+36 -25
View File
@@ -9,6 +9,7 @@ use crate::trust;
use crate::ui_hosts::ConnectRequest;
use adw::prelude::*;
use gtk::glib;
use pf_client_core::orchestrate::{WakeOutcome, WakeWait};
use relm4::prelude::*;
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
@@ -16,7 +17,11 @@ use relm4::prelude::*;
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog
/// lets the user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s).
/// lets the user cancel.
///
/// The cadence itself is [`WakeWait`] — the same state machine the WinUI shell drives, ported
/// from Apple's `HostWaker` (design/client-architecture-split.md §3). What is left here is the
/// GTK half: the dialog, the advert drain, the re-key, and the route back into the trust gate.
pub fn wake_and_connect(
window: &adw::ApplicationWindow,
sender: &ComponentSender<AppModel>,
@@ -40,23 +45,17 @@ pub fn wake_and_connect(
let sender = sender.clone();
glib::spawn_future_local(async move {
use std::time::{Duration, Instant};
use std::time::Duration;
let events = crate::discovery::browse();
let started = Instant::now();
let budget = Duration::from_secs(90);
let resend = Duration::from_secs(6);
crate::wol::wake(&req.mac, req.addr.parse().ok());
let mut last_wake = Instant::now();
let mut wait = WakeWait::new();
loop {
if cancel.get() {
waiting.close();
return;
}
if last_wake.elapsed() >= resend {
crate::wol::wake(&req.mac, req.addr.parse().ok());
last_wake = Instant::now();
}
// Drain resolved adverts; a match (fingerprint, else addr:port) = it's up.
// Drain resolved adverts; a match (fingerprint, else addr:port) means it is up,
// and carries the address it came back on.
let mut seen: Option<(String, u16)> = None;
while let Ok(ev) = events.try_recv() {
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
continue;
@@ -66,30 +65,42 @@ pub fn wake_and_connect(
None => h.addr == req.addr && h.port == req.port,
};
if matched {
seen = Some((h.addr, h.port));
}
}
let tick = wait.tick(seen.is_some());
if tick.send_packet {
crate::wol::wake(&req.mac, req.addr.parse().ok());
}
match tick.outcome {
Some(WakeOutcome::Online) => {
waiting.close();
let mut req = req.clone();
// Re-key on a new DHCP lease so this + future connects dial the
// live address.
if h.addr != req.addr || h.port != req.port {
if let Some((addr, port)) =
seen.filter(|(a, p)| *a != req.addr || *p != req.port)
{
if let Some(fp) = &req.fp_hex {
trust::rekey_addr(fp, &h.addr, h.port);
trust::rekey_addr(fp, &addr, port);
}
req.addr = h.addr;
req.port = h.port;
req.addr = addr;
req.port = port;
}
sender.input(AppMsg::Connect(req));
return;
}
Some(WakeOutcome::TimedOut) => {
waiting.close();
sender.input(AppMsg::Toast(format!(
"Couldn't reach “{}” — is it powered and on the network?",
req.name
)));
return;
}
None => {}
}
if started.elapsed() >= budget {
waiting.close();
sender.input(AppMsg::Toast(format!(
"Couldn't reach “{}” — is it powered and on the network?",
req.name
)));
return;
}
glib::timeout_future(Duration::from_millis(500)).await;
glib::timeout_future(Duration::from_secs(1)).await;
}
});
}
+3
View File
@@ -22,3 +22,6 @@ mdns-sd = "0.20"
# encoder. libopus is already in the graph via `punktfunk-core`'s quic feature; this exposes the
# name directly. Cross-platform (cmake-vendored), so the probe builds + validates everywhere.
opus = "0.3"
[lints]
workspace = true
+1
View File
@@ -45,6 +45,7 @@
//! [--input-test | --mic-test [--mic-burst] | --touch-test | --rich-input-test]
//! [--pin HEX | --pair PIN] [--compositor NAME] [--gamepad NAME] | --discover [SECS]`
//! Env: `PUNKTFUNK_CLIENT_10BIT=1` / `PUNKTFUNK_CLIENT_444=1` advertise the 10-bit / 4:4:4 caps.
#![forbid(unsafe_code)]
use anyhow::{anyhow, Context, Result};
use punktfunk_core::config::GamepadPref;
+3
View File
@@ -45,3 +45,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# the SDK); same pattern as clients/windows.
[target.'cfg(windows)'.build-dependencies]
winresource = "0.1"
[lints]
workspace = true
+23 -8
View File
@@ -137,6 +137,13 @@ pub fn run(target: Option<&str>) -> u8 {
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
let json_status = arg_flag("--json-status");
let settings_at_start = trust::Settings::load();
// The console's window and its input models are built ONCE, from the global defaults, and
// live across every launch — so the presentation-tier fields below (stats tier, touch and
// mouse model, match-window, render scale) are latched here and a per-host profile cannot
// move them in this mode. Everything the HOST is told (mode, bitrate, codec, audio, pad) is
// re-resolved per launch and does honor the binding. Closing that gap means rebuilding the
// presenter's models per launch — profiles P4 territory, not P0.
let latched_mouse = settings_at_start.mouse_mode();
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
// connect; `on_connected` reads it once the host lets us in and persists the host as PAIRED,
@@ -201,11 +208,16 @@ pub fn run(target: Option<&str>) -> u8 {
tracing::info!(%addr, %title, request_access,
launch = launch.as_deref().unwrap_or("desktop"),
"launching from the console");
// Settings re-load per launch: the console's own settings screen
// may have changed them since the last stream.
let settings = trust::Settings::load();
// Settings re-resolve per launch: the console's own settings screen may
// have changed the defaults since the last stream, and the host may carry
// a profile binding. Console (and therefore Decky, which spawns this
// binary) honors bindings with no console-side work — the resolver is the
// same one `--connect` goes through. No one-off here: picking a profile is
// a desktop-shell affordance in v1, pinned cards are the console's.
let (settings, profile) = trust::effective_settings(&addr, port, None);
let mut params = session_params(
&settings,
profile.map(|p| p.name),
addr.clone(),
port,
pin,
@@ -216,6 +228,13 @@ pub fn run(target: Option<&str>) -> u8 {
force_software,
vulkan,
);
// …with ONE field that must follow the latched model rather than this
// launch's: the cursor-channel advertisement says "this client draws the
// host cursor itself", which is only true while the presenter is in desktop
// mouse mode. A profile that flips `mouse_mode` here would make the host
// stop compositing the pointer into a presenter that isn't drawing one —
// a stream with no visible cursor at all.
params.cursor_forward = latched_mouse == trust::MouseMode::Desktop;
if request_access {
// The host PARKS the connect until the operator approves — outlast its
// approval window (host `PENDING_APPROVAL_WAIT`), matching the desktop
@@ -434,11 +453,7 @@ impl ServiceState {
name: if name.is_empty() { addr.clone() } else { name },
addr,
port,
fp_hex: String::new(),
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
..Default::default()
});
}
if let Err(e) = known.save() {
+31 -4
View File
@@ -13,6 +13,7 @@
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
#![forbid(unsafe_code)]
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
mod console;
@@ -124,6 +125,15 @@ mod session_main {
}
}
/// `--profile <id|name>` — the settings profile this one session runs with, overriding the
/// host's own binding for this launch only (never rebinding it): the shells' "Connect
/// with ▸ X" and a `punktfunk://…&profile=` link both land here. Absent = honor the host's
/// binding; `--profile ""` (or a bare `--profile`) forces the global defaults, which is
/// how "Connect with ▸ Default settings" reaches a bound host.
fn profile_arg() -> Option<String> {
arg_flag("--profile").then(|| arg_value("--profile").unwrap_or_default())
}
/// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the
/// shell's request-access flow passes ~185 s because the host PARKS the connection
/// until the operator clicks Approve.
@@ -135,13 +145,20 @@ mod session_main {
)
}
/// One session's pump parameters from the Settings store — shared by `--connect`
/// One session's pump parameters from the EFFECTIVE settings — shared by `--connect`
/// and every `--browse` launch. Explicit settings, `0` fields resolved to the
/// window's display (the GTK client reads the monitor under its window — same
/// contract).
///
/// `settings` is what [`trust::effective_settings`] returned, never a raw
/// `Settings::load()`: both callers resolve the host's profile first, so the two
/// construction sites cannot drift (they historically did — touching one and not the
/// other is a Windows-only build break). `profile` is that profile's name, for the
/// stats overlay's first line.
#[allow(clippy::too_many_arguments)]
pub(crate) fn session_params(
settings: &trust::Settings,
profile: Option<String>,
addr: String,
port: u16,
pin: [u8; 32],
@@ -246,6 +263,7 @@ mod session_main {
identity,
connect_timeout: connect_timeout(),
force_software,
profile,
}
}
@@ -431,14 +449,16 @@ mod session_main {
}
let Some(target) = arg_value("--connect") else {
eprintln!(
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--profile REF] [--fullscreen]\n\
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
\x20 punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]\n\
\n\
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\
gamepad console instead: bare --browse is the host list (discovery, PIN\n\
pairing, settings, wake-on-LAN); with a target it opens that host's game\n\
library. --connect never dials a host it has no pinned fingerprint for \n\
library. --profile picks a settings profile by id or name for this session\n\
only (\"\" = the global defaults); without it the host's own profile applies.\n\
--connect never dials a host it has no pinned fingerprint for \n\
enrol with --pair (no display needed), in the console, or from the desktop\n\
client."
);
@@ -453,7 +473,13 @@ mod session_main {
return EXIT_CONNECT_FAILED;
}
};
let settings = trust::Settings::load();
// Global defaults with this host's settings profile overlaid — the binding on the
// host record, or `--profile <id|name>` for a one-off (`--profile ""` forces the
// defaults). Resolved through the shared helper, exactly like the console's launches.
let (settings, profile) = trust::effective_settings(&addr, port, profile_arg().as_deref());
if let Some(p) = &profile {
tracing::info!(profile = %p.name, id = %p.id, "streaming with a settings profile");
}
// Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin
// connects silently; an unknown host is REFUSED — there is no dialog here, and a
@@ -523,6 +549,7 @@ mod session_main {
pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
session_params(
&settings,
profile.map(|p| p.name),
addr,
port,
pin,
+311
View File
@@ -0,0 +1,311 @@
{
"$comment": "Cross-language contract for the punktfunk:// grammar (design/client-deep-links.md §2). The Rust (crates/pf-client-core/src/deeplink.rs), Swift (PunktfunkShared/DeepLink.swift) and Kotlin parsers each run every case in their own test suite, so the three cannot drift into three different security postures. `expect` fields absent from a case must parse as absent; `emit` is the canonical URL the parsed link must serialise back to (note that pf:// never round-trips — it is an accepted input alias, never an output). `error` is the stable refusal code, not a message.",
"version": 1,
"cases": [
{
"name": "apple-shipped-uuid",
"url": "punktfunk://connect/11111111-2222-4333-8444-555555555555",
"expect": {
"route": "connect",
"host_ref": "11111111-2222-4333-8444-555555555555"
},
"emit": "punktfunk://connect/11111111-2222-4333-8444-555555555555"
},
{
"name": "apple-shipped-launch",
"url": "punktfunk://connect/11111111-2222-4333-8444-555555555555?launch=steam:570",
"expect": {
"route": "connect",
"host_ref": "11111111-2222-4333-8444-555555555555",
"launch": "steam:570"
},
"emit": "punktfunk://connect/11111111-2222-4333-8444-555555555555?launch=steam:570"
},
{
"name": "bare-reference-without-a-route",
"url": "punktfunk://11111111-2222-4333-8444-555555555555",
"expect": {
"route": "connect",
"host_ref": "11111111-2222-4333-8444-555555555555"
}
},
{
"name": "scheme-case-is-ignored",
"url": "PunktFunk://CONNECT/Desk",
"expect": {
"route": "connect",
"host_ref": "Desk"
}
},
{
"name": "pf-alias-parses-but-is-never-emitted",
"url": "pf://connect/Desk",
"expect": {
"route": "connect",
"host_ref": "Desk"
},
"emit": "punktfunk://connect/Desk"
},
{
"name": "trailing-slash-tolerated",
"url": "punktfunk://connect/Desk/",
"expect": {
"route": "connect",
"host_ref": "Desk"
}
},
{
"name": "address-reference-with-port",
"url": "punktfunk://connect/192.168.1.50:9777",
"expect": {
"route": "connect",
"host_ref": "192.168.1.50:9777"
}
},
{
"name": "percent-encoded-host-name",
"url": "punktfunk://connect/Wohnzimmer%20PC",
"expect": {
"route": "connect",
"host_ref": "Wohnzimmer PC"
},
"emit": "punktfunk://connect/Wohnzimmer%20PC"
},
{
"name": "self-emitted-full-form",
"url": "punktfunk://connect/11111111-2222-4333-8444-555555555555?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa",
"expect": {
"route": "connect",
"host_ref": "11111111-2222-4333-8444-555555555555",
"fp": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"host_addr": "192.168.1.50",
"host_port": 7777,
"launch": "steam:570",
"profile": "aaaaaaaaaaaa"
},
"emit": "punktfunk://connect/11111111-2222-4333-8444-555555555555?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa"
},
{
"name": "fingerprint-is-normalised-to-lowercase",
"url": "punktfunk://connect/Desk?fp=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"expect": {
"route": "connect",
"host_ref": "Desk",
"fp": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"name": "host-parameter-defaults-to-9777",
"url": "punktfunk://connect/Desk?host=192.168.1.50",
"expect": {
"route": "connect",
"host_ref": "Desk",
"host_addr": "192.168.1.50",
"host_port": 9777
}
},
{
"name": "host-parameter-ipv6-bracketed",
"url": "punktfunk://connect/Desk?host=%5B::1%5D:7777",
"expect": {
"route": "connect",
"host_ref": "Desk",
"host_addr": "::1",
"host_port": 7777
}
},
{
"name": "wake-route-parses",
"url": "punktfunk://wake/Desk",
"expect": {
"route": "wake",
"host_ref": "Desk"
}
},
{
"name": "browse-route-parses",
"url": "punktfunk://browse/Desk",
"expect": {
"route": "browse",
"host_ref": "Desk"
}
},
{
"name": "profile-by-name",
"url": "punktfunk://connect/Desk?profile=Work",
"expect": {
"route": "connect",
"host_ref": "Desk",
"profile": "Work"
}
},
{
"name": "unknown-parameters-are-ignored",
"url": "punktfunk://connect/Desk?bogus=1&launch=steam:1",
"expect": {
"route": "connect",
"host_ref": "Desk",
"launch": "steam:1"
}
},
{
"name": "empty-parameter-is-absent-not-an-error",
"url": "punktfunk://connect/Desk?launch=&profile=Work",
"expect": {
"route": "connect",
"host_ref": "Desk",
"profile": "Work"
}
},
{
"name": "first-occurrence-of-a-parameter-wins",
"url": "punktfunk://connect/Desk?fp=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"expect": {
"route": "connect",
"host_ref": "Desk",
"fp": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"name": "fragment-is-dropped",
"url": "punktfunk://connect/Desk#whatever",
"expect": {
"route": "connect",
"host_ref": "Desk"
}
},
{
"name": "a-query-hidden-in-the-fragment-is-not-parsed",
"url": "punktfunk://connect/Desk#?launch=steam:570",
"expect": {
"route": "connect",
"host_ref": "Desk"
}
},
{
"name": "non-ascii-label-survives-the-round-trip",
"url": "punktfunk://connect/Desk?name=B%C3%BCro%20%C2%B7%20Mac",
"expect": {
"route": "connect",
"host_ref": "Desk",
"name": "Büro · Mac"
},
"emit": "punktfunk://connect/Desk?name=B%C3%BCro%20%C2%B7%20Mac"
},
{
"name": "another-scheme-is-not-ours",
"url": "https://example.com/connect/Desk",
"error": "not-our-scheme"
},
{
"name": "scheme-without-authority-is-not-ours",
"url": "punktfunk:/connect/Desk",
"error": "not-our-scheme"
},
{
"name": "pairing-is-never-a-route",
"url": "punktfunk://pair/1234",
"error": "pair-refused"
},
{
"name": "bare-pair-is-refused-too",
"url": "punktfunk://pair",
"error": "pair-refused"
},
{
"name": "undefined-route-refuses",
"url": "punktfunk://teardown/Desk",
"error": "unknown-route"
},
{
"name": "route-without-a-host-reference",
"url": "punktfunk://connect/",
"error": "missing-host-ref"
},
{
"name": "bare-route-word-is-not-a-host-reference",
"url": "punktfunk://connect",
"error": "missing-host-ref"
},
{
"name": "truncated-percent-escape",
"url": "punktfunk://connect/Desk%2",
"error": "bad-escape"
},
{
"name": "non-hex-percent-escape",
"url": "punktfunk://connect/De%zzsk",
"error": "bad-escape"
},
{
"name": "invalid-utf8-escape",
"url": "punktfunk://connect/De%FFsk",
"error": "bad-escape"
},
{
"name": "newline-smuggled-into-the-reference",
"url": "punktfunk://connect/Desk%0A",
"error": "control-char"
},
{
"name": "nul-byte-smuggled-into-the-reference",
"url": "punktfunk://connect/Desk%00",
"error": "control-char"
},
{
"name": "fingerprint-too-short",
"url": "punktfunk://connect/Desk?fp=abc",
"error": "bad-fingerprint"
},
{
"name": "fingerprint-not-hex",
"url": "punktfunk://connect/Desk?fp=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
"error": "bad-fingerprint"
},
{
"name": "host-parameter-with-an-unparsable-port",
"url": "punktfunk://connect/Desk?host=box:notaport",
"error": "bad-host-param"
},
{
"name": "launch-id-with-a-space",
"url": "punktfunk://connect/Desk?launch=steam%20570",
"error": "bad-launch-id"
},
{
"name": "launch-id-with-a-quote",
"url": "punktfunk://connect/Desk?launch=%22evil%22",
"error": "bad-launch-id"
},
{
"name": "launch-id-with-a-shell-metacharacter",
"url": "punktfunk://connect/Desk?launch=steam:570%60id%60",
"error": "bad-launch-id"
},
{
"name": "launch-id-over-the-cap",
"url": "punktfunk://connect/Desk?launch=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"error": "param-too-long"
},
{
"name": "profile-reference-over-the-cap",
"url": "punktfunk://connect/Desk?profile=ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp",
"error": "param-too-long"
},
{
"name": "name-over-the-cap",
"url": "punktfunk://connect/Desk?name=nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn",
"error": "param-too-long"
},
{
"name": "host-reference-over-the-cap",
"url": "punktfunk://connect/hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh",
"error": "param-too-long"
},
{
"name": "url-over-the-total-cap",
"url": "punktfunk://connect/Desk?name=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"error": "too-long"
}
]
}
+3
View File
@@ -95,3 +95,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[target.'cfg(windows)'.build-dependencies]
winresource = "0.1"
windows-reactor-setup = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" }
[lints]
workspace = true
+48 -43
View File
@@ -7,6 +7,7 @@ use super::style::*;
use super::{AppCtx, Screen, Svc, Target};
use crate::discovery::DiscoveredHost;
use crate::trust::{self, KnownHost, KnownHosts};
use pf_client_core::orchestrate::{WakeOutcome, WakeWait};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
@@ -272,9 +273,8 @@ fn connect_spawn(
port: target.port,
fp_hex: fp_hex.clone(),
paired: persist_paired,
last_used: None,
mac: target.mac.clone(),
clipboard_sync: false,
..Default::default()
});
let _ = k.save();
}
@@ -419,15 +419,19 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
/// longer to POST/boot/re-advertise than a connect attempt will sit). On reappearance we dial it
/// (re-keying the saved host when it came back on a new IP); on timeout or Cancel we return to
/// the host list.
///
/// The cadence is [`WakeWait`], shared with the GTK shell and ported from Apple's `HostWaker`
/// (design/client-architecture-split.md §3) — the comment this function used to carry ("mirrors
/// the Apple HostWaker") is now literally true instead of aspirational.
fn wake_and_connect(
ctx: &Arc<AppCtx>,
target: Target,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
// First packet now; the poll loop re-sends every RESEND_SECS (a single one can be missed, and
// some NICs only wake on a fresh packet after dropping into a deeper sleep state).
crate::wol::wake(&target.mac, target.addr.parse().ok());
// The packets are the wait's business: `WakeWait` asks for one on its first tick and every
// 6 s after (a single one can be missed, and some NICs only wake on a fresh packet after
// dropping into a deeper sleep state).
// A fresh cancel flag per wake, installed where the "Waking…" screen's Cancel button reads it
// back (the same shared channel as the request-access flow); the poll loop checks the same `Arc`.
let cancel = Arc::new(AtomicBool::new(false));
@@ -439,12 +443,9 @@ fn wake_and_connect(
let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
std::thread::spawn(move || {
// Generous — a cold boot + service start can be a minute-plus; re-send periodically.
const TIMEOUT_SECS: u64 = 90;
const RESEND_SECS: u64 = 6;
let rx = crate::discovery::browse();
let mut seen: Vec<DiscoveredHost> = Vec::new();
let mut elapsed: u64 = 0;
let mut wait = WakeWait::new();
loop {
// Cancel already returned the UI to the host list — stop re-sending and tear down.
if cancel.load(Ordering::SeqCst) {
@@ -466,42 +467,46 @@ fn wake_and_connect(
_ => h.addr == target.addr && h.port == target.port,
})
.map(|h| (h.addr.clone(), h.port));
if let Some((addr, port)) = resolved {
let mut target = target.clone();
// Came back on a new IP (DHCP): dial the fresh address and re-key the saved host so
// the pin stays reachable next time (keyed by fingerprint; addr/port overwritten,
// `paired`/`mac` preserved by `upsert`).
if addr != target.addr || port != target.port {
target.addr = addr;
target.port = port;
if let Some(fp) = target.fp_hex.clone() {
let mut k = KnownHosts::load();
k.upsert(KnownHost {
name: target.name.clone(),
addr: target.addr.clone(),
port: target.port,
fp_hex: fp,
paired: false,
last_used: None,
mac: target.mac.clone(),
clipboard_sync: false,
});
let _ = k.save();
}
}
initiate(&ctx, target, &ss, &st);
return;
}
if elapsed >= TIMEOUT_SECS {
st.call("The host didn't come online.".to_string());
ss.call(Screen::Hosts);
return;
}
std::thread::sleep(Duration::from_secs(1));
elapsed += 1;
if elapsed % RESEND_SECS == 0 {
let tick = wait.tick(resolved.is_some());
if tick.send_packet {
crate::wol::wake(&target.mac, target.addr.parse().ok());
}
match tick.outcome {
Some(WakeOutcome::Online) => {
let mut target = target.clone();
// Came back on a new IP (DHCP): dial the fresh address and re-key the saved
// host so the pin stays reachable next time (keyed by fingerprint;
// addr/port overwritten, `paired`/`mac` preserved by `upsert`).
if let Some((addr, port)) =
resolved.filter(|(a, p)| *a != target.addr || *p != target.port)
{
target.addr = addr;
target.port = port;
if let Some(fp) = target.fp_hex.clone() {
let mut k = KnownHosts::load();
k.upsert(KnownHost {
name: target.name.clone(),
addr: target.addr.clone(),
port: target.port,
fp_hex: fp,
mac: target.mac.clone(),
..Default::default()
});
let _ = k.save();
}
}
initiate(&ctx, target, &ss, &st);
return;
}
Some(WakeOutcome::TimedOut) => {
st.call("The host didn't come online.".to_string());
ss.call(Screen::Hosts);
return;
}
None => {}
}
std::thread::sleep(Duration::from_secs(1));
}
});
}
+11
View File
@@ -243,6 +243,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
// Which Settings section the NavigationView shows (persists across visits this run).
// Opens on General — the first sidebar item, matching the Apple client's landing category.
let (settings_nav, set_settings_nav) = cx.use_async_state("general".to_string());
// Which LAYER the settings screen edits: "" = the global defaults, else a profile id
// (design/client-settings-profiles.md §5.1). Root state for the same reason as the section
// above — the ComboBox's change handler is wired in the reactor backend.
let (settings_scope, set_settings_scope) = cx.use_async_state(String::new());
// The profile a Delete… click is asking about; `Some` renders the confirmation. Root state
// because this page stays hook-free (its handlers are wired in the reactor backend).
let (settings_delete, set_settings_delete) = cx.use_async_state(Option::<String>::None);
// Connected-controller count, mirrored from the gamepad service by a poll thread
// (thread-driven state must be root state — see the module docs). Drives the hosts
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
@@ -489,6 +496,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
&set_screen,
&settings_nav,
&set_settings_nav,
&settings_scope,
&set_settings_scope,
&settings_delete,
&set_settings_delete,
nav_progress,
),
Screen::Licenses => licenses::licenses_page(&set_screen),
+1 -2
View File
@@ -57,9 +57,8 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
port: target3.port,
fp_hex: trust::hex(&fp),
paired: true,
last_used: None,
mac: target3.mac.clone(),
clipboard_sync: false,
..Default::default()
});
let _ = k.save();
connect(&ctx3, &target3, Some(fp), &ss, &st);
+328 -55
View File
@@ -13,7 +13,8 @@
use super::style::*;
use super::{AppCtx, Screen};
use crate::trust::Settings;
use crate::trust::{KnownHosts, Settings};
use pf_client_core::profiles::{ProfilesFile, StreamProfile};
use pf_client_core::trust::StatsVerbosity;
use punktfunk_core::config::GamepadPref;
use std::sync::Arc;
@@ -110,22 +111,121 @@ const COMPOSITORS: &[(&str, &str)] = &[
/// A `ComboBox` bound to one settings field: shows `names`, starts at `current`, and runs
/// `apply(settings, picked_index)` under the settings lock, then saves. The index handed to
/// `apply` is already clamped to `names`.
/// The sentinel the scope ComboBox's last entry carries — "New profile…" is an action, not a
/// layer, and a real id can never collide with it (ids are 12 hex chars).
const NEW_PROFILE: &str = "\u{1}new";
/// Rename / duplicate / delete for the profile in scope. Rename is a text box rather than a
/// modal because this toolkit's ContentDialog is text-only (the same constraint that put the
/// host editor in a tile); it commits on change, which is how every other control here works.
fn profile_actions(
profile: &StreamProfile,
set_scope: &AsyncSetState<String>,
set_delete: &AsyncSetState<Option<String>>,
) -> Element {
let id = profile.id.clone();
let name_box = {
let id = id.clone();
text_box(&profile.name)
.placeholder_text("Profile name")
.on_text_changed(move |t: String| {
let name = t.trim().to_string();
if name.is_empty() {
return;
}
let mut catalog = ProfilesFile::load();
// Names are unique case-insensitively — menus keyed by name are ambiguous
// otherwise. A collision simply doesn't commit; the box keeps what was typed.
if catalog.name_taken(&name, Some(&id)) {
return;
}
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) {
p.name = name;
let _ = catalog.save();
}
})
};
let duplicate = {
let (id, set_scope) = (id.clone(), set_scope.clone());
button("Duplicate").on_click(move || {
let mut catalog = ProfilesFile::load();
let Some(source) = catalog.find_by_id(&id).cloned() else {
return;
};
let name = (2..)
.map(|n| format!("{} {n}", source.name))
.find(|n| !catalog.name_taken(n, None))
.unwrap_or_else(|| source.name.clone());
let mut copy = StreamProfile::new(name);
copy.overrides = source.overrides.clone();
copy.accent = source.accent.clone();
let new_id = copy.id.clone();
catalog.profiles.push(copy);
if catalog.save().is_ok() {
set_scope.call(new_id);
}
})
};
let delete = {
let (id, set_delete) = (id.clone(), set_delete.clone());
button("Delete\u{2026}").on_click(move || set_delete.call(Some(id.clone())))
};
described(
vstack((name_box, hstack((duplicate, delete)).spacing(8.0))).spacing(8.0),
"Renaming applies as you type. Deleting leaves hosts that used it on Default settings.",
)
}
/// Persist one control's edit into the layer being edited.
///
/// This shell commits PER CONTROL (unlike the GTK one, which writes when its dialog closes),
/// so it can't hand the profile a list of touched fields. It hands over the effective settings
/// before and after instead, and [`SettingsOverlay::absorb`] records the field that moved —
/// the comparison is against what the control was SHOWING, so picking a value that happens to
/// equal the global still records an override (the pin the design asks for).
fn commit(ctx: &Arc<AppCtx>, scope: &str, edit: impl FnOnce(&mut Settings)) {
if scope.is_empty() {
let mut s = ctx.settings.lock().unwrap();
edit(&mut s);
s.save();
return;
}
let mut catalog = ProfilesFile::load();
let base = ctx.settings.lock().unwrap().clone();
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) else {
return; // deleted from under us; the next render falls back to the defaults scope
};
let before = p.overrides.apply(&base);
let mut after = before.clone();
edit(&mut after);
p.overrides.absorb(&before, &after);
if let Err(e) = catalog.save() {
tracing::warn!(error = %format!("{e:#}"), "saving the profile catalog");
}
}
/// The layer the settings screen is editing, resolved for display: `None` = the defaults.
fn active_profile(scope: &str) -> Option<StreamProfile> {
(!scope.is_empty())
.then(|| ProfilesFile::load().find_by_id(scope).cloned())
.flatten()
}
fn setting_combo(
ctx: &Arc<AppCtx>,
scope: &str,
header: &str,
names: Vec<String>,
current: usize,
apply: impl Fn(&mut Settings, usize) + 'static,
) -> ComboBox {
let ctx = ctx.clone();
let (ctx, scope) = (ctx.clone(), scope.to_string());
let max = names.len().saturating_sub(1);
ComboBox::new(names)
.header(header)
.selected_index(current as i32)
.on_selection_changed(move |i: i32| {
let mut s = ctx.settings.lock().unwrap();
apply(&mut s, (i.max(0) as usize).min(max));
s.save();
commit(&ctx, &scope, |s| apply(s, (i.max(0) as usize).min(max)));
})
}
@@ -139,19 +239,18 @@ fn presets<V>(table: &[(V, &str)], is_current: impl Fn(&V) -> bool) -> (Vec<Stri
/// A `ToggleSwitch` bound to one boolean settings field.
fn setting_toggle(
ctx: &Arc<AppCtx>,
scope: &str,
header: &str,
on: bool,
apply: impl Fn(&mut Settings, bool) + 'static,
) -> ToggleSwitch {
let ctx = ctx.clone();
let (ctx, scope) = (ctx.clone(), scope.to_string());
ToggleSwitch::new(on)
.header(header)
.on_content("On")
.off_content("Off")
.on_toggled(move |v: bool| {
let mut s = ctx.settings.lock().unwrap();
apply(&mut s, v);
s.save();
commit(&ctx, &scope, |s| apply(s, v));
})
}
@@ -220,14 +319,35 @@ fn group(header: Option<&str>, fields: Vec<Element>, footer: Option<&str>) -> Ve
/// state (this page stays hook-free): `on_selection_changed` is wired in the reactor backend, so
/// only a root `AsyncSetState` reliably re-renders the new section in. `progress` is the
/// section-switch entrance tween (0 → 1), mapped onto the content column's opacity + offset.
#[allow(clippy::too_many_arguments)]
pub(crate) fn settings_page(
ctx: &Arc<AppCtx>,
set_screen: &AsyncSetState<Screen>,
section: &str,
set_section: &AsyncSetState<String>,
scope_id: &str,
set_scope: &AsyncSetState<String>,
delete_pending: &Option<String>,
set_delete: &AsyncSetState<Option<String>>,
progress: f64,
) -> Element {
let s = ctx.settings.lock().unwrap().clone();
// The layer being edited. A scope pointing at a deleted profile degrades to the defaults,
// the same rule a dangling host binding follows.
let active = active_profile(scope_id);
let scope: &str = match &active {
Some(p) => &p.id,
None => "",
};
let profile_mode = active.is_some();
// Every control shows the EFFECTIVE value: the global underneath with this profile's
// overrides on top, so a row the profile doesn't override reads as the live global.
let s = {
let base = ctx.settings.lock().unwrap().clone();
match &active {
Some(p) => p.overrides.apply(&base),
None => base,
}
};
// --- Display ---------------------------------------------------------------------------
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
@@ -253,7 +373,7 @@ pub(crate) fn settings_page(
};
(names, i)
};
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
let res_combo = setting_combo(ctx, scope, "Resolution", res_names, res_i, |s, i| {
s.match_window = i == 1;
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
});
@@ -271,7 +391,7 @@ pub(crate) fn settings_page(
let i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
(names, i)
};
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| {
let hz_combo = setting_combo(ctx, scope, "Refresh rate", hz_names, hz_i, |s, i| {
s.refresh_hz = REFRESH[i];
});
let (scale_names, scale_i) = {
@@ -285,18 +405,20 @@ pub(crate) fn settings_page(
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
(names, i)
};
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
let scale_combo = setting_combo(ctx, scope, "Render scale", scale_names, scale_i, |s, i| {
s.render_scale = RENDER_SCALES[i];
});
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
let comp_combo = setting_combo(ctx, scope, "Host compositor", comp_names, comp_i, |s, i| {
s.compositor = COMPOSITORS[i].0.to_string();
});
let auto_wake_toggle = setting_toggle(ctx, "Auto-wake on connect", s.auto_wake, |s, on| {
s.auto_wake = on
});
let auto_wake_toggle =
setting_toggle(ctx, scope, "Auto-wake on connect", s.auto_wake, |s, on| {
s.auto_wake = on
});
let fullscreen_toggle = setting_toggle(
ctx,
scope,
"Start streams fullscreen",
s.fullscreen_on_stream,
|s, on| s.fullscreen_on_stream = on,
@@ -304,7 +426,7 @@ pub(crate) fn settings_page(
// --- Video -----------------------------------------------------------------------------
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
let decoder_combo = setting_combo(ctx, scope, "Video decoder", dec_names, dec_i, |s, i| {
s.decoder = DECODERS[i].0.to_string();
});
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
@@ -318,7 +440,7 @@ pub(crate) fn settings_page(
.position(|n| *n == s.adapter)
.map_or(0, |i| i + 1);
let gpus = gpus.clone();
setting_combo(ctx, "GPU", names, current, move |s, i| {
setting_combo(ctx, scope, "GPU", names, current, move |s, i| {
s.adapter = if i == 0 {
String::new()
} else {
@@ -327,7 +449,7 @@ pub(crate) fn settings_page(
})
});
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
let codec_combo = setting_combo(ctx, scope, "Video codec", codec_names, codec_i, |s, i| {
s.codec = CODECS[i].0.to_string();
});
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
@@ -343,9 +465,13 @@ pub(crate) fn settings_page(
s.save();
})
};
let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| {
s.hdr_enabled = on
});
let hdr_toggle = setting_toggle(
ctx,
scope,
"HDR (10-bit, BT.2020 PQ)",
s.hdr_enabled,
|s, on| s.hdr_enabled = on,
);
// --- Input -----------------------------------------------------------------------------
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
@@ -394,23 +520,27 @@ pub(crate) fn settings_page(
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
});
let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| {
let pad_combo = setting_combo(ctx, scope, "Gamepad type", pad_names, pad_i, |s, i| {
s.gamepad = GAMEPADS[i].0.to_string();
});
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
let touch_combo = setting_combo(ctx, scope, "Touch input", touch_names, touch_i, |s, i| {
s.touch_mode = TOUCH_MODES[i].0.to_string();
});
let (mouse_names, mouse_i) = presets(MOUSE_MODES, |v| *v == s.mouse_mode);
let mouse_combo = setting_combo(ctx, "Mouse input", mouse_names, mouse_i, |s, i| {
let mouse_combo = setting_combo(ctx, scope, "Mouse input", mouse_names, mouse_i, |s, i| {
s.mouse_mode = MOUSE_MODES[i].0.to_string();
});
let invert_scroll_toggle =
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
s.invert_scroll = on
});
let invert_scroll_toggle = setting_toggle(
ctx,
scope,
"Invert scroll direction",
s.invert_scroll,
|s, on| s.invert_scroll = on,
);
let shortcuts_toggle = setting_toggle(
ctx,
scope,
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
s.inhibit_shortcuts,
|s, on| s.inhibit_shortcuts = on,
@@ -418,20 +548,28 @@ pub(crate) fn settings_page(
// --- Audio -----------------------------------------------------------------------------
let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels);
let channels_combo = setting_combo(ctx, "Audio channels", ac_names, ac_i, |s, i| {
let channels_combo = setting_combo(ctx, scope, "Audio channels", ac_names, ac_i, |s, i| {
s.audio_channels = AUDIO_CHANNELS[i].0;
});
let mic_toggle = setting_toggle(
ctx,
scope,
"Stream microphone to the host",
s.mic_enabled,
|s, on| s.mic_enabled = on,
);
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
s.set_stats_verbosity(STATS_TIERS[i].0);
});
let hud_combo = setting_combo(
ctx,
scope,
"Stats overlay (HUD)",
hud_names,
hud_i,
|s, i| {
s.set_stats_verbosity(STATS_TIERS[i].0);
},
);
let licenses_button = {
let ss = set_screen.clone();
@@ -439,6 +577,7 @@ pub(crate) fn settings_page(
};
let library_toggle = setting_toggle(
ctx,
scope,
"Show game library (experimental)",
s.library_enabled,
|s, on| s.library_enabled = on,
@@ -506,9 +645,12 @@ pub(crate) fn settings_page(
],
None,
));
// Decoder and GPU are facts about THIS device's hardware — never per profile.
out.extend(group(
Some("Decoding"),
{
if profile_mode {
Vec::new()
} else {
let mut fields = vec![described(
decoder_combo,
"Automatic picks the hardware path this GPU does best \u{2014} Direct3D \
@@ -578,21 +720,28 @@ pub(crate) fn settings_page(
"Controllers",
group(
None,
vec![
[
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
// forwards every controller as its own player. Same picker, different rule.
described(
// Which physical pad this device forwards is a device fact (tier G), so it
// renders only in the defaults scope; the EMULATED type below is profileable.
(!profile_mode).then(|| {
described(
forward_combo,
"Every connected controller is forwarded, each as its own player. Pick \
one to force single-player \u{2014} only it reaches the host.",
),
described(
)
}),
Some(described(
pad_combo,
"The virtual pad created on the host. Automatic matches your controller \
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
motion.",
),
],
)),
]
.into_iter()
.flatten()
.collect(),
Some("Applies from the next session."),
),
),
@@ -626,19 +775,23 @@ pub(crate) fn settings_page(
_ => {
let mut out = group(
Some("Session"),
vec![
described(
fullscreen_toggle,
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
vec![described(
fullscreen_toggle,
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
live.",
),
)]
.into_iter()
// Auto-wake is about this host and this network, not about "Game vs Work" —
// it stays global in v1 (design §3, tier H/G).
.chain((!profile_mode).then(|| {
described(
auto_wake_toggle,
"Connecting to a saved host that\u{2019}s offline sends Wake-on-LAN and \
waits for it to boot. Turn off if hosts behind a VPN look offline when \
they aren\u{2019}t.",
),
],
)
}))
.collect(),
None,
);
out.extend(group(
@@ -651,13 +804,18 @@ pub(crate) fn settings_page(
)],
None,
));
// The library browser is an app-level toggle for this device, not a per-profile one.
out.extend(group(
Some("Library"),
vec![described(
if profile_mode {
Vec::new()
} else {
vec![described(
library_toggle,
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} list \
their Steam and custom games and launch one directly. No extra host setup.",
)],
)]
},
None,
));
("General", out)
@@ -698,6 +856,55 @@ pub(crate) fn settings_page(
// left inset belongs to WinUI's own template (a string prop is all we can set), so it
// sat noticeably right of the cards under it. In the content column it shares the cards'
// left edge by construction.
// The scope switcher sits above the section title, so it reads as "which layer all of
// this belongs to" rather than as one section's setting.
let catalog = ProfilesFile::load();
let mut scope_names = vec!["Default settings".to_string()];
let mut scope_ids: Vec<String> = vec![String::new()];
for p in &catalog.profiles {
scope_names.push(p.name.clone());
scope_ids.push(p.id.clone());
}
scope_names.push("New profile\u{2026}".to_string());
scope_ids.push(NEW_PROFILE.to_string());
let scope_i = scope_ids.iter().position(|i| i == scope).unwrap_or(0);
let switcher = described(
ComboBox::new(scope_names)
.header("Editing")
.selected_index(scope_i as i32)
.on_selection_changed({
let (set_scope, ids) = (set_scope.clone(), scope_ids.clone());
move |i: i32| {
let Some(id) = ids.get(i.max(0) as usize) else {
return;
};
if id == NEW_PROFILE {
// Creation needs no prompt here: WinUI's ContentDialog is text-only in
// this toolkit, so a new profile takes an auto-numbered name and is
// renamed from the row below — one fewer modal, same end state.
let mut catalog = ProfilesFile::load();
let name = (1..)
.map(|n| format!("Profile {n}"))
.find(|n| !catalog.name_taken(n, None))
.unwrap_or_else(|| "Profile".to_string());
let profile = StreamProfile::new(name);
let new_id = profile.id.clone();
catalog.profiles.push(profile);
if catalog.save().is_ok() {
set_scope.call(new_id);
}
return;
}
set_scope.call(id.clone());
}
}),
"A profile overrides only what you change while it is selected; everything else \
follows Default settings.",
);
let mut header_rows = vec![switcher];
if let Some(p) = &active {
header_rows.push(profile_actions(p, set_scope, set_delete));
}
let titled: Vec<Element> = std::iter::once(
text_block(title)
.font_size(28.0)
@@ -706,6 +913,7 @@ pub(crate) fn settings_page(
.margin(edges(0.0, 0.0, 0.0, 6.0))
.into(),
)
.chain(group(None, header_rows, None))
.chain(groups)
.collect();
// The keyed column MUST sit inside a panel's child list, not directly under the
@@ -717,12 +925,74 @@ pub(crate) fn settings_page(
// combos render blank until touched. A panel (vstack) takes the keyed path, so the key
// remounts the whole column and every prop is applied fresh.
let content = scroll_view(
vstack(vec![vstack(titled).spacing(10.0).with_key(section).into()])
.margin(edges(24.0, 20.0, 28.0, 40.0)),
// ⚠️ Keyed on (scope, section), not section alone: switching SCOPE re-renders the same
// section's controls with different values, and an in-place diff re-sets each reused
// ComboBox's items (clearing WinUI's selection) while skipping `selected_index`
// wherever the two scopes' values compare equal — the combo then renders blank. A
// fresh mount applies every prop. Same reason the section key exists.
vstack(vec![vstack(titled)
.spacing(10.0)
.with_key(format!("{scope}/{section}"))
.into()])
.margin(edges(24.0, 20.0, 28.0, 40.0)),
)
.opacity(progress)
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
NavigationView::new(items, content)
// The delete confirmation, when armed. Declarative, like every other dialog in this shell:
// it is an element in the tree with `is_open`, not a call.
let confirm: Option<Element> = delete_pending.as_ref().and_then(|id| {
let p = ProfilesFile::load().find_by_id(id).cloned()?;
// The warning counts what actually breaks: hosts that fall back to the defaults, and
// pinned cards that disappear (design §6).
let known = KnownHosts::load();
let bound = known
.hosts
.iter()
.filter(|h| h.profile_id.as_deref() == Some(p.id.as_str()))
.count();
let pinned = known
.hosts
.iter()
.filter(|h| h.pinned_profiles.iter().any(|x| x == &p.id))
.count();
let mut body = format!("\u{201c}{}\u{201d} will be removed.", p.name);
if bound > 0 {
body.push_str(&format!(
" {bound} host{} will fall back to Default settings.",
if bound == 1 { "" } else { "s" }
));
}
if pinned > 0 {
body.push_str(&format!(
" {pinned} pinned card{} will disappear.",
if pinned == 1 { "" } else { "s" }
));
}
let (id, set_scope, set_delete) = (p.id.clone(), set_scope.clone(), set_delete.clone());
Some(
ContentDialog::new("Delete profile?")
.content(body)
.primary_button_text("Delete")
.close_button_text("Cancel")
.is_open(true)
.on_closed(move |r: ContentDialogResult| {
set_delete.call(None);
if r != ContentDialogResult::Primary {
return;
}
let mut catalog = ProfilesFile::load();
catalog.profiles.retain(|p| p.id != id);
// Bindings and pins are left dangling on purpose: they resolve as "no
// profile" everywhere, and rewriting every host record here would be a
// second, racier source of truth.
if catalog.save().is_ok() {
set_scope.call(String::new());
}
})
.into(),
)
});
let nav = NavigationView::new(items, content)
.pane_title("Settings")
.selected_tag(section)
.on_selection_changed({
@@ -734,6 +1004,9 @@ pub(crate) fn settings_page(
.on_back_requested({
let ss = set_screen.clone();
move || ss.call(Screen::Hosts)
})
.into()
});
match confirm {
Some(dialog) => vstack(vec![nav.into(), dialog]).into(),
None => nav.into(),
}
}
+17 -1
View File
@@ -13,8 +13,17 @@
// console behind the couch UI looks like a crash.
#![cfg_attr(windows, windows_subsystem = "windows")]
// The shared client log sink (std-only): a couch launch has no console, so the session's
// stderr would otherwise evaporate — same reasoning as the shell's tee. This shim only
// initializes + forwards; the module's subscriber-side surface stays unused here.
#[cfg(windows)]
#[path = "../logfile.rs"]
#[allow(dead_code)]
mod logfile;
#[cfg(windows)]
fn main() {
logfile::init();
// The session binary ships beside us in the package; fall back to PATH for a dev run.
let session = std::env::current_exe()
.ok()
@@ -27,7 +36,14 @@ fn main() {
if !std::env::args().any(|a| a == "--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(_) => std::process::exit(1),
}
+98
View File
@@ -0,0 +1,98 @@
//! Persistent client log file: `%LOCALAPPDATA%\punktfunk\logs\client.log`.
//!
//! The shell is a `windows_subsystem` binary and spawns `punktfunk-session` with
//! `CREATE_NO_WINDOW` — a normal GUI/MSIX launch has NO console, so before this module every
//! log line (the shell's and, worse, the session's whole receive/decode/present forensic
//! trail) evaporated exactly when a user hit a problem worth reporting. The 2026-07 PyroWave
//! latency-sawtooth field report had to be triaged from host logs alone because the client
//! side had nowhere to land.
//!
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
/// Rotate at the next start once the file exceeds this (the host's cap).
const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
fn log_dir() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
}
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
pub(crate) fn path() -> Option<PathBuf> {
Some(log_dir()?.join("client.log"))
}
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
/// subscriber installs; every later [`tee`] shares the handle.
pub(crate) fn init() {
SINK.get_or_init(|| {
let dir = log_dir()?;
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join("client.log");
if std::fs::metadata(&path).is_ok_and(|m| m.len() > ROTATE_BYTES) {
let old = dir.join("client.log.old");
// Windows `rename` refuses an existing destination — drop the old generation first.
let _ = std::fs::remove_file(&old);
let _ = std::fs::rename(&path, &old);
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()?;
Some(Arc::new(Mutex::new(file)))
});
}
/// A writer that duplicates onto stderr (dev runs from a terminal keep their interleaved
/// output) and the log file (GUI runs finally keep anything at all). The tracing subscriber's
/// `with_writer` factory and the session-stderr forwarder both use it.
pub(crate) struct Tee;
/// `with_writer` factory (`fn() -> Tee` satisfies `MakeWriter`).
pub(crate) fn tee() -> Tee {
Tee
}
impl Write for Tee {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let _ = io::stderr().write_all(buf);
if let Some(Some(f)) = SINK.get() {
let _ = f.lock().unwrap().write_all(buf);
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
let _ = io::stderr().flush();
if let Some(Some(f)) = SINK.get() {
let _ = f.lock().unwrap().flush();
}
Ok(())
}
}
/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never
/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the
/// pipe (child exit).
pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
let _ = std::thread::Builder::new()
.name("punktfunk-session-log".into())
.spawn(move || {
let mut reader = io::BufReader::new(stderr);
let mut line = String::new();
let mut tee = Tee;
while matches!(reader.read_line(&mut line), Ok(n) if n > 0) {
let _ = tee.write_all(line.as_bytes());
line.clear();
}
});
}
+21 -1
View File
@@ -26,6 +26,8 @@ mod discovery;
#[cfg(windows)]
mod gpu;
#[cfg(windows)]
mod logfile;
#[cfg(windows)]
mod probe;
#[cfg(windows)]
mod shell_window;
@@ -49,11 +51,20 @@ fn main() {
}
set_app_user_model_id();
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
// a GUI/MSIX launch has no console, so without the file the client side of any field report
// simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean.
logfile::init();
tracing_subscriber::fmt()
.with_ansi(false)
.with_writer(logfile::tee)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
if let Some(p) = logfile::path() {
tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)");
}
let args: Vec<String> = std::env::args().collect();
let flag = |name: &str| args.iter().any(|a| a == name);
@@ -88,7 +99,16 @@ fn main() {
if !flag("--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
// Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch
// (Start-menu tile, Steam shortcut) has no console to inherit either.
cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(e) => {
eprintln!("could not start the console UI: {e}");
+7 -1
View File
@@ -169,13 +169,19 @@ fn spawn_with(
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
// Piped through the log tee: dev-terminal runs keep the interleaved stderr they always
// had, and GUI runs — which have no console — finally keep the session's whole
// receive/decode/present log in the client log file.
.stderr(Stdio::piped())
.creation_flags(CREATE_NO_WINDOW);
let mut child = cmd
.spawn()
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
tracing::info!(host = %host_label, "session binary spawned");
if let Some(stderr) = child.stderr.take() {
crate::logfile::forward_child_stderr(stderr);
}
let stdout = child.stdout.take().expect("piped stdout");
// Park the child where the kill handle (and the reader, for the final reap) reach it.
*slot.0.lock().unwrap() = Some(child);
+3
View File
@@ -15,3 +15,6 @@ links = "vpl"
cmake = "0.1"
# Same bindgen configuration as pyrowave-sys (runtime = dlopen libclang).
bindgen = { version = "0.72", features = ["runtime"], default-features = false }
[lints]
workspace = true
+3
View File
@@ -58,3 +58,6 @@ windows = { version = "0.62", features = [
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
] }
[lints]
workspace = true
+86 -28
View File
@@ -134,8 +134,9 @@ pub trait Capturer: Send {
// ---- Stream properties ------------------------------------------------------------------
/// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown /
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`), or a generic HDR10
/// block once an HDR stream is negotiated (Linux — neither the portal nor gamescope exposes a
/// real mastering volume). `None` = unknown / SDR / a backend that doesn't expose it.
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
@@ -362,6 +363,12 @@ pub struct ZeroCopyPolicy {
/// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
pub pyrowave_modifiers: Vec<u64>,
/// The resolved encoder can ingest a packed 10-bit PQ CUDA payload (`pf_encode::linux_hdr_cuda_ok`
/// — direct-SDK NVENC only). An HDR capture builds the GPU importer ONLY when this holds:
/// libav's HDR route wants a P010 hardware frame it swscales into, so a packed-2:10:10:10 CUDA
/// buffer would land in a P010 surface as garbage. `false` ⇒ HDR takes the CPU path, exactly as
/// it did before the direct backend learned 10-bit.
pub hdr_cuda_ok: bool,
}
/// Discovers gamescope's nested Xwayland cursor targets — `(DISPLAY, XAUTHORITY)`, one per
@@ -387,15 +394,21 @@ pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
}
/// Whether the **native-plane** capturer (a compositor virtual output) can deliver an HDR (10-bit
/// PQ/BT.2020) source on this platform — the capture-side gate the punktfunk/1 handshake consults
/// before negotiating 10-bit (mirroring [`capturer_supports_444`]).
/// PQ/BT.2020) source **on this platform alone**, without knowing which compositor will be
/// driven — the platform half of the gate the punktfunk/1 handshake consults before negotiating
/// 10-bit (mirroring [`capturer_supports_444`]).
///
/// Linux: `false`. GNOME 50 added HDR **screen sharing** for *monitor* streams only — Mutter's
/// `RecordVirtual` virtual-monitor streams advertise 8-bit BGRx/BGRA exclusively (still true on
/// the GNOME 51 dev branch), and virtual outputs report no BT2020/PQ colour capabilities, so they
/// can't be flipped into HDR mode via DisplayConfig either. The Linux HDR path that DOES exist —
/// the GNOME 50+ portal **monitor mirror** (`open_portal_monitor` with `want_hdr`) — is gated
/// separately by the GameStream plane (`host_hdr_capable` + the live monitor colour-mode probe).
/// Linux: `false`, and this is NOT the whole Linux answer any more. It says only that no Linux
/// virtual output is HDR-capable *by platform*: Mutter's `RecordVirtual` virtual-monitor streams
/// advertise 8-bit BGRx/BGRA exclusively (still true on the GNOME 51 dev branch) and report no
/// BT2020/PQ colour capabilities, and KWin/wlroots virtual outputs are the same. The one Linux
/// virtual output that CAN be 10-bit — gamescope's PipeWire node, with our carried
/// `pipewire-hdr` patch (`packaging/gamescope`) — depends on the resolved compositor **and** the
/// resolved gamescope binary, neither of which this crate knows. The host resolves it in
/// `capture::capturer_supports_hdr_for(compositor)`, which consults this for the platform floor;
/// the other Linux HDR path (the GNOME 50+ portal **monitor mirror**, `open_portal_monitor` with
/// `want_hdr`) is gated separately by the GameStream plane (`host_hdr_capable` + the live monitor
/// colour-mode probe).
#[cfg(target_os = "linux")]
pub fn capturer_supports_hdr() -> bool {
false
@@ -410,28 +423,67 @@ pub fn capturer_supports_hdr() -> bool {
false
}
/// Process-wide latch: a `want_hdr` portal capture failed to negotiate the HDR (10-bit PQ) offer —
/// the compositor never accepted it (monitor left HDR mode between the probe and the negotiation,
/// NVIDIA EGL not listing LINEAR for XR30, a pre-50 Mutter…). Later sessions consult
/// [`hdr_capture_failed`] and fall back to the SDR offer instead of re-running the same doomed
/// 10-second negotiation timeout on every reconnect. Sticky until host restart (matching the
/// zero-copy downgrade latches); the log line at latch time says so.
/// Which HDR capture source a `want_hdr` negotiation failure belongs to.
///
/// The failure latch below is **per source**, because the two Linux HDR sources fail for
/// completely unrelated reasons and share nothing but the word "HDR": the portal monitor mirror
/// fails when the mirrored monitor leaves HDR mode (a live, box-state fact), a gamescope virtual
/// output fails when the spawned binary has no 10-bit formats (a static, binary-identity fact).
/// A single process-wide latch let either one disable the other until the host restarted.
#[cfg(target_os = "linux")]
static HDR_CAPTURE_FAILED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HdrSource {
/// The GNOME 50+ portal **monitor mirror** (`open_portal_monitor` with `want_hdr`) — the
/// GameStream plane's HDR path.
PortalMonitor,
/// A compositor **virtual output** (`open_virtual_output` with `want_hdr`) — today only
/// gamescope's PipeWire node, with the carried `pipewire-hdr` patch.
VirtualOutput,
}
/// Per-source latch: a `want_hdr` capture failed to negotiate the HDR (10-bit PQ) offer — the
/// producer never accepted it (monitor left HDR mode between the probe and the negotiation,
/// NVIDIA EGL not listing LINEAR for XR30, an unpatched gamescope…). Later sessions **on that
/// same source** consult [`hdr_capture_failed`] and fall back to the SDR offer instead of
/// re-running the same doomed 10-second negotiation timeout on every reconnect. Sticky until host
/// restart (matching the zero-copy downgrade latches); the log line at latch time says so.
/// Indexed by [`HdrSource`] — see its doc for why one shared latch was wrong.
#[cfg(target_os = "linux")]
static HDR_CAPTURE_FAILED: [std::sync::atomic::AtomicBool; 2] = [
std::sync::atomic::AtomicBool::new(false),
std::sync::atomic::AtomicBool::new(false),
];
#[cfg(target_os = "linux")]
pub fn hdr_capture_failed() -> bool {
HDR_CAPTURE_FAILED.load(std::sync::atomic::Ordering::Relaxed)
impl HdrSource {
fn slot(self) -> usize {
match self {
HdrSource::PortalMonitor => 0,
HdrSource::VirtualOutput => 1,
}
}
}
#[cfg(target_os = "linux")]
pub(crate) fn note_hdr_capture_failed() {
if !HDR_CAPTURE_FAILED.swap(true, std::sync::atomic::Ordering::Relaxed) {
tracing::warn!(
"HDR capture negotiation failed — this host will offer SDR capture for the rest of \
the process lifetime (restart the host after fixing the monitor's HDR mode to retry)"
);
pub fn hdr_capture_failed(source: HdrSource) -> bool {
HDR_CAPTURE_FAILED[source.slot()].load(std::sync::atomic::Ordering::Relaxed)
}
#[cfg(target_os = "linux")]
pub(crate) fn note_hdr_capture_failed(source: HdrSource) {
if !HDR_CAPTURE_FAILED[source.slot()].swap(true, std::sync::atomic::Ordering::Relaxed) {
match source {
HdrSource::PortalMonitor => tracing::warn!(
"HDR capture negotiation failed on the monitor mirror — this host will offer SDR \
for that source for the rest of the process lifetime (restart the host after \
fixing the monitor's HDR mode to retry)"
),
HdrSource::VirtualOutput => tracing::warn!(
"HDR capture negotiation failed on the virtual output — this host will offer SDR \
for that source for the rest of the process lifetime (is the spawned gamescope \
the punktfunk build? see packaging/gamescope)"
),
}
}
}
#[cfg(target_os = "windows")]
@@ -543,7 +595,7 @@ pub fn open_portal_monitor(
) -> Result<Box<dyn Capturer>> {
linux::PortalCapturer::open(
anchored,
want_hdr && !hdr_capture_failed(),
want_hdr && !hdr_capture_failed(HdrSource::PortalMonitor),
want_metadata_cursor,
policy,
)
@@ -553,7 +605,11 @@ pub fn open_portal_monitor(
/// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The
/// caller (host facade) explodes its `VirtualOutput` into these primitives + owns nothing after —
/// the capturer takes `keepalive`, so dropping it releases the output. `allow_zerocopy` mirrors
/// `OutputFormat::gpu`; `want_444` selects the planar-YUV444 GPU convert.
/// `OutputFormat::gpu`; `want_444` selects the planar-YUV444 GPU convert. `want_hdr` offers the
/// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually
/// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the
/// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a
/// session that negotiated PQ cannot fall back to SDR afterwards.
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn open_virtual_output(
@@ -563,6 +619,7 @@ pub fn open_virtual_output(
keepalive: Box<dyn Send>,
allow_zerocopy: bool,
want_444: bool,
want_hdr: bool,
policy: ZeroCopyPolicy,
expect_exact_dims: bool,
) -> Result<Box<dyn Capturer>> {
@@ -573,6 +630,7 @@ pub fn open_virtual_output(
keepalive,
allow_zerocopy,
want_444,
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
policy,
expect_exact_dims,
)
+61 -13
View File
@@ -104,6 +104,13 @@ struct CaptureSignals {
/// Set once the stream negotiated one of the 10-bit PQ formats, i.e. frames really are
/// PQ/BT.2020 — drives `hdr_meta`.
hdr_negotiated: Arc<AtomicBool>,
/// Set by the PipeWire thread once it ACTUALLY advertised the EGL→CUDA dmabuf-only offer
/// (importer constructed, importable modifiers found, not the raw passthrough, not HDR).
/// `plan.build_importer` alone cannot answer this — the importer may fail to construct (no
/// CUDA on this box), in which case no dmabuf was offered and a negotiation timeout must NOT
/// latch the GPU offer off. Read by `next_frame`'s timeout diagnosis, the EGL→CUDA twin of
/// the raw-passthrough arm.
gpu_dmabuf_offer: Arc<AtomicBool>,
/// The LIVE cursor overlay, published from every buffer's `SPA_META_Cursor` — including the
/// cursor-only "corrupted" buffers that never become frames — so the encode loop's forwarder
/// tracks pointer-only motion on a static desktop (the frame-attached overlay alone goes stale
@@ -123,6 +130,7 @@ impl CaptureSignals {
streaming: Arc::new(AtomicBool::new(false)),
broken: Arc::new(AtomicBool::new(false)),
hdr_negotiated: Arc::new(AtomicBool::new(false)),
gpu_dmabuf_offer: Arc::new(AtomicBool::new(false)),
cursor_live: Arc::new(std::sync::Mutex::new(None)),
frame_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
}
@@ -159,6 +167,9 @@ pub struct PortalCapturer {
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the
/// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta).
hdr_offer: bool,
/// Which HDR source this capturer is — the latch a failed [`hdr_offer`](Self::hdr_offer)
/// belongs to. See [`super::HdrSource`] for why the latch is not one process-wide flag.
hdr_source: super::HdrSource,
/// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis.
node_id: u32,
/// Stops the PipeWire loop on teardown (sent in `Drop`). Without it a dropped or failed
@@ -293,7 +304,7 @@ impl PortalCapturer {
},
policy,
)?
.into_capturer(node_id, None, Some(portal)))
.into_capturer(node_id, None, Some(portal), super::HdrSource::PortalMonitor))
}
/// Build a capturer from an already-created virtual output's PipeWire node. The host facade
@@ -304,6 +315,8 @@ impl PortalCapturer {
/// [`OutputFormat::gpu`](pf_frame::OutputFormat): `false` forces the CPU mmap path, `true` keeps
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
/// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see
/// [`crate::open_virtual_output`] for who is allowed to pass it.
#[allow(clippy::too_many_arguments)]
pub fn from_virtual_output(
remote_fd: Option<OwnedFd>,
@@ -312,6 +325,7 @@ impl PortalCapturer {
keepalive: Box<dyn Send>,
allow_zerocopy: bool,
want_444: bool,
want_hdr: bool,
policy: ZeroCopyPolicy,
expect_exact_dims: bool,
) -> Result<PortalCapturer> {
@@ -319,11 +333,14 @@ impl PortalCapturer {
node_id,
allow_zerocopy,
want_444,
want_hdr,
expect_exact_dims,
"connecting PipeWire to virtual output"
);
// Virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise 8-bit
// BGRx/BGRA exclusively, GNOME 50 and 51-dev alike) — never run the HDR offer here.
// Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise
// 8-bit BGRx/BGRA exclusively, GNOME 50 and 51-dev alike; KWin/wlroots the same), so
// `want_hdr` reaches here ONLY for a gamescope node off our `pipewire-hdr` build — the
// host resolves that before the Welcome (`capture::capturer_supports_hdr_for`).
Ok(spawn_pipewire(
remote_fd,
node_id,
@@ -331,15 +348,19 @@ impl PortalCapturer {
CaptureOpts {
allow_zerocopy,
want_444,
// Virtual outputs are SDR-only upstream (see the comment above).
want_hdr: false,
want_hdr,
expect_exact_dims,
},
policy,
)?
// No portal thread on this path: the node belongs to a virtual output the caller created,
// and `keepalive` is what releases it.
.into_capturer(node_id, Some(keepalive), None))
.into_capturer(
node_id,
Some(keepalive),
None,
super::HdrSource::VirtualOutput,
))
}
}
@@ -370,6 +391,7 @@ impl PwHandles {
node_id: u32,
keepalive: Option<Box<dyn Send>>,
portal: Option<PortalSession>,
hdr_source: super::HdrSource,
) -> PortalCapturer {
PortalCapturer {
slot: self.slot,
@@ -378,6 +400,7 @@ impl PwHandles {
stall_since: None,
vaapi_dmabuf: self.vaapi_dmabuf,
hdr_offer: self.hdr_offer,
hdr_source,
node_id,
quit: Some(self.quit),
join: Some(self.join),
@@ -444,9 +467,11 @@ fn spawn_pipewire(
native_nv12_session: policy.native_nv12_session,
raw_dmabuf_import_disabled: pf_zerocopy::raw_dmabuf_import_disabled(),
gpu_import_disabled: pf_zerocopy::gpu_import_disabled(),
gpu_dmabuf_negotiation_failed: pf_zerocopy::gpu_dmabuf_negotiation_disabled(),
// Default ON; `PUNKTFUNK_PIPEWIRE_NV12=0` (or any falsy spelling — the shared parser, not a
// bare `!= "0"` string compare) restores the packed-RGB negotiation.
native_nv12_env_on: pf_host_config::env_on("PUNKTFUNK_PIPEWIRE_NV12").unwrap_or(true),
hdr_cuda_ok: policy.hdr_cuda_ok,
});
// The capturer's timeout diagnosis reads the SAME resolved bool the thread acts on.
let vaapi_dmabuf = plan.vaapi_passthrough;
@@ -624,11 +649,15 @@ impl Capturer for PortalCapturer {
&& self.join.as_ref().is_some_and(|j| !j.is_finished())
}
/// Generic HDR10 mastering metadata once the stream negotiated a 10-bit PQ format. Mutter
/// exposes no per-monitor mastering volume through the screencast, so this is the standard
/// HDR10 default block (BT.2020 primaries, D65 white, 1000 / 0.005 cd/m², CLL unknown) — the
/// same fallback Windows uses when a display reports nothing. The native stream loop prefers
/// the client display's own volume when the client sent one (`Hello::display_hdr`).
/// Generic HDR10 mastering metadata once the stream negotiated a 10-bit PQ format — for
/// BOTH Linux HDR sources (the portal monitor mirror and a gamescope virtual output).
/// Neither producer exposes a mastering volume through the screencast: Mutter has no
/// per-monitor one, and gamescope's PipeWire node carries no per-frame HDR metadata (the
/// game's `VK_EXT_hdr_metadata` blob stops at the compositor — forwarding it needs the
/// patch's v2 custom SPA meta). So this is the standard HDR10 default block (BT.2020
/// primaries, D65 white, 1000 / 0.005 cd/m², CLL unknown) — the same fallback Windows uses
/// when a display reports nothing. The native stream loop prefers the client display's own
/// volume when the client sent one (`Hello::display_hdr`).
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
if !self.signals.hdr_negotiated.load(Ordering::Relaxed) {
return None;
@@ -713,7 +742,7 @@ impl PortalCapturer {
// GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30.
// Latch the process-wide SDR downgrade so the next session (Moonlight
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
super::note_hdr_capture_failed();
super::note_hdr_capture_failed(self.hdr_source);
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer is the mirrored \
@@ -721,7 +750,7 @@ impl PortalCapturer {
reconnect to stream SDR",
self.node_id
))
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
} else if self.vaapi_dmabuf && !pf_zerocopy::zerocopy_forced() {
// The dmabuf-only raw-passthrough offer was never accepted. Latch the
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer
// instead of failing this same negotiation forever. The latch is SCOPED to the
@@ -738,6 +767,25 @@ impl PortalCapturer {
rebuild will renegotiate without dmabuf",
self.node_id
))
} else if self.signals.gpu_dmabuf_offer.load(Ordering::Relaxed)
&& !pf_zerocopy::zerocopy_forced()
{
// The EGL→CUDA dmabuf-only offer was never accepted — the twin of the raw-
// passthrough arm above (the offer the thread ACTUALLY made, per the signal
// it set — see `CaptureSignals::gpu_dmabuf_offer`). One timeout is conclusive:
// a compositor that allocates none of the importer's modifiers refuses them
// identically on every retry, so latch the offer off and let the pipeline
// rebuild renegotiate the CPU path instead of re-running this same 10 s
// timeout on every reconnect. A forced PUNKTFUNK_ZEROCOPY=1 keeps erroring
// loudly instead (same rule as the raw arm).
pf_zerocopy::note_gpu_dmabuf_negotiation_failed();
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (EGLCUDA GPU import) downgrading THIS \
offer to the CPU path for the rest of the process; the pipeline rebuild \
will renegotiate without dmabuf",
self.node_id
))
} else {
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): format negotiation never \
+129 -30
View File
@@ -127,8 +127,14 @@ pub(super) struct NegotiationInputs {
pub raw_dmabuf_import_disabled: bool,
/// `pf_zerocopy::gpu_import_disabled()` — repeated import-worker deaths.
pub gpu_import_disabled: bool,
/// `pf_zerocopy::gpu_dmabuf_negotiation_disabled()` — a previous EGL→CUDA dmabuf-only offer
/// timed out (the compositor accepts none of the importer's modifiers).
pub gpu_dmabuf_negotiation_failed: bool,
/// `PUNKTFUNK_PIPEWIRE_NV12` (default ON) — allow the producer-side NV12 preference.
pub native_nv12_env_on: bool,
/// [`ZeroCopyPolicy::hdr_cuda_ok`] — the resolved encoder can ingest a packed 10-bit PQ CUDA
/// payload. Only the direct-SDK NVENC backend can.
pub hdr_cuda_ok: bool,
}
/// The resolved zero-copy negotiation decision — **one resolver, consumed by the PipeWire thread
@@ -154,8 +160,8 @@ pub(super) struct NegotiationPlan {
/// Diagnostic: this capture WOULD have taken the raw passthrough, but its scoped latch is
/// set (the encoder repeatedly failed to import, or a previous negotiation timed out).
pub raw_dmabuf_latched: bool,
/// Diagnostic: this capture WOULD have built the EGL→CUDA importer, but repeated
/// import-worker deaths latched the GPU import off.
/// Diagnostic: this capture WOULD have built the EGL→CUDA importer, but a latch fired —
/// repeated import-worker deaths, or a previous dmabuf-offer negotiation timeout.
pub gpu_import_latched: bool,
}
@@ -163,8 +169,11 @@ pub(super) struct NegotiationPlan {
///
/// The four invariants this encodes were previously prose-only comments spread across the
/// prologue; `negotiation_plan_invariants` in the tests below pins each one:
/// 1. HDR never builds the EGL→CUDA importer (its de-tile blit is 8-bit RGBA8 → silent depth
/// loss); the HDR consumers are the CPU mmap path and the raw passthrough.
/// 1. HDR never takes the TILED EGL de-tile blit (it renders into an 8-bit `GL_RGBA8` texture
/// → silent depth loss). It may still build the importer, because the HDR pod family
/// advertises LINEAR only ([`build_hdr_dmabuf_format`]) — so an HDR dmabuf necessarily
/// takes the Vulkan-bridge / CUDA-external-memory arm, which is byte-exact for any 4 Bpp
/// packed format. The per-frame gate in `.process` enforces the tiled half.
/// 2. 4:4:4 never prefers producer NV12 (a 4:4:4 session must not be subsampled).
/// 3. Producer-native NV12 only on a `native_nv12_session` under an active raw passthrough
/// (libav VAAPI would misread the two-plane buffer; the CUDA importer expects packed RGB).
@@ -174,10 +183,22 @@ pub(super) fn negotiation_plan(i: NegotiationInputs) -> NegotiationPlan {
// CSC) or a PyroWave session (the wavelet encoder's own Vulkan device, any vendor).
let raw_passthrough = i.backend_is_vaapi || i.pyrowave_session;
// Building the EGL→CUDA importer would waste a CUDA probe under a raw passthrough — or
// worse, succeed and produce CUDA payloads only NVENC can consume. HDR is excluded by
// invariant 1. `gpu_import_disabled` is the repeated-worker-death latch (a wedged GPU stack
// must not crash-loop).
let build_importer = i.zerocopy && !raw_passthrough && !i.want_hdr && !i.gpu_import_disabled;
// worse, succeed and produce CUDA payloads only NVENC can consume. `gpu_import_disabled` is
// the repeated-worker-death latch (a wedged GPU stack must not crash-loop);
// `gpu_dmabuf_negotiation_failed` is the offer's own timeout latch (a compositor that accepts
// none of the importer's modifiers refuses them identically on every retry, so the next
// session negotiates the CPU path instead of re-paying the 10 s timeout).
//
// HDR is NOT excluded outright (invariant 1): its pods are LINEAR-only, so it lands on the
// Vulkan-bridge arm, never the 8-bit de-tile blit. But it is excluded where the encoder cannot
// take a packed 10-bit CUDA payload — the libav fallback's HDR route swscales into a P010
// hardware frame, so it must keep getting CPU frames. Without that term a
// `PUNKTFUNK_NVENC_DIRECT=0` host would stream garbage.
let build_importer = i.zerocopy
&& !raw_passthrough
&& !i.gpu_import_disabled
&& !i.gpu_dmabuf_negotiation_failed
&& (!i.want_hdr || i.hdr_cuda_ok);
// Note there is no `importer.is_none()` term, unlike the expression this replaces: it was
// redundant (`build_importer` already excludes `raw_passthrough`, so the importer is
// necessarily absent here) and it is what made the decision look impure — the reason
@@ -200,7 +221,12 @@ pub(super) fn negotiation_plan(i: NegotiationInputs) -> NegotiationPlan {
&& !i.force_shm
&& raw_passthrough
&& i.raw_dmabuf_import_disabled,
gpu_import_latched: i.zerocopy && !raw_passthrough && !i.want_hdr && i.gpu_import_disabled,
// Every `build_importer` term EXCEPT the two latches, and then either latch — i.e. exactly
// "this capture would have built the importer, but a latch stopped it".
gpu_import_latched: i.zerocopy
&& !raw_passthrough
&& (!i.want_hdr || i.hdr_cuda_ok)
&& (i.gpu_import_disabled || i.gpu_dmabuf_negotiation_failed),
}
}
@@ -321,14 +347,15 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
// attach no fence. Covers both the GPU import and the CPU mmap read below.
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
match pf_zerocopy::dmabuf_fence::wait_read_ready(datas[0].fd(), 100) {
Ok(waited) => {
Ok(outcome) => {
static F1: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
if F1.swap(false, Ordering::Relaxed) {
tracing::info!(
waited,
"dmabuf implicit-fence sync active (waited=true → driver fences \
the render, race closed; false no implicit fence, zero-copy \
may still show stale frames)"
?outcome,
"dmabuf implicit-fence sync active (Signaled → driver fences the \
render, race closed; NoFence no implicit fence, zero-copy may \
still show stale frames; TimedOut fence pending past 100ms, \
proceeded anyway)"
);
}
}
@@ -450,10 +477,20 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
// through to the shm de-pad copy below.
let mut gpu_import_broken = false;
if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) {
// Defense-in-depth: the 10-bit PQ formats must never enter the EGL→CUDA import (its
// de-tile blit is 8-bit RGBA8 — silent depth loss). An HDR offer never builds the
// importer, so this gate only matters if those invariants ever drift apart.
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf && !fmt.is_hdr_rgb10() {
// Invariant 1's teeth: a 10-bit PQ frame may take the LINEAR (Vulkan-bridge → CUDA
// external memory) arm, which moves 4 Bpp words verbatim, but must NEVER take the TILED
// EGL de-tile blit — that renders into an 8-bit `GL_RGBA8` texture and would crush the
// depth silently. The HDR pods advertise LINEAR only, so a tiled modifier here means the
// producer ignored the offer; drop to the CPU path rather than trust it.
let hdr_tiled = fmt.is_hdr_rgb10() && ud.modifier != 0;
if hdr_tiled {
warn_once(
"HDR frame arrived with a tiled modifier — the GPU de-tile blit is 8-bit, so \
this stream falls back to the CPU path (the producer ignored our LINEAR-only \
HDR offer)",
);
}
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf && !hdr_tiled {
let plane = pf_zerocopy::DmabufPlane {
fd: datas[0].fd(),
offset: datas[0].chunk().offset(),
@@ -473,8 +510,13 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
// stays RGB, falling to the encoder's clear-error path (`want_444` with an
// RGB CUDA payload) rather than silently subsampling. A LINEAR NV12 convert
// failure latches RGB for the stream (mid-frame fallback, no drop).
let yuv444 = ud.yuv444 && modifier.is_some();
let mut nv12 = ud.nv12 && !ud.yuv444;
// A 10-bit frame takes NEITHER convert: both the GL and the Vulkan compute CSCs
// write 8-bit planes, and NVENC ingests the packed 10-bit RGB natively
// (`ARGB10`/`ABGR10`) with its own BT.2020 CSC. So HDR stays packed RGB all the
// way to the encoder — no depth loss, no extra pass.
let ten_bit = fmt.is_hdr_rgb10();
let yuv444 = ud.yuv444 && modifier.is_some() && !ten_bit;
let mut nv12 = ud.nv12 && !ud.yuv444 && !ten_bit;
let imported = if let Some(m) = modifier {
if yuv444 {
importer.import_yuv444(&plane, w as u32, h as u32, fourcc, Some(m))
@@ -805,11 +847,12 @@ pub fn pipewire_thread(
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's dmabuf
// kills the worker, not this host. If construction fails, log and fall back to the CPU path
// (we simply won't request dmabuf below); `plan.build_importer` already encodes WHEN to try
// at all (not under a raw passthrough, not for HDR, not once repeated worker deaths latched
// the import off — see `negotiation_plan`).
// at all (not under a raw passthrough, not once repeated worker deaths latched the import
// off — see `negotiation_plan`).
if plan.gpu_import_latched {
tracing::warn!(
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
"zero-copy GPU import disabled for this host process (repeated import-worker deaths, \
or a previous dmabuf negotiation timeout) using CPU path"
);
}
let mut importer = if plan.build_importer {
@@ -861,6 +904,13 @@ pub fn pipewire_thread(
// The one runtime-dependent half of the decision (see `NegotiationPlan::want_dmabuf`): it
// needs the modifier list the importer's construction actually yielded.
let want_dmabuf = plan.want_dmabuf(importer.is_some(), &modifiers);
// Record whether THIS capture really advertises the EGL→CUDA dmabuf-only offer, for the
// capturer's negotiation-timeout diagnosis (its latch must fire only for an offer that was
// actually made — `plan.build_importer` alone can't know the importer constructed).
signals.gpu_dmabuf_offer.store(
want_dmabuf && !vaapi_passthrough && !want_hdr,
Ordering::Relaxed,
);
if force_shm {
tracing::info!(
"capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)"
@@ -1397,7 +1447,9 @@ mod tests {
native_nv12_session: false,
raw_dmabuf_import_disabled: false,
gpu_import_disabled: false,
gpu_dmabuf_negotiation_failed: false,
native_nv12_env_on: true,
hdr_cuda_ok: true,
}
}
@@ -1413,17 +1465,19 @@ mod tests {
/// The four invariants that were prose-only comments in `pipewire_thread`'s prologue.
#[test]
fn negotiation_plan_invariants() {
// 1. HDR NEVER builds the EGL→CUDA importer — its de-tile blit is 8-bit RGBA8, so an
// importer here would silently crush the 10-bit depth.
// 1. HDR builds the importer on the NVENC path — its pods are LINEAR-only, so the frames
// take the Vulkan-bridge/CUDA arm (byte-exact for 4 Bpp), never the 8-bit de-tile
// blit. (The tiled half of the invariant is enforced per frame in `.process`, which
// sees the negotiated modifier this plan cannot.)
for want_444 in [false, true] {
let p = negotiation_plan(NegotiationInputs {
want_hdr: true,
want_444,
..nvenc()
});
assert!(!p.build_importer, "HDR must not build the importer");
assert!(p.build_importer, "HDR on NVENC keeps zero-copy");
}
// …on every backend, including the ones that take the raw passthrough.
// …but never under a raw passthrough (VAAPI/PyroWave import the dmabuf themselves).
assert!(
!negotiation_plan(NegotiationInputs {
want_hdr: true,
@@ -1431,6 +1485,26 @@ mod tests {
})
.build_importer
);
// …and never when the resolved encoder can't take a packed 10-bit CUDA payload (libav
// NVENC: its HDR route swscales into a P010 hardware frame). SDR is unaffected — the term
// is HDR-only, so a libav host keeps its 8-bit zero-copy.
assert!(
!negotiation_plan(NegotiationInputs {
want_hdr: true,
hdr_cuda_ok: false,
..nvenc()
})
.build_importer,
"HDR must stay on the CPU path where the encoder can't ingest 10-bit CUDA"
);
assert!(
negotiation_plan(NegotiationInputs {
hdr_cuda_ok: false,
..nvenc()
})
.build_importer,
"the HDR-only guard must not touch an SDR session"
);
// 2. 4:4:4 never prefers producer NV12 (a 4:4:4 session must not be subsampled).
let p = negotiation_plan(NegotiationInputs {
@@ -1511,6 +1585,29 @@ mod tests {
}
}
/// The EGL→CUDA offer's own negotiation-timeout latch gates `build_importer` — the twin of
/// the raw passthrough's latch, so a compositor that accepts none of the importer's modifiers
/// stops being asked (previously it re-ran the same 10 s timeout on every session, forever).
/// The raw passthrough is untouched by it.
#[test]
fn gpu_dmabuf_negotiation_latch_gates_only_the_importer() {
let p = negotiation_plan(NegotiationInputs {
gpu_dmabuf_negotiation_failed: true,
..nvenc()
});
assert!(!p.build_importer, "latched offer must not be re-made");
assert!(p.gpu_import_latched, "the downgrade must be diagnosable");
let p = negotiation_plan(NegotiationInputs {
gpu_dmabuf_negotiation_failed: true,
..vaapi_native_nv12()
});
assert!(
p.vaapi_passthrough,
"the raw passthrough has its own latch — this one must not touch it"
);
assert!(!p.gpu_import_latched, "no importer was ever wanted here");
}
/// A PyroWave session on an NVIDIA box takes the raw passthrough (the wavelet encoder's own
/// Vulkan device imports dmabufs on any vendor) and therefore must NOT also build the
/// EGL→CUDA importer — whose payloads only NVENC can consume.
@@ -1569,16 +1666,18 @@ mod tests {
});
assert!(!p.build_importer);
assert!(p.gpu_import_latched);
// It is NOT reported for a capture that would never have built one anyway (HDR, or a
// raw passthrough) — that would misdirect the operator.
// Reported for an HDR capture too — HDR takes the same importer (LINEAR/Vulkan-bridge
// arm), so the latch really did cost it zero-copy.
assert!(
!negotiation_plan(NegotiationInputs {
negotiation_plan(NegotiationInputs {
gpu_import_disabled: true,
want_hdr: true,
..nvenc()
})
.gpu_import_latched
);
// It is NOT reported for a capture that would never have built one anyway (a raw
// passthrough) — that would misdirect the operator.
assert!(
!negotiation_plan(NegotiationInputs {
gpu_import_disabled: true,
+69 -7
View File
@@ -10,13 +10,18 @@
use anyhow::{anyhow, Context, Result};
use std::os::fd::OwnedFd;
/// Whether any monitor of the live GNOME session is currently in BT.2100 (HDR) colour mode — the
/// Whether the monitor this host would mirror is currently in BT.2100 (HDR) colour mode — the
/// precondition for Mutter's monitor screencast advertising the 10-bit PQ formats (GNOME 50+;
/// Mutter only appends the HDR formats while the mirrored monitor's colour state is BT.2020+PQ).
/// Queried over the session bus: `DisplayConfig.GetCurrentState`, monitor property
/// `"color-mode" == 1` (`META_COLOR_MODE_BT2100`). `false` on any error — not GNOME, a pre-48
/// Mutter without colour modes, no monitors — so callers fall back to the honest SDR offer.
/// Blocking (one D-Bus round-trip on a fresh connection); call from control-plane threads only.
///
/// **Scoped to `PUNKTFUNK_CAPTURE_MONITOR` when it is set** (`design/per-monitor-portal-capture.md`
/// §7.4). Without a pin this asks "is ANY monitor in HDR mode", which was a fair heuristic while the
/// capture path took whatever monitor it was handed — but once the operator names the head, an
/// HDR-capable *neighbour* must not talk this host into offering PQ formats for an SDR panel.
pub fn gnome_hdr_monitor_active() -> bool {
use ashpd::zbus;
// GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is
@@ -74,12 +79,21 @@ pub fn gnome_hdr_monitor_active() -> bool {
.body()
.deserialize()
.context("parse GetCurrentState")?;
Ok(monitors.iter().any(|(_spec, _modes, props)| {
props
.get("color-mode")
.and_then(|v| u32::try_from(v).ok())
.is_some_and(|mode| mode == 1) // META_COLOR_MODE_BT2100
}))
// `spec.0` is the connector; "color-mode" 1 is META_COLOR_MODE_BT2100.
let heads: Vec<(&str, bool)> = monitors
.iter()
.map(|(spec, _modes, props)| {
let hdr = props
.get("color-mode")
.and_then(|v| u32::try_from(v).ok())
.is_some_and(|mode| mode == 1);
(spec.0.as_str(), hdr)
})
.collect();
Ok(hdr_offer_for(
&heads,
pf_host_config::config().capture_monitor.as_deref(),
))
})
};
match probe() {
@@ -91,6 +105,24 @@ pub fn gnome_hdr_monitor_active() -> bool {
}
}
/// Should this host offer the HDR (10-bit PQ) formats, given each head as `(connector, is_bt2100)`
/// and the `PUNKTFUNK_CAPTURE_MONITOR` pin?
///
/// Pinned: only that head's colour mode counts — an HDR-capable neighbour must not talk the host
/// into offering PQ for the SDR panel it is actually streaming. A pin naming no live head reports
/// SDR rather than falling back to "any": the session is about to fail on that same missing
/// monitor, and an over-claimed HDR offer would be a second, quieter wrong answer.
/// Unpinned: the pre-existing "any monitor is in HDR mode" heuristic, unchanged.
fn hdr_offer_for(heads: &[(&str, bool)], pinned: Option<&str>) -> bool {
match pinned {
Some(want) => heads
.iter()
.find(|(connector, _)| connector.eq_ignore_ascii_case(want))
.is_some_and(|(_, hdr)| *hdr),
None => heads.iter().any(|(_, hdr)| *hdr),
}
}
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`).
/// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap
/// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position +
@@ -355,3 +387,33 @@ pub(super) fn portal_thread_remote_desktop(
// See `portal_thread`: drop the runtime before the caller's completion signal.
drop(rt);
}
#[cfg(test)]
mod hdr_offer_tests {
use super::hdr_offer_for;
#[test]
fn unpinned_keeps_the_any_monitor_heuristic() {
assert!(hdr_offer_for(&[("DP-1", false), ("HDMI-A-1", true)], None));
assert!(!hdr_offer_for(&[("DP-1", false)], None));
}
/// The regression this exists to prevent: an HDR TV on HDMI while the pinned head is an SDR
/// desk monitor. Before scoping, the host offered PQ formats for a panel that can't show them.
#[test]
fn a_pin_ignores_an_hdr_neighbour() {
let heads = [("DP-1", false), ("HDMI-A-1", true)];
assert!(!hdr_offer_for(&heads, Some("DP-1")));
assert!(hdr_offer_for(&heads, Some("HDMI-A-1")));
}
#[test]
fn a_pin_matches_case_insensitively_like_the_resolver() {
assert!(hdr_offer_for(&[("HDMI-A-1", true)], Some("hdmi-a-1")));
}
#[test]
fn a_pin_naming_no_live_head_reports_sdr() {
assert!(!hdr_offer_for(&[("DP-1", true)], Some("DP-9")));
}
}
+41 -17
View File
@@ -245,10 +245,31 @@ impl Drop for KeyedMutexGuard<'_> {
/// broker duplicates sensitive handles into it. The pid is driver-reported (the frame channel's
/// [`control::AddReply::wudf_pid`], or the gamepad bootstrap's `driver_pid`); a spoofed devnode / a
/// tampered mailbox could name an arbitrary process to receive the channel, so this is the
/// confused-deputy gate. Best-effort image-path identity is proportionate: a fully-compromised REAL
/// driver is already a channel endpoint, and any *other* process (attacker exe, a non-driver pid)
/// fails this WUDFHost image check. `what` names the channel in the error (e.g. `"frame-channel"`);
/// shared with the gamepad sealed channel (`inject/windows/gamepad_raii.rs`).
/// confused-deputy gate. `what` names the channel in the error (e.g. `"frame-channel"`); shared with
/// the gamepad sealed channel (`inject/windows/gamepad_raii.rs`).
///
/// # What this does and does NOT prove (security-review 2026-07-28)
///
/// It proves the target's image is the system WUDFHost binary. It does **not** prove the target is
/// *the* WUDFHost hosting our devnode, and it is not an authorization check: `WUDFHost.exe` is
/// world-executable, so anyone who can name a pid to a broker can first spawn their own copy (e.g.
/// `CREATE_SUSPENDED`, which parks it indefinitely with the right image path) and pass this check.
/// It therefore only screens out *non-WUDFHost* pids — an attacker exe, a stale/wrong devnode.
///
/// Whether that is sufficient depends entirely on who can name the pid, so it must be judged at each
/// caller, NOT here:
/// - **Frame channel** — sufficient. The pid is `AddReply::wudf_pid`, which the driver fills with its
/// own `GetCurrentProcessId()` and returns over the pf-vdisplay control device, whose DACL is
/// `D:P(A;;GA;;;SY)(A;;GA;;;BA)`. Only SYSTEM/Administrators can speak on that channel, and both are
/// out of scope by SECURITY.md.
/// - **Gamepad/mouse channel** — NOT sufficient on its own. The pid comes from a `Global\` mailbox
/// that LocalService can write, so this check does not identify the caller; see the corrected
/// analysis and the one-delivery rule in `pf-inject/src/inject/windows/gamepad_raii.rs`.
///
/// Do not add a token/session/account check here expecting it to fix the gamepad case: a genuine
/// UMDF host and a LocalService attacker's spawned WUDFHost are both session 0 and both LocalService,
/// so such checks discriminate nothing while risking a false negative that would silently kill
/// display capture and every virtual pad.
///
/// # Safety
/// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`.
@@ -732,6 +753,18 @@ impl IddPushCapturer {
return; // no new sample since last consume
}
self.desc_seq = seq;
// A topology reassert is in flight (the exclusive watchdog announced it): every sample in
// this window is potentially the TRANSIENT eviction state, and acting on one recreates
// the ring at a mode the reassert's recovery chain (`recreate_ring_in_place`, keyed off
// the reassert generation) is about to undo — the field hdr=true→false→true double
// recreate. Consume the sample, disarm the debounce, act on nothing; a REAL change that
// races the window survives it (the descriptor still differs once the hold clears, and
// the poller re-samples in ~250 ms). This also keeps the negotiated-depth pin-back below
// from issuing a CCD write mid-eviction, where it would fight the reassert itself.
if pf_win_display::topology_churn::held() {
self.pending_desc = None;
return;
}
// Two cases re-assert the NEGOTIATED depth instead of following a mid-session "Use HDR"
// flip — flip the display back and treat the descriptor as the negotiated state (so the ring
// is never recreated at the wrong format):
@@ -758,13 +791,8 @@ impl IddPushCapturer {
// Reading back immediately can catch a flip that has not settled yet; that costs one
// debounce cycle (the poller re-samples in ~250 ms) and never a wrong ring, which is why
// this does not block the frame path on a settle poll the way `open_on` does.
// SAFETY: both are `unsafe fn`s over CCD DisplayConfig; each takes a copy of the plain
// `u32` target id (plus a `bool`), forms no lasting borrow, and returns an owned value.
let requested =
unsafe { pf_win_display::win_display::set_advanced_color(self.target_id, want) };
// SAFETY: as above — a read-only CCD query over a copy of the plain `u32` target id.
let observed =
unsafe { pf_win_display::win_display::advanced_color_enabled(self.target_id) };
let requested = pf_win_display::win_display::set_advanced_color(self.target_id, want);
let observed = pf_win_display::win_display::advanced_color_enabled(self.target_id);
// A failed READ is not evidence of a failed flip — keep the poller's sample then.
now.hdr = observed.unwrap_or(now.hdr);
if now.hdr != want && !self.hdr_pin_warned {
@@ -1096,9 +1124,7 @@ impl IddPushCapturer {
if !self.display_hdr {
return;
}
// SAFETY: `sdr_white_level_scale` is an `unsafe fn` running a read-only CCD query over owned
// local buffers; the `Copy` target id crosses by value and it returns an owned value.
let queried = unsafe { pf_win_display::win_display::sdr_white_level_scale(self.target_id) };
let queried = pf_win_display::win_display::sdr_white_level_scale(self.target_id);
self.sdr_white_scale = queried.unwrap_or(self.sdr_white_scale);
tracing::info!(
target_id = self.target_id,
@@ -1797,9 +1823,7 @@ impl Capturer for IddPushCapturer {
// driver's stash (measured: new_fps=0 forever after the re-attach). CDS_RESET forces a
// real mode-set at the CURRENT mode — the same lever bring-up's ADD path relies on —
// and the ring recreate below then re-attaches after that churn, not before it.
// SAFETY: `resolve_gdi_name` runs the CCD query FFI over a `Copy` target id (owned
// return) — same contract as every sibling call in this file.
match unsafe { pf_win_display::win_display::resolve_gdi_name(self.target_id) } {
match pf_win_display::win_display::resolve_gdi_name(self.target_id) {
Some(gdi) => {
if !pf_win_display::win_display::force_mode_reset(&gdi) {
tracing::warn!(
@@ -69,17 +69,13 @@ pub(super) fn kick_dwm_compose(target_id: u32) {
let mut pos = POINT::default();
// SAFETY: plain FFI; `pos` is a valid out-param for this synchronous call.
let have_pos = unsafe { GetCursorPos(&mut pos) }.is_ok();
// SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned local
// buffers; the `Copy` target id crosses by value.
let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
let rect = pf_win_display::win_display::source_desktop_rect(target_id);
// HID-first (see the doc comment): the registered virtual-mouse kick works from any
// session/desktop and wakes an off display. Both geometries come from CCD (global database),
// NOT per-session GDI metrics, so the aim is right even from a non-console session. Fall
// through to SendInput only when the hook isn't registered / the mouse isn't up.
if let (Some(kick), Some(rect)) = (crate::HID_COMPOSE_KICK.get(), rect) {
// SAFETY: `desktop_bounds` only runs the CCD QueryDisplayConfig FFI over owned local
// buffers.
let bounds = unsafe { pf_win_display::win_display::desktop_bounds() };
let bounds = pf_win_display::win_display::desktop_bounds();
if let Some(bounds) = bounds {
if kick(rect, bounds) {
return;
@@ -72,9 +72,7 @@ impl CursorShared {
MappedSection { handle: map, view }
};
// Desktop origin of this monitor's source — for the desktop→frame coordinate shift.
// SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned
// locals (same call the compose-kick path makes).
let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
let rect = pf_win_display::win_display::source_desktop_rect(target_id);
let origin = rect.map(|(x, y, _w, _h)| (x, y)).unwrap_or((0, 0));
Ok(CursorShared {
section,
@@ -187,10 +187,7 @@ fn run(
// transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would
// report every position invisible.
//
// SAFETY: `source_desktop_rect` is an `unsafe fn` running the read-only CCD
// `QueryDisplayConfig` over owned local buffers; the `Copy` target id crosses by value
// and it returns owned `(x, y, w, h)` values, borrowing nothing.
let fresh = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
let fresh = pf_win_display::win_display::source_desktop_rect(target_id);
if let Some(fresh) = fresh {
if fresh != rect {
tracing::info!(
@@ -59,14 +59,10 @@ impl DescriptorPoller {
let mut last_slow_log: Option<Instant> = None;
while !stop_t.load(Ordering::Relaxed) {
let t = Instant::now();
// SAFETY: both are read-only CCD queries taking only a copy of the plain `u32`
// target id (see their own SAFETY docs); nothing is borrowed across the calls.
let (hdr, res) = unsafe {
(
let (hdr, res) = (
pf_win_display::win_display::advanced_color_enabled(target_id),
pf_win_display::win_display::active_resolution(target_id),
)
};
);
let took = t.elapsed();
if took >= Self::SLOW
&& last_slow_log.is_none_or(|t| t.elapsed() >= Duration::from_secs(10))
@@ -281,11 +281,8 @@ impl IddPushCapturer {
// a fullscreen game can hold the virtual display at a different mode (esp. across a reconnect), so
// matching the actual mode lets the first frame flow instead of being dropped (game-capture bug
// GB1). Falls back to the negotiated mode when the CCD read is unavailable.
// SAFETY: `active_resolution` is an `unsafe fn` (Win32 CCD `QueryDisplayConfig`) that takes only a
// copy of the plain `u32` CCD target id and returns owned `(w, h)` values; it forms no borrows from
// us and validates the id internally, returning `None` on any failure (handled by `unwrap_or`).
let (w, h) = unsafe { pf_win_display::win_display::active_resolution(target.target_id) }
.unwrap_or((pw, ph));
let (w, h) =
pf_win_display::win_display::active_resolution(target.target_id).unwrap_or((pw, ph));
if (w, h) != (pw, ph) {
tracing::info!(
target_id = target.target_id,
@@ -115,7 +115,7 @@ impl StallWatch {
delivered no frame for the gap; the present path stalled below capture"
);
if let Some(period) = stall.metronomic {
let suspects = pf_win_display::display_events::connected_inactive_externals();
let suspects = pf_win_display::display_events::connected_inactive_physicals();
let suspects = if suspects.is_empty() {
"none".to_string()
} else {
@@ -151,10 +151,13 @@ impl StallWatch {
display-poller software (the SteelSeries-GG/SignalRGB class \
correlate 'slow display-descriptor poll' lines), or the DWM present \
clock (try a different refresh rate). If connected_inactive lists a \
display, its standby probing is the prime suspect: unplug it at the \
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
streaming"
display, its standby servicing is the prime suspect. For a LAPTOP \
PANEL (the exclusive isolate deactivated it the dark-but-connected \
head is itself the disturbance on hybrid laptops): keep it active \
with `topology: primary`, or try the `pnp_disable_monitors` axis. \
For an external display: unplug it at the GPU, disable its OSD auto \
input scan (TVs: instant-on/quick-start + CEC off), use an \
HPD-holding adapter/dummy, or keep it active while streaming"
);
}
}
+6
View File
@@ -41,6 +41,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tracing = "0.1"
# Stable ids for profiles and host records (profiles.rs) — the OS RNG only, same version the
# workspace already resolves for punktfunk-core. No uuid crate: the v4 layout is four lines.
rand = "0.9"
# Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
# need the hidapi driver). Linux links the system SDL3; Windows builds it from source
@@ -76,3 +79,6 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
# still strictly per-session opt-in (Settings codec pick / PUNKTFUNK_PREFER_PYROWAVE=1).
default = ["pyrowave"]
pyrowave = ["dep:pyrowave-sys", "dep:ash"]
[lints]
workspace = true
+754
View File
@@ -0,0 +1,754 @@
//! The `punktfunk://` URL grammar — one parser/emitter for Linux, Windows, the session and
//! the CLI (design/client-deep-links.md §2). Swift (`PunktfunkShared/DeepLink.swift`) and
//! Kotlin keep their own ports; all three are held together by the shared vector file
//! `clients/shared/deeplink-vectors.json`, which this module's tests consume verbatim.
//!
//! ```text
//! punktfunk://connect/<host-ref>[?fp=<64-hex>][&host=<addr[:port]>][&launch=<id>]
//! [&profile=<ref>][&name=<label>]
//! ```
//!
//! The invariant the grammar exists to keep: **a URL may only ever do what a click on an
//! existing card could do, minus trust decisions.** So it carries *references* to things that
//! already exist on this device — a host record, a settings profile, a library id — and never
//! values: no resolution, no bitrate, no codec. A web page must not be able to shape a
//! session beyond picking among the user's own configurations. `pair` is deliberately not a
//! route and never will be; pairing stays an interactive ceremony.
//!
//! `pf://` parses as an alias so a hand-typed or legacy link still works, but nothing ever
//! *emits* or registers it (§2: claiming a two-letter scheme on MSIX/Apple is unconditional
//! squatting, and a link that resolves on one platform only is a trap).
use crate::trust::{KnownHost, KnownHosts};
/// Hostile-input caps (§8). The total is generous for a real link and small enough that a
/// pasted megabyte never reaches the decoder.
pub const MAX_URL_LEN: usize = 2048;
pub const MAX_HOST_REF_LEN: usize = 128;
pub const MAX_LAUNCH_LEN: usize = 128;
pub const MAX_PROFILE_LEN: usize = 64;
pub const MAX_NAME_LEN: usize = 64;
/// The default native port, as everywhere else in the clients.
pub const DEFAULT_PORT: u16 = 9777;
/// What the URL asks for. `Wake`/`Browse` are reserved in the grammar and parse today; a
/// front-end that hasn't implemented them refuses with a notice rather than silently
/// connecting — the grammar is the contract, per-platform support is not.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Route {
/// The default, and the only route an emitter builds today.
#[default]
Connect,
Wake,
Browse,
}
impl Route {
pub fn as_str(self) -> &'static str {
match self {
Route::Connect => "connect",
Route::Wake => "wake",
Route::Browse => "browse",
}
}
}
/// A parsed, validated link. Every field is already length- and charset-checked, so a
/// consumer never has to re-validate hostile input; what it still has to do is *resolve*
/// (§3): the references may name things that don't exist here.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct DeepLink {
pub route: Route,
/// The host reference as written: a stable record id, a host name, or `addr[:port]`.
pub host_ref: String,
/// Expected host certificate fingerprint, lowercase hex (64 chars).
pub fp: Option<String>,
/// Recovery address for a stable id that no longer resolves (store wiped, reinstall).
pub host: Option<(String, u16)>,
/// A store-qualified library id (`steam:570`) for the host to launch on arrival.
pub launch: Option<String>,
/// A settings-profile reference (id, or a unique name) — one-off, never rebinding.
pub profile: Option<String>,
/// Display label for the unknown-host confirmation sheet (external emitters).
pub name: Option<String>,
}
/// Why a URL was rejected. The `code` strings are the cross-language contract (the vector
/// file names them) — Swift and Kotlin report the same code for the same input, which is what
/// keeps three parsers from drifting into three different security postures.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ParseError {
/// Not a `punktfunk://` (or `pf://`) URL at all — the caller should ignore it, not warn.
NotOurScheme,
TooLong,
/// A route this grammar doesn't define.
UnknownRoute(String),
/// `punktfunk://pair/…` — pairing is an interactive ceremony, never a link (§2).
PairRefused,
MissingHostRef,
/// A `%` escape that isn't two hex digits, or a decode that isn't UTF-8.
BadEscape,
/// A control character survived decoding — no legitimate field contains one.
ControlChar,
/// A parameter past its cap; carries the parameter name.
ParamTooLong(&'static str),
/// `fp=` that isn't 64 hex characters.
BadFingerprint,
/// `host=` that isn't `addr[:port]` with a parsable port.
BadHostParam,
/// `launch=` outside the printable, shell-safe id charset the host and Decky agree on.
BadLaunchId,
}
impl ParseError {
/// The stable code shared with the Swift/Kotlin ports and the vector file.
pub fn code(&self) -> &'static str {
match self {
ParseError::NotOurScheme => "not-our-scheme",
ParseError::TooLong => "too-long",
ParseError::UnknownRoute(_) => "unknown-route",
ParseError::PairRefused => "pair-refused",
ParseError::MissingHostRef => "missing-host-ref",
ParseError::BadEscape => "bad-escape",
ParseError::ControlChar => "control-char",
ParseError::ParamTooLong(_) => "param-too-long",
ParseError::BadFingerprint => "bad-fingerprint",
ParseError::BadHostParam => "bad-host-param",
ParseError::BadLaunchId => "bad-launch-id",
}
}
/// A sentence for the notice a refusing front-end shows. Deliberately names the failing
/// reference: "a shortcut that can't honor its profile says so instead of streaming with
/// the wrong settings" (§10.6) applies to every refusal here.
pub fn message(&self) -> String {
match self {
ParseError::NotOurScheme => "That isn't a Punktfunk link.".into(),
ParseError::TooLong => "That link is too long to be genuine.".into(),
ParseError::UnknownRoute(r) => format!("Punktfunk links can't do \"{r}\"."),
ParseError::PairRefused => {
"Pairing can't be done from a link — pair the host in Punktfunk first.".into()
}
ParseError::MissingHostRef => "That link doesn't say which host to use.".into(),
ParseError::BadEscape | ParseError::ControlChar => {
"That link is malformed and was ignored.".into()
}
ParseError::ParamTooLong(p) => format!("That link's \"{p}\" value is too long."),
ParseError::BadFingerprint => "That link's host fingerprint isn't a valid one.".into(),
ParseError::BadHostParam => "That link's host address isn't valid.".into(),
ParseError::BadLaunchId => "That link's game id isn't a valid one.".into(),
}
}
}
/// Parse a `punktfunk://` (or `pf://`) URL. Everything hostile is rejected here, once, for
/// every front-end: over-long input, malformed escapes, control characters, out-of-charset
/// launch ids and fingerprints that aren't fingerprints.
pub fn parse(url: &str) -> Result<DeepLink, ParseError> {
if url.len() > MAX_URL_LEN {
return Err(ParseError::TooLong);
}
let (scheme, rest) = url.split_once("://").ok_or(ParseError::NotOurScheme)?;
if !scheme.eq_ignore_ascii_case("punktfunk") && !scheme.eq_ignore_ascii_case("pf") {
return Err(ParseError::NotOurScheme);
}
// A fragment is never part of this grammar; drop it rather than folding it into the last
// parameter (where it would smuggle unvalidated text past the caps).
let rest = rest.split('#').next().unwrap_or("");
let (path, query) = match rest.split_once('?') {
Some((p, q)) => (p, q),
None => (rest, ""),
};
let path = path.trim_end_matches('/');
let (route_word, host_ref_raw) = match path.split_once('/') {
Some((r, h)) => (r, h),
// A single segment: Apple's shipped links are always `connect/<uuid>`, but a bare
// reference is unambiguous as long as it isn't one of the route words — those stay
// routes (with a missing reference), so `punktfunk://pair` refuses instead of hunting
// for a host called "pair".
None if is_route_word(path) => (path, ""),
None => ("connect", path),
};
let route = match route_word.to_ascii_lowercase().as_str() {
"connect" => Route::Connect,
"wake" => Route::Wake,
"browse" => Route::Browse,
"pair" => return Err(ParseError::PairRefused),
other => return Err(ParseError::UnknownRoute(other.to_string())),
};
let host_ref = decode(host_ref_raw)?;
if host_ref.is_empty() {
return Err(ParseError::MissingHostRef);
}
if host_ref.chars().count() > MAX_HOST_REF_LEN {
return Err(ParseError::ParamTooLong("host-ref"));
}
let mut link = DeepLink {
route,
host_ref,
..Default::default()
};
for pair in query.split('&').filter(|s| !s.is_empty()) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
let key = decode(key)?.to_ascii_lowercase();
let value = decode(value)?;
if value.is_empty() {
continue; // `?launch=` with nothing after it is "not given", not an error.
}
// First occurrence wins, and unknown keys are ignored: a newer emitter's parameter
// must not turn an otherwise valid link into a refusal, and appending a second `fp=`
// must not be able to override the first.
match key.as_str() {
"fp" if link.fp.is_none() => {
let fp = value.to_ascii_lowercase();
if fp.len() != 64 || !fp.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ParseError::BadFingerprint);
}
link.fp = Some(fp);
}
"host" if link.host.is_none() => {
link.host = Some(parse_addr_port(&value).ok_or(ParseError::BadHostParam)?);
}
"launch" if link.launch.is_none() => {
if value.len() > MAX_LAUNCH_LEN {
return Err(ParseError::ParamTooLong("launch"));
}
if !is_safe_launch_id(&value) {
return Err(ParseError::BadLaunchId);
}
link.launch = Some(value);
}
"profile" if link.profile.is_none() => {
if value.chars().count() > MAX_PROFILE_LEN {
return Err(ParseError::ParamTooLong("profile"));
}
link.profile = Some(value);
}
"name" if link.name.is_none() => {
if value.chars().count() > MAX_NAME_LEN {
return Err(ParseError::ParamTooLong("name"));
}
link.name = Some(value);
}
_ => {}
}
}
Ok(link)
}
impl DeepLink {
/// The canonical URL for this link — always `punktfunk://`, never the `pf://` alias.
/// Self-emitted links carry the stable id AND `host`+`fp`, so a shortcut written today
/// still resolves after a reinstall wipes the store (§2, §5).
pub fn to_url(&self) -> String {
let mut s = format!(
"punktfunk://{}/{}",
self.route.as_str(),
encode(&self.host_ref)
);
let mut sep = '?';
let mut push = |s: &mut String, key: &str, value: &str| {
s.push(sep);
sep = '&';
s.push_str(key);
s.push('=');
s.push_str(&encode(value));
};
if let Some(fp) = &self.fp {
push(&mut s, "fp", fp);
}
if let Some((addr, port)) = &self.host {
let host = if *port == DEFAULT_PORT {
addr.clone()
} else if addr.contains(':') {
format!("[{addr}]:{port}") // literal IPv6 needs its brackets back
} else {
format!("{addr}:{port}")
};
push(&mut s, "host", &host);
}
if let Some(launch) = &self.launch {
push(&mut s, "launch", launch);
}
if let Some(profile) = &self.profile {
push(&mut s, "profile", profile);
}
if let Some(name) = &self.name {
push(&mut s, "name", name);
}
s
}
/// The self-emitted form for a saved host: id first (address-independent), with the
/// address and pin alongside so the link degrades to a confirmation sheet instead of a
/// dead click when the record is gone.
pub fn for_host(host: &KnownHost, launch: Option<&str>, profile: Option<&str>) -> DeepLink {
DeepLink {
route: Route::Connect,
host_ref: host
.id
.clone()
.unwrap_or_else(|| format!("{}:{}", host.addr, host.port)),
fp: (!host.fp_hex.is_empty()).then(|| host.fp_hex.clone()),
host: Some((host.addr.clone(), host.port)),
launch: launch.map(str::to_string),
profile: profile.map(str::to_string),
name: None,
}
}
/// True when this link's `fp` contradicts what we have pinned for that host — the link is
/// stale or lying, and the only safe answer is a hard refusal (§3.1).
pub fn pin_conflict(&self, host: &KnownHost) -> bool {
match (&self.fp, host.fp_hex.is_empty()) {
(Some(fp), false) => !fp.eq_ignore_ascii_case(&host.fp_hex),
_ => false,
}
}
}
/// What the local host store made of a link's references.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HostResolution {
/// Index into `KnownHosts::hosts` — a record we already trust (subject to
/// [`DeepLink::pin_conflict`]).
Known(usize),
/// No record, but the link says where to dial: the confirmation sheet's input, from which
/// the normal pairing/TOFU flow proceeds under the user's eyes. Never an auto-connect.
Unknown {
addr: String,
port: u16,
name: Option<String>,
fp: Option<String>,
},
/// The name matched more than one saved host — refuse with a notice, never guess (§8).
Ambiguous,
/// A reference that resolves to nothing and carries no address to fall back on.
Unresolvable,
}
/// Resolve a link's host reference against the local store, in the documented order: stable
/// record id → unique case-insensitive name → `addr[:port]` literal. The `host=` parameter is
/// the recovery path — a self-emitted shortcut that outlived the record it was written from
/// still lands on the right box (degraded to the confirmation sheet).
///
/// Returns an index rather than a borrow so callers can keep mutating the store (rekey,
/// touch-last-used) without fighting the borrow checker.
pub fn resolve_host(link: &DeepLink, known: &KnownHosts) -> HostResolution {
if let Some(i) = known
.hosts
.iter()
.position(|h| h.id.as_deref().is_some_and(|id| id == link.host_ref))
{
return HostResolution::Known(i);
}
let by_name: Vec<usize> = known
.hosts
.iter()
.enumerate()
.filter(|(_, h)| h.name.eq_ignore_ascii_case(&link.host_ref))
.map(|(i, _)| i)
.collect();
match by_name.len() {
1 => return HostResolution::Known(by_name[0]),
0 => {}
_ => return HostResolution::Ambiguous,
}
// `addr[:port]` literal, then the `host=` recovery parameter — both matched the way every
// other per-host lookup in the client matches (addr + port). The literal is only
// considered when the reference could BE an address: a stale record id must fall through
// to `host=` (or to a refusal), never be offered as a box to dial.
let literal = looks_like_address(&link.host_ref)
.then(|| parse_addr_port(&link.host_ref))
.flatten();
for candidate in [literal.clone(), link.host.clone()].into_iter().flatten() {
if let Some(i) = known
.hosts
.iter()
.position(|h| h.addr == candidate.0 && h.port == candidate.1)
{
return HostResolution::Known(i);
}
}
match literal.or_else(|| link.host.clone()) {
Some((addr, port)) => HostResolution::Unknown {
addr,
port,
name: link.name.clone(),
fp: link.fp.clone(),
},
None => HostResolution::Unresolvable,
}
}
/// Could this reference be a network address (an IP literal or a host name) rather than a
/// record id or a display name? Only then may an unmatched reference become "an unknown host
/// at this address" for the confirmation sheet. A stable id that no longer resolves is NOT an
/// address: offering to dial a UUID as a hostname would turn a wiped store into a confusing
/// dead end instead of the `host=`-driven recovery §2 specifies.
fn looks_like_address(s: &str) -> bool {
let uuid_shaped = s.len() == 36
&& s.char_indices().all(|(i, c)| match i {
8 | 13 | 18 | 23 => c == '-',
_ => c.is_ascii_hexdigit(),
});
!uuid_shaped
&& !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '[' | ']'))
}
/// The reserved first path segments — everything the grammar routes on, plus `pair`, which is
/// reserved precisely so it can be refused rather than mistaken for a host name.
fn is_route_word(s: &str) -> bool {
matches!(
s.to_ascii_lowercase().as_str(),
"connect" | "wake" | "browse" | "pair"
)
}
/// `addr`, `addr:port`, `[v6]`, `[v6]:port` — `None` when the port isn't a number. A bare
/// IPv6 literal (`::1`) keeps its colons and takes the default port; anything else splits at
/// the last colon, like every other host-parsing site in the clients.
fn parse_addr_port(s: &str) -> Option<(String, u16)> {
if s.is_empty() {
return None;
}
if let Some(rest) = s.strip_prefix('[') {
let (addr, tail) = rest.split_once(']')?;
if addr.is_empty() {
return None;
}
return match tail {
"" => Some((addr.to_string(), DEFAULT_PORT)),
t => Some((addr.to_string(), t.strip_prefix(':')?.parse().ok()?)),
};
}
match s.rsplit_once(':') {
// `::1` and friends: the head still has a colon, so this isn't a port separator.
Some((head, _)) if head.contains(':') => Some((s.to_string(), DEFAULT_PORT)),
Some((addr, port)) if !addr.is_empty() => Some((addr.to_string(), port.parse().ok()?)),
Some(_) => None,
None => Some((s.to_string(), DEFAULT_PORT)),
}
}
/// The launch-id charset the whole product already agrees on: printable, non-space ASCII with
/// no shell metacharacters (Decky rides ids through Steam launch options as an env token, so
/// a quote or a backtick genuinely breaks something downstream). Validation only — the id is
/// opaque and the host matches it verbatim against its own library.
fn is_safe_launch_id(id: &str) -> bool {
!id.is_empty()
&& id
.bytes()
.all(|b| (0x21..=0x7e).contains(&b) && !br#""'\$`"#.contains(&b))
}
/// Strict percent-decoding: `%` must be followed by exactly two hex digits, the result must be
/// UTF-8, and no control character may survive. Lenient decoders are how `%00`, a stray `\n`
/// or a half-escape end up inside a filename or a log line.
fn decode(s: &str) -> Result<String, ParseError> {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' => {
let hex = bytes.get(i + 1..i + 3).ok_or(ParseError::BadEscape)?;
let hi = (hex[0] as char).to_digit(16).ok_or(ParseError::BadEscape)?;
let lo = (hex[1] as char).to_digit(16).ok_or(ParseError::BadEscape)?;
out.push((hi * 16 + lo) as u8);
i += 3;
}
b => {
out.push(b);
i += 1;
}
}
}
let text = String::from_utf8(out).map_err(|_| ParseError::BadEscape)?;
if text.chars().any(|c| c.is_control()) {
return Err(ParseError::ControlChar);
}
Ok(text)
}
/// Percent-encode for emission: unreserved characters plus `:` (legal in a query value and
/// left alone by Apple's `URLComponents`, so the three emitters agree on `steam:570`).
fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b':' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trust::KnownHost;
fn host(name: &str, addr: &str, id: &str, fp: &str) -> KnownHost {
KnownHost {
name: name.into(),
addr: addr.into(),
port: DEFAULT_PORT,
fp_hex: fp.into(),
paired: true,
id: Some(id.into()),
..Default::default()
}
}
/// Every case in the cross-language vector file, which the Swift and Kotlin ports consume
/// too — this is what keeps three parsers from drifting into three security postures.
#[test]
fn shared_vectors() {
let raw = include_str!("../../../clients/shared/deeplink-vectors.json");
let file: serde_json::Value = serde_json::from_str(raw).expect("vector file parses");
let cases = file["cases"].as_array().expect("cases array");
assert!(
cases.len() > 20,
"the vector file is the contract; keep it rich"
);
for case in cases {
let name = case["name"].as_str().unwrap();
let url = case["url"].as_str().unwrap();
let got = parse(url);
match case.get("error").and_then(|e| e.as_str()) {
Some(code) => {
let err = got.expect_err(&format!("{name}: expected {code}, parsed ok"));
assert_eq!(err.code(), code, "{name}");
}
None => {
let link = got.unwrap_or_else(|e| panic!("{name}: {e:?}"));
let want = &case["expect"];
assert_eq!(
link.route.as_str(),
want["route"].as_str().unwrap(),
"{name}"
);
assert_eq!(link.host_ref, want["host_ref"].as_str().unwrap(), "{name}");
let opt = |k: &str| want.get(k).and_then(|v| v.as_str()).map(str::to_string);
assert_eq!(link.fp, opt("fp"), "{name} fp");
assert_eq!(link.launch, opt("launch"), "{name} launch");
assert_eq!(link.profile, opt("profile"), "{name} profile");
assert_eq!(link.name, opt("name"), "{name} name");
let (addr, port) = match &link.host {
Some((a, p)) => (Some(a.clone()), Some(u64::from(*p))),
None => (None, None),
};
assert_eq!(addr, opt("host_addr"), "{name} host_addr");
assert_eq!(
port,
want.get("host_port").and_then(|v| v.as_u64()),
"{name} host_port"
);
if let Some(emit) = case.get("emit").and_then(|v| v.as_str()) {
assert_eq!(link.to_url(), emit, "{name} emit");
}
}
}
}
}
/// A `Result` from the parser is not enough on its own: the codes are the shared
/// vocabulary, so they must be exactly what the vector file (and the ports) name.
#[test]
fn refusals_are_specific() {
assert_eq!(parse("https://example.com/"), Err(ParseError::NotOurScheme));
assert_eq!(parse("punktfunk:/connect/x"), Err(ParseError::NotOurScheme));
assert_eq!(
parse(&format!("punktfunk://connect/{}", "a".repeat(MAX_URL_LEN))),
Err(ParseError::TooLong)
);
assert_eq!(parse("punktfunk://pair/1234"), Err(ParseError::PairRefused));
assert_eq!(
parse("punktfunk://teardown/host"),
Err(ParseError::UnknownRoute("teardown".into()))
);
assert_eq!(
parse("punktfunk://connect/"),
Err(ParseError::MissingHostRef)
);
assert_eq!(
parse(&format!(
"punktfunk://connect/{}",
"n".repeat(MAX_HOST_REF_LEN + 1)
)),
Err(ParseError::ParamTooLong("host-ref"))
);
}
/// The one-click contract in resolution form: an id beats a name beats an address, an
/// ambiguous name refuses, and a link whose record is gone still lands on the
/// confirmation sheet via `host=`+`fp=` instead of dying.
#[test]
fn host_resolution_order_and_recovery() {
let fp = "a".repeat(64);
let known = KnownHosts {
hosts: vec![
host(
"Desk",
"192.168.1.50",
"11111111-2222-4333-8444-555555555555",
&fp,
),
host(
"Couch",
"192.168.1.60",
"66666666-7777-4888-8999-aaaaaaaaaaaa",
"",
),
host(
"Couch",
"192.168.1.61",
"bbbbbbbb-cccc-4ddd-8eee-ffffffffffff",
"",
),
],
};
let r = |url: &str| resolve_host(&parse(url).unwrap(), &known);
assert_eq!(
r("punktfunk://connect/11111111-2222-4333-8444-555555555555"),
HostResolution::Known(0)
);
assert_eq!(r("punktfunk://connect/desk"), HostResolution::Known(0));
assert_eq!(r("punktfunk://connect/couch"), HostResolution::Ambiguous);
assert_eq!(
r("punktfunk://connect/192.168.1.50"),
HostResolution::Known(0)
);
assert_eq!(
r("punktfunk://connect/192.168.1.50:9777"),
HostResolution::Known(0)
);
// A stale id with the recovery parameters: the address finds the record anyway.
assert_eq!(
r("punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
HostResolution::Known(0)
);
// Nothing local matches: the sheet gets the address, the claimed name and the pin —
// which is what makes the first connect verified rather than blind TOFU.
assert_eq!(
r(&format!(
"punktfunk://connect/10.0.0.9:7000?name=Studio&fp={fp}"
)),
HostResolution::Unknown {
addr: "10.0.0.9".into(),
port: 7000,
name: Some("Studio".into()),
fp: Some(fp.clone()),
}
);
// An unmatched reference that could be an address (an mDNS/DNS name, a new IP) is
// offered as an unknown host — the sheet, never an auto-connect.
assert_eq!(
r("punktfunk://connect/nas.local"),
HostResolution::Unknown {
addr: "nas.local".into(),
port: DEFAULT_PORT,
name: None,
fp: None,
}
);
// But a stale record id is not a hostname: without `host=` there is nothing to dial,
// and dialing "11111111-…" would be a confusing dead end rather than a recovery.
assert_eq!(
r("punktfunk://connect/00000000-0000-4000-8000-000000000000"),
HostResolution::Unresolvable
);
// Neither is a display name that can't be an address.
assert_eq!(
r("punktfunk://connect/Basement%20PC"),
HostResolution::Unresolvable
);
// A pin that contradicts the stored one is the link lying — the caller hard-refuses.
let link = parse(&format!("punktfunk://connect/desk?fp={}", "b".repeat(64))).unwrap();
assert!(link.pin_conflict(&known.hosts[0]));
assert!(!parse(&format!("punktfunk://connect/desk?fp={fp}"))
.unwrap()
.pin_conflict(&known.hosts[0]));
// No pin stored (an address-only record) → nothing to contradict; the trust flow runs.
assert!(!link.pin_conflict(&known.hosts[1]));
}
/// Self-emitted links round-trip and carry all three references, so they survive both a
/// re-addressed host and a wiped store.
#[test]
fn self_emitted_links_round_trip() {
let fp = "c".repeat(64);
let mut h = host(
"Desk",
"192.168.1.50",
"11111111-2222-4333-8444-555555555555",
&fp,
);
h.port = 7777;
let link = DeepLink::for_host(&h, Some("steam:570"), Some("aaaaaaaaaaaa"));
let url = link.to_url();
assert_eq!(
url,
"punktfunk://connect/11111111-2222-4333-8444-555555555555\
?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\
&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa"
);
assert_eq!(parse(&url).unwrap(), link);
// A record with no id yet (pre-migration store) still emits something resolvable.
let mut plain = h.clone();
plain.id = None;
assert_eq!(
DeepLink::for_host(&plain, None, None).host_ref,
"192.168.1.50:7777"
);
// Names with spaces and non-ASCII survive the round trip.
let link = DeepLink {
host_ref: "Wohnzimmer PC".into(),
name: Some("Büro · Mac".into()),
..Default::default()
};
assert!(link
.to_url()
.starts_with("punktfunk://connect/Wohnzimmer%20PC?"));
assert_eq!(parse(&link.to_url()).unwrap(), link);
}
/// `addr[:port]` parsing, including the bracketed IPv6 forms a link can carry.
#[test]
fn addr_port_forms() {
assert_eq!(
parse_addr_port("192.168.1.5"),
Some(("192.168.1.5".into(), 9777))
);
assert_eq!(
parse_addr_port("192.168.1.5:1234"),
Some(("192.168.1.5".into(), 1234))
);
assert_eq!(parse_addr_port("::1"), Some(("::1".into(), 9777)));
assert_eq!(parse_addr_port("[::1]"), Some(("::1".into(), 9777)));
assert_eq!(parse_addr_port("[::1]:1234"), Some(("::1".into(), 1234)));
assert_eq!(parse_addr_port("host:notaport"), None);
assert_eq!(parse_addr_port("[::1]junk"), None);
assert_eq!(parse_addr_port(""), None);
// An emitted IPv6 host parameter comes back bracketed so it parses again.
let link = DeepLink {
host_ref: "x".into(),
host: Some(("::1".into(), 1234)),
..Default::default()
};
assert_eq!(parse(&link.to_url()).unwrap().host, link.host);
}
}
+16
View File
@@ -27,6 +27,19 @@ pub mod gamepad;
pub mod keymap;
#[cfg(any(target_os = "linux", windows))]
pub mod library;
// The `punktfunk://` grammar (design/client-deep-links.md §2): one parser/emitter for the
// shells, the session and the CLI, held to the Swift/Kotlin ports by a shared vector file.
#[cfg(any(target_os = "linux", windows))]
pub mod deeplink;
// The brain layer (design/client-architecture-split.md §3): what a connect is, the wake
// state machine every front-end drives, and the session spawn + stdout contract.
#[cfg(any(target_os = "linux", windows))]
pub mod orchestrate;
// Client settings profiles: the override catalog + the one connect-time resolver
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
// the bindings live on.
#[cfg(any(target_os = "linux", windows))]
pub mod profiles;
#[cfg(any(target_os = "linux", windows))]
pub mod session;
#[cfg(any(target_os = "linux", windows))]
@@ -37,6 +50,9 @@ pub mod video;
mod video_color;
#[cfg(any(target_os = "linux", windows))]
mod video_software;
// libav ownership helpers shared by the hardware decoders below (`AvBuffer`).
#[cfg(any(target_os = "linux", windows))]
mod video_libav;
#[cfg(target_os = "linux")]
mod video_vaapi;
#[cfg(any(target_os = "linux", windows))]
+840
View File
@@ -0,0 +1,840 @@
//! The brain layer: what a connect *is*, and the one implementation of how it runs
//! (design/client-architecture-split.md §3).
//!
//! Wake-then-connect exists three times today — GTK's `WakeConnect`/`wake_fallback`, the
//! WinUI shell's `wake_and_connect`, Apple's `HostWaker` (whose comment in the Windows copy
//! literally says "mirrors the Apple HostWaker") — and the deep-link and profile work would
//! have made it five. This module is where that collapses: a [`ConnectPlan`] is built from a
//! card click, a CLI verb or a URL (one constructor each, one type out), and the orchestrator
//! runs it. Front-ends render; they don't decide.
//!
//! The split that keeps this honest is [`UiDelegate`]: prompts, progress and error surfaces
//! stay in the front-end, because a GTK dialog, a WinUI page, a Skia console screen and a
//! terminal prompt genuinely are different things — but *when* to prompt, *how long* to wait
//! for a sleeping box and *what counts as a refusal* are decided here, once.
//!
//! Wake timings are Apple's `HostWaker` verbatim, because it is the implementation that got
//! them right: a magic packet is fire-and-forget and a cold box takes 2060 s to POST, boot
//! and re-advertise — far longer than any dial will sit — so the packet is re-sent every 6 s
//! (a single one gets missed, and some NICs only wake on a fresh packet after dropping into a
//! deeper sleep state), presence is polled once a second, and the whole wait is bounded at
//! 90 s, after which it PARKS for retry rather than erroring out from under the user.
use crate::deeplink::{DeepLink, HostResolution, Route};
use crate::profiles::{ProfilesFile, Resolution, StreamProfile};
use crate::trust::{effective_settings, KnownHost, KnownHosts, Settings};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
/// The host a plan dials, flattened out of whichever record or reference produced it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HostTarget {
pub name: String,
pub addr: String,
pub port: u16,
/// The pinned fingerprint. `None` = no pin, which the session binary refuses by design —
/// a plan without one may only exist after the front-end's trust ceremony.
pub fp_hex: Option<String>,
pub mac: Vec<String>,
pub id: Option<String>,
}
impl From<&KnownHost> for HostTarget {
fn from(h: &KnownHost) -> HostTarget {
HostTarget {
name: h.name.clone(),
addr: h.addr.clone(),
port: h.port,
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
mac: h.mac.clone(),
id: h.id.clone(),
}
}
}
/// A resolved intent: everything needed to start one session, with every policy question
/// already answered. Built once, then executed — front-ends don't re-decide any of it.
#[derive(Clone, Debug, PartialEq)]
pub struct ConnectPlan {
pub host: HostTarget,
/// Library id for the host to launch on arrival.
pub launch: Option<String>,
/// The settings profile this connect resolved with (display + the stats overlay).
pub profile: Option<StreamProfile>,
/// The one-off profile reference to hand the session, if this connect overrides the host's
/// binding: `Some(id)` for "Connect with ▸ X", `Some("")` to force the defaults, `None` to
/// let the session resolve the host's own binding (the two paths call the same resolver,
/// so they cannot disagree).
pub profile_override: Option<String>,
/// Effective settings — global defaults with the profile overlaid. What the front-end
/// reads for anything it needs to know up front (fullscreen, match-window…).
pub settings: Settings,
/// Send a magic packet up front and fall back to wake-and-wait if the dial fails. Off for
/// hosts with no MAC, and when the user turned auto-wake off (VPN hosts look offline when
/// they aren't, and the wake+wait only adds delay).
pub wake: bool,
/// Handshake budget override — the request-access flow passes ~185 s because the host
/// PARKS the connection until an operator approves.
pub connect_timeout_secs: Option<u64>,
/// The pin came from an advert rather than the store: persist it once the session reports
/// ready (ready proves the host really holds that identity).
pub tofu: bool,
}
impl ConnectPlan {
/// The plain card-click plan: this host, its binding (or a one-off profile), its wake
/// policy. `one_off_profile` is the "Connect with ▸" pick — `Some("")` forces the global
/// defaults on a bound host, `None` honors the binding. Loads the stores; the pure form is
/// [`ConnectPlan::resolve`], which a front-end that already holds them should use.
pub fn for_host(
host: &KnownHost,
launch: Option<&str>,
one_off_profile: Option<&str>,
) -> ConnectPlan {
let (settings, profile) = effective_settings(&host.addr, host.port, one_off_profile);
ConnectPlan {
host: HostTarget::from(host),
launch: launch.map(str::to_string),
profile,
profile_override: one_off_profile.map(str::to_string),
wake: settings.auto_wake && !host.mac.is_empty(),
settings,
connect_timeout_secs: None,
tofu: false,
}
}
/// The same plan, built from stores the caller already has — no disk, no clock, no
/// environment. This is the form the URL router uses: a front-end loads the three stores
/// once per event and every decision below is a pure function of them.
///
/// Profile precedence is the design's, unchanged: the one-off pick, else the host's
/// binding, else nothing; `Some("")` forces the defaults; anything dangling resolves as
/// no profile rather than an error.
pub fn resolve(
host: &KnownHost,
launch: Option<&str>,
one_off_profile: Option<&str>,
catalog: &ProfilesFile,
base: &Settings,
) -> ConnectPlan {
let profile = match one_off_profile {
Some("") => None,
Some(reference) => catalog.resolve(reference).0.cloned(),
None => host
.profile_id
.as_deref()
.and_then(|id| catalog.find_by_id(id))
.cloned(),
};
let settings = match &profile {
Some(p) => p.overrides.apply(base),
None => base.clone(),
};
ConnectPlan {
host: HostTarget::from(host),
launch: launch.map(str::to_string),
profile,
profile_override: one_off_profile.map(str::to_string),
wake: settings.auto_wake && !host.mac.is_empty(),
settings,
connect_timeout_secs: None,
tofu: false,
}
}
/// The session binary's argv for this plan — the one place the flags are assembled, so a
/// shell, the CLI and a URL launch cannot spawn subtly different sessions.
pub fn session_args(&self) -> Vec<String> {
let mut args = vec![
"--connect".into(),
format!("{}:{}", self.host.addr, self.host.port),
];
if let Some(fp) = &self.host.fp_hex {
args.push("--fp".into());
args.push(fp.clone());
}
if let Some(launch) = &self.launch {
args.push("--launch".into());
args.push(launch.clone());
}
// Only a one-off rides the flag: without it the session resolves the host's own
// binding through the same helper this plan used.
if let Some(profile) = &self.profile_override {
args.push("--profile".into());
args.push(profile.clone());
}
if let Some(secs) = self.connect_timeout_secs {
args.push("--connect-timeout".into());
args.push(secs.to_string());
}
if self.settings.fullscreen_on_stream {
args.push("--fullscreen".into());
}
args
}
}
/// What a URL turned into. Everything a front-end must not decide for itself lives in this
/// enum: an unknown host is a *prompt*, never a connect, and a route this build doesn't do is
/// a notice, never a silent no-op.
#[derive(Clone, Debug, PartialEq)]
pub enum PlanOutcome {
Connect(Box<ConnectPlan>),
/// The link resolved to no local record. The front-end shows the confirmation sheet with
/// exactly this, and the normal pairing/TOFU flow proceeds under the user's eyes (§3.1).
ConfirmUnknown(Box<UnknownHost>),
/// A route the grammar defines but this front-end hasn't implemented yet.
Unsupported(Route),
}
/// The confirmation sheet's contents for a link to a host we don't know.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UnknownHost {
pub addr: String,
pub port: u16,
/// The label the link claimed — shown as *claimed*, never trusted.
pub name: Option<String>,
/// The fingerprint the link expects; pre-fills the sheet's pin so the first connect is
/// verified rather than blind trust-on-first-use.
pub fp: Option<String>,
pub launch: Option<String>,
pub profile: Option<String>,
}
/// Why a link can't become a plan. Each of these is a *notice*, never a degraded connect:
/// predictability over best-effort — a shortcut that silently streams with the wrong settings
/// or to the wrong box is worse than one that explains itself (design/client-deep-links.md §8).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlanError {
/// The host name matched more than one saved host.
AmbiguousHost(String),
/// Nothing local matched and the link carries no address to fall back on.
UnresolvableHost(String),
/// The link's `fp` contradicts the pin we hold — the link is stale or lying.
PinConflict {
host: String,
},
UnknownProfile(String),
AmbiguousProfile(String),
}
impl PlanError {
/// The notice text. Every one of these names the reference that failed, because "it didn't
/// work" on a shortcut double-click is unactionable.
pub fn message(&self) -> String {
match self {
PlanError::AmbiguousHost(r) => {
format!("More than one saved host is called \"{r}\" — open Punktfunk and pick one.")
}
PlanError::UnresolvableHost(r) => {
format!("No saved host matches \"{r}\".")
}
PlanError::PinConflict { host } => format!(
"That link's fingerprint doesn't match the one saved for {host} — it's out of \
date, or it isn't that host. Nothing was connected."
),
PlanError::UnknownProfile(p) => {
format!("That link asks for a settings profile called \"{p}\", which doesn't exist here.")
}
PlanError::AmbiguousProfile(p) => {
format!("More than one settings profile is called \"{p}\" — rename one, or use its id in the link.")
}
}
}
}
/// Build a plan from a `punktfunk://` link against this device's stores — the shared half of
/// every platform's URL router (§4). The security rules of §3 live here, not in the shells:
/// no pairing, no silent trust, references resolved or refused.
///
/// Preempting a live session is the one rule that stays with the caller: only the front-end
/// knows whether a session is running, and the answer ("focus it" / "end that one first")
/// is UI, not policy.
pub fn plan_from_link(
link: &DeepLink,
known: &KnownHosts,
catalog: &ProfilesFile,
base: &Settings,
) -> Result<PlanOutcome, PlanError> {
if link.route != Route::Connect {
return Ok(PlanOutcome::Unsupported(link.route));
}
// The profile is resolved BEFORE anything is dialled: a link that can't honor its profile
// must say so instead of streaming with the wrong settings.
if let Some(reference) = &link.profile {
match catalog.resolve(reference) {
(Some(_), _) => {}
(_, Resolution::Ambiguous) => {
return Err(PlanError::AmbiguousProfile(reference.clone()))
}
_ => return Err(PlanError::UnknownProfile(reference.clone())),
}
}
match crate::deeplink::resolve_host(link, known) {
HostResolution::Known(i) => {
let host = &known.hosts[i];
if link.pin_conflict(host) {
return Err(PlanError::PinConflict {
host: host.name.clone(),
});
}
// A link with no `profile=` honors the host's binding, exactly like a card
// click — the URL adds nothing there, so it changes nothing.
let mut plan = ConnectPlan::resolve(
host,
link.launch.as_deref(),
link.profile.as_deref(),
catalog,
base,
);
// A record we know but never pinned (added by address, never paired) is not a
// silent connect either: the session refuses without a pin, and the front-end
// should run its trust flow. Hand it back as the confirmation case.
if plan.host.fp_hex.is_none() {
return Ok(PlanOutcome::ConfirmUnknown(Box::new(UnknownHost {
addr: plan.host.addr,
port: plan.host.port,
name: Some(plan.host.name),
fp: link.fp.clone(),
launch: link.launch.clone(),
profile: link.profile.clone(),
})));
}
if plan.host.name.is_empty() {
// An address-only record has no label; the link's claimed one is fine for a
// window title (it names nothing that is trusted).
plan.host.name = link.name.clone().unwrap_or_else(|| plan.host.addr.clone());
}
Ok(PlanOutcome::Connect(Box::new(plan)))
}
HostResolution::Unknown {
addr,
port,
name,
fp,
} => Ok(PlanOutcome::ConfirmUnknown(Box::new(UnknownHost {
addr,
port,
name,
fp,
launch: link.launch.clone(),
profile: link.profile.clone(),
}))),
HostResolution::Ambiguous => Err(PlanError::AmbiguousHost(link.host_ref.clone())),
HostResolution::Unresolvable => Err(PlanError::UnresolvableHost(link.host_ref.clone())),
}
}
// ---------------------------------------------------------------------------------------
// Wake-and-wait — the reference state machine, ported from Apple's `HostWaker`.
// ---------------------------------------------------------------------------------------
/// How long to wait for a woken host to come back. Generous on purpose: a cold boot plus
/// service start is routinely a minute-plus.
pub const WAKE_TIMEOUT_SECS: u64 = 90;
/// How often to re-send the magic packet while waiting.
pub const WAKE_RESEND_SECS: u64 = 6;
/// The wake-and-wait loop as a pure step function, so every front-end drives it from its own
/// loop (relm4 messages, a WinUI thread, the console's service tick, a CLI's sleep) and they
/// all still agree on the timings — and so the behavior is testable without waiting 90 s.
#[derive(Clone, Debug)]
pub struct WakeWait {
elapsed_secs: u64,
timeout_secs: u64,
resend_secs: u64,
}
/// What the caller should do for this one-second step.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WakeTick {
/// Send (or re-send) the magic packet now.
pub send_packet: bool,
/// Seconds waited so far — the "Waking… 12s" line.
pub seconds: u64,
/// `None` = keep waiting (sleep a second, then tick again).
pub outcome: Option<WakeOutcome>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WakeOutcome {
/// The host answered — proceed with the connect.
Online,
/// The budget ran out. The UI PARKS here (Try again / Cancel); it does not error out
/// from under the user, because "it didn't wake in 90 s" is often "give it 10 more".
TimedOut,
}
impl Default for WakeWait {
fn default() -> WakeWait {
WakeWait {
elapsed_secs: 0,
timeout_secs: WAKE_TIMEOUT_SECS,
resend_secs: WAKE_RESEND_SECS,
}
}
}
impl WakeWait {
/// A wait with the shipped timings.
pub fn new() -> WakeWait {
WakeWait::default()
}
/// One second of the wait. `online` is this tick's presence reading (an mDNS advert, a
/// reachability probe — whichever the front-end has; both are "did it answer").
///
/// Order matters and matches the reference: the packet goes out *before* the presence
/// check, so an already-awake host costs one wasted packet rather than a lost second, and
/// the timeout is checked after it — a host that appears on the last tick still wins.
pub fn tick(&mut self, online: bool) -> WakeTick {
let send_packet = self.elapsed_secs % self.resend_secs == 0;
let seconds = self.elapsed_secs;
let outcome = if online {
Some(WakeOutcome::Online)
} else if self.elapsed_secs >= self.timeout_secs {
Some(WakeOutcome::TimedOut)
} else {
self.elapsed_secs += 1;
None
};
WakeTick {
send_packet,
seconds,
outcome,
}
}
/// Restart the same wait — "Try again" after a timeout replays it exactly (the reference's
/// captured `replay` closure, minus the closure).
pub fn restart(&mut self) {
self.elapsed_secs = 0;
}
pub fn seconds(&self) -> u64 {
self.elapsed_secs
}
}
/// The front-end's obligations. Everything here is presentation; nothing here decides policy.
pub trait UiDelegate {
/// A link or a card points at a host we don't know (or never pinned). Return true to
/// proceed into the trust flow. A non-interactive front-end returns false — refusing is
/// always safe, and the CLI reports it as "needs interaction" rather than pairing blind.
fn confirm_unknown_host(&mut self, host: &UnknownHost) -> bool;
/// Render "Waking <host>… 12s" / the timed-out park state.
fn wake_progress(&mut self, host: &HostTarget, tick: WakeTick);
/// The session ended, one way or another.
fn report(&mut self, outcome: &ConnectOutcome);
}
/// How a connect finished — the typed outcome every front-end maps onto its own surface.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ConnectOutcome {
/// The stream ran and ended cleanly; `Some` carries the host's stated reason.
Ended(Option<String>),
/// The dial failed (and, where applicable, the wake wait did too).
ConnectFailed(String),
/// Trust rejected: no pin, or the pin no longer matches. Never retried silently.
TrustRejected(String),
/// The session binary itself failed to start or died abnormally.
RendererFailed(String),
/// The user cancelled.
Cancelled,
}
// ---------------------------------------------------------------------------------------
// Session spawn + the stdout contract.
// ---------------------------------------------------------------------------------------
/// One event from the session child's stdout contract (`{"ready":true}`, `{"error":…}`,
/// `{"ended":…}`, then EOF and an exit code). Parsed in one place so a shell, the console and
/// the CLI cannot disagree about what "ready" or "trust rejected" means.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SessionEvent {
/// First frame presented — the stream is up.
Ready,
Error {
msg: String,
trust_rejected: bool,
},
Ended(String),
/// EOF: the child is gone. `-1` = killed by a signal.
Exited(i32),
}
/// Parse one stdout line of the session contract; `None` for anything else (`stats:` lines,
/// stray output).
pub fn parse_session_line(line: &str) -> Option<SessionEvent> {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
if v.get("ready").and_then(|r| r.as_bool()) == Some(true) {
return Some(SessionEvent::Ready);
}
if let Some(msg) = v.get("error").and_then(|m| m.as_str()) {
return Some(SessionEvent::Error {
msg: msg.to_string(),
trust_rejected: v.get("trust_rejected").and_then(|t| t.as_bool()) == Some(true),
});
}
if let Some(msg) = v.get("ended").and_then(|m| m.as_str()) {
return Some(SessionEvent::Ended(msg.to_string()));
}
None
}
/// The session binary: installed next to this executable, else `$PATH` (a dev run out of
/// `target/…` lands on the sibling).
pub fn session_binary() -> std::path::PathBuf {
if let Ok(exe) = std::env::current_exe() {
let sibling = exe.with_file_name(SESSION_BIN);
if sibling.exists() {
return sibling;
}
}
SESSION_BIN.into()
}
#[cfg(windows)]
const SESSION_BIN: &str = "punktfunk-session.exe";
#[cfg(not(windows))]
const SESSION_BIN: &str = "punktfunk-session";
/// Kills the spawned session child — the Cancel button of a parked request-access connect,
/// and the CLI's Ctrl-C path. Safe any time; a child that already exited is a no-op.
#[derive(Clone, Debug, Default)]
pub struct CancelHandle(Arc<Mutex<Option<Child>>>);
impl CancelHandle {
pub fn kill(&self) {
if let Some(child) = self.0.lock().unwrap().as_mut() {
let _ = child.kill();
}
}
}
/// Spawn the session for this plan and supervise its stdout contract on a reader thread,
/// handing each event to `on_event` (which every front-end maps onto its own messages). The
/// final [`SessionEvent::Exited`] always arrives, so a caller can release its busy flag in
/// exactly one place.
/// `cancel` lets a front-end hold the abort handle BEFORE the child exists (a request-access
/// dialog arms its Cancel button first, then spawns); pass `None` to get a fresh one back.
pub fn spawn_session(
plan: &ConnectPlan,
cancel: Option<CancelHandle>,
on_event: impl FnMut(SessionEvent) + Send + 'static,
) -> Result<CancelHandle, String> {
let mut cmd = Command::new(session_binary());
cmd.args(plan.session_args())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit()); // the session's logs interleave with the front-end's
let mut child = cmd
.spawn()
.map_err(|e| format!("couldn't start {}: {e}", SESSION_BIN))?;
tracing::info!(
host = %plan.host.addr, port = plan.host.port,
profile = plan.profile.as_ref().map(|p| p.name.as_str()).unwrap_or("-"),
"session binary spawned"
);
let stdout = child.stdout.take().expect("piped stdout");
let slot = cancel.unwrap_or_default();
*slot.0.lock().unwrap() = Some(child);
let reader_slot = slot.clone();
let mut on_event = on_event;
std::thread::Builder::new()
.name("pf-session-io".into())
.spawn(move || {
use std::io::BufRead as _;
for line in std::io::BufReader::new(stdout).lines() {
let Ok(line) = line else { break };
if let Some(ev) = parse_session_line(&line) {
on_event(ev);
}
}
// EOF — reap (a cancel-killed child lands here too; -1 = died on a signal).
let code = reader_slot
.0
.lock()
.unwrap()
.take()
.and_then(|mut c| c.wait().ok())
.and_then(|s| s.code())
.unwrap_or(-1);
tracing::info!(code, "session binary exited");
on_event(SessionEvent::Exited(code));
})
.map_err(|e| format!("session reader thread: {e}"))?;
Ok(slot)
}
/// Become the session process (`--exec`): the CLI's gamescope-wrapper mode, where the launched
/// process identity must be the streaming one — a supervising parent would break focus and
/// lifecycle under gamescope. Never returns on success. Windows has no `exec`, so there this
/// runs the child to completion and exits with its code, which is the same contract minus the
/// pid.
pub fn exec_session(plan: &ConnectPlan) -> std::io::Error {
let mut cmd = Command::new(session_binary());
cmd.args(plan.session_args());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
cmd.exec()
}
#[cfg(not(unix))]
{
match cmd.status() {
Ok(s) => std::process::exit(s.code().unwrap_or(1)),
Err(e) => e,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deeplink;
fn host(name: &str, addr: &str, id: &str, fp: &str) -> KnownHost {
KnownHost {
name: name.into(),
addr: addr.into(),
port: 9777,
fp_hex: fp.into(),
paired: true,
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
id: Some(id.into()),
..Default::default()
}
}
/// The wait is Apple's `HostWaker` second for second: a packet at 0 and every 6 s after,
/// a presence check each second, 90 s of budget, and a park (not an error) at the end.
#[test]
fn wake_wait_matches_the_reference_cadence() {
let mut w = WakeWait::new();
// t=0: packet goes out before the first presence check.
let t = w.tick(false);
assert!(t.send_packet);
assert_eq!(t.seconds, 0);
assert_eq!(t.outcome, None);
// t=1..5 wait quietly, t=6 re-sends.
for s in 1..6 {
let t = w.tick(false);
assert!(!t.send_packet, "no packet at {s}s");
assert_eq!(t.seconds, s);
}
assert!(w.tick(false).send_packet); // t=6
assert_eq!(w.seconds(), 7);
// A host that answers ends the wait immediately, whatever second it is.
let mut w = WakeWait::new();
w.tick(false);
let t = w.tick(true);
assert_eq!(t.outcome, Some(WakeOutcome::Online));
// The budget: still waiting at 90 s of elapsed time, timed out on the tick after.
let mut w = WakeWait::new();
for _ in 0..WAKE_TIMEOUT_SECS {
assert_eq!(w.tick(false).outcome, None);
}
assert_eq!(w.seconds(), WAKE_TIMEOUT_SECS);
let t = w.tick(false);
assert_eq!(t.outcome, Some(WakeOutcome::TimedOut));
// A timed-out wait doesn't advance — it parks, and stays parked until asked again.
assert_eq!(w.tick(false).outcome, Some(WakeOutcome::TimedOut));
// …and a host that comes back while parked still wins ("Try again" isn't required).
assert_eq!(w.tick(true).outcome, Some(WakeOutcome::Online));
// Retry replays the identical wait.
w.restart();
assert_eq!(w.seconds(), 0);
assert!(w.tick(false).send_packet);
}
/// The argv every door spawns through. A one-off profile rides the flag; a host BINDING
/// deliberately doesn't — the session resolves it with the same helper, so passing it
/// would be a second source of truth.
#[test]
fn session_args_are_assembled_in_one_place() {
let h = host(
"Desk",
"192.168.1.50",
"11111111-2222-4333-8444-555555555555",
&"a".repeat(64),
);
let mut plan = ConnectPlan {
host: HostTarget::from(&h),
launch: Some("steam:570".into()),
profile: None,
profile_override: None,
settings: Settings {
fullscreen_on_stream: false,
..Default::default()
},
wake: true,
connect_timeout_secs: None,
tofu: false,
};
assert_eq!(
plan.session_args(),
vec![
"--connect",
"192.168.1.50:9777",
"--fp",
&"a".repeat(64),
"--launch",
"steam:570"
]
);
plan.profile_override = Some("aaaaaaaaaaaa".into());
plan.connect_timeout_secs = Some(185);
plan.settings.fullscreen_on_stream = true;
let args = plan.session_args();
assert!(args.windows(2).any(|w| w == ["--profile", "aaaaaaaaaaaa"]));
assert!(args.windows(2).any(|w| w == ["--connect-timeout", "185"]));
assert!(args.contains(&"--fullscreen".to_string()));
// "Connect with ▸ Default settings" on a bound host is an EMPTY override, which is
// not the same as no override — it has to survive as a flag.
plan.profile_override = Some(String::new());
let args = plan.session_args();
let i = args.iter().position(|a| a == "--profile").unwrap();
assert_eq!(args[i + 1], "");
}
/// The §3 security rules, in the layer that owns them: an unknown host is a prompt, a
/// contradicted pin is a refusal, an unhonorable profile is a refusal, and an ambiguous
/// reference is never guessed at.
#[test]
fn link_plans_refuse_rather_than_degrade() {
let fp = "a".repeat(64);
let known = KnownHosts {
hosts: vec![
host(
"Desk",
"192.168.1.50",
"11111111-2222-4333-8444-555555555555",
&fp,
),
host(
"Couch",
"192.168.1.60",
"22222222-3333-4444-8555-666666666666",
"",
),
host(
"Couch",
"192.168.1.61",
"33333333-4444-4555-8666-777777777777",
"",
),
],
};
// Pure inputs — the test never touches the config directory.
let catalog = ProfilesFile::default();
let base = Settings::default();
let plan =
|url: &str| plan_from_link(&deeplink::parse(url).unwrap(), &known, &catalog, &base);
// A known, pinned host with a matching (or absent) fp: a plain connect.
let out = plan("punktfunk://connect/Desk").unwrap();
match out {
PlanOutcome::Connect(p) => {
assert_eq!(p.host.addr, "192.168.1.50");
assert_eq!(p.profile_override, None);
assert!(p.host.fp_hex.is_some());
}
other => panic!("expected a connect, got {other:?}"),
}
// A lying/stale fingerprint never connects, and says which host it was about.
assert_eq!(
plan(&format!("punktfunk://connect/Desk?fp={}", "b".repeat(64))),
Err(PlanError::PinConflict {
host: "Desk".into()
})
);
// Ambiguity is reported, never resolved by picking the first.
assert_eq!(
plan("punktfunk://connect/Couch"),
Err(PlanError::AmbiguousHost("Couch".into()))
);
assert_eq!(
plan("punktfunk://connect/00000000-0000-4000-8000-000000000000"),
Err(PlanError::UnresolvableHost(
"00000000-0000-4000-8000-000000000000".into()
))
);
// A profile the catalog can't honor refuses BEFORE anything is dialled.
assert_eq!(
plan("punktfunk://connect/Desk?profile=NoSuchProfile"),
Err(PlanError::UnknownProfile("NoSuchProfile".into()))
);
// An unknown address is a confirmation sheet, never an auto-connect — and it carries
// the claimed name and the expected pin so the first connect is verified, not TOFU.
match plan(&format!(
"punktfunk://connect/10.0.0.9:7000?name=Studio&fp={fp}"
))
.unwrap()
{
PlanOutcome::ConfirmUnknown(u) => assert_eq!(
*u,
UnknownHost {
addr: "10.0.0.9".into(),
port: 7000,
name: Some("Studio".into()),
fp: Some(fp.clone()),
launch: None,
profile: None,
}
),
other => panic!("expected a confirmation, got {other:?}"),
}
// A saved host we never pinned is the same case: known ≠ trusted.
match plan("punktfunk://connect/192.168.1.60").unwrap() {
PlanOutcome::ConfirmUnknown(u) => {
assert_eq!(u.addr, "192.168.1.60");
assert_eq!(u.name.as_deref(), Some("Couch"));
}
other => panic!("expected a confirmation, got {other:?}"),
}
// Routes that parse but aren't implemented here are a notice, not a silent drop.
assert!(matches!(
plan("punktfunk://wake/Desk").unwrap(),
PlanOutcome::Unsupported(Route::Wake)
));
}
/// The stdout contract, parsed once for every front-end.
#[test]
fn session_contract_lines() {
assert_eq!(
parse_session_line(r#"{"ready":true}"#),
Some(SessionEvent::Ready)
);
assert_eq!(
parse_session_line(r#"{"error":"no route","trust_rejected":false}"#),
Some(SessionEvent::Error {
msg: "no route".into(),
trust_rejected: false
})
);
assert_eq!(
parse_session_line(r#"{"error":"pin","trust_rejected":true}"#),
Some(SessionEvent::Error {
msg: "pin".into(),
trust_rejected: true
})
);
assert_eq!(
parse_session_line(r#"{"ended":"Host ended the session"}"#),
Some(SessionEvent::Ended("Host ended the session".into()))
);
// Stats lines and stray output are never events.
assert_eq!(parse_session_line("stats: 1280×800@60 · 60 fps"), None);
assert_eq!(parse_session_line(""), None);
assert_eq!(parse_session_line(r#"{"other":1}"#), None);
}
}
+674
View File
@@ -0,0 +1,674 @@
//! Client settings profiles — named bundles of setting overrides applied on top of the
//! global [`Settings`] (design/client-settings-profiles.md §4).
//!
//! A profile overrides only the fields the user touched; everything else keeps following the
//! global defaults *live*, so fixing a global once fixes it everywhere. That is why an overlay
//! is sparse `Option`s rather than a snapshot copy, and why `Some(x)` is written on touch and
//! `None` on an explicit "reset to default" — never by diffing against the current global (a
//! `Some` equal to today's global is a legitimate *pin*: the profile keeps `x` when the global
//! later moves).
//!
//! The catalog lives in its own `client-profiles.json` beside the settings file, deliberately
//! NOT inside it: the settings file has five whole-file load-modify-save writers (two shells,
//! the console settings screen, the session's resize callback, Decky) with no merge, so a
//! profile written by one would be dropped by the next. This file is touched only by
//! profile-aware code, and written temp+rename.
//!
//! Which host uses which profile is a field on the host record ([`crate::trust::KnownHost`]),
//! not a map keyed here — see §4.1 of the design for why the catalog owns no host keys.
use crate::trust::{config_dir, write_atomic, Settings, StatsVerbosity};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
/// The catalog file's schema version. Bumped only for a breaking shape change — additive
/// fields ride the unknown-key preservation below.
pub const PROFILES_VERSION: u32 = 1;
/// Every profileable ("tier P") setting, as `Option<T>`: `None` = inherit the global value,
/// live. Tier-H fields (host properties like `clipboard_sync`) and tier-G ones (this
/// device's hardware/endpoints) are deliberately absent — see the design's §3 curation.
///
/// `extra` preserves keys this build doesn't know (a newer client's tier-P field): the
/// don't-clobber rule that already governs the GTK `ChoiceRow` pickers extends to the whole
/// overlay — opening and saving a profile on an older client must not erase what a newer one
/// stored.
#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SettingsOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_hz: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub match_window: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bitrate_kbps: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub render_scale: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codec: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hdr_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compositor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_channels: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mic_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub touch_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mouse_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub invert_scroll: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inhibit_shortcuts: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats_verbosity: Option<StatsVerbosity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fullscreen_on_stream: Option<bool>,
/// Overlay keys a newer client wrote and this one doesn't model — carried through a
/// load→save round-trip untouched.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl SettingsOverlay {
/// The one resolution seam: this overlay on top of `base`. Pure — no store reads, no
/// clock, no environment — so it is fully testable field by field.
pub fn apply(&self, base: &Settings) -> Settings {
let mut s = base.clone();
if let Some(v) = self.width {
s.width = v;
}
if let Some(v) = self.height {
s.height = v;
}
if let Some(v) = self.refresh_hz {
s.refresh_hz = v;
}
if let Some(v) = self.match_window {
s.match_window = v;
}
if let Some(v) = self.bitrate_kbps {
s.bitrate_kbps = v;
}
if let Some(v) = self.render_scale {
s.render_scale = v;
}
if let Some(v) = &self.codec {
s.codec = v.clone();
}
if let Some(v) = self.hdr_enabled {
s.hdr_enabled = v;
}
if let Some(v) = &self.compositor {
s.compositor = v.clone();
}
if let Some(v) = self.audio_channels {
s.audio_channels = v;
}
if let Some(v) = self.mic_enabled {
s.mic_enabled = v;
}
if let Some(v) = &self.touch_mode {
s.touch_mode = v.clone();
}
if let Some(v) = &self.mouse_mode {
s.mouse_mode = v.clone();
}
if let Some(v) = self.invert_scroll {
s.invert_scroll = v;
}
if let Some(v) = self.inhibit_shortcuts {
s.inhibit_shortcuts = v;
}
if let Some(v) = &self.gamepad {
s.gamepad = v.clone();
}
if let Some(v) = self.stats_verbosity {
// Through the setter so the legacy `show_stats` bool stays coherent for
// pre-tier binaries reading the same settings file.
s.set_stats_verbosity(v);
}
if let Some(v) = self.fullscreen_on_stream {
s.fullscreen_on_stream = v;
}
s
}
/// Record, as overrides, every tier-P field that differs between two settings snapshots.
///
/// This is for front-ends that commit PER CONTROL rather than per dialog (the WinUI shell
/// writes on every change; the GTK one writes once on close). They can't hand over a list
/// of touched fields, so they hand over "the effective settings before this control fired"
/// and "after": the only field that can differ is the one the user just touched.
///
/// That is not the diff-on-save this design rejects. The comparison is against the
/// EFFECTIVE settings — what the control was showing — not against the globals, so setting
/// a value back to what the global happens to be still records an override, which is the
/// pin the design asks for. It only ever adds overrides; removing one is an explicit
/// reset, which is a different operation.
pub fn absorb(&mut self, before: &Settings, after: &Settings) {
if after.width != before.width {
self.width = Some(after.width);
}
if after.height != before.height {
self.height = Some(after.height);
}
if after.refresh_hz != before.refresh_hz {
self.refresh_hz = Some(after.refresh_hz);
}
if after.match_window != before.match_window {
self.match_window = Some(after.match_window);
}
if after.bitrate_kbps != before.bitrate_kbps {
self.bitrate_kbps = Some(after.bitrate_kbps);
}
if after.render_scale != before.render_scale {
self.render_scale = Some(after.render_scale);
}
if after.codec != before.codec {
self.codec = Some(after.codec.clone());
}
if after.hdr_enabled != before.hdr_enabled {
self.hdr_enabled = Some(after.hdr_enabled);
}
if after.compositor != before.compositor {
self.compositor = Some(after.compositor.clone());
}
if after.audio_channels != before.audio_channels {
self.audio_channels = Some(after.audio_channels);
}
if after.mic_enabled != before.mic_enabled {
self.mic_enabled = Some(after.mic_enabled);
}
if after.touch_mode != before.touch_mode {
self.touch_mode = Some(after.touch_mode.clone());
}
if after.mouse_mode != before.mouse_mode {
self.mouse_mode = Some(after.mouse_mode.clone());
}
if after.invert_scroll != before.invert_scroll {
self.invert_scroll = Some(after.invert_scroll);
}
if after.inhibit_shortcuts != before.inhibit_shortcuts {
self.inhibit_shortcuts = Some(after.inhibit_shortcuts);
}
if after.gamepad != before.gamepad {
self.gamepad = Some(after.gamepad.clone());
}
if after.stats_verbosity() != before.stats_verbosity() {
self.stats_verbosity = Some(after.stats_verbosity());
}
if after.fullscreen_on_stream != before.fullscreen_on_stream {
self.fullscreen_on_stream = Some(after.fullscreen_on_stream);
}
}
/// Drop one override by its overlay field name, putting the row back to inheriting. The
/// names are the serialised ones, so a UI can carry them as plain strings; `resolution`
/// is the one alias, covering the width/height/match-window tri-state a single control
/// drives on every client. Returns false for a name this build doesn't know.
pub fn clear(&mut self, field: &str) -> bool {
match field {
"resolution" => {
self.width = None;
self.height = None;
self.match_window = None;
}
"width" => self.width = None,
"height" => self.height = None,
"refresh_hz" => self.refresh_hz = None,
"match_window" => self.match_window = None,
"bitrate_kbps" => self.bitrate_kbps = None,
"render_scale" => self.render_scale = None,
"codec" => self.codec = None,
"hdr_enabled" => self.hdr_enabled = None,
"compositor" => self.compositor = None,
"audio_channels" => self.audio_channels = None,
"mic_enabled" => self.mic_enabled = None,
"touch_mode" => self.touch_mode = None,
"mouse_mode" => self.mouse_mode = None,
"invert_scroll" => self.invert_scroll = None,
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
"gamepad" => self.gamepad = None,
"stats_verbosity" => self.stats_verbosity = None,
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
_ => return false,
}
true
}
/// True when the profile overrides nothing — "inherits everything", the state a freshly
/// created profile starts in. Unknown-key carry-through counts: a profile that only holds
/// a newer client's field is not empty.
pub fn is_empty(&self) -> bool {
*self == SettingsOverlay::default()
}
}
/// One named bundle of overrides. `id` is stable across renames — bindings, pins and deep
/// links all point at it, never at the name.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StreamProfile {
pub id: String,
/// User-facing and editable; unique case-insensitively (menus are ambiguous otherwise —
/// enforced by the editing UIs via [`ProfilesFile::name_taken`]).
pub name: String,
/// `#RRGGBB` chip color. The UI may ignore it; the schema reserves it (pinned cards use
/// it to tint their subtitle).
#[serde(skip_serializing_if = "Option::is_none")]
pub accent: Option<String>,
#[serde(default)]
pub overrides: SettingsOverlay,
/// Profile keys a newer client wrote — preserved across a load→save round-trip.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl StreamProfile {
/// A new, empty profile: inherits everything (the right creation default under
/// inherit-by-exception — "Duplicate" covers starting from another profile).
pub fn new(name: impl Into<String>) -> StreamProfile {
StreamProfile {
id: new_profile_id(),
name: name.into(),
accent: None,
overrides: SettingsOverlay::default(),
extra: BTreeMap::new(),
}
}
}
/// What a `profile=` / `--profile` reference resolved to. Ambiguity is reported rather than
/// guessed: a link or flag naming two profiles must refuse, not pick one (design
/// client-deep-links.md §8).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Resolution {
Found,
NotFound,
/// More than one profile carries this name (case-insensitively).
Ambiguous,
}
/// The profile catalog — client-wide, not per host: "Work" applied to three hosts is one
/// profile, and the per-host part is only the binding on the host record.
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct ProfilesFile {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub profiles: Vec<StreamProfile>,
}
impl ProfilesFile {
pub fn path() -> anyhow::Result<PathBuf> {
Ok(config_dir()?.join("client-profiles.json"))
}
/// The stored catalog, or an empty one — a missing or unreadable file is "no profiles",
/// never an error: nothing about streaming may hinge on this file existing.
pub fn load() -> ProfilesFile {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
/// Persist temp+rename, so a crash or a full disk mid-write leaves the previous catalog
/// intact instead of a truncated one.
pub fn save(&mut self) -> anyhow::Result<()> {
self.version = PROFILES_VERSION;
let p = Self::path()?;
std::fs::create_dir_all(p.parent().unwrap())?;
write_atomic(&p, &serde_json::to_vec_pretty(self)?)?;
Ok(())
}
pub fn find_by_id(&self, id: &str) -> Option<&StreamProfile> {
self.profiles.iter().find(|p| p.id == id)
}
/// Resolve a reference the way every surface must: exact id first, then a unique
/// case-insensitive name. Ambiguous names resolve to [`Resolution::Ambiguous`], never to
/// the first match.
pub fn resolve(&self, reference: &str) -> (Option<&StreamProfile>, Resolution) {
if let Some(p) = self.find_by_id(reference) {
return (Some(p), Resolution::Found);
}
let mut hits = self
.profiles
.iter()
.filter(|p| p.name.eq_ignore_ascii_case(reference));
match (hits.next(), hits.next()) {
(Some(p), None) => (Some(p), Resolution::Found),
(Some(_), Some(_)) => (None, Resolution::Ambiguous),
_ => (None, Resolution::NotFound),
}
}
/// Is this name already used (case-insensitively) by a *different* profile? The
/// create/rename guard — `except` is the profile being renamed, so renaming "Work" to
/// "work" is allowed.
pub fn name_taken(&self, name: &str, except: Option<&str>) -> bool {
self.profiles
.iter()
.any(|p| p.name.eq_ignore_ascii_case(name) && Some(p.id.as_str()) != except)
}
}
/// 12 lowercase hex chars — the `library::new_id` shape, minted from the OS RNG (no uuid
/// dependency, no collision in any realistic catalog).
pub fn new_profile_id() -> String {
let b: [u8; 6] = rand::random();
hex_lower(&b)
}
/// A random UUID-v4 in the canonical 8-4-4-4-12 form — the stable host-record identity
/// (design §4.5). Matches the shape Apple's `StoredHost.id` already has, so a deep link's
/// host-ref grammar is one format on every platform.
pub fn new_record_uuid() -> String {
let mut b: [u8; 16] = rand::random();
b[6] = (b[6] & 0x0f) | 0x40; // version 4
b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant
let h = hex_lower(&b);
format!(
"{}-{}-{}-{}-{}",
&h[0..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..32]
)
}
fn hex_lower(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// The overlay applies field by field: a `Some` wins, a `None` keeps the base's live
/// value — including values that happen to equal the base (an explicit pin).
#[test]
fn overlay_applies_only_what_it_overrides() {
let base = Settings {
width: 1920,
height: 1080,
bitrate_kbps: 20000,
codec: "hevc".into(),
..Default::default()
};
let empty = SettingsOverlay::default();
let out = empty.apply(&base);
assert_eq!((out.width, out.height), (1920, 1080));
assert_eq!(out.bitrate_kbps, 20000);
assert_eq!(out.codec, "hevc");
assert!(empty.is_empty());
let overlay = SettingsOverlay {
width: Some(3840),
height: Some(2160),
refresh_hz: Some(120),
bitrate_kbps: Some(80000),
render_scale: Some(1.5),
codec: Some("av1".into()),
hdr_enabled: Some(false),
compositor: Some("gamescope".into()),
audio_channels: Some(6),
mic_enabled: Some(true),
touch_mode: Some("pointer".into()),
mouse_mode: Some("desktop".into()),
invert_scroll: Some(true),
inhibit_shortcuts: Some(false),
gamepad: Some("dualsense".into()),
match_window: Some(true),
fullscreen_on_stream: Some(false),
stats_verbosity: Some(StatsVerbosity::Detailed),
..Default::default()
};
assert!(!overlay.is_empty());
let out = overlay.apply(&base);
assert_eq!((out.width, out.height, out.refresh_hz), (3840, 2160, 120));
assert_eq!(out.bitrate_kbps, 80000);
assert_eq!(out.render_scale, 1.5);
assert_eq!(out.codec, "av1");
assert!(!out.hdr_enabled);
assert_eq!(out.compositor, "gamescope");
assert_eq!(out.audio_channels, 6);
assert!(out.mic_enabled);
assert_eq!(out.touch_mode, "pointer");
assert_eq!(out.mouse_mode, "desktop");
assert!(out.invert_scroll);
assert!(!out.inhibit_shortcuts);
assert_eq!(out.gamepad, "dualsense");
assert!(out.match_window);
assert!(!out.fullscreen_on_stream);
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
// The tier goes through the setter, so the legacy bool a pre-tier binary reads
// stays coherent with it.
assert!(out.show_stats);
// Tier-G/H fields are not in the overlay at all — the device's decoder pick, its
// audio endpoints and the per-host clipboard decision survive any profile.
assert_eq!(out.decoder, base.decoder);
assert_eq!(out.speaker_device, base.speaker_device);
// An overlay that only carries a value equal to the base is still an override: the
// profile pins it, so a later global change doesn't move it.
let pin = SettingsOverlay {
bitrate_kbps: Some(20000),
..Default::default()
};
assert!(!pin.is_empty());
let mut moved = base.clone();
moved.bitrate_kbps = 50000;
assert_eq!(pin.apply(&moved).bitrate_kbps, 20000);
}
/// `absorb` records exactly the field a control changed, compares against the EFFECTIVE
/// settings (so a value equal to the global is still a pin), and never removes anything.
#[test]
fn absorb_records_the_touched_field_only() {
let base = Settings {
bitrate_kbps: 20000,
codec: "hevc".into(),
..Default::default()
};
let mut o = SettingsOverlay::default();
// One control fires: before = what it was showing, after = what the user picked.
let before = o.apply(&base);
let mut after = before.clone();
after.codec = "av1".into();
o.absorb(&before, &after);
assert_eq!(o.codec.as_deref(), Some("av1"));
assert_eq!(o.bitrate_kbps, None, "nothing else may be recorded");
// Setting it BACK to the global's value is still an override — the pin case. This is
// what makes absorb different from diffing against the globals at save time.
let before = o.apply(&base);
let mut after = before.clone();
after.codec = "hevc".into();
o.absorb(&before, &after);
assert_eq!(o.codec.as_deref(), Some("hevc"));
let mut moved = base.clone();
moved.codec = "h264".into();
assert_eq!(o.apply(&moved).codec, "hevc");
// The stats tier goes through the resolver, not the legacy bool.
let before = o.apply(&base);
let mut after = before.clone();
after.set_stats_verbosity(StatsVerbosity::Detailed);
o.absorb(&before, &after);
assert_eq!(o.stats_verbosity, Some(StatsVerbosity::Detailed));
// Identical snapshots record nothing.
let before = o.apply(&base);
let mut o2 = o.clone();
o2.absorb(&before, &before);
assert_eq!(o2, o);
}
/// `clear` is the explicit way back to inheriting, including the resolution tri-state.
#[test]
fn clear_drops_one_override() {
let mut o = SettingsOverlay {
width: Some(3840),
height: Some(2160),
match_window: Some(false),
codec: Some("av1".into()),
..Default::default()
};
assert!(o.clear("codec"));
assert_eq!(o.codec, None);
assert!(o.clear("resolution"));
assert_eq!((o.width, o.height, o.match_window), (None, None, None));
assert!(o.is_empty());
assert!(!o.clear("no_such_field"));
}
/// Stats verbosity Off must survive `apply` — it is a legitimate override, and going
/// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too.
#[test]
fn overlay_can_turn_the_stats_overlay_off() {
let mut base = Settings::default();
base.set_stats_verbosity(StatsVerbosity::Detailed);
let overlay = SettingsOverlay {
stats_verbosity: Some(StatsVerbosity::Off),
..Default::default()
};
let out = overlay.apply(&base);
assert_eq!(out.stats_verbosity(), StatsVerbosity::Off);
assert!(!out.show_stats);
}
/// A catalog round-trips, and values this build can't represent survive it: an unknown
/// codec string (a newer client's option) stays as written, and an unknown overlay KEY
/// is carried through untouched rather than erased — the don't-clobber rule.
#[test]
fn catalog_round_trips_and_preserves_what_it_cannot_represent() {
// `r##` — the accent value below contains a `"#` pair that would close an `r#` literal.
let stored = r##"{
"version": 1,
"profiles": [
{
"id": "a1b2c3d4e5f6",
"name": "Game",
"accent": "#ff8800",
"overrides": {
"width": 3840, "height": 2160, "refresh_hz": 120,
"codec": "vvc-from-the-future",
"some_new_axis": {"nested": true},
"stats_verbosity": "compact"
},
"future_profile_key": 7
},
{ "id": "0f0f0f0f0f0f", "name": "Work" }
]
}"##;
let file: ProfilesFile = serde_json::from_str(stored).unwrap();
assert_eq!(file.profiles.len(), 2);
let game = file.find_by_id("a1b2c3d4e5f6").unwrap();
assert_eq!(game.accent.as_deref(), Some("#ff8800"));
assert_eq!(game.overrides.codec.as_deref(), Some("vvc-from-the-future"));
assert_eq!(
game.overrides.stats_verbosity,
Some(StatsVerbosity::Compact)
);
// A profile with no `overrides` key at all is the empty (inherit-everything) one.
assert!(file
.find_by_id("0f0f0f0f0f0f")
.unwrap()
.overrides
.is_empty());
let text = serde_json::to_string(&file).unwrap();
assert!(text.contains("vvc-from-the-future"));
assert!(text.contains("some_new_axis"));
assert!(text.contains("future_profile_key"));
// Absent overrides serialize away entirely — the file stays readable.
assert!(!text.contains("null"));
let round: ProfilesFile = serde_json::from_str(&text).unwrap();
let game = round.find_by_id("a1b2c3d4e5f6").unwrap();
assert_eq!(game.overrides.width, Some(3840));
assert_eq!(game.overrides.extra.len(), 1);
assert_eq!(game.extra.len(), 1);
// The unknown value still applies: the session hands the string to the host, which
// is the component that decides what it can encode.
let applied = game.overrides.apply(&Settings::default());
assert_eq!(applied.codec, "vvc-from-the-future");
}
/// Resolution order: id first, then a unique case-insensitive name; two profiles sharing
/// a name resolve to Ambiguous (the caller refuses) rather than to whichever came first.
#[test]
fn resolve_prefers_ids_and_refuses_ambiguity() {
let file = ProfilesFile {
version: 1,
profiles: vec![
StreamProfile {
id: "111111111111".into(),
name: "Work".into(),
..StreamProfile::new("")
},
StreamProfile {
id: "222222222222".into(),
name: "work".into(),
..StreamProfile::new("")
},
StreamProfile {
id: "333333333333".into(),
name: "Game".into(),
..StreamProfile::new("")
},
],
};
assert_eq!(file.resolve("111111111111").1, Resolution::Found);
assert_eq!(file.resolve("Work").1, Resolution::Ambiguous);
assert_eq!(file.resolve("game").1, Resolution::Found);
assert_eq!(file.resolve("GAME").0.unwrap().id, "333333333333");
assert_eq!(file.resolve("nope").1, Resolution::NotFound);
assert_eq!(file.resolve("").1, Resolution::NotFound);
// Duplicate-name guard, and the rename-in-place exemption.
assert!(file.name_taken("GAME", None));
assert!(!file.name_taken("GAME", Some("333333333333")));
assert!(file.name_taken("GAME", Some("111111111111")));
assert!(!file.name_taken("Travel", None));
}
/// Minted ids have the documented shapes and don't repeat.
#[test]
fn minted_ids_are_well_formed() {
let a = new_profile_id();
assert_eq!(a.len(), 12);
assert!(a
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
assert_ne!(a, new_profile_id());
let u = new_record_uuid();
assert_eq!(u.len(), 36);
let parts: Vec<&str> = u.split('-').collect();
assert_eq!(
parts.iter().map(|p| p.len()).collect::<Vec<_>>(),
vec![8, 4, 4, 4, 12]
);
assert!(u.chars().all(|c| c == '-' || c.is_ascii_hexdigit()));
assert_eq!(parts[2].as_bytes()[0], b'4'); // version nibble
assert!(matches!(parts[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'));
assert_ne!(u, new_record_uuid());
}
}
+5
View File
@@ -73,6 +73,11 @@ pub struct SessionParams {
/// re-requests a keyframe. Decode itself succeeds in that state, so nothing else
/// would recover — without this the stream stays black.
pub force_software: Arc<AtomicBool>,
/// Name of the settings profile these params were resolved with (`None` = the global
/// defaults). Display only — every value it influenced is already baked into the fields
/// above; it rides along so the stats overlay can answer "which profile am I on?" without
/// re-reading any store (design/client-settings-profiles.md §5.2).
pub profile: Option<String>,
}
/// The session pump's share of the unified stats window (design/stats-unification.md):
+416 -9
View File
@@ -9,11 +9,12 @@
//! shell stays the settings file's only writer (the session only reads). Pre-unification
//! shell files (≤ 0.8.4: `show_hud`, `engine`) still load — see the migration test below.
use crate::profiles::{ProfilesFile, Resolution, StreamProfile};
use anyhow::{anyhow, Context, Result};
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::endpoint;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
pub fn config_dir() -> Result<PathBuf> {
#[cfg(windows)]
@@ -89,6 +90,25 @@ fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) {
let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600));
}
/// Write a config file the safe way: a sibling temp file, then a rename over the target. A
/// plain `fs::write` truncates first, so a crash, a full disk or a power cut between truncate
/// and the last byte leaves an empty/half file — and these stores are what a client needs to
/// find its hosts at all. Rename is atomic within a directory on both Unix and Windows
/// (`MoveFileEx` with replace), so a reader ever sees the old file or the new one, never a
/// torn one. Same discipline as the host's `session_settings.rs`.
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, bytes)?;
match std::fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
// Don't leave the temp behind to confuse the next writer (or a backup tool).
let _ = std::fs::remove_file(&tmp);
Err(e)
}
}
}
pub fn hex(fp: &[u8; 32]) -> String {
fp.iter().map(|b| format!("{b:02x}")).collect()
}
@@ -130,6 +150,63 @@ pub struct KnownHost {
/// also advertise `HOST_CAP_CLIPBOARD` and have its own policy enabled.
#[serde(default)]
pub clipboard_sync: bool,
/// This host's default settings profile (design/client-settings-profiles.md §4.1) — the
/// one a plain click uses. `None`, or an id whose profile was deleted, means the global
/// defaults, i.e. exactly today's behavior; a dangling binding never errors and never
/// blocks a connect.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_id: Option<String>,
/// Profiles pinned as extra cards for this host (design §5.2a); order = card order.
/// Presentation only — NOT the default (that's `profile_id`) — and duplicates/dangling
/// ids are dropped when the list is resolved against the catalog.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pinned_profiles: Vec<String>,
/// Stable record identity (design §4.5): minted lazily for records that predate it, never
/// changed afterwards, so a deep link or a future cross-reference has something to point
/// at that survives a rename or a new DHCP lease. **No lookup in this crate is keyed by
/// it** — `fp_hex`/`addr:port` stay the lookup keys; this is groundwork.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
}
impl Default for KnownHost {
/// A blank record with a fresh stable id — the base every construction site builds on
/// (`KnownHost { name, addr, port, ..Default::default() }`), so adding a field here can't
/// silently produce records that lack it. That is not hypothetical: `clipboard_sync`
/// survives today only because [`KnownHosts::upsert`] happens to skip it.
fn default() -> KnownHost {
KnownHost {
name: String::new(),
addr: String::new(),
port: 9777,
fp_hex: String::new(),
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
profile_id: None,
pinned_profiles: Vec::new(),
id: Some(crate::profiles::new_record_uuid()),
}
}
}
impl KnownHost {
/// This host's pinned profiles that still exist, in card order, without duplicates — what
/// a grid renders. Dangling pins (the profile was deleted) simply disappear, per design
/// §5.2a: a pin is presentation state, never a reason to show an error.
pub fn resolved_pins<'a>(&self, catalog: &'a ProfilesFile) -> Vec<&'a StreamProfile> {
let mut out: Vec<&StreamProfile> = Vec::new();
for id in &self.pinned_profiles {
if out.iter().any(|p| p.id == *id) {
continue;
}
if let Some(p) = catalog.find_by_id(id) {
out.push(p);
}
}
out
}
}
#[derive(Default, Serialize, Deserialize)]
@@ -142,18 +219,42 @@ impl KnownHosts {
Ok(config_dir()?.join("client-known-hosts.json"))
}
/// The store, with any pre-[`KnownHost::id`] records given one. The mint is written back
/// best-effort right here rather than "on the next save" so the id a caller sees is the
/// id that is on disk — an identity that changed every load would be worse than none.
/// A read-only config dir just keeps re-minting in memory, which harms nothing: no lookup
/// is keyed by the id yet (design §4.5).
pub fn load() -> KnownHosts {
Self::path()
let mut k: KnownHosts = Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
.unwrap_or_default();
if k.mint_missing_ids() {
let _ = k.save();
}
k
}
/// Give every record still missing one a stable id; returns true if anything changed
/// (i.e. whether this needs persisting). Idempotent — a store that has been through it
/// once is left byte-identical.
pub fn mint_missing_ids(&mut self) -> bool {
let mut minted = false;
for h in &mut self.hosts {
if h.id.as_deref().is_none_or(str::is_empty) {
h.id = Some(crate::profiles::new_record_uuid());
minted = true;
}
}
minted
}
pub fn save(&self) -> Result<()> {
let p = Self::path()?;
std::fs::create_dir_all(p.parent().unwrap())?;
std::fs::write(&p, serde_json::to_string_pretty(self)?)?;
// Temp+rename: losing this file to a torn write costs the user every pairing.
write_atomic(&p, serde_json::to_string_pretty(self)?.as_bytes())?;
Ok(())
}
@@ -189,6 +290,24 @@ impl KnownHosts {
if !entry.mac.is_empty() {
h.mac = entry.mac;
}
// Everything below is state the user set ON this record, which a refresh (a
// reconnect, a re-pair, a rediscovery) never carries and therefore must never
// clear: the per-host clipboard decision — which survives today only because this
// function happens not to mention it — plus the profile binding, its pinned
// cards, and the stable id. Only an upsert that actually carries a value moves
// one of them.
if entry.clipboard_sync {
h.clipboard_sync = true;
}
if entry.profile_id.is_some() {
h.profile_id = entry.profile_id;
}
if !entry.pinned_profiles.is_empty() {
h.pinned_profiles = entry.pinned_profiles;
}
if h.id.as_deref().is_none_or(str::is_empty) {
h.id = entry.id;
}
} else {
self.hosts.push(entry);
}
@@ -199,15 +318,17 @@ impl KnownHosts {
/// ceremony, delegated approval, headless pairing) ends in.
pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: bool) {
let mut known = KnownHosts::load();
// `..Default::default()` deliberately: this builds a record from a trust decision only,
// so every user-set field (clipboard, profile binding, pins) must arrive as "not carried"
// — `upsert` then leaves an existing host's own settings alone. A hand-written literal
// here is how those fields would get silently reset on the next re-pair.
known.upsert(KnownHost {
name: name.to_string(),
addr: addr.to_string(),
port,
fp_hex: fp_hex.to_string(),
paired,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
..Default::default()
});
let _ = known.save();
}
@@ -539,7 +660,7 @@ impl MouseMode {
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
/// Stream mode; `0` = the native size/refresh of the monitor the window is on,
@@ -769,15 +890,82 @@ impl Settings {
.unwrap_or_default()
}
/// Fire-and-forget by design (a failed settings write must never take a stream down),
/// but temp+rename: this file has five whole-file writers, and a torn one loads as
/// `Default` — i.e. silently resets every setting the user has.
pub fn save(&self) {
let Ok(p) = Self::path() else { return };
let _ = std::fs::create_dir_all(p.parent().unwrap());
if let Ok(s) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(&p, s);
let _ = write_atomic(&p, s.as_bytes());
}
}
}
/// The one settings resolver every front-end and the session binary go through
/// (design/client-settings-profiles.md §4.4/§4.6): global defaults, with the profile this
/// connect uses overlaid.
///
/// ```text
/// effective = overlay(profile).apply(global)
/// profile = one-off override ?? host binding ?? none
/// ```
///
/// `one_off` is the "Connect with ▸ X" / `--profile` / `profile=` pick, by id or unique name;
/// `Some("")` forces the global defaults on a bound host. It never rebinds anything — the
/// host's default is changed only by an explicit act in the UI.
///
/// Nothing here fails: an unknown one-off falls back to the *defaults* (not to the host's
/// binding — a connect that was explicitly asked for "Work" must not silently run "Game"),
/// and a dangling binding resolves as none, exactly today's behavior. The host is looked up
/// by `addr:port`, the same match the per-host clipboard decision has always used —
/// consistency with the shipped precedent beats purity here (§4.6).
pub fn effective_settings(
addr: &str,
port: u16,
one_off: Option<&str>,
) -> (Settings, Option<StreamProfile>) {
let base = Settings::load();
let catalog = ProfilesFile::load();
let bound = KnownHosts::load()
.hosts
.iter()
.find(|h| h.addr == addr && h.port == port)
.and_then(|h| h.profile_id.clone());
match resolve_profile(&catalog, bound.as_deref(), one_off) {
Some(p) => (p.overrides.apply(&base), Some(p)),
None => (base, None),
}
}
/// The profile half of [`effective_settings`], split out so the precedence rules are testable
/// without touching the config directory: one-off pick ?? host binding ?? none.
fn resolve_profile(
catalog: &ProfilesFile,
bound: Option<&str>,
one_off: Option<&str>,
) -> Option<StreamProfile> {
match one_off {
// `--profile ""` — "Connect with ▸ Default settings" on a bound host.
Some("") => None,
Some(reference) => match catalog.resolve(reference) {
(Some(p), _) => Some(p.clone()),
(_, res) => {
tracing::warn!(
profile = %reference,
ambiguous = res == Resolution::Ambiguous,
"no such settings profile — streaming with the default settings"
);
None
}
},
// A binding is an id, never a name: it was written by a picker, and resolving it by
// name would let renaming another profile hijack it. Dangling → the defaults.
None => bound.and_then(|id| catalog.find_by_id(id).cloned()),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -882,4 +1070,223 @@ mod tests {
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
assert!(parse_hex32(&h.fp_hex).is_some());
}
/// A pre-profiles known-hosts file loads unchanged — no binding, no pins — and its
/// records serialize back without the new keys, so an older client reading the same file
/// sees exactly what it wrote. The id is minted only when `load()` runs (the migration
/// step), not by deserialization.
#[test]
fn known_hosts_migration_is_a_no_op_on_a_pre_profiles_store() {
let old = r#"{"hosts":[{
"name": "Gaming PC", "addr": "192.168.1.50", "port": 9777,
"fp_hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"paired": true, "clipboard_sync": true
}]}"#;
let mut k: KnownHosts = serde_json::from_str(old).unwrap();
let h = &k.hosts[0];
assert_eq!(h.profile_id, None);
assert!(h.pinned_profiles.is_empty());
assert_eq!(h.id, None);
assert!(h.clipboard_sync);
let text = serde_json::to_string(&k).unwrap();
assert!(!text.contains("profile_id"));
assert!(!text.contains("pinned_profiles"));
assert!(!text.contains("\"id\""));
// Minting is idempotent: the second pass reports nothing to persist and leaves the
// id it handed out alone.
assert!(k.mint_missing_ids());
let minted = k.hosts[0].id.clone().unwrap();
assert_eq!(minted.len(), 36);
assert!(!k.mint_missing_ids());
assert_eq!(k.hosts[0].id.as_deref(), Some(minted.as_str()));
// An empty-string id (a hand-edited store) counts as missing, not as an identity.
k.hosts[0].id = Some(String::new());
assert!(k.mint_missing_ids());
assert_ne!(k.hosts[0].id.as_deref(), Some(""));
}
/// `upsert` refreshes what a reconnect actually knows and preserves what the user set:
/// the profile binding, the pinned cards, the clipboard decision and the stable id all
/// survive a trust-decision upsert that carries none of them (the bug `clipboard_sync`
/// only ever avoided by accident).
#[test]
fn upsert_preserves_user_set_host_state() {
let fp = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let mut k = KnownHosts {
hosts: vec![KnownHost {
name: "Desk".into(),
addr: "192.168.1.50".into(),
port: 9777,
fp_hex: fp.into(),
paired: true,
last_used: Some(1000),
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
clipboard_sync: true,
profile_id: Some("aaaaaaaaaaaa".into()),
pinned_profiles: vec!["bbbbbbbbbbbb".into()],
id: Some("11111111-2222-4333-8444-555555555555".into()),
}],
};
// What `persist_host` builds: a trust decision, nothing else.
k.upsert(KnownHost {
name: "Desk".into(),
addr: "192.168.1.51".into(), // new lease
port: 9777,
fp_hex: fp.into(),
paired: false, // must not demote
..Default::default()
});
let h = &k.hosts[0];
assert_eq!(k.hosts.len(), 1);
assert_eq!(h.addr, "192.168.1.51");
assert!(h.paired);
assert_eq!(h.last_used, Some(1000));
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
assert!(h.clipboard_sync);
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]);
assert_eq!(
h.id.as_deref(),
Some("11111111-2222-4333-8444-555555555555")
);
// A carried value does move the binding (that is how the UI rebinds through upsert).
k.upsert(KnownHost {
fp_hex: fp.into(),
profile_id: Some("cccccccccccc".into()),
pinned_profiles: vec!["dddddddddddd".into()],
..Default::default()
});
assert_eq!(k.hosts[0].profile_id.as_deref(), Some("cccccccccccc"));
assert_eq!(k.hosts[0].pinned_profiles, vec!["dddddddddddd".to_string()]);
}
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
/// presentation state, so a dangling one is never an error surface.
#[test]
fn resolved_pins_drop_duplicates_and_dangling_ids() {
use crate::profiles::{ProfilesFile, StreamProfile};
let catalog = ProfilesFile {
version: 1,
profiles: vec![
StreamProfile {
id: "aaaaaaaaaaaa".into(),
name: "Work".into(),
..StreamProfile::new("")
},
StreamProfile {
id: "bbbbbbbbbbbb".into(),
name: "Game".into(),
..StreamProfile::new("")
},
],
};
let h = KnownHost {
pinned_profiles: vec![
"bbbbbbbbbbbb".into(),
"deleted00000".into(),
"bbbbbbbbbbbb".into(),
"aaaaaaaaaaaa".into(),
],
..Default::default()
};
let names: Vec<&str> = h
.resolved_pins(&catalog)
.iter()
.map(|p| p.name.as_str())
.collect();
assert_eq!(names, vec!["Game", "Work"]);
assert!(KnownHost::default().resolved_pins(&catalog).is_empty());
}
/// The connect-time precedence: a one-off pick beats the host's binding, `""` forces the
/// defaults, a dangling binding resolves as none, and a one-off that can't be honored
/// falls back to the DEFAULTS rather than to the host's own profile — "connect with Work"
/// must never quietly run "Game".
#[test]
fn profile_resolution_precedence() {
use crate::profiles::{ProfilesFile, StreamProfile};
let catalog = ProfilesFile {
version: 1,
profiles: vec![
StreamProfile {
id: "aaaaaaaaaaaa".into(),
name: "Game".into(),
..StreamProfile::new("")
},
StreamProfile {
id: "bbbbbbbbbbbb".into(),
name: "Work".into(),
..StreamProfile::new("")
},
StreamProfile {
id: "cccccccccccc".into(),
name: "work".into(),
..StreamProfile::new("")
},
],
};
let name_of = |p: Option<StreamProfile>| p.map(|p| p.name);
// No binding, no pick: today's behavior.
assert_eq!(resolve_profile(&catalog, None, None), None);
// The binding drives a plain connect…
assert_eq!(
name_of(resolve_profile(&catalog, Some("aaaaaaaaaaaa"), None)),
Some("Game".into())
);
// …a one-off overrides it, by id or by unique name…
assert_eq!(
name_of(resolve_profile(
&catalog,
Some("aaaaaaaaaaaa"),
Some("bbbbbbbbbbbb")
)),
Some("Work".into())
);
assert_eq!(
name_of(resolve_profile(&catalog, None, Some("GAME"))),
Some("Game".into())
);
// …and `""` forces the defaults on a bound host.
assert_eq!(
resolve_profile(&catalog, Some("aaaaaaaaaaaa"), Some("")),
None
);
// A deleted binding is not an error, it is "no profile".
assert_eq!(resolve_profile(&catalog, Some("deleted00000"), None), None);
// Unknown and ambiguous one-offs fall back to the defaults, NOT to the binding.
assert_eq!(
resolve_profile(&catalog, Some("aaaaaaaaaaaa"), Some("nope")),
None
);
assert_eq!(
resolve_profile(&catalog, Some("aaaaaaaaaaaa"), Some("work")),
None
);
// A binding resolves by id only — a profile NAMED like the bound id doesn't hijack it.
assert_eq!(resolve_profile(&catalog, Some("Game"), None), None);
}
/// The atomic write replaces the target in one step and leaves no temp behind — the
/// discipline all three client stores now share.
#[test]
fn write_atomic_replaces_and_cleans_up() {
let dir = std::env::temp_dir().join(format!(
"pf-client-core-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("store.json");
write_atomic(&p, b"{\"a\":1}").unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}");
write_atomic(&p, b"{\"a\":2}").unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":2}");
assert!(!p.with_extension("json.tmp").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+22 -17
View File
@@ -37,6 +37,7 @@
//! LUID) so the shared textures never cross GPUs on a multi-adapter box.
use crate::video::ColorDesc;
use crate::video_libav::AvBuffer;
use anyhow::{anyhow, bail, Context as _, Result};
use ffmpeg_next as ffmpeg;
use std::ffi::c_void;
@@ -225,11 +226,13 @@ fn decode_profile_supported(device: &ID3D11Device, codec_id: ffmpeg::codec::Id)
unsafe fn d3d11va_decode_supported(hw_device: *mut ffmpeg::ffi::AVBufferRef) -> bool {
use ffmpeg::ffi::*;
unsafe {
let frames_ref = av_hwframe_ctx_alloc(hw_device);
if frames_ref.is_null() {
// Scope-bound: this probe owns the frames ctx for the length of the check and the drop
// below releases it on BOTH exits, instead of the early return relying on the null case and
// the success path unref'ing by hand.
let Some(frames_ref) = AvBuffer::from_raw(av_hwframe_ctx_alloc(hw_device)) else {
return false;
}
let frames = (*frames_ref).data as *mut AVHWFramesContext;
};
let frames = (*frames_ref.as_ptr()).data as *mut AVHWFramesContext;
(*frames).format = AVPixelFormat::AV_PIX_FMT_D3D11;
(*frames).sw_format = AVPixelFormat::AV_PIX_FMT_NV12;
(*frames).width = 1920;
@@ -237,9 +240,7 @@ unsafe fn d3d11va_decode_supported(hw_device: *mut ffmpeg::ffi::AVBufferRef) ->
(*frames).initial_pool_size = DECODE_POOL_SIZE;
let fhw = (*frames).hwctx as *mut AVD3D11VAFramesContext;
(*fhw).bind_flags = BIND_DECODER;
let r = av_hwframe_ctx_init(frames_ref);
let mut fr = frames_ref;
av_buffer_unref(&mut fr);
let r = av_hwframe_ctx_init(frames_ref.as_ptr());
r >= 0
}
}
@@ -467,7 +468,14 @@ impl SharedRing {
pub(crate) struct D3d11vaDecoder {
ctx: *mut ffmpeg::ffi::AVCodecContext,
hw_device: *mut ffmpeg::ffi::AVBufferRef,
/// The D3D11VA hwdevice, owned. Nothing reads this field after construction — the codec context
/// took its own ref via `av_buffer_ref` — it exists so the device outlives the decoder and is
/// unref'd exactly once when it drops. Declared after `ctx` so it still releases AFTER the
/// `Drop` below frees packet/frame/context, which is the order the hand-written unref had.
/// `dead_code` is answered here rather than by removing the field (that would free the device
/// early) or by an underscore name (that would hide what it is).
#[allow(dead_code)]
hw_device: AvBuffer,
packet: *mut ffmpeg::ffi::AVPacket,
frame: *mut ffmpeg::ffi::AVFrame,
device: ID3D11Device,
@@ -525,20 +533,19 @@ impl D3d11vaDecoder {
ffi::av_buffer_unref(&mut hw);
bail!("av_hwdevice_ctx_init: {}", ffmpeg::Error::from(r));
}
// Owned from here: every `bail!` below drops it, so none of them unref by hand.
let hw_device = AvBuffer::from_raw(hw_device)
.context("av_hwdevice_ctx_alloc(D3D11VA) gave no device")?;
// Up-front viability probe (see `d3d11va_decode_supported`).
if !d3d11va_decode_supported(hw_device) {
let mut hw = hw_device;
ffi::av_buffer_unref(&mut hw);
if !d3d11va_decode_supported(hw_device.as_ptr()) {
bail!("GPU can't create the D3D11VA decode surface pool");
}
let codec = ffi::avcodec_find_decoder(codec_id.into());
if codec.is_null() {
let mut hw = hw_device;
ffi::av_buffer_unref(&mut hw);
bail!("no {codec_id:?} decoder");
}
let ctx = ffi::avcodec_alloc_context3(codec);
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
(*ctx).get_format = Some(get_format_d3d11);
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
(*ctx).thread_count = 1; // hwaccel: threads only add latency
@@ -549,8 +556,6 @@ impl D3d11vaDecoder {
if r < 0 {
let mut ctx = ctx;
ffi::avcodec_free_context(&mut ctx);
let mut hw = hw_device;
ffi::av_buffer_unref(&mut hw);
bail!("avcodec_open2 (D3D11VA): {}", ffmpeg::Error::from(r));
}
Ok(D3d11vaDecoder {
@@ -789,7 +794,7 @@ impl Drop for D3d11vaDecoder {
ffi::av_packet_free(&mut self.packet);
ffi::av_frame_free(&mut self.frame);
ffi::avcodec_free_context(&mut self.ctx);
ffi::av_buffer_unref(&mut self.hw_device);
// `hw_device` is an `AvBuffer` and unrefs itself when the field drops, right after this.
}
// `ring` drops after the codec: no decode can be in flight past avcodec_free_context,
// and the slots' CloseHandle only closes OUR handle — a presenter-side import that is
+59
View File
@@ -0,0 +1,59 @@
//! Shared libav ownership helpers for the hardware decoders (`video_vaapi`, `video_vulkan`,
//! `video_d3d11`).
//!
//! The host has its own copy of this in `pf-encode`'s `enc/libav.rs`. The two crates do not depend
//! on each other — host encode and client decode share no code path — and the client's copy needs
//! something the host's does not ([`AvBuffer::into_raw`], for the decoder contexts that take
//! ownership of our ref), so a shared crate would have to carry an ffmpeg dependency and both sets
//! of semantics to save ~20 lines. It is not worth the edge.
use ffmpeg_next::ffi;
/// An owned `AVBufferRef`, unref'd exactly once when it drops.
///
/// Each decoder constructor creates a hwdevice and then does several more fallible things with it
/// — find the codec, alloc a context, open it — and every one of those failure branches used to
/// unref the device by hand, alongside a `Drop` doing it once more. Miss a branch and a decoder
/// device leaks per failed negotiation; double it up and the process aborts. Ownership lives here
/// instead, so an early `bail!` releases whatever exists and the branches carry no cleanup.
pub(crate) struct AvBuffer(*mut ffi::AVBufferRef);
impl AvBuffer {
/// Take ownership of a freshly-created `AVBufferRef`, rejecting the null an ffmpeg allocator
/// returns on failure.
///
/// # Safety
/// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value —
/// nothing else may unref it.
pub(crate) unsafe fn from_raw(p: *mut ffi::AVBufferRef) -> Option<Self> {
(!p.is_null()).then_some(AvBuffer(p))
}
/// The borrowed pointer, for calls that read the ref without taking it (e.g. `av_buffer_ref`,
/// which makes its own).
pub(crate) fn as_ptr(&self) -> *mut ffi::AVBufferRef {
self.0
}
/// Give up ownership: the caller becomes responsible for the unref.
///
/// This exists for the `get_format` callbacks, which hand a frames context to the codec —
/// `(*ctx).hw_frames_ctx = fr` means *the codec owns our ref now*, and it unrefs it when the
/// context closes. Dropping an `AvBuffer` there as well would be the double-unref this type is
/// meant to prevent, so the transfer is made explicit rather than left implicit.
pub(crate) fn into_raw(self) -> *mut ffi::AVBufferRef {
let p = self.0;
std::mem::forget(self);
p
}
}
impl Drop for AvBuffer {
fn drop(&mut self) {
// SAFETY: `self.0` is the non-null ref `from_raw` took ownership of, and this type is its
// sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends, and `into_raw` forgets
// instead of dropping), so this runs exactly once for that reference. `av_buffer_unref`
// drops the one reference and nulls the pointer through the `&mut`.
unsafe { ffi::av_buffer_unref(&mut self.0) };
}
}
@@ -29,6 +29,13 @@
//! rings are retired, not destroyed — the presenter may still hold their views (see
//! [`RETIRE_HANDOVERS`]).
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is `pyrowave-sys` C-API and ash/Vulkan compute calls almost line for line;
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
use crate::video::{ColorDesc, VulkanDecodeDevice};
use anyhow::{bail, Context as _, Result};
use ash::vk;
+15 -7
View File
@@ -5,7 +5,8 @@ use crate::video::{
AVERROR_EAGAIN,
};
use crate::video_color::ColorDesc;
use anyhow::{anyhow, bail, Result};
use crate::video_libav::AvBuffer;
use anyhow::{anyhow, bail, Context, Result};
use ffmpeg_next as ffmpeg;
use std::ptr;
@@ -32,7 +33,14 @@ unsafe extern "C" fn pick_vaapi(
#[cfg(target_os = "linux")]
pub(crate) struct VaapiDecoder {
ctx: *mut ffmpeg::ffi::AVCodecContext,
hw_device: *mut ffmpeg::ffi::AVBufferRef,
/// The VAAPI hwdevice, owned. Nothing reads this field after construction — the codec context
/// took its own ref via `av_buffer_ref` — it exists so the device outlives the decoder and is
/// unref'd exactly once when it drops. Declared after `ctx` so it still releases AFTER the
/// `Drop` below frees packet/frame/context, which is the order the hand-written unref had.
/// `dead_code` is answered here rather than by removing the field (that would free the device
/// early) or by an underscore name (that would hide what it is).
#[allow(dead_code)]
hw_device: AvBuffer,
packet: *mut ffmpeg::ffi::AVPacket,
frame: *mut ffmpeg::ffi::AVFrame,
}
@@ -57,14 +65,16 @@ impl VaapiDecoder {
if r < 0 {
bail!("no VAAPI device ({})", ffmpeg::Error::from(r));
}
// Owned from here: every `bail!` below drops it, so none of them unref by hand.
let hw_device = AvBuffer::from_raw(hw_device)
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
// The negotiated codec's decoder id (av_codec_id maps 1:1 from ffmpeg::codec::Id).
let codec = ffi::avcodec_find_decoder(codec_id.into());
if codec.is_null() {
ffi::av_buffer_unref(&mut hw_device);
bail!("no {codec_id:?} decoder");
}
let ctx = ffi::avcodec_alloc_context3(codec);
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
(*ctx).get_format = Some(pick_vaapi);
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
(*ctx).thread_count = 1; // hwaccel: threads only add latency
@@ -80,8 +90,6 @@ impl VaapiDecoder {
if r < 0 {
let mut ctx = ctx;
ffi::avcodec_free_context(&mut ctx);
let mut hw_device = hw_device;
ffi::av_buffer_unref(&mut hw_device);
bail!("avcodec_open2: {}", ffmpeg::Error::from(r));
}
Ok(VaapiDecoder {
@@ -237,7 +245,7 @@ impl Drop for VaapiDecoder {
ffi::av_packet_free(&mut self.packet);
ffi::av_frame_free(&mut self.frame);
ffi::avcodec_free_context(&mut self.ctx);
ffi::av_buffer_unref(&mut self.hw_device);
// `hw_device` is an `AvBuffer` and unrefs itself when the field drops, right after this.
}
}
}
+31 -12
View File
@@ -1,4 +1,10 @@
//! FFmpeg Vulkan Video decode over the presenter's own VkDevice (zero-copy VkImage).
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw libav + ash calls on the presenter's VkDevice almost line for line;
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
#![allow(clippy::unnecessary_cast)]
use crate::video::{
@@ -6,7 +12,8 @@ use crate::video::{
AVERROR_EAGAIN,
};
use crate::video_color::ColorDesc;
use anyhow::{bail, Result};
use crate::video_libav::AvBuffer;
use anyhow::{bail, Context, Result};
use ffmpeg_next as ffmpeg;
use std::ptr;
@@ -18,7 +25,14 @@ use std::ptr;
/// frames are `AVVkFrame`s whose VkImage the presenter feeds straight to its CSC pass.
pub(crate) struct VulkanDecoder {
ctx: *mut ffmpeg::ffi::AVCodecContext,
hw_device: *mut ffmpeg::ffi::AVBufferRef,
/// The Vulkan hwdevice, owned. Nothing reads this field after construction — the codec context
/// took its own ref via `av_buffer_ref` — it exists so the device outlives the decoder and is
/// unref'd exactly once when it drops. Declared after `ctx` so it still releases AFTER the
/// `Drop` below frees packet/frame/context, which is the order the hand-written unref had.
/// `dead_code` is answered here rather than by removing the field (that would free the device
/// early) or by an underscore name (that would hide what it is).
#[allow(dead_code)]
hw_device: AvBuffer,
packet: *mut ffmpeg::ffi::AVPacket,
frame: *mut ffmpeg::ffi::AVFrame,
/// `vkWaitSemaphores` on the shared device — the decode-complete measurement
@@ -187,6 +201,9 @@ impl VulkanDecoder {
ffi::av_buffer_unref(&mut hw_device);
return Err(averr("av_hwdevice_ctx_init(VULKAN)", r));
}
// Owned from here: every failure path below drops it instead of unref'ing by hand.
let hw_device = AvBuffer::from_raw(hw_device)
.context("av_hwdevice_ctx_alloc(VULKAN) gave no device")?;
// vkWaitSemaphores for the pump's decode-complete stat: loader →
// vkGetDeviceProcAddr → device fn (core 1.2, guaranteed by our gate).
@@ -201,18 +218,16 @@ impl VulkanDecoder {
c"vkWaitSemaphores".as_ptr(),
));
if wait_semaphores.is_none() {
ffi::av_buffer_unref(&mut hw_device);
bail!("vkWaitSemaphores unresolvable on this device");
}
let vk_device = (*hwctx).act_dev;
let codec = ffi::avcodec_find_decoder(codec_id.into());
if codec.is_null() {
ffi::av_buffer_unref(&mut hw_device);
bail!("no {codec_id:?} decoder");
}
let ctx = ffi::avcodec_alloc_context3(codec);
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
(*ctx).get_format = Some(pick_vulkan);
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
(*ctx).thread_count = 1; // hwaccel: threads only add latency
@@ -223,7 +238,6 @@ impl VulkanDecoder {
if r < 0 {
let mut ctx = ctx;
ffi::avcodec_free_context(&mut ctx);
ffi::av_buffer_unref(&mut hw_device);
return Err(averr("avcodec_open2 (vulkan)", r));
}
Ok(VulkanDecoder {
@@ -358,7 +372,7 @@ impl Drop for VulkanDecoder {
ffi::av_packet_free(&mut self.packet);
ffi::av_frame_free(&mut self.frame);
ffi::avcodec_free_context(&mut self.ctx);
ffi::av_buffer_unref(&mut self.hw_device);
// `hw_device` is an `AvBuffer` and unrefs itself when the field drops, right after this.
}
}
}
@@ -396,24 +410,29 @@ unsafe extern "C" fn pick_vulkan(
tracing::warn!(code = r, "avcodec_get_hw_frames_parameters(VULKAN) failed");
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
}
let fc = (*fr).data as *mut ffi::AVHWFramesContext;
// Owned until the codec takes it at the bottom: the init-failure path below just returns
// and the drop releases it.
let Some(fr) = AvBuffer::from_raw(fr) else {
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
};
let fc = (*fr.as_ptr()).data as *mut ffi::AVHWFramesContext;
let vkfc = (*fc).hwctx as *mut pf_ffvk::AVVulkanFramesContext;
// MUTABLE_FORMAT: per-plane views (spec requirement); ALIAS is FFmpeg's default.
// (`as _`: the FlagBits constants are i32 under MSVC, the img_flags field u32.)
(*vkfc).img_flags = (pf_ffvk::VkImageCreateFlagBits_VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT
| pf_ffvk::VkImageCreateFlagBits_VK_IMAGE_CREATE_ALIAS_BIT)
as _;
let r = ffi::av_hwframe_ctx_init(fr);
let r = ffi::av_hwframe_ctx_init(fr.as_ptr());
if r < 0 {
tracing::warn!(code = r, "av_hwframe_ctx_init(VULKAN) failed");
let mut fr = fr;
ffi::av_buffer_unref(&mut fr);
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
}
if !(*ctx).hw_frames_ctx.is_null() {
ffi::av_buffer_unref(&mut (*ctx).hw_frames_ctx);
}
(*ctx).hw_frames_ctx = fr; // the codec owns our ref now
// Ownership TRANSFERS to the codec here, so hand over the raw pointer and forget the
// wrapper — dropping it as well would be the double-unref `AvBuffer` exists to prevent.
(*ctx).hw_frames_ctx = fr.into_raw();
ffi::AVPixelFormat::AV_PIX_FMT_VULKAN
}
}
+3
View File
@@ -52,3 +52,6 @@ windows = { version = "0.62", features = [
"Win32_System_Ole",
"Win32_UI_WindowsAndMessaging",
] }
[lints]
workspace = true
+3
View File
@@ -33,3 +33,6 @@ sdl3 = { version = "0.18", features = ["hidapi", "ash"] }
[target.'cfg(windows)'.dependencies]
sdl3 = { version = "0.18", features = ["hidapi", "ash", "build-from-source"] }
[lints]
workspace = true
+111 -16
View File
@@ -48,6 +48,10 @@ struct Drawn {
height: u32,
stats: Option<String>,
hint: Option<String>,
/// The UI scale this was drawn at, in percent — part of the damage key so dragging the window
/// to a differently-scaled monitor re-renders the chrome at the new size instead of keeping
/// the stale one (the text is identical, so nothing else here would notice).
scale_pct: u16,
/// The start banner's alpha, quantized — a fade step is a redraw, steady is not.
banner_step: u8,
/// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a
@@ -56,6 +60,26 @@ struct Drawn {
resize_step: u16,
}
/// The stream chrome's base metrics, in pixels at 100 % scale (96 dpi). Everything here is
/// multiplied by `FrameCtx::scale` before it is drawn: the overlay composites into the swapchain
/// 1:1 in PHYSICAL pixels, so on a 4K panel at 200 % an unscaled 14 px OSD renders at half its
/// intended physical size — legible on a 1080p monitor, a squint on a HiDPI laptop.
mod base {
/// The monospace OSD/hint/label size. Also the size the shared `Font` is built at, so the
/// scale factor below is exactly the multiplier applied to it.
pub const FONT_PX: f32 = 14.0;
/// Top-left inset of the stats panel.
pub const OSD_MARGIN: f32 = 12.0;
/// Stats-panel inner padding and corner radius.
pub const OSD_PAD_X: f32 = 10.0;
pub const OSD_PAD_Y: f32 = 8.0;
pub const OSD_RADIUS: f32 = 8.0;
/// Hint/banner pill padding and its gap from the bottom edge.
pub const PILL_PAD_X: f32 = 14.0;
pub const PILL_PAD_Y: f32 = 8.0;
pub const PILL_BOTTOM: f32 = 24.0;
}
/// Where the console starts (the session binary's `--browse` forms).
pub enum ConsoleEntry {
/// The host list (bare `--browse`).
@@ -221,7 +245,7 @@ impl Overlay for SkiaOverlay {
skia_safe::FontStyle::normal(),
)
.context("no monospace typeface (fontconfig alias or system family)")?;
self.font = Some(Font::new(typeface, 14.0));
self.font = Some(Font::new(typeface, base::FONT_PX));
self.fonts = Some(crate::theme::build_fonts()?);
self.gpu = Some(Gpu {
@@ -360,11 +384,15 @@ impl Overlay for SkiaOverlay {
self.drawn = Drawn::default(); // forget content so re-show re-renders
return Ok(None);
}
// 1 % granularity: fine enough that no real display scale is rounded into another, coarse
// enough that float noise on the same monitor can't churn the damage gate every frame.
let scale = ctx.scale.clamp(0.5, 4.0);
let want = Drawn {
width: ctx.width,
height: ctx.height,
stats: ctx.stats.map(str::to_owned),
hint: ctx.hint.map(str::to_owned),
scale_pct: (scale * 100.0).round() as u16,
banner_step,
resize_step,
};
@@ -387,16 +415,20 @@ impl Overlay for SkiaOverlay {
let canvas = slot.surface.canvas();
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
// Each drawer re-derives the face at its own (fit-clamped) size rather than the canvas
// being transformed: Skia hints and rasterizes glyphs at the requested size, so this
// stays crisp where a magnified 14 px bitmap would be mush. Only on a damage redraw —
// a steady stream re-renders nothing at all.
let font = self.font.as_ref().expect("init ran");
// The resize scrim sits UNDER the OSD/hint so those stay legible over it.
if let Some(phase) = resize_phase {
draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase);
draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase, scale);
}
if let Some(stats) = &want.stats {
draw_osd_panel(canvas, font, stats, 12.0, 12.0);
draw_osd_panel(canvas, font, stats, ctx.width, scale);
}
if let Some(hint) = &want.hint {
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0);
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0, scale);
} else if banner_step > 0 {
// The start banner: the leave/stats shortcuts, fading out on its own —
// discoverable without the stats overlay, gone before it annoys.
@@ -408,6 +440,7 @@ impl Overlay for SkiaOverlay {
ctx.width,
ctx.height,
banner_alpha as f32,
scale,
);
}
}
@@ -543,25 +576,61 @@ impl SkiaOverlay {
}
}
/// The stats OSD: a translucent rounded panel, one text line per `\n` (the GTK OSD's
/// look, minus the toolkit).
fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
/// The chrome face at `scale`. `with_size` only fails on a nonsensical size (the caller clamps),
/// in which case the unscaled face is still better than no text.
fn chrome_font(font: &Font, scale: f32) -> Font {
font.with_size(base::FONT_PX * scale)
.unwrap_or_else(|| font.clone())
}
/// Shrink `scale` until a box of `width_at_scale` (which must be linear in the scale — every
/// chrome metric is) fits in `budget`. Scaling text up by the display's DPI is only an
/// improvement while the result still fits the window: the capture hint is a ~150-character line
/// that already spans most of a 1280 px window at 100 %, so at 200 % it would run off both edges
/// and lose its ends. Fitting keeps it whole, just smaller than the nominal scale.
fn fit_scale(scale: f32, width_at_scale: f32, budget: f32) -> f32 {
if width_at_scale > budget && width_at_scale > 0.0 {
(scale * budget / width_at_scale).max(0.1)
} else {
scale
}
}
/// The stats OSD: a translucent rounded panel in the top-left, one text line per `\n` (the GTK
/// OSD's look, minus the toolkit), sized for the display's UI `scale`.
fn draw_osd_panel(canvas: &Canvas, base_font: &Font, text: &str, width: u32, scale: f32) {
let lines: Vec<&str> = text.lines().collect();
// Panel width is linear in the scale, so measuring once at the requested scale is enough to
// solve for the scale that keeps the Detailed tier's long lines inside the window instead of
// running them past the right edge on a HiDPI display.
let width_at = |s: f32| {
let font = chrome_font(base_font, s);
let widest = lines
.iter()
.map(|l| font.measure_str(l, None).0)
.fold(0.0f32, f32::max);
widest + 2.0 * (base::OSD_PAD_X + base::OSD_MARGIN) * s
};
let scale = fit_scale(scale, width_at(scale), width as f32);
let font = chrome_font(base_font, scale);
let (_, metrics) = font.metrics();
let line_h = metrics.descent - metrics.ascent + metrics.leading;
let lines: Vec<&str> = text.lines().collect();
let widest = lines
.iter()
.map(|l| font.measure_str(l, None).0)
.fold(0.0f32, f32::max);
let (pad_x, pad_y) = (10.0, 8.0);
let (pad_x, pad_y) = (base::OSD_PAD_X * scale, base::OSD_PAD_Y * scale);
let (x, y) = (base::OSD_MARGIN * scale, base::OSD_MARGIN * scale);
let panel = Rect::from_xywh(
x,
y,
widest + 2.0 * pad_x,
line_h * lines.len() as f32 + 2.0 * pad_y,
);
let radius = base::OSD_RADIUS * scale;
canvas.draw_rrect(
RRect::new_rect_xy(panel, 8.0, 8.0),
RRect::new_rect_xy(panel, radius, radius),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
);
let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None);
@@ -569,7 +638,7 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
canvas.draw_str(
line,
Point::new(x + pad_x, y + pad_y - metrics.ascent + line_h * i as f32),
font,
&font,
&text_paint,
);
}
@@ -581,7 +650,16 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
/// window. This is the presenter's analog of the Apple client's blur overlay: the overlay
/// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim
/// hides the stretched in-between frame instead (same intent, one draw).
fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phase: f64) {
fn draw_resize_scrim(
canvas: &Canvas,
base_font: &Font,
width: u32,
height: u32,
phase: f64,
scale: f32,
) {
// Short, centered label — it always fits, so it just takes the display scale as-is.
let font = &chrome_font(base_font, scale);
let (wf, hf) = (width as f32, height as f32);
canvas.draw_rect(
Rect::from_wh(wf, hf),
@@ -602,16 +680,33 @@ fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phas
);
}
/// The capture hint / start banner: a centered pill near the bottom edge.
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) {
/// The capture hint / start banner: a centered pill near the bottom edge. `scale` = the display's
/// UI scale (the text size already rides in `font`).
fn draw_hint_pill(
canvas: &Canvas,
base_font: &Font,
text: &str,
width: u32,
height: u32,
alpha: f32,
scale: f32,
) {
// The capture hint is one long line that already fills most of a 1280 px window at 100 %;
// scaled by a 2× display it would overrun both edges, so fit it to the window (a 4 % gutter
// keeps it off the very edge).
let pill_w =
|s: f32| chrome_font(base_font, s).measure_str(text, None).0 + 2.0 * base::PILL_PAD_X * s;
let scale = fit_scale(scale, pill_w(scale), width as f32 * 0.96);
let font = &chrome_font(base_font, scale);
let (_, metrics) = font.metrics();
let line_h = metrics.descent - metrics.ascent;
let text_w = font.measure_str(text, None).0;
let (pad_x, pad_y) = (14.0, 8.0);
let (pad_x, pad_y) = (base::PILL_PAD_X * scale, base::PILL_PAD_Y * scale);
let w = text_w + 2.0 * pad_x;
let h = line_h + 2.0 * pad_y;
let x = (width as f32 - w) / 2.0;
let y = height as f32 - h - 24.0;
let y = height as f32 - h - base::PILL_BOTTOM * scale;
canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None),
+3
View File
@@ -18,3 +18,6 @@ publish = false
[dependencies]
# `min_const_generics`: Pod/Zeroable for `[u8; N]` of any N (the gamepad SHM reserved tails are >32).
bytemuck = { version = "1.19", features = ["derive", "min_const_generics"] }
[lints]
workspace = true
+359 -12
View File
@@ -20,7 +20,7 @@
//!
//! The GUID and LUID are carried as plain integers; the host converts to `windows::core::GUID` /
//! `windows::Win32::Foundation::LUID` and the driver to its own bindgen types via the same constants.
#![forbid(unsafe_code)]
#![cfg_attr(not(test), no_std)]
extern crate alloc;
@@ -681,7 +681,7 @@ pub mod frame {
};
}
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_dualsense`).
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_gamepad`).
///
/// These were hand-duplicated as `OFF_*`/`SHM_*` constants in `inject/{gamepad,dualsense}_windows.rs`
/// and (as bare literals — `*view.add(140)`) in the standalone `xusb-driver`/`dualsense-driver`
@@ -699,12 +699,12 @@ pub mod gamepad {
/// XUSB section magic — the exact u32 the shipped host + `pf_xusb` driver compare (loosely "PFXU").
pub const XUSB_MAGIC: u32 = 0x5558_4650;
/// Pad section magic — the exact u32 the shipped host + `pf_dualsense` driver compare (loosely
/// Pad section magic — the exact u32 the shipped host + `pf_gamepad` driver compare (loosely
/// "PFDS"). (Note: the two magics happen to use opposite byte-order mnemonics in the legacy code;
/// only the u32 value is the contract.)
pub const PAD_MAGIC: u32 = 0x5046_4453;
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
/// `device_type` selector the `pf_gamepad` driver reads to pick its HID identity. The section is
/// zeroed, so `0` = DualSense is the default; one driver serves every identity.
pub const DEVTYPE_DUALSENSE: u8 = 0;
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
@@ -730,7 +730,235 @@ pub mod gamepad {
/// gained `pad_index` (carved from reserved space) so the driver rejects a cross-pad delivery.
/// A v1 driver opens `Global\pf…-shm-<i>` (which no longer exists) and a v1 host never creates
/// the mailbox a v2 driver polls, so a mixed pairing fails closed either way.
pub const GAMEPAD_PROTO_VERSION: u32 = 2;
///
/// v3: the **channel proof** ([`ChannelProof`]) — the host stopped trusting the mailbox's
/// `driver_pid` and now learns the duplication target over the DEVICE STACK instead. A v2 driver
/// answers no proof, so a v3 host refuses to deliver to it; a v2 host never asks, and a v3 driver
/// refuses the v2 handshake on the `host_proto` check. Mixed pairings fail closed both ways, with
/// the existing "update host + drivers together" diagnostic.
pub const GAMEPAD_PROTO_VERSION: u32 = 3;
// ── the channel proof (v3): who to hand the DATA section to ──────────────────────────────────
//
// WHY THIS EXISTS. Through v2 the host took the duplication target from the mailbox's
// `driver_pid`. The mailbox has to be openable by LocalService (that is what the driver's own
// WUDFHost runs as), and the delivery gate — `verify_is_wudfhost` — only checks that the named
// process's IMAGE is `%SystemRoot%\System32\WUDFHost.exe`, which is world-executable. So any
// LocalService principal, notably the deliberately de-privileged plugin runner, could spawn its
// own WUDFHost, publish that pid, and be handed the pad's DATA section: forged HID input into the
// interactive desktop (the mouse section drives a real absolute pointer) and a read of the remote
// user's controller state (security-review 2026-07-28).
//
// WHY IT HAS TO COME FROM THE DEVICE STACK. That race cannot be closed on the host side alone.
// Everything the real driver can read at LocalService — the devnode's Location, its
// `Device Parameters` key, the object namespace — an attacker at LocalService can read too, so no
// host-published secret tells the two apart. The ONE thing an attacker cannot forge is *being the
// driver bound to our devnode*: only that process answers I/O sent to the device the host itself
// created (and the host looks the device up by the instance id `SwDeviceCreate` handed back, so a
// planted look-alike devnode is not in the running). Asking the devnode "which process are you?"
// therefore yields a pid the host can trust, and the mailbox is demoted to what it always should
// have been: a rendezvous for a handle VALUE that is meaningless anywhere but in that process.
//
// Two transports, because the drivers are two different shapes:
// * `pf_xusb` is a plain UMDF2 driver that owns `GUID_DEVINTERFACE_XUSB` and dispatches its own
// IOCTLs -> [`IOCTL_PF_XUSB_GET_CHANNEL_PROOF`].
// * `pf_gamepad` / `pf_mouse` are HID minidrivers with no control device (hidclass owns the
// stack, and UMDF has no control-device objects), so the reachable read path is a HID string
// -> [`HID_STRING_INDEX_CHANNEL_PROOF`], which needs no report-descriptor change. That
// matters: the pads' descriptors, VID/PID and serials are what Steam and SDL fingerprint,
// and a new feature report there would risk the identity work this driver exists to get right.
/// Proof magic ("PFCP" — punktfunk channel proof), and the `PFCP` prefix of the text form.
pub const PROOF_MAGIC: u32 = 0x5043_4650;
/// Reserved HID string index the `pf_gamepad` / `pf_mouse` minidrivers answer with their
/// [`ChannelProof`], fetched by the host with `HidD_GetIndexedString`.
///
/// ⚠️ MEASURED UNUSABLE on .173 (Win11 26200): hidclass does not carry an arbitrary indexed-string
/// request to a UMDF HID minidriver — `HidD_GetIndexedString` failed for EVERY index, including
/// ones the driver demonstrably serves through the named wrappers. Kept because it costs one
/// failed IOCTL and is the right thing to ask first if a later Windows starts forwarding it; the
/// transports that actually work are [`PF_PAD_CONTROL_INTERFACE_GUID_U128`] (if hidclass lets it
/// through) and, for `pf_mouse`, the serial string ([`proof_is_serial_string`]).
///
/// 16-bit on purpose: both `IOCTL_HID_GET_INDEXED_STRING` and `IOCTL_HID_GET_STRING` pack their
/// argument as `(language_id << 16) | string_index`, so only the low word survives the trip and
/// both drivers mask before comparing. `0x5046` ("PF") is still far outside the 1..=255 range a
/// real USB/HID string-descriptor index can occupy, so it cannot collide with a string the OS, a
/// game, or Steam asks for.
pub const HID_STRING_INDEX_CHANNEL_PROOF: u32 = 0x5046;
// ❌ A private device interface (`WdfDeviceCreateDeviceInterface`) was tried here as a
// hidclass-independent transport for the HID minidrivers and MEASURED DEAD on .173 (Win11
// 26200): it registers and enumerates, but `CreateFile` on it is refused (ERROR_GEN_FAILURE)
// because hidclass owns `IRP_MJ_CREATE` on a devnode it is the FDO for. Do not re-try it for
// `pf_gamepad`/`pf_mouse`; see `pf_umdf_util::hid` for the full measurement.
/// The proof question itself, on whichever interface carries it.
/// `CTL_CODE(0x8000, 0x0FE0, METHOD_BUFFERED, FILE_ANY_ACCESS)`: a function code no xusb22 IOCTL
/// uses, `METHOD_BUFFERED` so the answer is a plain buffer copy, and `FILE_ANY_ACCESS` so the host
/// can ask over a `CreateFile` handle opened with NO access rights (the same way it must open a
/// HID collection). Answering it leaks nothing — a pid is not a secret, and the proof is only
/// worth anything to a process that can already duplicate handles.
pub const IOCTL_PF_GET_CHANNEL_PROOF: u32 = 0x8000_3F80;
/// Whether a driver serves its channel proof AS its HID serial-number string.
///
/// The one transport measured to work against a UMDF HID minidriver today: on .173,
/// `HidD_GetSerialNumberString` succeeds on a zero-access handle and returns the driver's own
/// text, so a proof placed there reaches the host. Enabled for **`pf_mouse` only** — its serial
/// (`PFMOUSE00`) is inert, whereas the pads' serials are what SDL and Steam dedup controllers on,
/// and Steam is already known to mangle a pad's displayed name over serial FORMAT alone.
///
/// `pf_mouse` is also the one that matters most: its section drives a real absolute pointer, so a
/// hijacked mouse channel is desktop control, where a hijacked pad channel is gamepad input.
pub const fn proof_is_serial_string(pad_kind_is_mouse: bool) -> bool {
pad_kind_is_mouse
}
/// The feature report the **PS pad identities** (DualSense / DualShock 4 / Edge) answer the
/// channel proof on.
///
/// `0x85` is already DECLARED as a Feature report in all three captured descriptors and was
/// previously unserved — the driver failed it with `STATUS_INVALID_PARAMETER`. That is what makes
/// this transport free: **no report-descriptor change**, so the VID/PID, report layout, serial and
/// product strings that Steam and SDL fingerprint are untouched, and `HidD_GetFeature` is allowed
/// through by hidclass because the id is in the descriptor. Nothing can have depended on the old
/// failure: SDL reads `0x05`/`0x09`/`0x20` (DualSense) and `0x02`/`0x12`/`0xA3` (DS4); `0x85` is
/// one of Sony's vendor reports neither it nor Steam asks for.
pub const HID_FEATURE_REPORT_CHANNEL_PROOF: u8 = 0x85;
/// The Steam Deck identity's private proof command.
///
/// The Deck descriptor declares ONE unnumbered feature report and Steam drives it as a
/// command/response protocol (`0x83` GET_ATTRIBUTES, `0xAE` GET_STRING_ATTRIBUTE); the driver
/// echoes commands it doesn't know. So the proof rides that same contract — SET_FEATURE this
/// command, then GET_FEATURE the reply — again with no descriptor change. TWO bytes, not one, so
/// a Steam command byte we haven't catalogued can never be mistaken for it.
pub const DECK_PROOF_CMD: [u8; 2] = [0xF9, 0x50];
/// What a driver answers when the host asks, over the device stack, who it is.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct ChannelProof {
/// [`PROOF_MAGIC`].
pub magic: u32,
/// The driver's [`GAMEPAD_PROTO_VERSION`].
pub proto: u32,
/// The pad index the driver read from its devnode Location — cross-checked against the pad
/// the host is delivering, so a mis-resolved devnode can't cross-wire two pads.
pub pad_index: u32,
/// `GetCurrentProcessId()` of the driver's WUDFHost: the duplication target.
pub wudf_pid: u32,
}
impl ChannelProof {
/// This driver's answer. `pad_index` comes from the devnode Location, `wudf_pid` from
/// `GetCurrentProcessId()`.
pub fn new(pad_index: u32, wudf_pid: u32) -> ChannelProof {
ChannelProof {
magic: PROOF_MAGIC,
proto: GAMEPAD_PROTO_VERSION,
pad_index,
wudf_pid,
}
}
/// Validate an answer against the pad the host is actually delivering, yielding the pid to
/// duplicate into. `Err` carries the operator-facing reason — every rejection is a refusal to
/// deliver, so the host must be able to say precisely which check failed rather than falling
/// back to a pid it cannot trust.
pub fn check(&self, expect_pad_index: u32) -> Result<u32, &'static str> {
if self.magic != PROOF_MAGIC {
return Err(
"the devnode's answer is not a punktfunk channel proof (bad magic) — \
some other driver is bound to this device",
);
}
if self.proto != GAMEPAD_PROTO_VERSION {
return Err(
"the driver bound to this devnode speaks a different gamepad protocol \
update the host and the drivers together",
);
}
if self.pad_index != expect_pad_index {
return Err(
"the devnode answered for a DIFFERENT pad index — the interface lookup \
resolved the wrong device",
);
}
if self.wudf_pid == 0 {
return Err("the driver reported pid 0");
}
Ok(self.wudf_pid)
}
/// The 16 wire bytes of the `pf_xusb` IOCTL answer. Offered here (rather than leaving each
/// side to reach for `bytemuck`) so the driver crates need no extra dependency and both
/// sides go through one length-checked pair with [`from_bytes`](Self::from_bytes).
pub fn to_bytes(self) -> [u8; 16] {
let mut out = [0u8; 16];
out.copy_from_slice(bytemuck::bytes_of(&self));
out
}
/// Parse [`to_bytes`](Self::to_bytes). `None` if the device returned fewer bytes than a whole
/// proof — a short read must refuse the delivery, never be zero-extended into a pid.
///
/// `pod_read_unaligned`, NOT `from_bytes`: the feature-report form offsets the proof by one
/// byte (the report id sits at 0), so the slice is not 4-aligned and `from_bytes` panics on
/// it. Device I/O buffers carry no alignment guarantee either.
pub fn from_bytes(b: &[u8]) -> Option<ChannelProof> {
(b.len() >= 16).then(|| bytemuck::pod_read_unaligned::<ChannelProof>(&b[..16]))
}
/// The proof as a HID **feature report** of exactly `len` bytes: `[report_id, proof(16), 0…]`.
/// A HID feature reply carries its report id in byte 0 and is sized by the descriptor, so the
/// driver pads to whatever length the caller's buffer declares. `None` if `len` cannot hold
/// the id plus a whole proof.
pub fn to_feature_report(self, report_id: u8, len: usize) -> Option<alloc::vec::Vec<u8>> {
if len < 17 {
return None;
}
let mut out = alloc::vec![0u8; len];
out[0] = report_id;
out[1..17].copy_from_slice(&self.to_bytes());
Some(out)
}
/// Parse [`to_feature_report`](Self::to_feature_report) — skips the leading report id.
pub fn from_feature_report(b: &[u8]) -> Option<ChannelProof> {
Self::from_bytes(b.get(1..)?)
}
/// Render as the ASCII text a HID indexed-string answer carries:
/// `PFCP:<proto>:<pad_index>:<wudf_pid>`. Text rather than the raw struct because
/// `HidD_GetIndexedString` is a string channel, and because a human reading a driver log or
/// poking the device with a HID inspector should be able to see what the pad answered.
pub fn to_hid_string(self) -> String {
alloc::format!("PFCP:{}:{}:{}", self.proto, self.pad_index, self.wudf_pid)
}
/// Parse [`to_hid_string`](Self::to_hid_string). `None` on ANY deviation — a foreign string
/// index answered, a truncated read, a non-decimal field, trailing junk — so a host that
/// cannot read a well-formed proof refuses to deliver instead of guessing at a pid.
pub fn from_hid_string(s: &str) -> Option<ChannelProof> {
let rest = s.strip_prefix("PFCP:")?;
let mut it = rest.split(':');
let proto = it.next()?.parse::<u32>().ok()?;
let pad_index = it.next()?.parse::<u32>().ok()?;
let wudf_pid = it.next()?.parse::<u32>().ok()?;
if it.next().is_some() {
return None; // trailing field: not a shape we minted
}
Some(ChannelProof {
magic: PROOF_MAGIC,
proto,
pad_index,
wudf_pid,
})
}
}
/// Bootstrap-mailbox magic (`"PFBT"` LE) — the host stamps it LAST (after `host_proto`), so a
/// driver only trusts a fully-initialized mailbox.
@@ -754,16 +982,22 @@ pub mod gamepad {
/// 1. host creates it (zeroed), stamps `host_proto` then `magic` (in that order);
/// 2. driver opens it by name (pad index from `pszDeviceLocation`), writes `driver_proto`, and —
/// iff `host_proto` matches its own version — publishes `driver_pid`;
/// 3. host polls `driver_pid`, verifies the pid is a genuine WUDFHost, duplicates the unnamed DATA
/// section into it, then writes `data_handle` + `handle_pid` and bumps `handle_seq` LAST;
/// 3. host asks the DEVNODE who the driver is ([`ChannelProof`]) — **not** the mailbox — verifies
/// that pid is a genuine WUDFHost, duplicates the unnamed DATA section into it, then writes
/// `data_handle` + `handle_pid` and bumps `handle_seq` LAST;
/// 4. driver sees a fresh `handle_seq` addressed to its own pid, maps `data_handle`, and validates
/// the mapped section's magic + `pad_index` before use.
///
/// Deliberately safe to leave named + LS-openable: it carries only pids (not sensitive) and a
/// handle VALUE (meaningless outside the target WUDFHost's handle table). A sibling LocalService
/// that tampers with it can at worst mis-route a delivery — a gamepad DoS, never a read or an
/// injection (it cannot place a valid section handle in the WUDFHost, and the driver's
/// magic+`pad_index` validation rejects any handle that doesn't resolve to this pad's section).
/// **Trust boundary (v3).** This mailbox is writable by LocalService — it has to be, since that is
/// what the driver's own WUDFHost runs as — so NOTHING in it may decide where the DATA section
/// goes. Through v2 `driver_pid` did decide that, which was the security-review 2026-07-28 hole
/// (see [`ChannelProof`]); step 3 now sources the pid from the device stack and `driver_pid` is
/// advisory only — a liveness/diagnostic hint. What is left here is a handle VALUE, meaningless
/// outside the one process it was minted for, plus pids and version numbers, none of them secret.
/// A LocalService tamperer can therefore still deny a pad (overwrite the fields, squat the name)
/// but can no longer read or inject: it cannot place a valid section handle in the WUDFHost, the
/// driver's magic + `pad_index` validation rejects any handle that does not resolve to this pad's
/// section, and the delivery target is no longer its to choose.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct PadBootstrap {
@@ -942,6 +1176,12 @@ pub mod gamepad {
assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE);
assert!(size_of::<OutSlot>() == 68);
assert!(size_of::<ChannelProof>() == 16);
assert!(offset_of!(ChannelProof, magic) == 0);
assert!(offset_of!(ChannelProof, proto) == 4);
assert!(offset_of!(ChannelProof, pad_index) == 8);
assert!(offset_of!(ChannelProof, wudf_pid) == 12);
assert!(size_of::<PadBootstrap>() == 32);
assert!(offset_of!(PadBootstrap, magic) == 0);
assert!(offset_of!(PadBootstrap, host_proto) == 4);
@@ -1486,4 +1726,111 @@ mod tests {
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
assert_ne!(PF_VDISPLAY_INTERFACE_GUID_U128, SUDOVDA);
}
/// The channel proof is what the host trusts INSTEAD of the mailbox's `driver_pid`
/// (security-review 2026-07-28), so both wire forms — the `pf_xusb` IOCTL struct and the HID
/// indexed-string text the two minidrivers answer — have to survive a round trip byte-for-byte,
/// and every malformed shape has to be rejected rather than half-parsed into a pid the host
/// would then duplicate a live input section into.
#[test]
fn channel_proof_round_trips_in_both_wire_forms() {
use gamepad::*;
let proof = ChannelProof::new(2, 4242);
assert_eq!(proof.magic, PROOF_MAGIC);
assert_eq!(PROOF_MAGIC, u32::from_le_bytes(*b"PFCP"));
assert_eq!(proof.proto, GAMEPAD_PROTO_VERSION);
// XUSB IOCTL form: the raw 16-byte struct.
let bytes = bytemuck::bytes_of(&proof);
assert_eq!(bytes.len(), 16);
assert_eq!(*bytemuck::from_bytes::<ChannelProof>(bytes), proof);
// HID indexed-string form: the same four fields as text.
let s = proof.to_hid_string();
assert_eq!(s, alloc::format!("PFCP:{GAMEPAD_PROTO_VERSION}:2:4242"));
assert_eq!(ChannelProof::from_hid_string(&s), Some(proof));
// Every malformed shape parses to None — the host then refuses to deliver.
for bad in [
"",
"PFCP",
"PFCP:",
"PFCP:3:0", // truncated read
"PFCP:3:0:4242:9", // trailing field we never mint
"PFCP:3:0:-1", // not a u32
"PFCP:3:0:0x10", // not decimal
"PFCP:3:0: 4242", // whitespace is not trimmed away into a valid pid
"NOPE:3:0:4242", // another driver answered this string index
"pfcp:3:0:4242", // prefix is case-sensitive
] {
assert_eq!(
ChannelProof::from_hid_string(bad),
None,
"malformed proof {bad:?} must not parse"
);
}
}
/// `check` is the gate that decides whether a pid is allowed to receive a pad's whole input and
/// rumble surface, so pin each refusal: a foreign driver, a version skew, and — the one that
/// would silently cross-wire two live pads — an answer from the wrong devnode.
#[test]
fn channel_proof_check_refuses_everything_it_should() {
use gamepad::*;
assert_eq!(ChannelProof::new(0, 1234).check(0), Ok(1234));
assert_eq!(ChannelProof::new(3, 1234).check(3), Ok(1234));
// Right shape, WRONG pad: the interface lookup resolved another pad's devnode.
assert!(ChannelProof::new(1, 1234).check(0).is_err());
// A driver that isn't ours answered the reserved string index / IOCTL.
let mut foreign = ChannelProof::new(0, 1234);
foreign.magic = 0xDEAD_BEEF;
assert!(foreign.check(0).is_err());
// Version skew must fail closed, not "probably compatible".
let mut old = ChannelProof::new(0, 1234);
old.proto = GAMEPAD_PROTO_VERSION - 1;
assert!(old.check(0).is_err());
// pid 0 is never a duplication target.
assert!(ChannelProof::new(0, 0).check(0).is_err());
}
/// A v2 driver answers no proof at all and a v2 host never asks, so the version must have moved
/// — this is the tripwire that stops the two halves shipping out of step.
#[test]
fn gamepad_proto_is_at_the_channel_proof_version() {
assert_eq!(gamepad::GAMEPAD_PROTO_VERSION, 3);
}
/// The pad identities carry the proof in a HID FEATURE report — the transport chosen because
/// `0x85` is already declared in the captured descriptors, so nothing about the device's
/// Steam/SDL-visible identity changes. Pin the framing (report id in byte 0, proof in 1..17,
/// zero padding to the descriptor's length) and the short-read refusal.
#[test]
fn channel_proof_feature_report_round_trips_and_refuses_short_reads() {
use gamepad::*;
let proof = ChannelProof::new(1, 4242);
let rep = proof
.to_feature_report(HID_FEATURE_REPORT_CHANNEL_PROOF, 64)
.expect("64 bytes is plenty");
assert_eq!(rep.len(), 64);
assert_eq!(
rep[0], 0x85,
"byte 0 is the report id, as every HID feature reply is"
);
assert!(rep[17..].iter().all(|&b| b == 0), "tail is zero padding");
assert_eq!(ChannelProof::from_feature_report(&rep), Some(proof));
// Exactly big enough, and one byte too small.
assert!(proof.to_feature_report(0x85, 17).is_some());
assert!(proof.to_feature_report(0x85, 16).is_none());
// A truncated read must NOT be zero-extended into a pid.
assert_eq!(ChannelProof::from_feature_report(&rep[..16]), None);
assert_eq!(ChannelProof::from_feature_report(&[]), None);
// The Deck's private command is two bytes so a stray Steam command can't collide, and is
// distinct from the commands the driver already serves.
assert_eq!(DECK_PROOF_CMD.len(), 2);
assert!(!DECK_PROOF_CMD.starts_with(&[0x83]) && !DECK_PROOF_CMD.starts_with(&[0xAE]));
assert!(!DECK_PROOF_CMD.starts_with(&[0xEB]) && !DECK_PROOF_CMD.starts_with(&[0x8F]));
}
}
+3
View File
@@ -92,3 +92,6 @@ pyrowave = ["dep:pyrowave-sys"]
# (design/native-qsv-encoder.md). ⚠ Like `nvenc`: hand builds need this feature or
# Intel boxes fall through to the ffmpeg path / software.
qsv = ["dep:libvpl-sys"]
[lints]
workspace = true
+91
View File
@@ -26,6 +26,97 @@ pub(crate) fn pixel_to_av(p: Pixel) -> ffi::AVPixelFormat {
ffi::AVPixelFormat::from(p)
}
/// An owned `AVBufferRef` — unref'd exactly once, when it drops.
///
/// The hwdevice/hwframes constructors used to unref by hand on *every* failure branch (three in the
/// CUDA path, two in VAAPI) and then once more in a hand-written `Drop`. That shape leaks the moment
/// somebody adds a branch and forgets the cleanup, and double-unrefs the moment two of them run —
/// and neither mistake is visible at the call site or catchable by the compiler. Ownership lives in
/// this type instead: an early `?` drops whatever was built so far, in reverse construction order,
/// with no cleanup code at the call site at all.
///
/// **Drop order** matters to the callers holding two of these. A frames context internally holds its
/// own reference on its device, and the code this replaced deliberately unref'd frames *before*
/// device. Rust drops struct fields in DECLARATION order, so a struct holding both must declare
/// frames before device to keep that. Refcounting makes either order sound in principle —
/// the device cannot die while a frames ctx still references it — but the observable order is kept
/// exactly as it shipped rather than quietly inverted by a field reorder.
pub(crate) struct AvBuffer(*mut ffi::AVBufferRef);
impl AvBuffer {
/// Take ownership of a freshly-created `AVBufferRef`, rejecting the null that an ffmpeg
/// allocator returns on failure (so the `is_null` check every caller used to open-code happens
/// once, here).
///
/// # Safety
/// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value —
/// nothing else may unref it.
pub(crate) unsafe fn from_raw(p: *mut ffi::AVBufferRef) -> Option<Self> {
(!p.is_null()).then_some(AvBuffer(p))
}
/// The borrowed pointer, for the ffmpeg calls that read a ref without consuming it. Borrowed
/// only — the `AvBuffer` stays the owner, so callers must not unref what this returns.
pub(crate) fn as_ptr(&self) -> *mut ffi::AVBufferRef {
self.0
}
}
impl Drop for AvBuffer {
fn drop(&mut self) {
// SAFETY: `self.0` is the non-null ref `from_raw` took ownership of, and this type is its
// sole owner (it is neither `Clone` nor `Copy`, and `as_ptr` only lends), so this runs
// exactly once for that reference. `av_buffer_unref` drops the one reference and nulls the
// pointer through the `&mut`.
unsafe { ffi::av_buffer_unref(&mut self.0) };
}
}
/// An owned `AVFilterGraph`, freed exactly once when it drops.
///
/// The dmabuf path built its graph beside three `AvBuffer`s and unwound all four by hand on each
/// of eight failure branches — a four-line cleanup block copied eight times, once inside a macro.
/// Freeing the graph is the same ownership question as unref'ing a buffer, so it gets the same
/// answer.
///
/// Linux-only: the VAAPI dmabuf path is the sole filter-graph user in this crate. The Windows
/// AMF/QSV backends feed the encoder directly and build no graph, so on Windows this type would be
/// dead code — cfg'd out rather than `allow`ed, because "nothing here uses it" is the honest
/// statement and an `allow` would keep it compiling after it stopped being true anywhere.
#[cfg(target_os = "linux")]
pub(crate) struct AvFilterGraph(*mut ffi::AVFilterGraph);
#[cfg(target_os = "linux")]
impl AvFilterGraph {
/// Allocate a filter graph, rejecting the null `avfilter_graph_alloc` returns on OOM.
///
/// Safe: the call takes no arguments and has no precondition a caller could violate — the only
/// contract is what happens to the result, and that is exactly what this type owns.
pub(crate) fn alloc() -> Option<Self> {
// SAFETY: parameterless allocator; it returns either a fresh graph whose ownership passes
// to the value returned here, or null (rejected below).
let g = unsafe { ffi::avfilter_graph_alloc() };
(!g.is_null()).then_some(AvFilterGraph(g))
}
/// The borrowed pointer, for the `avfilter_*` calls that build into the graph without taking
/// ownership of it.
pub(crate) fn as_ptr(&self) -> *mut ffi::AVFilterGraph {
self.0
}
}
#[cfg(target_os = "linux")]
impl Drop for AvFilterGraph {
fn drop(&mut self) {
// SAFETY: `self.0` is the non-null graph `alloc` took ownership of, and this type is its
// sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends), so this runs exactly once.
// `avfilter_graph_free` frees the graph together with the filter contexts and per-filter
// device refs it owns, and nulls the pointer through the `&mut`.
unsafe { ffi::avfilter_graph_free(&mut self.0) };
}
}
/// One `receive_packet` attempt, with the not-ready states kept distinct so a blocking drain can
/// tell "still encoding" (retry) from "stream over" (stop). The Linux NVENC/VAAPI polls collapse
/// `Again`/`Eof` to `None`; the Windows AMF/QSV path keeps them apart for its deadline-driven loop.
+88 -43
View File
@@ -8,6 +8,12 @@
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) hwcontext calls almost line for line;
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -22,7 +28,8 @@ use std::os::raw::c_int;
use std::ptr;
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_ITU709,
SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -60,8 +67,11 @@ struct AVCUDADeviceContext {
/// CUDA hardware-frame contexts that wrap our shared `CUcontext`, so `hevc_nvenc` reads the
/// imported device buffer directly. Owns two `AVBufferRef`s, unref'd on drop.
struct CudaHw {
device_ref: *mut ffi::AVBufferRef,
frames_ref: *mut ffi::AVBufferRef,
// Declared frames-BEFORE-device on purpose: these drop in declaration order, and that
// reproduces exactly what the hand-written `Drop` this replaced did (the frames ctx holds its
// own reference on the device). Do not reorder these two fields.
frames_ref: AvBuffer,
device_ref: AvBuffer,
}
impl CudaHw {
@@ -72,57 +82,42 @@ impl CudaHw {
/// (`nvenc_open_einval`), and a hwdevice/hwframes EINVAL is a config error no bitrate can
/// fix — enrolling it would burn ~10 doomed encoder opens before surfacing the real failure.
unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> {
let mut device_ref = ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA);
if device_ref.is_null() {
bail!("av_hwdevice_ctx_alloc(CUDA) failed");
}
let dev_ctx = (*device_ref).data as *mut ffi::AVHWDeviceContext;
// Each `?`/`bail!` below drops whatever has been built so far — `AvBuffer`'s `Drop` is the
// single unref path, so the failure branches carry no cleanup of their own.
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
))
.context("av_hwdevice_ctx_alloc(CUDA) failed")?;
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
let cu = (*dev_ctx).hwctx as *mut AVCUDADeviceContext;
(*cu).cuda_ctx = cu_ctx; // share the importer's context
let r = ffi::av_hwdevice_ctx_init(device_ref);
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
if r < 0 {
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwdevice_ctx_init failed ({r})");
}
let mut frames_ref = ffi::av_hwframe_ctx_alloc(device_ref);
if frames_ref.is_null() {
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwframe_ctx_alloc failed");
}
let fc = (*frames_ref).data as *mut ffi::AVHWFramesContext;
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
.context("av_hwframe_ctx_alloc failed")?;
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
(*fc).sw_format = pixel_to_av(sw_format);
(*fc).width = w as c_int;
(*fc).height = h as c_int;
(*fc).initial_pool_size = 0; // we supply the device pointers
let r = ffi::av_hwframe_ctx_init(frames_ref);
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
if r < 0 {
ffi::av_buffer_unref(&mut frames_ref);
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwframe_ctx_init failed ({r})");
}
Ok(CudaHw {
device_ref,
frames_ref,
device_ref,
})
}
}
impl Drop for CudaHw {
fn drop(&mut self) {
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `CudaHw::new` created
// (it bails before returning `Self` if either alloc fails, so a live `CudaHw` always holds
// both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`. This
// `Drop` runs exactly once and `CudaHw` owns these refs exclusively → no double-free /
// use-after-free. Frames are unref'd before the device (the frames ctx internally refs the
// device; refcounted, so the order is sound regardless).
unsafe {
ffi::av_buffer_unref(&mut self.frames_ref);
ffi::av_buffer_unref(&mut self.device_ref);
}
}
}
// No `Drop` for `CudaHw`: each `AvBuffer` field unrefs itself, in declaration order (frames, then
// device — see the field comment). The hand-written unref pair this replaced had to be kept in sync
// with every failure branch in `new`; now there is exactly one unref path and it cannot be skipped.
/// Map a captured layout to the NVENC input pixel format, and whether a 3→4 byte expand is
/// needed (packed RGB/BGR have no padding byte; the NVENC `*0` formats do).
@@ -345,11 +340,23 @@ impl NvencEncoder {
};
}
// NV12 / 4:4:4 paths: we do the RGB→YUV conversion ourselves as BT.709 (swscale), so
// signal that in the bitstream VUI (colorspace/range/primaries/transfer) — otherwise the
// client decoder assumes a default and the picture comes out washed-out / wrong-contrast.
// The RGB-input 4:2:0 path leaves these unset (NVENC's internal CSC writes its own VUI).
// Matches the Windows NV12 path's BT.709 limited-range signalling.
// Colour signalling, written for EVERY session (colorspace/range/primaries/transfer) —
// otherwise the client decoder assumes a default and the picture comes out washed-out /
// wrong-contrast. Matches the Windows NV12 path's BT.709 limited-range signalling.
//
// The packed-RGB 4:2:0 path used to be excluded, on the belief that "NVENC's internal CSC
// writes its own VUI". It does not: libavcodec's nvenc wrapper derives
// `colourDescriptionPresentFlag` from these very AVCodecContext fields, so leaving them
// UNSPECIFIED produced a stream with NO colour description at all. Every punktfunk client
// then falls back to BT.709 (`csc_rows`) and looks fine, but vendor TV decoders guess from
// RESOLUTION — an LG webOS panel reads a 4K SDR stream as BT.2020 and washes it out.
// BT.709 limited is the honest answer for that path too: NVENC's internal RGB→YUV is the
// same conversion both direct-SDK backends feed from an ARGB surface
// (`nvenc_cuda.rs`/`windows/nvenc.rs`), and `nvenc_core.rs` already stamps 709-limited on
// those unconditionally. This only makes the libav sibling consistent with them.
//
// Reachable whenever the direct-SDK path is not: a CPU/dmabuf (non-CUDA) capture, a build
// without `--features nvenc`, or PUNKTFUNK_NVENC_DIRECT=0.
//
// PUNKTFUNK_444_FULLRANGE=1 (experimental, 4:4:4-only): convert AND signal FULL range —
// recovers the ~12% of code space limited-range quantization gives up, for the exact
@@ -372,7 +379,7 @@ impl NvencEncoder {
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
}
} else if matches!(format, PixelFormat::Nv12) || want_444 {
} else {
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
@@ -409,8 +416,8 @@ impl NvencEncoder {
unsafe {
let raw = video.as_mut_ptr();
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
(*raw).hw_device_ctx = ffi::av_buffer_ref(hw.device_ref);
(*raw).hw_frames_ctx = ffi::av_buffer_ref(hw.frames_ref);
(*raw).hw_device_ctx = ffi::av_buffer_ref(hw.device_ref.as_ptr());
(*raw).hw_frames_ctx = ffi::av_buffer_ref(hw.frames_ref.as_ptr());
}
Some(hw)
} else {
@@ -835,7 +842,8 @@ impl NvencEncoder {
.cuda
.as_ref()
.context("CUDA hw context missing (encoder opened in CPU mode)")?
.frames_ref;
.frames_ref
.as_ptr();
// The device→device copy below uses our shared context directly; make it current on the
// encode thread (ffmpeg pushes its own around the pool alloc, so order is fine).
pf_zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?;
@@ -984,6 +992,12 @@ impl Drop for QuietLibavLog {
/// takes for a live 4:4:4 stream — and reports whether it succeeded. HEVC-only; the result is cached
/// by the caller ([`crate::can_encode_444`]). A GPU/driver/ffmpeg without RExt 4:4:4 fails
/// the open here, so the host resolves the session to 4:2:0 before the Welcome (honest downgrade).
///
/// ⚠️ Only consulted when libav will really serve the session (`PUNKTFUNK_NVENC_DIRECT=0`, or a
/// build without `--features nvenc`). A direct-SDK host answers from the driver's caps bit instead
/// (`nvenc_cuda::probe_support`) — running THIS probe there mixes ffmpeg's NVENC client into a
/// direct-SDK process, which is the LOG-3 field bug: one successful `hevc_nvenc` FREXT open+close
/// wedged every later NVENC open process-wide (`NV_ENC_ERR_INVALID_VERSION`) until a host restart.
pub fn probe_can_encode_444(codec: Codec) -> bool {
if codec != Codec::H265 {
return false;
@@ -1037,6 +1051,37 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
.is_ok()
}
#[cfg(test)]
mod cuda_hw_tests {
use super::*;
/// `CudaHw` owns its two `AVBufferRef`s through `AvBuffer`, so *construct and drop* is the
/// entire contract: a missed unref leaks, a doubled one aborts inside glibc. Nothing else in
/// the suite covers it — the NVENC smoke tests take the CPU path and never build one, and the
/// VAAPI twin's tests need AMD/Intel silicon. Looping the cycle is the point: a double-unref
/// shows up as an abort, and a leak shows as the allocator growing across iterations.
///
/// `#[ignore]`d (needs a real CUDA device):
/// `cargo test -p pf-encode cuda_hw_alloc_drop_cycles -- --ignored --nocapture`
#[test]
#[ignore = "needs a real CUDA device (run on an NVIDIA host, not the build box)"]
fn cuda_hw_alloc_drop_cycles() {
ffmpeg::init().expect("libav init");
let cu_ctx = pf_zerocopy::cuda::context().expect("shared CUDA context");
for i in 0..8 {
// SAFETY: `CudaHw::new` requires libav initialized (asserted above) and a valid
// `CUcontext` — `cu_ctx` is the live shared context from `pf_zerocopy`. NV12 at
// 640x480 are a valid format and positive dims. The handle drops at the end of each
// iteration, which is precisely the unref path under test.
let hw = unsafe { CudaHw::new(cu_ctx.cast(), Pixel::NV12, 640, 480) }
.unwrap_or_else(|e| panic!("CudaHw::new failed on iteration {i}: {e:#}"));
assert!(!hw.device_ref.as_ptr().is_null(), "device ref went null");
assert!(!hw.frames_ref.as_ptr().is_null(), "frames ref went null");
}
eprintln!("8 CudaHw alloc/drop cycles completed without abort");
}
}
#[cfg(test)]
mod hdr_tests {
use super::*;
+448 -25
View File
@@ -57,6 +57,12 @@
//! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly
//! and the VAAPI/software backends carry the session).
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw CUDA driver + `nvEncodeAPI` entry-table calls almost line for line;
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -107,6 +113,12 @@ struct EncodeApi {
*mut nv::NV_ENC_CAPS_PARAM,
*mut core::ffi::c_int,
) -> nv::NVENCSTATUS,
// The two entry points behind [`probe_support`] — the driver's own list of encode GUIDs
// this chip exposes. Mandatory like every other entry: both have existed since NVENC 1.0, so a
// driver missing them is broken in ways the rest of this table would not survive either.
get_encode_guid_count: unsafe extern "C" fn(*mut c_void, *mut u32) -> nv::NVENCSTATUS,
get_encode_guids:
unsafe extern "C" fn(*mut c_void, *mut nv::GUID, u32, *mut u32) -> nv::NVENCSTATUS,
get_encode_preset_config_ex: unsafe extern "C" fn(
*mut c_void,
nv::GUID,
@@ -168,6 +180,141 @@ fn api() -> &'static EncodeApi {
try_api().expect("NVENC call before a successful try_api() gate")
}
/// Everything the host advertisement asks of this GPU's NVENC, answered by the driver itself on
/// ONE throwaway session: the encode-GUID list (which codecs exist at all) and the HEVC 4:4:4 cap.
#[derive(Clone, Copy)]
pub(crate) struct ProbedSupport {
/// Which codecs this chip's NVENC encodes (`nvEncGetEncodeGUIDs`). All-`false` = the probe
/// could not answer — [`crate::CodecSupport::wire_mask`] turns that into `None` so the caller
/// keeps the static superset (fail open).
pub codecs: crate::CodecSupport,
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` for the HEVC GUID — whether this chip can encode
/// full-chroma 4:4:4 HEVC. `false` when unanswered (fail CLOSED, unlike `codecs`: the honest
/// downgrade is a 4:2:0 session, not a dead one).
pub hevc_444: bool,
}
/// The cached [`probe_support_uncached`] answer — one throwaway session per process lifetime.
pub(crate) fn probe_support() -> ProbedSupport {
static CACHE: std::sync::OnceLock<ProbedSupport> = std::sync::OnceLock::new();
*CACHE.get_or_init(probe_support_uncached)
}
/// Which codecs **this GPU's** NVENC can actually encode — and whether HEVC can go 4:4:4 — asked
/// of the driver itself (`nvEncGetEncodeGUIDs` + `nvEncGetEncodeCaps`) instead of assumed from the
/// SDK version.
///
/// Why this exists: the host used to advertise a static `H.264 | HEVC | AV1` superset for every
/// NVIDIA box, so a chip without HEVC NVENC (1st-gen Maxwell, e.g. GTX 960M — HEVC needs 2nd-gen
/// Maxwell+, AV1 needs Ada+) still offered HEVC. A client reasonably negotiated H265 and got a dead
/// session: `hevc_nvenc` "No capable devices found", eight pipeline retries, ~15 s of blank video,
/// then a disconnect. The GUID list is a property of the chip+driver, so it is equally right for
/// the direct-SDK backend and the libav `*_nvenc` one.
///
/// ⚠️ Deliberately NOT the VAAPI probe's shape (open a tiny libav encoder per codec). That would run
/// ffmpeg's NVENC client, and mixing it with this direct-SDK client in one process is the prime
/// suspect for the open bug where one `probe_can_encode_444` open wedges NVENC **process-wide**
/// (`NV_ENC_ERR_INVALID_VERSION` on every later session until a host restart — LOG-3, Droff,
/// 0.19.2). This asks the SAME client, on the SAME shared CUDA context, that real sessions use —
/// one extra session open of a kind the encoder already performs per open (`query_caps`), cached
/// once per process by [`probe_support`]. The 4:4:4 cap rides the same session for the same
/// reason: it used to be its own libav `hevc_nvenc` FREXT open — the exact open LOG-3 caught
/// wedging NVENC — and the direct backend re-checks the same cap at session open anyway
/// (`query_caps` → `yuv444_supported`), so the caps bit is the answer the live session will obey.
///
/// Every failure path returns "nothing probed" (see the [`ProbedSupport`] field docs for the
/// per-field fail direction).
fn probe_support_uncached() -> ProbedSupport {
let unknown = ProbedSupport {
codecs: crate::CodecSupport {
h264: false,
h265: false,
av1: false,
},
hevc_444: false,
};
let Ok(api) = try_api() else {
return unknown;
};
let cu_ctx = match cuda::context() {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "NVENC codec probe: no CUDA context");
return unknown;
}
};
// SAFETY: `try_api()` returned Ok, so every fn pointer below is a live entry point from the
// driver's own function list. `params`/`enc`/`count`/`written` are live locals that outlive
// their synchronous calls; `device` is the process-shared CUDA context (`cuda::context()`
// returned Ok), the same handle `query_caps` passes. `guids` is sized to the count the driver
// just reported and its pointer is valid for that many `GUID`s, matching the
// `guidArraySize` argument. The session is destroyed on every path out — including the failed
// open, which the NVENC docs still require (the driver may have taken the slot before
// erroring; skipping it leaks toward the concurrent-session cap).
unsafe {
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA,
device: cu_ctx,
apiVersion: nv::NVENCAPI_VERSION,
..Default::default()
};
let mut enc: *mut c_void = ptr::null_mut();
if let Err(e) = (api.open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
if !enc.is_null() {
let _ = (api.destroy_encoder)(enc);
}
tracing::warn!(
error = %format!("{:#}", nvenc_status::call_err("open_encode_session_ex (codec probe)", e)),
"NVENC codec probe failed — keeping the static codec advertisement"
);
return unknown;
}
// The handshake with the kernel module succeeded (same latch `query_caps` sets).
nvenc_status::note_session_opened();
let mut count = 0u32;
let counted = (api.get_encode_guid_count)(enc, &mut count).nv_ok().is_ok();
let mut guids = vec![nv::GUID::default(); count as usize];
let mut written = 0u32;
let listed = counted
&& count > 0
&& (api.get_encode_guids)(enc, guids.as_mut_ptr(), count, &mut written)
.nv_ok()
.is_ok();
guids.truncate(written as usize);
// The 4:4:4 cap needs the session that is still open — query it before the destroy. Only
// meaningful against a listed HEVC GUID (a cap query for an absent codec is undefined).
let mut hevc_444 = false;
if listed && guids.contains(&nv::NV_ENC_CODEC_HEVC_GUID) {
let mut param = nv::NV_ENC_CAPS_PARAM {
version: nv::NV_ENC_CAPS_PARAM_VER,
capsToQuery: nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE,
reserved: [0; 62],
};
let mut val: core::ffi::c_int = 0;
hevc_444 = (api.get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
.nv_ok()
.is_ok()
&& val != 0;
}
let _ = (api.destroy_encoder)(enc);
if !listed {
tracing::warn!(
"NVENC codec probe: driver listed no encode GUIDs — keeping the static advertisement"
);
return unknown;
}
ProbedSupport {
codecs: crate::CodecSupport {
h264: guids.contains(&nv::NV_ENC_CODEC_H264_GUID),
h265: guids.contains(&nv::NV_ENC_CODEC_HEVC_GUID),
av1: guids.contains(&nv::NV_ENC_CODEC_AV1_GUID),
},
hevc_444,
}
}
}
fn load_api() -> std::result::Result<EncodeApi, String> {
// SAFETY: `Library::new` runs `libnvidia-encode.so.1`'s initializers — the trusted NVIDIA driver
// library, so loading has no unexpected effects; `map_err` handles its absence (AMD/Intel/no
@@ -223,6 +370,8 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?,
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
get_encode_guid_count: list.nvEncGetEncodeGUIDCount.ok_or(MISSING)?,
get_encode_guids: list.nvEncGetEncodeGUIDs.ok_or(MISSING)?,
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?,
destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?,
@@ -415,21 +564,46 @@ fn retrieve_loop(
}
}
/// The NVENC input buffer format for a captured `DeviceBuffer`'s layout. NV12/YUV444 are the zero-
/// copy worker's convert outputs; packed RGB (`ABGR`) is the fallback where NVENC does the internal
/// CSC. 10-bit is never produced on Linux today (Phase 5.1), so everything is 8-bit.
fn buffer_format(buf: &cuda::DeviceBuffer) -> nv::NV_ENC_BUFFER_FORMAT {
/// The NVENC input buffer format for a captured frame. NV12/YUV444 are the zero-copy worker's
/// convert outputs and are recognised from the `DeviceBuffer`'s layout; the packed formats are 4
/// bytes per pixel either way, so their DEPTH and channel order can only come from the capture
/// format — which is why `fmt` is a parameter and not something derived from `buf`.
///
/// Packed RGB lets NVENC do the CSC internally, which is exactly what an HDR gamescope session
/// wants: the frame is already PQ-encoded BT.2020 RGB, and NVENC's internal conversion follows the
/// configured VUI matrix (BT.2020 NCL for HDR — see `apply_low_latency_config`), so there is no
/// host-side CSC pass and no depth loss anywhere on the path.
fn buffer_format(buf: &cuda::DeviceBuffer, fmt: pf_frame::PixelFormat) -> nv::NV_ENC_BUFFER_FORMAT {
if buf.yuv444 {
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
} else if buf.is_nv12() {
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12
} else {
// Packed 4-byte BGRA-order (the `copy_device_to_device` fallback path); NVENC's `ARGB`
// ingests this layout + does the internal CSC, matching the proven Windows RGB-input path.
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
match fmt {
// `x:R:G:B` 2:10:10:10 LE — NVENC's `ARGB10` is the same word layout (B in the low
// 10 bits, R in bits 20-29).
pf_frame::PixelFormat::X2Rgb10 => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB10,
// `x:B:G:R` 2:10:10:10 LE — NVENC's `ABGR10` (R in the low 10 bits).
pf_frame::PixelFormat::X2Bgr10 => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10,
// Packed 4-byte BGRA-order (the `copy_device_to_device` fallback path); NVENC's `ARGB`
// ingests this layout + does the internal CSC, matching the proven Windows RGB-input
// path.
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
}
}
}
/// Is `fmt` one of NVENC's packed 10-bit RGB inputs? Decides the session's effective bit depth and
/// HDR flag — the input format is the only honest source for both (a 10-bit-negotiated session
/// whose capture came back 8-bit must encode, and label, 8-bit).
fn is_ten_bit_input(fmt: nv::NV_ENC_BUFFER_FORMAT) -> bool {
matches!(
fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB10
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
)
}
/// One encoder-owned input surface + its NVENC registration. The surface is copied into each
/// use (device→device) and the registration is created once at session init, unregistered at teardown.
struct RingSlot {
@@ -453,6 +627,10 @@ fn slot_fmt_of(fmt: nv::NV_ENC_BUFFER_FORMAT) -> SlotFormat {
match fmt {
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => SlotFormat::Yuv444,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => SlotFormat::Nv12,
// Still 4 bytes per pixel, so the slot GEOMETRY matches `Argb` — but the cursor blend
// must unpack 10-bit channels instead of bytes, hence a separate mode per channel order.
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB10 => SlotFormat::X2Rgb10,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10 => SlotFormat::X2Bgr10,
_ => SlotFormat::Argb,
}
}
@@ -653,9 +831,10 @@ unsafe impl Send for NvencCudaEncoder {}
impl NvencCudaEncoder {
/// Signature mirrors `super::NvencEncoder::open` so the Linux dispatcher fork is a one-line swap.
/// `format`/`cuda` are advisory: the session's real input format is derived from the first
/// captured `DeviceBuffer`'s layout (lazy init in `submit`), and this backend only accepts CUDA
/// frames (a CPU/dmabuf payload `bail`s). `bit_depth` is pinned to 8 on Linux (Phase 5.1 will
/// lift it once P010 capture exists).
/// captured frame (lazy init in `submit`), and this backend only accepts CUDA frames (a
/// CPU/dmabuf payload `bail`s). The effective `bit_depth`/`hdr` are derived from that same
/// input format rather than trusted from the negotiation — a 10-bit session whose capture came
/// back 8-bit must encode 8-bit AND say so, never mislabel.
#[allow(clippy::too_many_arguments)]
pub fn open(
codec: Codec,
@@ -672,16 +851,7 @@ impl NvencCudaEncoder {
// The runtime `.so` load is the real "is NVENC possible here" gate: fail the open with a
// clear reason instead of an opaque session error on the first frame.
try_api().map_err(|e| anyhow!("NVENC (Linux direct) unavailable: {e}"))?;
if bit_depth >= 10 {
// An HDR (GNOME 50 portal) session never reaches this backend: its X2RGB10 frames ride
// the CPU/dmabuf paths (no CUDA import for the 10-bit formats yet), so the dispatcher
// opens the libav P010 path instead. Reaching here 10-bit means a CUDA capture payload
// on a 10-bit session — not wired; encode 8-bit rather than mislabel.
tracing::warn!(
"Linux direct-NVENC: 10-bit requested but the CUDA capture path has no 10-bit \
import yet (HDR rides the libav P010 path) encoding 8-bit SDR"
);
}
Ok(Self {
encoder: ptr::null_mut(),
cu_ctx: ptr::null_mut(),
@@ -692,7 +862,9 @@ impl NvencCudaEncoder {
fps,
bitrate_bps,
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12,
bit_depth: 8,
// Provisional until the first frame names the real input format (see `submit`'s init
// block, which sets both from `buffer_fmt`).
bit_depth,
// 4:4:4 is HEVC-only; confirmed against the frame layout + GPU support at init.
chroma_444: chroma.is_444() && codec == Codec::H265,
yuv444_supported: false,
@@ -1021,8 +1193,8 @@ impl NvencCudaEncoder {
let mut cfg = preset.presetCfg;
// Steps 3-7 (RC/VBV, tier+level, chroma+bit-depth, colour VUI, RFI DPB) are the shared
// low-latency contract. On Linux the full-chroma input is a YUV444 surface and the input is
// 8-bit today, so AV1's input-depth is 0.
// low-latency contract. On Linux the full-chroma input is a YUV444 surface; AV1's
// input-depth follows the surface format (10-bit for a packed PQ/BT.2020 HDR capture).
let yuv444_input = matches!(
self.buffer_fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
@@ -1037,7 +1209,11 @@ impl NvencCudaEncoder {
chroma_444: self.chroma_444,
full_chroma_input: yuv444_input,
bit_depth: self.bit_depth,
av1_input_depth_minus8: 0,
av1_input_depth_minus8: if is_ten_bit_input(self.buffer_fmt) {
2
} else {
0
},
hdr: self.hdr,
rfi_supported: self.rfi_supported,
slices: self.slices,
@@ -1533,7 +1709,7 @@ impl Encoder for NvencCudaEncoder {
self.maybe_disengage_async();
// Re-init on a size change (the capturer can return at a different resolution after a mode
// switch). Format changes (NV12↔YUV444) likewise re-init.
let new_fmt = buffer_format(buf);
let new_fmt = buffer_format(buf, captured.format);
let size_changed =
self.inited && (self.width != captured.width || self.height != captured.height);
let fmt_changed = self.inited && self.buffer_fmt != new_fmt;
@@ -1554,6 +1730,21 @@ impl Encoder for NvencCudaEncoder {
self.width = captured.width;
self.height = captured.height;
self.buffer_fmt = new_fmt;
// Depth + HDR follow the INPUT, like the Windows backend: a packed 10-bit PQ/BT.2020
// capture (an HDR gamescope output) selects Main10 / AV1 10-bit and the BT.2020 PQ
// colour signalling; anything else is 8-bit SDR. Deriving it here rather than
// trusting the negotiated depth is what keeps the label and the bitstream in step
// when capture and negotiation disagree.
let ten_bit_in = is_ten_bit_input(new_fmt);
if self.bit_depth >= 10 && !ten_bit_in {
tracing::warn!(
format = ?captured.format,
"Linux direct-NVENC: 10-bit negotiated but the capture delivered an 8-bit \
format encoding 8-bit SDR (the stream is labelled to match)"
);
}
self.bit_depth = if ten_bit_in { 10 } else { 8 };
self.hdr = ten_bit_in;
// 4:4:4 honesty: engage FREXT only on a genuine YUV444 input; a subsampled NV12/RGB input
// can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful.
self.chroma_444 = self.chroma_444 && buf.yuv444;
@@ -2264,6 +2455,32 @@ mod tests {
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
use pf_zerocopy::cuda::DeviceBuffer;
/// The 10-bit input mapping is load-bearing in a way a smoke test can't reach: pick the wrong
/// NVENC format for a packed 2:10:10:10 capture and the encoder reads the words as 8-bit
/// `ARGB` — a picture that decodes, looks *almost* right, and is silently 8-bit with the
/// channels shifted. These are the two tables that decide it.
#[test]
fn ten_bit_rgb_maps_to_the_matching_nvenc_format_and_blend_mode() {
use nv::NV_ENC_BUFFER_FORMAT as F;
// `x:R:G:B` (B in the low bits) is NVENC's ARGB10; `x:B:G:R` is ABGR10.
assert!(is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_ARGB10));
assert!(is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_ABGR10));
assert!(!is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_ARGB));
assert!(!is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_NV12));
assert!(!is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_YUV444));
// …and each gets the cursor-blend mode that unpacks ITS channel order. Swapping these
// would tint the pointer (R and B exchanged) with nothing else out of place.
assert_eq!(
slot_fmt_of(F::NV_ENC_BUFFER_FORMAT_ARGB10),
SlotFormat::X2Rgb10
);
assert_eq!(
slot_fmt_of(F::NV_ENC_BUFFER_FORMAT_ABGR10),
SlotFormat::X2Bgr10
);
assert_eq!(slot_fmt_of(F::NV_ENC_BUFFER_FORMAT_ARGB), SlotFormat::Argb);
}
fn nv12_frame(w: u32, h: u32, i: u32) -> CapturedFrame {
// Content is uninitialized device memory — NVENC encodes it fine; this smoke test asserts the
// session/registration/encode/RFI machinery, not picture fidelity (that's the on-glass A/B).
@@ -2284,6 +2501,46 @@ mod tests {
/// and assert the next AU carries the recovery-anchor tag (the F2 fix) and that `caps()`
/// advertises RFI. Needs an NVIDIA GPU + driver. Run:
/// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_cuda_smoke --nocapture
/// ON-HARDWARE: the codec/4:4:4 advertisement probe against the real driver. Asserts the two
/// invariants that matter for what the host advertises — every NVENC-capable GPU ever made can
/// encode H.264, so a probe that comes back with `h264 = false` while NVENC is otherwise
/// working means the enumeration itself is broken (and would silently narrow the host's
/// advertisement); and the answer must be stable across calls (asserted on the UNCACHED fn —
/// the cached [`probe_support`] would make it vacuous), since one cached answer drives every
/// negotiation. Prints the mask so a run on an OLD card (Maxwell GM107 = h264 only, no 4:4:4 —
/// the GPU this probe exists for) is self-documenting. Run:
/// cargo test -p pf-encode --features nvenc -- --ignored nvenc_codec_probe --nocapture
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on an NVIDIA box"]
fn nvenc_codec_probe_reports_real_gpu_support() {
let probed = probe_support_uncached();
let caps = probed.codecs;
eprintln!(
"NVENC probe: h264={} h265={} av1={} hevc_444={}",
caps.h264, caps.h265, caps.av1, probed.hevc_444
);
assert!(
caps.h264,
"every NVENC generation encodes H.264 — a false here means the GUID enumeration \
failed, which would narrow the host's codec advertisement"
);
assert!(
!probed.hevc_444 || caps.h265,
"a 4:4:4-capable HEVC that is not in the GUID list is contradictory"
);
let again = probe_support_uncached();
assert_eq!(
(caps.h264, caps.h265, caps.av1, probed.hevc_444),
(
again.codecs.h264,
again.codecs.h265,
again.codecs.av1,
again.hevc_444
),
"the probe must be stable — it is cached once and drives every later negotiation"
);
}
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_smoke_rfi_anchor() {
@@ -2358,6 +2615,172 @@ mod tests {
);
}
/// A packed 2:10:10:10 (`X2Rgb10`) CUDA frame — the layout an HDR gamescope capture imports
/// through the Vulkan bridge, and what NVENC ingests as `ARGB10` with no host CSC at all.
/// The device memory is uninitialised: this smoke asserts the session/registration/encode
/// machinery, not picture fidelity (that is the AMD round-trip's job — the CSC here is
/// NVENC's own ASIC, not our shader).
fn rgb10_frame(w: u32, h: u32, i: u32) -> CapturedFrame {
let buf = DeviceBuffer::alloc(w, h).expect("alloc packed RGB device buffer");
CapturedFrame {
width: w,
height: h,
pts_ns: i as u64 * 16_666_667,
format: PixelFormat::X2Rgb10,
payload: FramePayload::Cuda(buf),
cursor: None,
}
}
/// ON-HARDWARE: the HDR path — a packed 10-bit PQ/BT.2020 CUDA payload straight into NVENC as
/// `ARGB10`, which is what makes an NVIDIA HDR session zero-copy AND host-CSC-free: NVENC does
/// the BT.2020 conversion in the ASIC, following the VUI this session configures.
///
/// The load-bearing assertions are the ones that would catch a mislabelled stream: the encoder
/// must have DERIVED 10-bit from the input format (not merely been asked for it), and it must
/// report HDR — that pair is what selects Main10 / AV1-10 and the BT.2020 PQ signalling.
#[test]
#[ignore = "requires an NVIDIA GPU + driver with 10-bit encode"]
fn nvenc_cuda_hdr10_packed_rgb() {
for codec in [Codec::H265, Codec::Av1] {
const W: u32 = 1280;
const H: u32 = 720;
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
codec,
PixelFormat::X2Rgb10,
W,
H,
60,
20_000_000,
true,
10,
ChromaFormat::Yuv420,
false,
)
.expect("open NVENC CUDA session");
let mut aus = 0usize;
let mut first_key = false;
let mut stream: Vec<u8> = Vec::new();
for i in 0..4u32 {
enc.submit_indexed(&rgb10_frame(W, H, i), i)
.expect("submit");
while let Some(au) = enc.poll().expect("poll") {
if aus == 0 {
first_key = au.keyframe;
}
assert!(!au.data.is_empty(), "empty AU");
stream.extend_from_slice(&au.data);
aus += 1;
}
}
enc.flush().ok();
// Dumped for the out-of-band ffprobe check. In-tree we can assert the encoder's OWN
// view of the config; only a decoder confirms the BITSTREAM says Main10 / BT.2020 /
// PQ, which is what a client actually reads.
if let Ok(home) = std::env::var("HOME") {
let ext = if codec == Codec::Av1 { "obu" } else { "h265" };
let path = format!("{home}/nvenc-hdr10.{ext}");
if std::fs::write(&path, &stream).is_ok() {
println!(
"nvenc_cuda HDR10 {codec:?}: wrote {path} ({} bytes)",
stream.len()
);
}
}
assert!(aus > 0, "{codec:?}: no AUs produced");
assert!(first_key, "{codec:?}: first AU must be the session IDR");
// The whole point: depth + HDR came from the INPUT format, so the bitstream's profile
// and colour signalling describe what was actually encoded.
assert_eq!(enc.bit_depth, 10, "{codec:?}: must have derived 10-bit");
assert!(
enc.hdr,
"{codec:?}: must have derived HDR from the PQ format"
);
assert_eq!(
enc.buffer_fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB10,
"{codec:?}: X2Rgb10 must ingest as ARGB10"
);
println!("nvenc_cuda HDR10 {codec:?}: {aus} AUs, ARGB10 in, 10-bit derived");
}
}
/// ON-HARDWARE: the cursor blended into a **10-bit** input surface — `cursor_blend.comp`'s
/// MODE 3/4, which unpack 2:10:10:10 channels instead of bytes. New shader code, and the only
/// way a gamescope pointer reaches an HDR NVIDIA stream: the packed-RGB slot is what NVENC
/// ingests, so the blend has to happen in that layout rather than in a YUV plane.
///
/// Asserts the machinery — AUs come out, and the blend targets the 10-bit slot layout rather
/// than silently falling back to the 8-bit one, which would tint the pointer and shift its
/// channels. Blend CORRECTNESS is display-referred by design (see the shader), so it is
/// judged by eye on a dump, not here.
#[test]
#[ignore = "requires an NVIDIA GPU + driver with 10-bit encode"]
fn nvenc_cuda_hdr10_cursor_blend() {
const W: u32 = 1280;
const H: u32 = 720;
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
if !stream_ordered_requested() || async_retrieve_requested() {
println!("skipped: stream-ordered submit disabled by env");
return;
}
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::X2Rgb10,
W,
H,
60,
8_000_000,
true,
10,
ChromaFormat::Yuv420,
true, // cursor_blend: bring up the Vulkan slot ring + the 10-bit blend
)
.expect("open NVENC CUDA session");
let cursor = |serial: u64, x: i32, y: i32| pf_frame::CursorOverlay {
x,
y,
w: 32,
h: 32,
rgba: std::sync::Arc::new(vec![0xFF; 32 * 32 * 4]),
serial,
hot_x: 0,
hot_y: 0,
visible: true,
};
let mut aus = 0usize;
for i in 0..6u32 {
let mut frame = rgb10_frame(W, H, i);
// Bitmap serial flips at frame 3 (upload quiesce over in-flight ordered blends); the
// position moves every frame (push-constant path) — same shape as the 8-bit twin.
frame.cursor = Some(cursor(
if i < 3 { 1 } else { 2 },
40 + i as i32 * 9,
60 + i as i32 * 5,
));
enc.submit_indexed(&frame, i).expect("submit");
while let Some(au) = enc.poll().expect("poll") {
assert!(!au.data.is_empty(), "empty AU");
aus += 1;
}
}
enc.flush().ok();
assert!(aus > 0, "no AUs produced");
assert_eq!(enc.bit_depth, 10, "must be a 10-bit session");
assert_eq!(
slot_fmt_of(enc.buffer_fmt),
SlotFormat::X2Rgb10,
"the blend must target the 10-bit packed slot layout, not the 8-bit one"
);
assert!(
enc.caps().blends_cursor,
"the direct-SDK path must still report a cursor blend at 10-bit"
);
println!("nvenc_cuda HDR10 cursor blend: {aus} AUs, slot fmt X2Rgb10");
}
/// ON-HARDWARE (RTX box `.21`): the 4:4:4 path — a planar-YUV444 `DeviceBuffer` through an HEVC
/// FREXT (chromaFormatIDC=3) session, exercising the stacked-plane input surface + copy that NV12
/// doesn't. Asserts AUs come out and `caps().chroma_444` reports true (the GPU supports it). Run:
+28 -1
View File
@@ -21,6 +21,13 @@
//! on every AU. NOTE: until Phase 2 lands `CODEC_PYROWAVE` negotiation + a client decoder,
//! no shipping client can decode this — the backend is reachable only via an explicit
//! `PUNKTFUNK_ENCODER=pyrowave` and logs that loudly.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is `pyrowave-sys` C-API and ash/Vulkan compute calls almost line for line;
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every unsafe block in this module carries a `// SAFETY:` proof (parent module enforces it).
use super::vk_util::{
@@ -395,6 +402,9 @@ pub struct PyroWaveEncoder {
/// packet to it, so each wire shard carries whole self-delimiting packets. `None` =
/// one packet per AU (the dense MVP shape).
wire_chunk: Option<usize>,
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
/// WIRE, not just the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
wire_budget: crate::pyrowave_wire::WireBudget,
bitstream: Vec<u8>,
pending: VecDeque<EncodedFrame>,
frame_count: u64,
@@ -653,6 +663,7 @@ impl PyroWaveEncoder {
chroma444,
frame_budget: budget_for(bitrate, fps),
wire_chunk: None,
wire_budget: crate::pyrowave_wire::WireBudget::new(),
bitstream: Vec::new(),
pending: VecDeque::new(),
frame_count: 0,
@@ -1120,6 +1131,16 @@ impl PyroWaveEncoder {
Ok(self.cpu_img.unwrap().2)
}
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
/// a promise about the wire, not the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
fn rate_budget(&self) -> usize {
match self.wire_chunk {
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
None => self.frame_budget,
}
}
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
@@ -1175,6 +1196,8 @@ impl PyroWaveEncoder {
// paths propagate untouched and the recovery (`reset()`/`Drop`) `device_wait_idle()`s
// before anything touches `cmd`; a buffer that completed its one-time submit is INVALID,
// which the next `begin` may implicitly reset.
// Resolved before the closure (which borrows `self` mutably for the recording calls).
let rate_budget = self.rate_budget();
let record_and_submit = (|| -> Result<()> {
dev.begin_command_buffer(
self.cmd,
@@ -1396,7 +1419,7 @@ impl PyroWaveEncoder {
],
};
let rc = pw::pyrowave_rate_control {
maximum_bitstream_size: self.frame_budget,
maximum_bitstream_size: rate_budget,
};
pw::pyrowave_device_set_command_buffer(
self.pw_dev,
@@ -1476,6 +1499,10 @@ impl PyroWaveEncoder {
// single packet, or the datagram-aligned windowed AU (§4.4).
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
if self.wire_chunk.is_some() {
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
self.wire_budget.observe(raw, au.len());
}
self.frame_count += 1;
self.pending.push_back(EncodedFrame {
data: au,
@@ -0,0 +1,74 @@
#version 450
// The 10-bit HDR twin of `rgb2yuv.comp`: packed 2:10:10:10 RGB -> 10-bit 4:2:0 (BT.2020 NCL,
// limited range), for an HEVC Main10 session off a gamescope HDR capture.
//
// The source samples are ALREADY PQ-encoded BT.2020 R'G'B' (gamescope composites into the PQ
// container; see packaging/gamescope), so this is a pure 3x3 matrix on the code values — there is
// no transfer function to apply here, and applying one would be wrong. That is the whole
// difference from the SDR shader besides the coefficients and the store width.
//
// STORE LAYOUT. The scratch planes are `r16`/`rg16` and get `vkCmdCopyImage`d into the
// `G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture, whose plane texels put the 10-bit value in
// the HIGH bits of a 16-bit word (`X6` = 6 unused low bits). So a written normalized value `f`
// lands as `round(f * 65535)` and must equal `code10 << 6` — hence the `CODE10` factor below.
//
// What actually goes wrong if you change it: the `<< 6` PLACEMENT is the load-bearing part —
// dropping it (writing `code10 / 65535.0`) is 64x too dark, i.e. a black picture, which is at
// least obvious. Writing `code10 / 1023.0` instead is only ~0.1% high (65472 vs 65535 full
// scale) and is genuinely harmless; it is wrong, not dangerous. Verified by round-trip on RADV
// 2026-07-28: fed (160,160,800) 10-bit codes, decoded back (159,158,796).
//
// Rebuild: glslangValidator -V rgb2yuv10.comp -o rgb2yuv10.spv (vendored beside this file)
layout(local_size_x = 8, local_size_y = 8) in;
layout(binding = 0) uniform sampler2D rgb; // packed 2:10:10:10 input (sampled)
layout(binding = 1, r16) uniform writeonly image2D yImg; // full-res Y (10-bit in the high bits)
layout(binding = 2, rg16) uniform writeonly image2D uvImg; // half-res UV (interleaved)
layout(binding = 3) uniform sampler2D cursorTex; // straight-alpha RGBA cursor (top-left)
layout(push_constant) uniform Push {
ivec2 curOrigin; // top-left of the cursor in frame pixels (position - hotspot)
ivec2 curSize; // cursor w,h in pixels; x <= 0 => disabled
} pc;
// 10-bit code value -> the normalized float that stores it in the high bits of a 16-bit texel.
const float CODE10 = 64.0 / 65535.0;
// BT.2020 non-constant-luminance, limited range, expressed directly in 10-bit code values:
// Y' = 64 + 876*(0.2627R + 0.6780G + 0.0593B)
// Cb = 512 + 896*(B' - Y')/1.8814
// Cr = 512 + 896*(R' - Y')/1.4746
float lumaY(vec3 c) { return 64.0 + 230.1252*c.r + 593.9280*c.g + 51.9468*c.b; }
// Blend the cursor over `col` at frame pixel `p`, when `p` falls inside the cursor rectangle.
// The cursor bitmap is 8-bit sRGB and the frame is PQ, so this is a display-referred
// approximation — the same one the CPU path (`pw_cursor.rs::composite_cursor_rgb10`) and the
// CUDA path (`cursor_blend.comp` MODE 3/4) already make. Fine for a pointer.
vec3 withCursor(ivec2 p, vec3 col) {
if (pc.curSize.x <= 0) return col;
ivec2 cp = p - pc.curOrigin;
if (cp.x < 0 || cp.y < 0 || cp.x >= pc.curSize.x || cp.y >= pc.curSize.y) return col;
vec4 c = texelFetch(cursorTex, cp, 0);
return mix(col, c.rgb, c.a);
}
// Source may be SMALLER than the coded (16-aligned) Y plane — clamp every fetch to the source
// edge so the alignment-padding rows duplicate the last real row (see the SDR shader).
void main() {
ivec2 sz = imageSize(yImg);
ivec2 rmax = textureSize(rgb, 0) - 1;
ivec2 uvc = ivec2(gl_GlobalInvocationID.xy);
ivec2 p = uvc * 2;
if (p.x >= sz.x || p.y >= sz.y) return;
vec3 c00 = withCursor(p, texelFetch(rgb, min(p, rmax), 0).rgb);
vec3 c10 = withCursor(p + ivec2(1, 0), texelFetch(rgb, min(p + ivec2(1, 0), rmax), 0).rgb);
vec3 c01 = withCursor(p + ivec2(0, 1), texelFetch(rgb, min(p + ivec2(0, 1), rmax), 0).rgb);
vec3 c11 = withCursor(p + ivec2(1, 1), texelFetch(rgb, min(p + ivec2(1, 1), rmax), 0).rgb);
imageStore(yImg, p, vec4(lumaY(c00) * CODE10, 0, 0, 1));
imageStore(yImg, p + ivec2(1, 0), vec4(lumaY(c10) * CODE10, 0, 0, 1));
imageStore(yImg, p + ivec2(0, 1), vec4(lumaY(c01) * CODE10, 0, 0, 1));
imageStore(yImg, p + ivec2(1, 1), vec4(lumaY(c11) * CODE10, 0, 0, 1));
vec3 a = (c00 + c10 + c01 + c11) * 0.25;
float U = 512.0 - 125.1085*a.r - 322.8915*a.g + 448.0000*a.b;
float V = 512.0 + 448.0000*a.r - 411.9680*a.g - 36.0320*a.b;
imageStore(uvImg, uvc, vec4(U * CODE10, V * CODE10, 0, 1));
}
Binary file not shown.
+124 -117
View File
@@ -19,6 +19,13 @@
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) calls on borrowed AVFrame/AVBuffer
// pointers almost line for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per
// call that could only restate the signature. Clearing this file means DELETING the markers that
// carry no caller contract, not wrapping the calls — until then the lint is off HERE and enforced
// everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -36,7 +43,8 @@ use std::ptr;
use std::sync::{Mutex, OnceLock};
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFilterGraph, PollOutcome,
SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -396,8 +404,8 @@ pub fn probe_can_encode(codec: Codec) -> bool {
480,
30,
2_000_000,
hw.device_ref,
hw.frames_ref,
hw.device_ref.as_ptr(),
hw.frames_ref.as_ptr(),
false,
)
.is_ok(),
@@ -435,8 +443,8 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
480,
30,
2_000_000,
hw.device_ref,
hw.frames_ref,
hw.device_ref.as_ptr(),
hw.frames_ref.as_ptr(),
true,
)
.is_ok(),
@@ -463,8 +471,11 @@ pub fn probe_can_encode_444(_codec: Codec) -> bool {
/// VAAPI device + NV12 frames pool (the encoder's input surfaces for the CPU path).
struct VaapiHw {
device_ref: *mut ffi::AVBufferRef,
frames_ref: *mut ffi::AVBufferRef,
// Declared frames-BEFORE-device on purpose: these drop in declaration order, which reproduces
// exactly what the hand-written `Drop` this replaced did (the frames ctx holds its own
// reference on the device). Do not reorder these two fields.
frames_ref: AvBuffer,
device_ref: AvBuffer,
}
impl VaapiHw {
@@ -481,44 +492,32 @@ impl VaapiHw {
if r < 0 {
bail!("no VAAPI device ({:?}): {}", node, ffmpeg::Error::from(r));
}
let mut frames_ref = ffi::av_hwframe_ctx_alloc(device_ref);
if frames_ref.is_null() {
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwframe_ctx_alloc(VAAPI) failed");
}
let fc = (*frames_ref).data as *mut ffi::AVHWFramesContext;
// `av_hwdevice_ctx_create` wrote an owned ref into `device_ref`; take ownership of it here so
// every `bail!` below drops it (and the frames ref, once built) without cleanup of its own.
let device_ref = AvBuffer::from_raw(device_ref)
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
.context("av_hwframe_ctx_alloc(VAAPI) failed")?;
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*fc).sw_format = sw_format;
(*fc).width = w as c_int;
(*fc).height = h as c_int;
(*fc).initial_pool_size = pool;
let r = ffi::av_hwframe_ctx_init(frames_ref);
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
if r < 0 {
ffi::av_buffer_unref(&mut frames_ref);
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwframe_ctx_init(VAAPI) failed ({r})");
}
Ok(VaapiHw {
device_ref,
frames_ref,
device_ref,
})
}
}
impl Drop for VaapiHw {
fn drop(&mut self) {
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `VaapiHw::new`
// created (it bails before constructing `Self` if either alloc fails, so a live `VaapiHw`
// always holds both). `av_buffer_unref` drops one reference and nulls the pointer through the
// `&mut`. This `Drop` runs exactly once and `VaapiHw` owns these refs exclusively, so there
// is no double-free / use-after-free. Frames are unref'd before the device because the frames
// ctx internally holds a ref on the device (refcounted, so the order is sound either way).
unsafe {
ffi::av_buffer_unref(&mut self.frames_ref);
ffi::av_buffer_unref(&mut self.device_ref);
}
}
}
// No `Drop` for `VaapiHw`: each `AvBuffer` field unrefs itself, in declaration order (frames, then
// device — see the field comment). The hand-written unref pair this replaced had to stay in sync
// with every failure branch in `new`; now there is one unref path and it cannot be skipped.
struct CpuInner {
enc: encoder::video::Encoder,
@@ -567,8 +566,8 @@ impl CpuInner {
height,
fps,
bitrate_bps,
hw.device_ref,
hw.frames_ref,
hw.device_ref.as_ptr(),
hw.frames_ref.as_ptr(),
ten_bit,
)?
};
@@ -698,7 +697,7 @@ impl CpuInner {
if hwf.is_null() {
bail!("av_frame_alloc(hw) failed");
}
if ffi::av_hwframe_get_buffer(self.hw.frames_ref, hwf, 0) < 0 {
if ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), hwf, 0) < 0 {
ffi::av_frame_free(&mut hwf);
bail!("av_hwframe_get_buffer(VAAPI) failed");
}
@@ -746,14 +745,30 @@ impl Drop for CpuInner {
// ---------------------------------------------------------------------------------------------
struct DmabufInner {
enc: encoder::video::Encoder,
// FIELD ORDER IS LOAD-BEARING. These drop in declaration order, and this order reproduces
// exactly what the hand-written `Drop` this replaced did: graph, then frames, then the derived
// VAAPI device, then DRM — and `enc` last of all, since the old `Drop` ran before any field and
// so freed all four ahead of ffmpeg-next dropping the encoder. Every one of these holds its own
// reference, so refcounting makes any order sound; the point is that a field reorder must not
// silently change what ships. Do not move `enc`, and do not reorder the four below it.
/// The filter graph. Owner-only: `src`/`sink` below are borrowed filter contexts the graph owns,
/// and everything per-frame goes through those, so nothing reads this field again — it exists so
/// the graph outlives them and is freed exactly once. (`dead_code` answered here rather than by
/// removing the field, which would free the graph while `src`/`sink` still point into it.)
#[allow(dead_code)]
graph: AvFilterGraph,
/// DRM-PRIME frames context for the imported dmabufs (buffersrc input). Read per frame by
/// `submit`, which tags each imported `AVFrame` with a new ref of it.
drm_frames: AvBuffer,
/// VAAPI device driving `hwmap`/`scale_vaapi`/the encoder. Owner-only: the two filters and the
/// encoder each took their own ref at open, so nothing reads it again — it keeps the device
/// alive behind them.
#[allow(dead_code)]
vaapi_device: AvBuffer,
/// DRM device the source dmabuf frames reference (the buffersrc's `hw_frames_ctx` device).
drm_device: *mut ffi::AVBufferRef,
/// VAAPI device driving `hwmap`/`scale_vaapi`/the encoder.
vaapi_device: *mut ffi::AVBufferRef,
/// DRM-PRIME frames context for the imported dmabufs (buffersrc input).
drm_frames: *mut ffi::AVBufferRef,
graph: *mut ffi::AVFilterGraph,
/// Owner-only for the same reason: `drm_frames` holds its own ref on it.
#[allow(dead_code)]
drm_device: AvBuffer,
src: *mut ffi::AVFilterContext,
sink: *mut ffi::AVFilterContext,
width: u32,
@@ -763,6 +778,10 @@ struct DmabufInner {
/// submit (import+push vs CSC pull vs encoder send), the stage that dominates AMD/Intel
/// host latency (7.9 ms p50 at 1440p on the 780M).
frames: u64,
/// Declared LAST on purpose — see the field-order note at the top of this struct. The encoder
/// holds its own device ref and must drop after the graph and the three buffers, which is what
/// the hand-written `Drop` used to guarantee by running ahead of every field.
enc: encoder::video::Encoder,
}
impl DmabufInner {
@@ -829,70 +848,58 @@ impl DmabufInner {
ffmpeg::Error::from(r)
);
}
// Own each handle the moment it exists: from here every `bail!` drops whatever has been
// built so far, in reverse order, so not one failure branch below carries cleanup code.
let drm_device = AvBuffer::from_raw(drm_device)
.context("av_hwdevice_ctx_create(DRM) gave no device")?;
let mut vaapi_device: *mut ffi::AVBufferRef = ptr::null_mut();
let r = ffi::av_hwdevice_ctx_create_derived(
&mut vaapi_device,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
drm_device,
drm_device.as_ptr(),
0,
);
if r < 0 {
ffi::av_buffer_unref(&mut drm_device);
bail!("derive VAAPI from DRM: {}", ffmpeg::Error::from(r));
}
let vaapi_device = AvBuffer::from_raw(vaapi_device)
.context("av_hwdevice_ctx_create_derived(VAAPI) gave no device")?;
// DRM-PRIME frames context for the imported dmabufs.
let mut drm_frames = ffi::av_hwframe_ctx_alloc(drm_device);
if drm_frames.is_null() {
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("av_hwframe_ctx_alloc(DRM) failed");
}
let fc = (*drm_frames).data as *mut ffi::AVHWFramesContext;
let drm_frames = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(drm_device.as_ptr()))
.context("av_hwframe_ctx_alloc(DRM) failed")?;
let fc = (*drm_frames.as_ptr()).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME;
(*fc).sw_format = sw_format; // packed XR24 RGB plane, or XR30/XB30 for HDR
(*fc).width = width as c_int;
(*fc).height = height as c_int;
if ffi::av_hwframe_ctx_init(drm_frames) < 0 {
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
if ffi::av_hwframe_ctx_init(drm_frames.as_ptr()) < 0 {
bail!("av_hwframe_ctx_init(DRM) failed");
}
// Filter graph: buffer(drm_prime) → hwmap=derive_device=vaapi:mode=read →
// scale_vaapi=format=nv12 → buffersink.
let mut graph = ffi::avfilter_graph_alloc();
if graph.is_null() {
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("avfilter_graph_alloc failed");
}
let graph = AvFilterGraph::alloc().context("avfilter_graph_alloc failed")?;
let mk = |name: &CStr, inst: &CStr| -> *mut ffi::AVFilterContext {
let f = ffi::avfilter_get_by_name(name.as_ptr());
if f.is_null() {
return ptr::null_mut();
}
ffi::avfilter_graph_alloc_filter(graph, f, inst.as_ptr())
ffi::avfilter_graph_alloc_filter(graph.as_ptr(), f, inst.as_ptr())
};
let src = mk(c"buffer", c"in");
let hwmap = mk(c"hwmap", c"map");
let scale = mk(c"scale_vaapi", c"csc");
let sink = mk(c"buffersink", c"out");
if src.is_null() || hwmap.is_null() || scale.is_null() || sink.is_null() {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("a VAAPI filter (buffer/hwmap/scale_vaapi/buffersink) is missing");
}
// hwmap maps the DRM-PRIME input onto THIS vaapi device; scale_vaapi runs the CSC on
// it. Giving both our device (rather than `hwmap=derive_device`) keeps every surface —
// and the sink's output frames ctx the encoder adopts — on one VADisplay.
(*hwmap).hw_device_ctx = ffi::av_buffer_ref(vaapi_device);
(*scale).hw_device_ctx = ffi::av_buffer_ref(vaapi_device);
(*hwmap).hw_device_ctx = ffi::av_buffer_ref(vaapi_device.as_ptr());
(*scale).hw_device_ctx = ffi::av_buffer_ref(vaapi_device.as_ptr());
// buffersrc params: DRM-PRIME frames, the drm_frames ctx.
let par = ffi::av_buffersrc_parameters_alloc();
@@ -913,24 +920,16 @@ impl DmabufInner {
// the struct, not the ref. Our single owned `drm_frames` ref is retained, lives in
// `DmabufInner`, and is unref'd in `Drop`. Wrapping it in `av_buffer_ref` here would leak
// that extra ref every session (the persistent listener would accumulate them).
(*par).hw_frames_ctx = drm_frames;
(*par).hw_frames_ctx = drm_frames.as_ptr();
let r = ffi::av_buffersrc_parameters_set(src, par);
ffi::av_free(par as *mut _);
if r < 0 {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("av_buffersrc_parameters_set failed ({r})");
}
macro_rules! init {
($ctx:expr, $args:expr, $what:literal) => {{
let r = ffi::avfilter_init_str($ctx, $args);
if r < 0 {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!(concat!("init ", $what, " failed ({})"), r);
}
}};
@@ -944,28 +943,16 @@ impl DmabufInner {
ffi::avfilter_link(a, 0, b, 0)
};
if link(src, hwmap) < 0 || link(hwmap, scale) < 0 || link(scale, sink) < 0 {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("avfilter_link failed");
}
let r = ffi::avfilter_graph_config(graph, ptr::null_mut());
let r = ffi::avfilter_graph_config(graph.as_ptr(), ptr::null_mut());
if r < 0 {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("avfilter_graph_config failed ({r})");
}
// The encoder takes NV12 surfaces from the sink's output frames context.
let nv12_ctx = ffi::av_buffersink_get_hw_frames_ctx(sink);
if nv12_ctx.is_null() {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("filter sink has no VAAPI frames context");
}
// On encoder-open failure, free the graph + our owned buffer refs before bailing (matching
@@ -979,16 +966,12 @@ impl DmabufInner {
height,
fps,
bitrate_bps,
vaapi_device,
vaapi_device.as_ptr(),
nv12_ctx,
ten_bit,
) {
Ok(enc) => enc,
Err(e) => {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
return Err(e);
}
};
@@ -999,17 +982,17 @@ impl DmabufInner {
if ten_bit { "P010 (HDR10)" } else { "NV12" }
);
Ok(DmabufInner {
enc,
drm_device,
vaapi_device,
drm_frames,
graph,
drm_frames,
vaapi_device,
drm_device,
src,
sink,
width,
height,
fourcc: drm_fourcc,
frames: 0,
enc,
})
}
}
@@ -1084,7 +1067,7 @@ impl DmabufInner {
// Mesa's is BT.601, contradicting the BT.709-limited VUI the encoder signals).
(*drm).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
(*drm).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames);
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames.as_ptr());
(*drm).data[0] = Box::into_raw(desc) as *mut u8;
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
// which outlives this call — the graph reads the surface before submit returns).
@@ -1164,23 +1147,11 @@ impl DmabufInner {
}
}
impl Drop for DmabufInner {
fn drop(&mut self) {
// SAFETY: `graph`/`drm_frames`/`vaapi_device`/`drm_device` are the non-null objects
// `DmabufInner::open` built and moved into `self` (open bails before constructing `Self` if any
// alloc fails). `avfilter_graph_free` frees the graph (and the per-filter device refs it owns);
// each `av_buffer_unref` drops one ref and nulls the pointer via `&mut`. `DmabufInner` owns all
// four exclusively and `Drop` runs once → no double-free/use-after-free. The graph is freed
// first (it holds refs on the devices), then frames, then the derived VAAPI device, then DRM.
// (`self.enc` drops via ffmpeg-next afterward, holding its own refs.)
unsafe {
ffi::avfilter_graph_free(&mut self.graph);
ffi::av_buffer_unref(&mut self.drm_frames);
ffi::av_buffer_unref(&mut self.vaapi_device);
ffi::av_buffer_unref(&mut self.drm_device);
}
}
}
// No `Drop` for `DmabufInner`: `AvFilterGraph`/`AvBuffer` each free themselves, in field-declaration
// order — graph, frames, derived VAAPI device, DRM device, then `enc` — which is exactly the order
// the hand-written `Drop` this replaced used. That `Drop` had to be kept in step with eight separate
// failure branches in `open`, each repeating the same four-line unwind; there is now one release
// path per handle and no branch can skip it.
// ---------------------------------------------------------------------------------------------
@@ -1395,6 +1366,42 @@ impl Encoder for VaapiEncoder {
mod tests {
use super::*;
/// Construct/drop `DmabufInner` repeatedly on real VAAPI silicon.
///
/// This is the heaviest ownership change in the crate and the one with no other coverage.
/// `open` builds four owned objects — a DRM device, a VAAPI device derived from it, a DRM-PRIME
/// frames context, and a filter graph — and each of its eight failure branches used to unwind
/// them by hand, the same four-line block copied eight times (once inside a macro) plus a ninth
/// copy in `Drop`. All of that is now `AvBuffer`/`AvFilterGraph` field drops in declaration
/// order, so *construct and drop* is the entire contract. Looping is what separates the
/// outcomes: a double-free trips glibc, a missed one leaks a VAAPI device, a DRM device and a
/// whole filter graph per iteration.
///
/// `vaapi_cpu_encode_smoke` does NOT cover this — it drives the swscale/CPU-upload path, which
/// uses `VaapiHw` and never builds a graph.
///
/// `#[ignore]`d (needs a real VAAPI device):
/// `cargo test -p pf-encode dmabuf_inner_alloc_drop_cycles -- --ignored --nocapture`
/// ⚠️ Inside a distrobox on an immutable host, also set
/// `LIBVA_DRIVERS_PATH=/run/host/usr/lib64/dri` — the container's own mesa is typically older
/// than the host kernel's amdgpu and fails every encoder open with a bare ENOSYS.
#[test]
#[ignore = "needs a real VAAPI device (run on an AMD/Intel host, not the build box)"]
fn dmabuf_inner_alloc_drop_cycles() {
for i in 0..8 {
let inner = DmabufInner::open(Codec::H264, PixelFormat::Bgrx, 640, 480, 30, 8_000_000)
.unwrap_or_else(|e| panic!("DmabufInner::open failed on iteration {i}: {e:#}"));
assert!(!inner.graph.as_ptr().is_null(), "graph went null");
assert!(!inner.drm_frames.as_ptr().is_null(), "drm_frames went null");
assert!(
!inner.vaapi_device.as_ptr().is_null(),
"vaapi_device went null"
);
assert!(!inner.drm_device.as_ptr().is_null(), "drm_device went null");
}
eprintln!("8 DmabufInner alloc/drop cycles completed without abort");
}
/// The operator pin tries exactly one mode; a cached resolution ([`LP_MODE`]) tries only the
/// mode that worked; anything else runs the full ladder, full-feature FIRST — AMD's first-try
/// open must stay byte-for-byte unchanged.
+305 -37
View File
@@ -6,6 +6,13 @@
//! visibility churn, and ~800 lines of construction `unsafe` get their own review surface.
//! Steady-state encode logic stays in the parent.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw ash/Vulkan object construction and bitstream writing almost line
// for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only
// restate the signature. Clearing this file means DELETING the markers that carry no caller
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// The parent's whole item namespace (Frame, the consts, sibling helpers) — the point of the
// child-module shape. External imports are this file's own; `vk_util` is a crate-root sibling,
// so the path is `crate::`, not the parent-relative `super::` the parent uses.
@@ -20,17 +27,25 @@ pub(super) fn align_up(v: u64, a: u64) -> u64 {
}
/// Probe for the RGB-direct encode source (design/vulkan-rgb-direct-encode.md): can this device
/// take the captured RGB dmabuf directly, with the VCN EFC front-end doing the 709-narrow CSC,
/// via `VK_VALVE_video_encode_rgb_conversion` (RADV since Mesa 26.0, gated on EFC hardware)?
/// take the captured RGB dmabuf directly, with the VCN EFC front-end doing the CSC, via
/// `VK_VALVE_video_encode_rgb_conversion` (RADV since Mesa 26.0, gated on EFC hardware)?
/// `Ok((x_offset, y_offset))` carries the chroma-siting bits a session must be created with
/// (the preferred available bit per axis); `Err` is the first missing requirement, logged as
/// the open-time verdict.
///
/// `ten_bit` + `src_fmt` describe the session being planned: an HDR one needs the EFC to advertise
/// the BT.2020 model (not 709) and the 10-bit packed-RGB `src_fmt` as an encode-source format.
/// Both are hardware facts, so an EFC that cannot do HDR simply reports `Err` and the session
/// takes the compute CSC — no fallback all the way out to VAAPI.
#[allow(clippy::too_many_arguments)]
pub(super) unsafe fn probe_rgb_direct(
instance: &ash::Instance,
vq_inst: &ash::khr::video_queue::Instance,
pd: vk::PhysicalDevice,
codec_op: vk::VideoCodecOperationFlagsKHR,
av1: bool,
ten_bit: bool,
src_fmt: vk::Format,
) -> Result<(u32, u32), &'static str> {
use crate::vk_av1_encode as av1b;
use crate::vk_valve_rgb as vrgb;
@@ -58,10 +73,11 @@ pub(super) unsafe fn probe_rgb_direct(
if feat.video_encode_rgb_conversion == vk::FALSE {
return Err("no-feature");
}
// 3. Capabilities under the rgb-chained profile — the conversion must cover the compute
// CSC's colour math (rgb2yuv.comp: BT.709, narrow range; chroma siting is looser, see
// below). The profile chain is the same one every rgb-direct consumer presents.
let mut ps = RgbProfileStack::new(codec_op);
// 3. Capabilities under the rgb-chained profile — the conversion must cover the colour math
// the compute CSC would otherwise do (`rgb2yuv.comp`: BT.709 narrow; `rgb2yuv10.comp`:
// BT.2020 narrow), at this session's depth. Chroma siting is looser, see below. The
// profile chain is the same one every rgb-direct consumer presents.
let mut ps = RgbProfileStack::new(codec_op, ten_bit);
let profile = *ps.wire(av1);
let mut rgb_caps = vrgb::VideoEncodeRgbConversionCapabilitiesVALVE {
s_type: vrgb::stype(vrgb::ST_CAPABILITIES),
@@ -103,10 +119,13 @@ pub(super) unsafe fn probe_rgb_direct(
None
}
};
if rgb_caps.rgb_models & vrgb::MODEL_YCBCR_709 == 0
|| rgb_caps.rgb_ranges & vrgb::RANGE_NARROW == 0
{
return Err("no-709-narrow");
let want_model = rgb_model_for(ten_bit);
if rgb_caps.rgb_models & want_model == 0 || rgb_caps.rgb_ranges & vrgb::RANGE_NARROW == 0 {
return Err(if ten_bit {
"no-2020-narrow"
} else {
"no-709-narrow"
});
}
let (Some(x_offset), Some(y_offset)) = (
pick(rgb_caps.x_chroma_offsets),
@@ -114,8 +133,10 @@ pub(super) unsafe fn probe_rgb_direct(
) else {
return Err("no-chroma-siting");
};
// 4. The encode-src format set under this profile must offer BGRA with DRM-modifier tiling —
// the capture hands LINEAR BGRx dmabufs (fourcc XR24), which import as B8G8R8A8_UNORM.
// 4. The encode-src format set under this profile must offer the CAPTURED format with
// DRM-modifier tiling — LINEAR BGRx dmabufs (fourcc XR24) import as B8G8R8A8_UNORM, and a
// 10-bit PQ capture (XR30/XB30) as one of the packed 2:10:10:10 formats. A device whose
// EFC handles 8-bit RGB but not 10-bit lands here rather than at the session create.
let profile_arr = [profile];
let plist = vk::VideoProfileListInfoKHR::default().profiles(&profile_arr);
let mut fmt_info = vk::PhysicalDeviceVideoFormatInfoKHR::default()
@@ -132,15 +153,31 @@ pub(super) unsafe fn probe_rgb_direct(
if r != vk::Result::SUCCESS && r != vk::Result::INCOMPLETE {
return Err("no-rgb-format");
}
if !props[..count as usize].iter().any(|p| {
p.format == vk::Format::B8G8R8A8_UNORM
&& p.image_tiling == vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT
}) {
return Err("no-bgra-modifier-tiling");
if !props[..count as usize]
.iter()
.any(|p| p.format == src_fmt && p.image_tiling == vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
{
return Err(if ten_bit {
"no-rgb10-modifier-tiling"
} else {
"no-bgra-modifier-tiling"
});
}
Ok((x_offset, y_offset))
}
/// The EFC colour model a session of this depth needs: BT.709 for SDR, BT.2020 for HDR — the same
/// matrices the two compute-CSC shaders implement, so the encode source is interchangeable and the
/// SPS/sequence-header colour signalling is correct either way.
pub(super) fn rgb_model_for(ten_bit: bool) -> u32 {
use crate::vk_valve_rgb as vrgb;
if ten_bit {
vrgb::MODEL_YCBCR_2020
} else {
vrgb::MODEL_YCBCR_709
}
}
pub(super) unsafe fn make_video_image(
device: &ash::Device,
mp: &vk::PhysicalDeviceMemoryProperties,
@@ -225,6 +262,7 @@ pub(super) unsafe fn make_frame(
with_ts: bool,
csc: bool,
pad_fmt: Option<vk::Format>,
hdr: bool,
f: &mut Frame,
) -> Result<()> {
// "no cursor uploaded yet" sentinel — a real serial may be 0 (see `prep_cursor`).
@@ -263,6 +301,7 @@ pub(super) unsafe fn make_frame(
csc_dsl,
csc_pool,
sampler,
hdr,
f,
)?;
}
@@ -291,13 +330,15 @@ unsafe fn make_frame_csc(
csc_dsl: vk::DescriptorSetLayout,
csc_pool: vk::DescriptorPool,
sampler: vk::Sampler,
hdr: bool,
f: &mut Frame,
) -> Result<()> {
// NV12 encode-src (filled by the CSC copy) — concurrent compute+encode.
// 4:2:0 encode-src (filled by the CSC copy) — concurrent compute+encode.
let pic = yuv_format(hdr);
(f.nv12_src, f.nv12_mem) = make_video_image(
device,
mem_props,
NV12,
pic,
w,
h,
1,
@@ -305,12 +346,22 @@ unsafe fn make_frame_csc(
profile_list,
fams,
)?;
f.nv12_view = make_view(device, f.nv12_src, NV12, 0)?;
// CSC scratch (Y R8 full-res, UV RG8 half-res).
f.nv12_view = make_view(device, f.nv12_src, pic, 0)?;
// CSC scratch: Y full-res + UV half-res, in a single-plane format the shader can declare as a
// storage image AND that is SIZE-COMPATIBLE with the picture's planes (`vkCmdCopyImage`
// between differing formats requires equal texel-block size). 8-bit: R8/RG8 vs the NV12
// planes' 1/2 bytes. 10-bit: R16/RG16 vs the 3PACK16 planes' 2/4 bytes — the 10-bit ycbcr
// plane formats themselves are not storage-image formats, which is why the scratch is 16-bit
// and `rgb2yuv10.comp` writes the value into the high bits by hand.
let (y_fmt, uv_fmt) = if hdr {
(vk::Format::R16_UNORM, vk::Format::R16G16_UNORM)
} else {
(vk::Format::R8_UNORM, vk::Format::R8G8_UNORM)
};
(f.y_img, f.y_mem, f.y_view) = make_plain_image(
device,
mem_props,
vk::Format::R8_UNORM,
y_fmt,
w,
h,
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
@@ -318,7 +369,7 @@ unsafe fn make_frame_csc(
(f.uv_img, f.uv_mem, f.uv_view) = make_plain_image(
device,
mem_props,
vk::Format::R8G8_UNORM,
uv_fmt,
w / 2,
h / 2,
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
@@ -479,12 +530,20 @@ pub(super) unsafe fn build_parameters_h265(
rw: u32,
rh: u32,
quality_level: u32,
// 10-bit HDR session: Main10 + BT.2020/PQ colour signalling. Must agree with the video
// profile the session was CREATED with (`open_inner`'s `ten_bit`) — a Main SPS on a Main10
// session is a bitstream that says one thing and carries another.
ten_bit: bool,
) -> Result<(vk::VideoSessionParametersKHR, Vec<u8>)> {
use ash::vk::native as hh;
let mut ptl: hh::StdVideoH265ProfileTierLevel = std::mem::zeroed();
ptl.flags.set_general_progressive_source_flag(1);
ptl.flags.set_general_frame_only_constraint_flag(1);
ptl.general_profile_idc = hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN;
ptl.general_profile_idc = if ten_bit {
hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_10
} else {
hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN
};
ptl.general_level_idc = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_0;
let mut dpbm: hh::StdVideoH265DecPicBufMgr = std::mem::zeroed();
@@ -505,6 +564,11 @@ pub(super) unsafe fn build_parameters_h265(
sps.pic_width_in_luma_samples = w;
sps.pic_height_in_luma_samples = h;
sps.log2_max_pic_order_cnt_lsb_minus4 = 4;
// Main10's `bit_depth_*_minus8 = 2`. Zeroed (= 8-bit) for Main, as before.
if ten_bit {
sps.bit_depth_luma_minus8 = 2;
sps.bit_depth_chroma_minus8 = 2;
}
sps.log2_diff_max_min_luma_coding_block_size = 3;
sps.log2_diff_max_min_luma_transform_block_size = 3;
sps.max_transform_hierarchy_depth_inter = 4;
@@ -517,6 +581,29 @@ pub(super) unsafe fn build_parameters_h265(
sps.conf_win_bottom_offset = (h - rh) / 2; // 4:2:0 SubHeightC = 2
}
// Colour signalling, exactly what this session's CSC produced: `rgb2yuv.comp` is BT.709
// limited 8-bit, `rgb2yuv10.comp` is BT.2020 NCL limited 10-bit with a PQ transfer (the
// samples arrive PQ-encoded from the compositor and the matrix does not touch the transfer).
// Without the VUI the stream is "unspecified" and each decoder applies its own default: the
// punktfunk clients fall back to BT.709 (`pf_client_core::video_color::csc_rows`), but vendor
// TV decoders guess from RESOLUTION — an LG webOS panel reads a 4K SDR stream as BT.2020 and
// renders it visibly washed out.
//
// `vui` must outlive `create_video_session_parameters_khr` below — it does, `sps_arr` only
// copies the pointer and both live to the end of this function.
let mut vui: hh::StdVideoH265SequenceParameterSetVui = std::mem::zeroed();
vui.flags.set_video_signal_type_present_flag(1);
vui.flags.set_video_full_range_flag(0); // limited/studio swing
vui.flags.set_colour_description_present_flag(1);
vui.video_format = 5; // unspecified — the CICP triplet below is what matters
// CICP code points: 1 = BT.709, 9 = BT.2020 primaries / BT.2020 NCL matrix, 16 = SMPTE 2084.
let (prim, trc, mat) = if ten_bit { (9, 16, 9) } else { (1, 1, 1) };
vui.colour_primaries = prim;
vui.transfer_characteristics = trc;
vui.matrix_coeffs = mat;
sps.flags.set_vui_parameters_present_flag(1);
sps.pSequenceParameterSetVui = &vui;
let mut pps: hh::StdVideoH265PictureParameterSet = std::mem::zeroed();
pps.flags.set_cu_qp_delta_enabled_flag(1);
pps.flags.set_pps_loop_filter_across_slices_enabled_flag(1);
@@ -658,9 +745,10 @@ fn leb128(mut v: u64) -> Vec<u8> {
/// Bit-pack a `sequence_header_obu` (AV1 spec §5.5) into a size-delimited OBU. The field values here
/// MUST mirror the `StdVideoAV1SequenceHeader` handed to the driver in `build_parameters_av1` so the
/// driver-emitted frame OBUs parse against this header. Single operating point, 8-bit 4:2:0,
/// order-hint on, CDEF+restoration+filter-intra allowed, everything exotic (compound/warp/superres)
/// disabled — the profile our single-reference P-frame encoder actually uses.
/// driver-emitted frame OBUs parse against this header. Single operating point, 4:2:0 at 8 or 10
/// bits, order-hint on, CDEF+restoration+filter-intra allowed, everything exotic
/// (compound/warp/superres) disabled — the profile our single-reference P-frame encoder uses.
#[allow(clippy::too_many_arguments)]
fn av1_sequence_header_obu(
sb128: bool,
fwb: u32,
@@ -669,6 +757,7 @@ fn av1_sequence_header_obu(
max_h_m1: u32,
order_hint_bits_minus_1: u32,
seq_level_idx: u32,
ten_bit: bool,
) -> Vec<u8> {
let mut w = Av1BitWriter::new();
w.put(0, 3); // seq_profile = MAIN
@@ -703,10 +792,28 @@ fn av1_sequence_header_obu(
w.bit(0); // enable_superres
w.bit(0); // enable_cdef
w.bit(0); // enable_restoration
// color_config(): 8-bit 4:2:0, unspecified primaries/transfer/matrix, limited range
w.bit(0); // high_bitdepth
// color_config() (AV1 spec §5.5.2): 4:2:0 at the session's depth, limited range,
// carrying the CSC this backend actually performed — BT.709 for `rgb2yuv.comp`,
// BT.2020 + PQ for `rgb2yuv10.comp` (or the EFC's equivalent). AV1 has no VUI, so
// the CICP triplet lives here; omitting it (color_description_present_flag = 0)
// left the stream "unspecified" and vendor TV decoders guess colorimetry from
// resolution. Neither triplet hits the spec's sRGB special case (which would force
// color_range = 1 and drop the explicit range bit), so the field order below is the
// same as the unspecified form plus the three CICP bytes.
//
// `high_bitdepth` alone encodes 10-bit here: `twelve_bit` follows it ONLY for
// seq_profile 2, and ours is MAIN (0).
w.bit(ten_bit as u32); // high_bitdepth -> BitDepth = 10
w.bit(0); // mono_chrome
w.bit(0); // color_description_present_flag
w.bit(1); // color_description_present_flag
let (prim, trc, mat) = if ten_bit {
(9u32, 16u32, 9u32)
} else {
(1, 1, 1)
};
w.put(prim, 8); // color_primaries (1 = BT.709, 9 = BT.2020)
w.put(trc, 8); // transfer_characteristics (1 = BT.709, 16 = SMPTE 2084)
w.put(mat, 8); // matrix_coefficients (1 = BT.709, 9 = BT.2020 NCL)
w.bit(0); // color_range (studio/limited)
w.put(0, 2); // chroma_sample_position = CSP_UNKNOWN (subsampling_x==subsampling_y==1 for profile 0)
w.bit(0); // separate_uv_delta_q
@@ -737,6 +844,9 @@ pub(super) unsafe fn build_parameters_av1(
max_level: ash::vk::native::StdVideoAV1Level,
sb128: bool,
quality_level: u32,
// 10-bit HDR session — must agree with the video profile the session was CREATED with
// (`open_inner`'s `ten_bit`) and with the OBU packed below.
ten_bit: bool,
) -> Result<(vk::VideoSessionParametersKHR, Vec<u8>, Vec<u8>)> {
use crate::vk_av1_encode as av1;
use ash::vk::native as hh;
@@ -747,18 +857,33 @@ pub(super) unsafe fn build_parameters_av1(
let seq_level_idx = max_level; // StdVideoAV1Level's numeric value IS the AV1 seq_level_idx
// ---- Std sequence header (must match the OBU packed below) ----
// Limited range at the session's depth, mirroring the `color_config()` bits
// `av1_sequence_header_obu` packs — the two MUST stay identical or the driver's frame OBUs
// parse against a header we didn't write. `color_range` stays 0 (studio swing).
let mut cc_flags: hh::StdVideoAV1ColorConfigFlags = std::mem::zeroed();
let _ = &mut cc_flags; // all zero: mono_chrome/color_range/description/separate_uv_delta_q = 0
cc_flags.set_color_description_present_flag(1);
let mut cc: hh::StdVideoAV1ColorConfig = std::mem::zeroed();
cc.flags = cc_flags;
cc.BitDepth = 8;
// The Std struct carries the DEPTH; the driver derives the OBU's `high_bitdepth` from it.
cc.BitDepth = if ten_bit { 10 } else { 8 };
cc.subsampling_x = 1;
cc.subsampling_y = 1;
cc.color_primaries = hh::StdVideoAV1ColorPrimaries_STD_VIDEO_AV1_COLOR_PRIMARIES_BT_UNSPECIFIED;
cc.transfer_characteristics =
hh::StdVideoAV1TransferCharacteristics_STD_VIDEO_AV1_TRANSFER_CHARACTERISTICS_UNSPECIFIED;
cc.matrix_coefficients =
hh::StdVideoAV1MatrixCoefficients_STD_VIDEO_AV1_MATRIX_COEFFICIENTS_UNSPECIFIED;
let (prim, trc, mat) = if ten_bit {
(
hh::StdVideoAV1ColorPrimaries_STD_VIDEO_AV1_COLOR_PRIMARIES_BT_2020,
hh::StdVideoAV1TransferCharacteristics_STD_VIDEO_AV1_TRANSFER_CHARACTERISTICS_SMPTE_2084,
hh::StdVideoAV1MatrixCoefficients_STD_VIDEO_AV1_MATRIX_COEFFICIENTS_BT_2020_NCL,
)
} else {
(
hh::StdVideoAV1ColorPrimaries_STD_VIDEO_AV1_COLOR_PRIMARIES_BT_709,
hh::StdVideoAV1TransferCharacteristics_STD_VIDEO_AV1_TRANSFER_CHARACTERISTICS_BT_709,
hh::StdVideoAV1MatrixCoefficients_STD_VIDEO_AV1_MATRIX_COEFFICIENTS_BT_709,
)
};
cc.color_primaries = prim;
cc.transfer_characteristics = trc;
cc.matrix_coefficients = mat;
cc.chroma_sample_position =
hh::StdVideoAV1ChromaSamplePosition_STD_VIDEO_AV1_CHROMA_SAMPLE_POSITION_UNKNOWN;
@@ -829,8 +954,151 @@ pub(super) unsafe fn build_parameters_av1(
h - 1,
order_hint_bits_minus_1,
seq_level_idx,
ten_bit,
);
let mut keyframe_prefix = td.clone();
keyframe_prefix.extend_from_slice(&seq_obu);
Ok((params, keyframe_prefix, td))
}
#[cfg(test)]
mod tests {
use super::*;
/// Walks a bit-packed AV1 sequence header field-by-field (spec §5.5.1 order, for the fixed
/// configuration `av1_sequence_header_obu` emits) and returns the `color_config()` values.
/// Deliberately an INDEPENDENT walk rather than a mirror of the writer: it is the only thing
/// that catches a field width or ordering change upstream of `color_config`, which would leave
/// the colour bits parsing at the wrong offset — the exact desync the module doc warns about.
fn read_color_config(
obu: &[u8],
fwb: u32,
fhb: u32,
seq_level_idx: u32,
) -> (u8, u8, u8, u8, u8, u8) {
// obu_header (1 byte) + leb128 size — the payload starts after both.
assert_eq!(
obu[0], 0x0a,
"obu_header: OBU_SEQUENCE_HEADER + has_size_field"
);
let mut i = 1;
while obu[i] & 0x80 != 0 {
i += 1;
}
let payload = &obu[i + 1..];
let mut pos = 0usize;
let mut take = |bits: u32| -> u32 {
let mut v = 0u32;
for _ in 0..bits {
let byte = payload[pos / 8];
v = (v << 1) | u32::from((byte >> (7 - (pos % 8))) & 1);
pos += 1;
}
v
};
assert_eq!(take(3), 0, "seq_profile = MAIN");
take(1); // still_picture
assert_eq!(take(1), 0, "reduced_still_picture_header");
assert_eq!(take(1), 0, "timing_info_present_flag");
assert_eq!(take(1), 0, "initial_display_delay_present_flag");
assert_eq!(take(5), 0, "operating_points_cnt_minus_1");
take(12); // operating_point_idc[0]
assert_eq!(take(5), seq_level_idx, "seq_level_idx[0]");
if seq_level_idx > 7 {
take(1); // seq_tier[0]
}
assert_eq!(take(4), fwb, "frame_width_bits_minus_1");
assert_eq!(take(4), fhb, "frame_height_bits_minus_1");
take(fwb + 1); // max_frame_width_minus_1
take(fhb + 1); // max_frame_height_minus_1
take(1); // frame_id_numbers_present_flag
take(1); // use_128x128_superblock
take(1); // enable_filter_intra
take(1); // enable_intra_edge_filter
take(1); // enable_interintra_compound
take(1); // enable_masked_compound
take(1); // enable_warped_motion
take(1); // enable_dual_filter
let order_hint = take(1); // enable_order_hint
assert_eq!(
order_hint, 1,
"enable_order_hint (our single-ref P-frame config)"
);
take(1); // enable_jnt_comp
take(1); // enable_ref_frame_mvs
assert_eq!(take(1), 1, "seq_choose_screen_content_tools = SELECT");
// seq_force_screen_content_tools = SELECT (> 0), so seq_choose_integer_mv is present.
assert_eq!(take(1), 1, "seq_choose_integer_mv = SELECT");
take(3); // order_hint_bits_minus_1
take(1); // enable_superres
take(1); // enable_cdef
take(1); // enable_restoration
// color_config(). `high_bitdepth` is returned rather than asserted — the 10-bit case
// below is the whole point of reading it.
let high_bitdepth = take(1) as u8;
assert_eq!(take(1), 0, "mono_chrome");
let described = take(1) as u8;
let (cp, tc, mc) = if described == 1 {
(take(8) as u8, take(8) as u8, take(8) as u8)
} else {
(2, 2, 2) // CICP "unspecified"
};
let range = take(1) as u8;
take(2); // chroma_sample_position
assert_eq!(take(1), 0, "separate_uv_delta_q");
assert_eq!(take(1), 0, "film_grain_params_present");
assert_eq!(take(1), 1, "trailing_one_bit");
(high_bitdepth, described, cp, tc, mc, range)
}
/// The sequence header must SIGNAL BT.709 limited — the CSC `rgb2yuv.comp` actually performs.
/// An unsignalled ("unspecified") AV1 stream makes vendor TV decoders guess colorimetry from
/// resolution: an LG webOS panel reads 4K SDR as BT.2020 and renders it washed out.
///
/// The values here must equal the `StdVideoAV1ColorConfig` in `build_parameters_av1` — the
/// driver packs its frame OBUs against that struct while clients parse this header, so a
/// mismatch desyncs every inter frame.
#[test]
fn av1_sequence_header_signals_bt709_limited() {
// 1920x1080: av_log2 gives 10/10 frame-size bits; level 4.0 (seq_level_idx 8) exercises
// the seq_tier branch, and sb128 both ways since it sits above color_config.
for (sb128, level) in [(false, 8u32), (true, 5u32)] {
let obu = av1_sequence_header_obu(sb128, 10, 10, 1919, 1079, 7, level, false);
let (depth10, described, cp, tc, mc, range) = read_color_config(&obu, 10, 10, level);
assert_eq!(depth10, 0, "high_bitdepth (8-bit session)");
assert_eq!(
described, 1,
"color_description_present_flag (sb128={sb128})"
);
assert_eq!(
(cp, tc, mc),
(1, 1, 1),
"CICP BT.709 primaries/transfer/matrix"
);
assert_eq!(range, 0, "color_range = studio/limited swing");
}
}
/// …and a 10-bit session must signal BT.2020 + PQ with `high_bitdepth` set. Same reason the
/// 8-bit twin exists, plus one that is specific to AV1: `high_bitdepth` sits BEFORE the CICP
/// bytes in `color_config()`, so getting it wrong does not just mislabel the depth — every
/// field after it parses one bit out of phase.
#[test]
fn av1_sequence_header_signals_bt2020_pq_at_10_bit() {
for (sb128, level) in [(false, 8u32), (true, 5u32)] {
let obu = av1_sequence_header_obu(sb128, 10, 10, 1919, 1079, 7, level, true);
let (depth10, described, cp, tc, mc, range) = read_color_config(&obu, 10, 10, level);
assert_eq!(depth10, 1, "high_bitdepth (sb128={sb128})");
assert_eq!(described, 1, "color_description_present_flag");
assert_eq!(
(cp, tc, mc),
(9, 16, 9),
"CICP BT.2020 primaries / SMPTE 2084 transfer / BT.2020-NCL matrix"
);
assert_eq!(range, 0, "color_range = studio/limited swing");
}
}
}
+25
View File
@@ -1,6 +1,13 @@
//! Small ash/Vulkan leaf helpers shared by the Linux Vulkan encode backends
//! (`vulkan_video.rs`, `pyrowave.rs`) — extracted verbatim from `vulkan_video.rs`
//! when the PyroWave backend arrived so the two don't fork copies.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw ash/Vulkan object construction almost line for line; narrowing it
// would add one `unsafe {}` plus one SAFETY comment per call that could only restate the signature.
// Clearing this file means DELETING the markers that carry no caller contract, not wrapping the
// calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every unsafe block carries a `// SAFETY:` proof (parent module enforces it).
use anyhow::Result;
@@ -50,9 +57,23 @@ pub(crate) fn fourcc_to_vk(fourcc: u32) -> Option<vk::Format> {
const XB24: u32 = 0x3432_4258; // XBGR8888
const AB24: u32 = 0x3432_4241; // ABGR8888
const NV12: u32 = 0x3231_564e; // DRM_FORMAT_NV12
// The 10-bit HDR capture formats. DRM packs these as a little-endian 32-bit word — XR30 is
// x:R:G:B with B in bits 0-9 — which is exactly Vulkan's `A2R10G10B10_UNORM_PACK32` bit
// layout, so the mapping is an identity on the word and not a byte swizzle like the 8-bit
// pair above.
//
// ⚠ Only `A2B10G10R10_UNORM_PACK32` is a Vulkan-mandatory SAMPLED format; `A2R10G10B10` is
// optional (widely supported on RADV/ANV, and we only ever `texelFetch` it — no filtering).
// Both are offered to the producer, so which one a session lands on is the producer's pick;
// if a device ever rejects the XR30 import, the fix is to drop that format from the capture
// offer rather than to convert here.
const XR30: u32 = 0x3033_5258; // DRM_FORMAT_XRGB2101010
const XB30: u32 = 0x3033_4258; // DRM_FORMAT_XBGR2101010
match fourcc {
XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM),
XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM),
XR30 => Some(vk::Format::A2R10G10B10_UNORM_PACK32),
XB30 => Some(vk::Format::A2B10G10R10_UNORM_PACK32),
NV12 => Some(vk::Format::G8_B8R8_2PLANE_420_UNORM),
_ => None,
}
@@ -62,6 +83,10 @@ pub(crate) fn pixel_to_vk(fmt: PixelFormat) -> Option<vk::Format> {
match fmt {
PixelFormat::Bgrx | PixelFormat::Bgra => Some(vk::Format::B8G8R8A8_UNORM),
PixelFormat::Rgbx | PixelFormat::Rgba => Some(vk::Format::R8G8B8A8_UNORM),
// The packed 10-bit PQ/BT.2020 capture formats (an HDR gamescope output). Sampling one
// yields the PQ code values normalized to [0,1] — which is what `rgb2yuv10.comp` wants.
PixelFormat::X2Rgb10 => Some(vk::Format::A2R10G10B10_UNORM_PACK32),
PixelFormat::X2Bgr10 => Some(vk::Format::A2B10G10R10_UNORM_PACK32),
_ => None,
}
}
+342 -63
View File
@@ -4,10 +4,18 @@
//! slot (no IDR): HEVC via an explicit short-term RPS, AV1 via `ref_frame_idx` + a
//! `primary_ref_frame = NONE` recovery anchor that also breaks the CDF chain.
//!
//! Capture delivers packed RGB (dmabuf/CPU); this backend imports it, runs an on-GPU RGB→NV12
//! BT.709 compute CSC, then encodes. Proven end-to-end in `punktfunk-planning/design/vkenc-probe-harness`.
//! Capture delivers packed RGB (dmabuf/CPU); this backend imports it, runs an on-GPU RGB→4:2:0
//! compute CSC, then encodes — 8-bit BT.709 for an SDR session (`rgb2yuv.comp`), 10-bit BT.2020
//! for an HDR one (`rgb2yuv10.comp` + an HEVC Main10 session; a 10-bit AV1 session is routed to
//! VAAPI instead). Proven end-to-end in `punktfunk-planning/design/vkenc-probe-harness`.
//! Opt-in via `PUNKTFUNK_VULKAN_ENCODE`; gated to HEVC/AV1 + a device that advertises the encode op.
//! The AV1 encode structs our pinned `ash 0.38` predates are vendored in `vk_av1_encode.rs`.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw ash/Vulkan Video calls against an app-owned DPB almost line for
// line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only
// restate the signature. Clearing this file means DELETING the markers that carry no caller
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
#![allow(clippy::too_many_arguments)]
use super::vk_util::{
@@ -23,12 +31,47 @@ use std::ffi::c_void;
use std::os::fd::AsRawFd;
const NV12: vk::Format = vk::Format::G8_B8R8_2PLANE_420_UNORM;
/// The 10-bit 4:2:0 picture/DPB format an HDR (HEVC Main10) session encodes from. `3PACK16`
/// stores each 10-bit sample in the HIGH bits of a 16-bit word — see `rgb2yuv10.comp`, whose
/// scratch planes are the size-compatible `R16`/`RG16` this gets `vkCmdCopyImage`d from.
const P010: vk::Format = vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16;
/// The session's 4:2:0 picture + DPB format for its bit depth. One function so the session
/// create-info, the DPB image, its views and the per-frame encode source cannot drift apart —
/// they must all name the SAME format or session creation fails.
const fn component_depth(ten_bit: bool) -> vk::VideoComponentBitDepthFlagsKHR {
if ten_bit {
vk::VideoComponentBitDepthFlagsKHR::TYPE_10
} else {
vk::VideoComponentBitDepthFlagsKHR::TYPE_8
}
}
const fn h265_profile_idc(ten_bit: bool) -> vk::native::StdVideoH265ProfileIdc {
if ten_bit {
vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_10
} else {
vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN
}
}
const fn yuv_format(hdr: bool) -> vk::Format {
if hdr {
P010
} else {
NV12
}
}
/// Max resident dmabuf imports (comfortably above any PipeWire pool depth; imports alias existing
/// buffers so this holds handles, not new allocations).
const IMPORT_CACHE_CAP: usize = 16;
// Prebuilt SPIR-V for the RGB→NV12 BT.709 compute CSC. Source is `rgb2yuv.comp` beside this file;
// regenerate with `glslangValidator -V rgb2yuv.comp -o rgb2yuv.spv` after editing the shader.
const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv");
/// The 10-bit HDR twin (`rgb2yuv10.comp`): packed 2:10:10:10 PQ/BT.2020 RGB → 10-bit 4:2:0,
/// BT.2020 NCL limited. Separate module rather than a spec constant because a shader's storage
/// image FORMAT (`r8`/`rg8` vs `r16`/`rg16`) is part of its layout, not specializable.
const CSC10_SPV: &[u8] = include_bytes!("rgb2yuv10.spv");
/// Fixed cursor-overlay texture size (px). Larger than any real pointer; the actual `w×h` uploads
/// into the top-left and the shader's push constant bounds sampling, so one allocation fits every
/// cursor and no per-size recreation is needed. See the CSC shader's `cursorTex`/push constant.
@@ -147,7 +190,7 @@ struct RgbProfileStack {
}
impl RgbProfileStack {
fn new(codec_op: vk::VideoCodecOperationFlagsKHR) -> Self {
fn new(codec_op: vk::VideoCodecOperationFlagsKHR, ten_bit: bool) -> Self {
use super::vk_av1_encode as av1b;
use super::vk_valve_rgb as vrgb;
Self {
@@ -160,9 +203,8 @@ impl RgbProfileStack {
.video_usage_hints(vk::VideoEncodeUsageFlagsKHR::STREAMING)
.video_content_hints(vk::VideoEncodeContentFlagsKHR::RENDERED)
.tuning_mode(vk::VideoEncodeTuningModeKHR::ULTRA_LOW_LATENCY),
h265: vk::VideoEncodeH265ProfileInfoKHR::default().std_profile_idc(
vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN,
),
h265: vk::VideoEncodeH265ProfileInfoKHR::default()
.std_profile_idc(h265_profile_idc(ten_bit)),
av1: av1b::VideoEncodeAV1ProfileInfoKHR {
s_type: av1b::stype(av1b::ST_PROFILE_INFO),
p_next: std::ptr::null(),
@@ -171,8 +213,8 @@ impl RgbProfileStack {
profile: vk::VideoProfileInfoKHR::default()
.video_codec_operation(codec_op)
.chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420)
.luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8)
.chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8),
.luma_bit_depth(component_depth(ten_bit))
.chroma_bit_depth(component_depth(ten_bit)),
}
}
@@ -200,26 +242,26 @@ struct NativeProfileStack {
}
impl NativeProfileStack {
fn new(codec_op: vk::VideoCodecOperationFlagsKHR) -> Self {
fn new(codec_op: vk::VideoCodecOperationFlagsKHR, ten_bit: bool) -> Self {
use super::vk_av1_encode as av1b;
Self {
usage: vk::VideoEncodeUsageInfoKHR::default()
.video_usage_hints(vk::VideoEncodeUsageFlagsKHR::STREAMING)
.video_content_hints(vk::VideoEncodeContentFlagsKHR::RENDERED)
.tuning_mode(vk::VideoEncodeTuningModeKHR::ULTRA_LOW_LATENCY),
h265: vk::VideoEncodeH265ProfileInfoKHR::default().std_profile_idc(
vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN,
),
h265: vk::VideoEncodeH265ProfileInfoKHR::default()
.std_profile_idc(h265_profile_idc(ten_bit)),
av1: av1b::VideoEncodeAV1ProfileInfoKHR {
s_type: av1b::stype(av1b::ST_PROFILE_INFO),
p_next: std::ptr::null(),
// AV1 Main covers 8 AND 10 bits — the depth rides `VideoProfileInfoKHR` alone.
std_profile: vk::native::StdVideoAV1Profile_STD_VIDEO_AV1_PROFILE_MAIN,
},
profile: vk::VideoProfileInfoKHR::default()
.video_codec_operation(codec_op)
.chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420)
.luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8)
.chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8),
.luma_bit_depth(component_depth(ten_bit))
.chroma_bit_depth(component_depth(ten_bit)),
}
}
@@ -239,7 +281,7 @@ impl NativeProfileStack {
/// profile rebuilds — the two must agree, profile identity is by value).
/// The physical device + encode queue family a session runs on: the FIRST device exposing a
/// `VIDEO_ENCODE` queue family that advertises `codec_op` (llvmpipe advertises none, so it drops
/// out implicitly). Shared by [`VulkanVideoEncoder::open_inner`] and [`probe_encode_support`].
/// out implicitly). Shared by [`VulkanVideoEncoder::open_inner`] and [`probe_encode_caps`].
///
/// # Safety
/// `instance` must be a live `ash::Instance` and `devices` handles enumerated from it.
@@ -270,59 +312,104 @@ unsafe fn find_encode_device(
None
}
/// Can this GPU + driver open a Vulkan Video **encode** session for `codec` at all?
/// What this device's Vulkan Video **encode** stack can actually do for one codec — the caps
/// probe the negotiation stands on, so a session is never planned around a guess.
///
/// This is the verdict the native-NV12 capture negotiation needs BEFORE it commits
/// ([`crate::linux_native_nv12_ok`]): once the producer hands over two-plane NV12 there is no VAAPI
/// fallback — libav would import it as packed RGB — so [`crate::open_video`] deliberately makes
/// that open-failure fatal, and a Mesa built without `VK_KHR_video_encode_h265` therefore kills the
/// session at its first frame instead of streaming on VAAPI.
/// Two questions, and they are genuinely independent: an encode queue for the codec operation may
/// exist while the silicon declines the 10-bit profile (older VCN, an Intel generation without
/// Main10 encode). Answering only the first is what used to push every HDR session to libav VAAPI
/// — losing real RFI recovery and the compute CSC's cursor blend for nothing on hardware that
/// could do it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct VulkanEncodeCaps {
/// An encode queue advertises this codec's operation.
pub supported: bool,
/// The device accepts an 8-bit 4:2:0 profile for it (the ordinary SDR session).
pub eight_bit: bool,
/// …and a 10-bit one: HEVC Main10 / AV1 Main at 10 bits, the HDR session's profile.
pub ten_bit: bool,
}
/// Probe [`VulkanEncodeCaps`] for `codec`. Uncached — [`crate::vulkan_encode_caps`] owns the
/// per-(GPU, codec) cache, exactly as it did for the old boolean.
///
/// Deliberately the FIRST check `open_inner` performs and hard-fails on, and nothing more: the same
/// [`find_encode_device`] scan, run against the same codec op. That makes it provably no stricter
/// than the open, so a failure here can only ever name a session that would have died anyway — it
/// can never talk a working host out of the fast path. It is also cheap: one instance plus
/// physical-device queries, no logical device, no video session, no VRAM. The later stages
/// (`create_device`, `create_video_session`, the capability query) can still fail for reasons this
/// does not model; those keep the session on the packed-RGB negotiation the ordinary way, because
/// the capture format is only committed once this said yes.
///
/// Probed PER CODEC, not once: `codec_op_for` selects a different queue-family bit for AV1, and
/// HEVC-encode-without-AV1-encode is the common VCN/ANV configuration.
pub(crate) fn probe_encode_support(codec: Codec) -> Result<(), &'static str> {
/// The depth answers come from `vkGetPhysicalDeviceVideoCapabilitiesKHR` against a profile built
/// at that depth, which is the same query the session open makes: a `true` here means the open
/// will get past the profile gate, and a `false` means it would have failed. No second source of
/// truth to drift.
pub(crate) fn probe_encode_caps(codec: Codec) -> VulkanEncodeCaps {
if !matches!(codec, Codec::H265 | Codec::Av1) {
return Err("the Vulkan Video backend encodes HEVC + AV1 only");
return VulkanEncodeCaps::default();
}
let codec_op = codec_op_for(codec == Codec::Av1);
let av1 = codec == Codec::Av1;
let codec_op = codec_op_for(av1);
// SAFETY: creates one Vulkan instance and issues only physical-device queries against it, then
// destroys it on EVERY path below before returning — no handle derived from it escapes, and
// nothing outside this call observes it. `Entry::load` only dlopens the loader (a missing
// libvulkan returns `Err`), touching no process state the rest of the crate relies on.
// `find_encode_device` gets a live instance and handles enumerated from it, as it requires.
// `find_encode_device` gets a live instance and handles enumerated from it, as it requires;
// `depth_supported` gets that same live instance plus the physical device it returned.
unsafe {
let Ok(entry) = ash::Entry::load() else {
return Err("no Vulkan loader");
return VulkanEncodeCaps::default();
};
let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_3);
let Ok(instance) = entry.create_instance(
&vk::InstanceCreateInfo::default().application_info(&app),
None,
) else {
return Err("vkCreateInstance failed");
return VulkanEncodeCaps::default();
};
let found = match instance.enumerate_physical_devices() {
Ok(devices) => find_encode_device(&instance, &devices, codec_op).is_some(),
Err(_) => false,
Ok(devices) => find_encode_device(&instance, &devices, codec_op).map(|(pd, _)| pd),
Err(_) => None,
};
let caps = match found {
Some(pd) => {
let vq_inst = ash::khr::video_queue::Instance::new(&entry, &instance);
VulkanEncodeCaps {
supported: true,
eight_bit: depth_supported(&vq_inst, pd, codec_op, av1, false),
ten_bit: depth_supported(&vq_inst, pd, codec_op, av1, true),
}
}
None => VulkanEncodeCaps::default(),
};
instance.destroy_instance(None);
if found {
Ok(())
} else {
Err("no VK_KHR_video_encode queue for this codec on any device")
}
caps
}
}
/// Does `pd` accept an encode profile for `codec_op` at this bit depth? The profile chain is
/// byte-identical to the one [`VulkanVideoEncoder::open_inner`] builds — that is the point.
///
/// # Safety
/// `vq_inst` must wrap the live instance `pd` was enumerated from.
unsafe fn depth_supported(
vq_inst: &ash::khr::video_queue::Instance,
pd: vk::PhysicalDevice,
codec_op: vk::VideoCodecOperationFlagsKHR,
av1: bool,
ten_bit: bool,
) -> bool {
let mut ps = NativeProfileStack::new(codec_op, ten_bit);
let profile = *ps.wire(av1);
let mut h265_caps = vk::VideoEncodeH265CapabilitiesKHR::default();
let mut av1_caps: super::vk_av1_encode::VideoEncodeAV1CapabilitiesKHR = std::mem::zeroed();
av1_caps.s_type = super::vk_av1_encode::stype(super::vk_av1_encode::ST_CAPABILITIES);
let mut enc_caps = vk::VideoEncodeCapabilitiesKHR::default();
let mut caps = vk::VideoCapabilitiesKHR::default();
if av1 {
av1_caps.p_next = &mut enc_caps as *mut _ as *mut c_void;
caps.p_next = &mut av1_caps as *mut _ as *mut c_void;
} else {
h265_caps.p_next = &mut enc_caps as *mut _ as *mut c_void;
caps.p_next = &mut h265_caps as *mut _ as *mut c_void;
}
(vq_inst.fp().get_physical_device_video_capabilities_khr)(pd, &profile, &mut caps)
== vk::Result::SUCCESS
}
fn codec_op_for(av1: bool) -> vk::VideoCodecOperationFlagsKHR {
if av1 {
vk::VideoCodecOperationFlagsKHR::from_raw(
@@ -463,6 +550,9 @@ struct Frame {
uv_img: vk::Image,
uv_mem: vk::DeviceMemory,
uv_view: vk::ImageView,
/// The CSC's output picture and this frame's encode source: NV12, or the 10-bit
/// `P010`/3PACK16 twin on an HDR session ([`yuv_format`]). The name predates the second
/// depth; every use goes through the format the session was created with.
nv12_src: vk::Image,
nv12_mem: vk::DeviceMemory,
nv12_view: vk::ImageView,
@@ -615,6 +705,11 @@ pub struct VulkanVideoEncoder {
/// aligned and an undersized direct source is the OOB-read class behind the 2026-07-20 field
/// GPU reset (those paths keep their aligned-size sources/staging).
native_nv12: bool,
/// This is a 10-bit (HDR) session: Main10 / AV1-10 profile, `P010` picture + DPB, and either
/// the BT.2020 compute CSC or the EFC's BT.2020 conversion. Every profile chain rebuilt after
/// `open` (the per-buffer dmabuf imports) must present the SAME depth, so it is carried here
/// rather than re-derived.
ten_bit: bool,
/// A [`reconfigure_bitrate`](Encoder::reconfigure_bitrate) rate not yet installed in the video
/// session. The next `record_submit` emits an `ENCODE_RATE_CONTROL` control command carrying it
@@ -669,6 +764,16 @@ impl VulkanVideoEncoder {
cursor_blend: bool,
) -> Result<Self> {
let native_nv12 = format == PixelFormat::Nv12;
// HDR: a packed 10-bit PQ/BT.2020 capture (a gamescope output off our `pipewire-hdr`
// build, or the GNOME 50+ portal monitor mirror). BOTH codecs and BOTH encode sources
// serve it — which of them this device can actually do is `probe_encode_caps`' answer,
// consulted by the dispatcher before we get here and re-checked by the profile query
// inside the open.
let ten_bit = format.is_hdr_rgb10();
// The RGB-direct (EFC) source needs the captured format as the session's picture format.
// `pixel_to_vk` covers every format the capture can hand a GPU session; the BGRA default
// only preserves the old behaviour for the CPU-only layouts, which never reach that arm.
let src_rgb_fmt = pixel_to_vk(format).unwrap_or(vk::Format::B8G8R8A8_UNORM);
// A cursor-blend session must keep the compute-CSC path — the only arm with the cursor
// blend — so it outranks an explicit RGB-direct pin (EFC cannot composite; the
// negotiation promised the client a composited pointer).
@@ -687,6 +792,8 @@ impl VulkanVideoEncoder {
bitrate_bps,
want_rgb,
native_nv12,
ten_bit,
src_rgb_fmt,
)
}
@@ -704,9 +811,42 @@ impl VulkanVideoEncoder {
bitrate_bps: u64,
want_rgb: bool,
) -> Result<Self> {
Self::open_opts_inner(codec, width, height, fps, bitrate_bps, want_rgb, false)
Self::open_opts_depth(codec, width, height, fps, bitrate_bps, want_rgb, false)
}
/// [`open_opts`](Self::open_opts) with the bit depth explicit — the 10-bit smokes' entry
/// point. A 10-bit session takes the packed 2:10:10:10 source format the HDR capture
/// negotiates (`xRGB_210LE` → `A2R10G10B10_UNORM_PACK32`; that is the one gamescope's node
/// actually fixates, verified on RADV 2026-07-28), so the smoke drives the same formats a
/// real session does.
#[cfg(test)]
pub(crate) fn open_opts_depth(
codec: Codec,
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
want_rgb: bool,
ten_bit: bool,
) -> Result<Self> {
Self::open_opts_inner(
codec,
width,
height,
fps,
bitrate_bps,
want_rgb,
false,
ten_bit,
if ten_bit {
vk::Format::A2R10G10B10_UNORM_PACK32
} else {
vk::Format::B8G8R8A8_UNORM
},
)
}
#[allow(clippy::too_many_arguments)]
fn open_opts_inner(
codec: Codec,
width: u32,
@@ -715,6 +855,8 @@ impl VulkanVideoEncoder {
bitrate_bps: u64,
want_rgb: bool,
native_nv12: bool,
ten_bit: bool,
src_rgb_fmt: vk::Format,
) -> Result<Self> {
if !matches!(codec, Codec::H265 | Codec::Av1) {
bail!("vulkan-encode backend supports HEVC + AV1 only (got {codec:?})");
@@ -737,6 +879,8 @@ impl VulkanVideoEncoder {
bitrate_bps.max(1_000_000),
want_rgb,
native_nv12,
ten_bit,
src_rgb_fmt,
)
}
}
@@ -752,6 +896,9 @@ impl VulkanVideoEncoder {
bitrate: u64,
want_rgb: bool,
native_nv12: bool,
// NOT `hdr`: this fn already binds that name to the parameter-set HEADER bytes below.
ten_bit: bool,
src_rgb_fmt: vk::Format,
) -> Result<Self> {
use super::vk_av1_encode as av1b;
use super::vk_valve_rgb as vrgb;
@@ -810,7 +957,7 @@ impl VulkanVideoEncoder {
let rgb_probe = if native_nv12 {
Err("not-probed(native NV12 source selected)")
} else {
probe_rgb_direct(&instance, &vq_inst, pd, codec_op, av1)
probe_rgb_direct(&instance, &vq_inst, pd, codec_op, av1, ten_bit, src_rgb_fmt)
};
let rgb_cfg: Option<RgbDirect> = match (&rgb_probe, want_rgb) {
(Ok((x, y)), true) => {
@@ -871,8 +1018,8 @@ impl VulkanVideoEncoder {
p_next: std::ptr::null(),
perform_encode_rgb_conversion: vk::TRUE,
};
let mut h265_profile = vk::VideoEncodeH265ProfileInfoKHR::default()
.std_profile_idc(vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN);
let mut h265_profile =
vk::VideoEncodeH265ProfileInfoKHR::default().std_profile_idc(h265_profile_idc(ten_bit));
let mut av1_profile = av1b::VideoEncodeAV1ProfileInfoKHR {
s_type: av1b::stype(av1b::ST_PROFILE_INFO),
p_next: std::ptr::null(),
@@ -885,11 +1032,16 @@ impl VulkanVideoEncoder {
if rgb_cfg.is_some() {
usage.p_next = &rgb_info as *const _ as *const c_void;
}
// A device that cannot encode 10-bit fails `get_physical_device_video_capabilities`
// below with VIDEO_PROFILE_FORMAT_NOT_SUPPORTED, which fails the open — and a failed
// Vulkan open falls back to libav VAAPI in `open_amd_intel`. That is the whole 10-bit
// capability gate: no separate probe, and no way to reach a half-configured session.
let depth = component_depth(ten_bit);
let mut profile = vk::VideoProfileInfoKHR::default()
.video_codec_operation(codec_op)
.chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420)
.luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8)
.chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8);
.luma_bit_depth(depth)
.chroma_bit_depth(depth);
if av1 {
av1_profile.p_next = &usage as *const _ as *const c_void;
profile.p_next = &av1_profile as *const _ as *const c_void;
@@ -1109,7 +1261,7 @@ impl VulkanVideoEncoder {
let mut rgb_sci = vrgb::VideoEncodeSessionRgbConversionCreateInfoVALVE {
s_type: vrgb::stype(vrgb::ST_SESSION_CREATE_INFO),
p_next: std::ptr::null(),
rgb_model: vrgb::MODEL_YCBCR_709,
rgb_model: rgb_model_for(ten_bit),
rgb_range: vrgb::RANGE_NARROW,
x_chroma_offset: rgb_cfg.as_ref().map_or(0, |c| c.x_offset),
y_chroma_offset: rgb_cfg.as_ref().map_or(0, |c| c.y_offset),
@@ -1118,15 +1270,15 @@ impl VulkanVideoEncoder {
.queue_family_index(encode_family)
.video_profile(&profile)
.picture_format(if rgb_cfg.is_some() {
vk::Format::B8G8R8A8_UNORM
src_rgb_fmt
} else {
NV12
yuv_format(ten_bit)
})
.max_coded_extent(vk::Extent2D {
width: w,
height: h,
})
.reference_picture_format(NV12)
.reference_picture_format(yuv_format(ten_bit))
.max_dpb_slots(DPB_SLOTS + 1)
.max_active_reference_pictures(1)
.std_header_version(&std_hdr);
@@ -1224,6 +1376,7 @@ impl VulkanVideoEncoder {
av1_caps.max_level,
av1_superblock128,
quality_level,
ten_bit,
)?
} else {
let (p, hdr) = build_parameters_h265(
@@ -1236,6 +1389,7 @@ impl VulkanVideoEncoder {
rw,
rh,
quality_level,
ten_bit,
)?;
(p, hdr, Vec::new())
};
@@ -1247,7 +1401,7 @@ impl VulkanVideoEncoder {
let (dpb_image, dpb_mem) = make_video_image(
&device,
&mem_props,
NV12,
yuv_format(ten_bit),
w,
h,
DPB_SLOTS,
@@ -1260,7 +1414,7 @@ impl VulkanVideoEncoder {
for slot in 0..DPB_SLOTS {
guard
.dpb_views
.push(make_view(&device, dpb_image, NV12, slot)?);
.push(make_view(&device, dpb_image, yuv_format(ten_bit), slot)?);
}
// NV12 encode-src, CSC scratch (Y/UV), bitstream, query and command buffers are all per
@@ -1281,7 +1435,11 @@ impl VulkanVideoEncoder {
None,
)?;
guard.sampler = sampler;
let spv = ash::util::read_spv(&mut std::io::Cursor::new(CSC_SPV))?;
let spv = ash::util::read_spv(&mut std::io::Cursor::new(if ten_bit {
CSC10_SPV
} else {
CSC_SPV
}))?;
let shader =
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?;
guard.shader = shader;
@@ -1395,7 +1553,8 @@ impl VulkanVideoEncoder {
rgb_cfg
.as_ref()
.is_some_and(|c| c.padded)
.then_some(vk::Format::B8G8R8A8_UNORM),
.then_some(src_rgb_fmt),
ten_bit,
guard.frames.last_mut().expect("frame just pushed"),
)?;
}
@@ -1455,6 +1614,7 @@ impl VulkanVideoEncoder {
cpu_expand: Vec::new(),
rgb: rgb_cfg,
native_nv12,
ten_bit,
pending_bitrate: None,
width: w,
height: h,
@@ -1615,7 +1775,8 @@ impl VulkanVideoEncoder {
ch: u32,
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
if self.native_nv12 {
let mut ps = NativeProfileStack::new(codec_op_for(self.codec == Codec::Av1));
let mut ps =
NativeProfileStack::new(codec_op_for(self.codec == Codec::Av1), self.ten_bit);
let profile = *ps.wire(self.codec == Codec::Av1);
let arr = [profile];
let mut plist = vk::VideoProfileListInfoKHR::default().profiles(&arr);
@@ -1644,7 +1805,7 @@ impl VulkanVideoEncoder {
None,
)
} else if self.rgb.is_some() {
let mut ps = RgbProfileStack::new(codec_op_for(self.codec == Codec::Av1));
let mut ps = RgbProfileStack::new(codec_op_for(self.codec == Codec::Av1), self.ten_bit);
let profile = *ps.wire(self.codec == Codec::Av1);
let arr = [profile];
let mut plist = vk::VideoProfileListInfoKHR::default().profiles(&arr);
@@ -1801,7 +1962,7 @@ impl VulkanVideoEncoder {
// usage, and shared with the encode queue (the compute queue only copies into
// it; the semaphore orders the hand-off, CONCURRENT avoids a QFOT).
let av1 = self.codec == Codec::Av1;
let mut ps = RgbProfileStack::new(codec_op_for(av1));
let mut ps = RgbProfileStack::new(codec_op_for(av1), self.ten_bit);
let profile = *ps.wire(av1);
let arr = [profile];
let mut plist = vk::VideoProfileListInfoKHR::default().profiles(&arr);
@@ -4160,7 +4321,7 @@ impl Drop for VulkanVideoEncoder {
mod build;
use self::build::{
align_up, build_parameters_av1, build_parameters_h265, make_frame, make_video_image,
probe_rgb_direct,
probe_rgb_direct, rgb_model_for,
};
#[cfg(test)]
@@ -4385,6 +4546,124 @@ mod tests {
}
}
/// A packed 2:10:10:10 (`xRGB_210LE` / `PixelFormat::X2Rgb10`) CPU frame — the layout a
/// gamescope HDR capture delivers, and the one its node actually fixates. Channels are given
/// as 10-bit code values so the test can speak in the units the PQ container uses.
fn cpu_frame_rgb10(w: u32, h: u32, pts_ns: u64, rgb10: [u16; 3]) -> CapturedFrame {
// x:R:G:B 2:10:10:10 little-endian — B in bits 0-9, G in 10-19, R in 20-29.
let word = ((rgb10[0] as u32 & 0x3FF) << 20)
| ((rgb10[1] as u32 & 0x3FF) << 10)
| (rgb10[2] as u32 & 0x3FF);
let mut buf = vec![0u8; (w * h * 4) as usize];
for px in buf.chunks_exact_mut(4) {
px.copy_from_slice(&word.to_le_bytes());
}
CapturedFrame {
width: w,
height: h,
pts_ns,
format: PixelFormat::X2Rgb10,
payload: FramePayload::Cpu(buf),
cursor: None,
}
}
/// The 10-bit twin of [`run_smoke_opts`]: a full HDR session end to end — Main10 / AV1-at-10
/// profile, `G10X6…3PACK16` picture + DPB, `rgb2yuv10.comp` (or the EFC's BT.2020 conversion
/// when `rgb`), and headers carrying the depth + BT.2020/PQ signalling.
///
/// This is the least-exercised path in the backend — nothing had ever created a 10-bit video
/// session here — so the assertions stay where a smoke test can be honest: every submit
/// encodes, the AU count matches, and the depth the encoder settled on is the one asked for.
/// Colour truth is out of band (the `dump_smoke` + ffmpeg recipe), because a shader that
/// wrote the 10 bits into the wrong end of the word still produces a decodable stream.
///
/// `None` = the device declined the 10-bit profile (or, with `rgb`, the BT.2020 EFC
/// conversion): a soft skip, not a failure — that is the same verdict `open_amd_intel`
/// consults to route such a session to VAAPI.
fn run_smoke_10bit(codec: Codec, rgb: bool) -> Option<Vec<crate::EncodedFrame>> {
let env_dim = |k: &str, d: u32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let (w, h) = (env_dim("PF_SMOKE_W", 256), env_dim("PF_SMOKE_H", 256));
let mut enc =
match VulkanVideoEncoder::open_opts_depth(codec, w, h, 60, 10_000_000, rgb, true) {
Ok(e) => e,
Err(e) => {
eprintln!("run_smoke_10bit({codec:?}, rgb={rgb}): open declined — {e:#}");
return None;
}
};
if rgb && enc.rgb.is_none() {
eprintln!("run_smoke_10bit: BT.2020 RGB-direct unavailable on this driver — skipping");
return None;
}
assert!(enc.ten_bit, "a 10-bit session must report 10-bit");
// 10-bit code values spanning the range, so a wrong shift shows up as a wildly wrong
// luminance in the dump rather than a plausible-looking picture.
let colors: [[u16; 3]; 8] = [
[160, 160, 800],
[160, 800, 160],
[800, 160, 160],
[800, 800, 160],
[160, 800, 800],
[800, 160, 800],
[480, 800, 320],
[320, 480, 800],
];
let mut aus: Vec<crate::EncodedFrame> = Vec::new();
for (i, c) in colors.iter().enumerate() {
enc.submit_indexed(&cpu_frame_rgb10(w, h, i as u64 * 16_666_667, *c), i as u32)
.expect("submit");
while let Some(au) = enc.poll().expect("poll") {
aus.push(au);
}
}
enc.flush().expect("flush");
while let Some(au) = enc.poll().expect("poll") {
aus.push(au);
}
assert_eq!(aus.len(), colors.len(), "one AU per submitted frame");
assert!(aus[0].keyframe, "frame 0 must be IDR");
for (i, au) in aus.iter().enumerate() {
assert!(!au.data.is_empty(), "AU {i} empty");
}
Some(aus)
}
/// HEVC **Main10** through the compute CSC — the AMD/Intel HDR path a gamescope session takes.
#[test]
#[ignore = "needs a real VK_KHR_video_encode_h265 device with a 10-bit profile"]
fn vulkan_smoke_10bit() {
if let Some(aus) = run_smoke_10bit(Codec::H265, false) {
dump_smoke(&aus, "10bit.h265");
}
}
/// AV1 at 10 bits — the codec whose driver coverage is thinner, hence its own smoke.
#[test]
#[ignore = "needs a real VK_KHR_video_encode_av1 device with a 10-bit profile"]
fn vulkan_smoke_10bit_av1() {
if let Some(aus) = run_smoke_10bit(Codec::Av1, false) {
dump_smoke(&aus, "10bit.obu");
}
}
/// HDR with **no host CSC at all**: the EFC does the BT.2020 conversion off the packed 10-bit
/// source. Soft-skips where the hardware advertises no `MODEL_YCBCR_2020`, which is the one
/// capability bit in this whole path that has never been seen reported by a real device.
#[test]
#[ignore = "needs VK_VALVE_video_encode_rgb_conversion with the BT.2020 model at 10-bit"]
fn vulkan_smoke_rgb_10bit() {
if let Some(aus) = run_smoke_10bit(Codec::H265, true) {
dump_smoke(&aus, "rgb.10bit.h265");
}
}
/// 24-bpp packed CPU frame (`PixelFormat::Rgb`/`Bgr` — the PipeWire portal's CPU
/// negotiation). `rgb` is (r, g, b) regardless of `fmt`'s byte order.
fn cpu_frame_24(w: u32, h: u32, pts_ns: u64, rgb: [u8; 3], fmt: PixelFormat) -> CapturedFrame {
+7
View File
@@ -5,6 +5,13 @@
//! `libloading`), the device binding (D3D11 vs CUDA), input-surface registration, and the
//! Windows-only async retrieve — stay in their backends. Sibling of [`super::nvenc_status`].
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw `nvEncodeAPI` entry-table calls almost line for line; narrowing it
// would add one `unsafe {}` plus one SAFETY comment per call that could only restate the signature.
// Clearing this file means DELETING the markers that carry no caller contract, not wrapping the
// calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
use super::Codec;
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
+86
View File
@@ -81,6 +81,61 @@ pub(crate) fn block_count_32x32(width: u32, height: u32, chroma444: bool) -> u32
count
}
/// Wire-aware deflation of the per-frame rate budget for the datagram-aligned mode.
///
/// [`build_au`]'s windowing inflates the codec bitstream on its way to the wire: greedy packing
/// of pyrowave's few-hundred-byte atomic block packets into `chunk`-sized windows leaves the
/// tail of most windows zero-padded, plus the 4-byte prefixes and FRAG-chain tails. At
/// 1440p/~850 KiB frames that is ×1.21.3 — the 2026-07 field report's "Automatic" 407 Mb/s
/// pin put 550 Mb/s on a 1 GbE link. The pin is a promise about the LINK, so the codec budget
/// must absorb the framing: this tracker measures the real AU/bitstream ratio per frame and
/// deflates the budget handed to pyrowave's rate control by its EMA. Sealed-datagram framing
/// (packet header + AEAD tag) and FEC parity are deliberately NOT compensated — H.26x sessions
/// carry those on top of the configured bitrate too, and the pin must mean the same thing for
/// every codec.
pub(crate) struct WireBudget {
/// EMA of `built AU bytes / packetized bitstream bytes`, ×1024 fixed point.
scale_x1024: u32,
}
impl WireBudget {
/// Startup prior (×1024 ≈ 1.25 — the 1440p field measurement's midpoint); the EMA
/// converges onto the session's real ratio within ~a second of frames.
const PRIOR_X1024: u32 = 1280;
/// EMA weight 1/8: content-driven per-frame wobble smooths out; a mode/bitrate change
/// re-converges in ~16 frames.
const EMA_SHIFT: u32 = 3;
/// Sanity clamp on the applied scale: never inflate the budget (×1.0 floor), never
/// deflate below half (×2.0 cap — tiny explicit bitrates window very coarsely).
const MIN_X1024: u32 = 1024;
const MAX_X1024: u32 = 2048;
pub(crate) fn new() -> WireBudget {
WireBudget {
scale_x1024: Self::PRIOR_X1024,
}
}
/// Record one frame's measured inflation (`bitstream_len` = the packetized codec bytes the
/// rate controller budgeted; `au_len` = the windowed AU that actually reaches the wire).
pub(crate) fn observe(&mut self, bitstream_len: usize, au_len: usize) {
if bitstream_len == 0 {
return;
}
let sample = ((au_len as u64 * 1024) / bitstream_len as u64)
.clamp(Self::MIN_X1024 as u64, Self::MAX_X1024 as u64) as u32;
let ema = self.scale_x1024 as i64;
self.scale_x1024 = (ema + ((sample as i64 - ema) >> Self::EMA_SHIFT)) as u32;
}
/// The rate-control budget that makes the WIRE hit `budget` bytes/frame under the
/// currently-measured inflation.
pub(crate) fn deflate(&self, budget: usize) -> usize {
let scale = self.scale_x1024.clamp(Self::MIN_X1024, Self::MAX_X1024) as u64;
((budget as u64 * 1024) / scale) as usize
}
}
/// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU.
/// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed
/// datagram-aligned AU (a whole number of `chunk`-sized windows).
@@ -259,6 +314,37 @@ mod tests {
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
}
/// The wire-budget tracker: converges its EMA onto the measured AU/bitstream inflation,
/// deflates the budget by exactly that ratio, and clamps runaway samples.
#[test]
fn wire_budget_converges_and_deflates() {
let mut wb = WireBudget::new();
// Prior ≈ ×1.25 (1280/1024): the first deflation is already conservative.
assert_eq!(wb.deflate(1_024_000), 819_200);
// Feed a steady ×1.30 inflation; the EMA must converge onto it.
for _ in 0..64 {
wb.observe(1000, 1300);
}
let b = wb.deflate(1_024_000);
let expect = 1_024_000_u64 * 1000 / 1300;
assert!(
(b as i64 - expect as i64).unsigned_abs() < 8_000,
"budget {b} should approach {expect}"
);
// A dense-ish run (×1.0) walks it back down to no deflation.
for _ in 0..64 {
wb.observe(1000, 1000);
}
assert_eq!(wb.deflate(1_024_000), 1_024_000);
// Garbage samples are clamped: an absurd ratio can at most halve the budget…
for _ in 0..256 {
wb.observe(10, 1000);
}
assert!(wb.deflate(1_024_000) >= 512_000);
// …and a zero-length observation is ignored, never a division by zero.
wb.observe(0, 1000);
}
#[test]
fn stamp_color_bits_sets_range_and_hdr_bits() {
let mut bs = vec![0u8; 16];
+152 -7
View File
@@ -3,11 +3,15 @@
//! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR.
//! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue).
//!
//! The RGB→YUV conversion is OURS, BT.709 limited range: openh264 writes no colour description
//! into the VUI (unspecified), so decoders fall back to their default — BT.709 limited on every
//! punktfunk client — and the pixels must match that default. The crate's own `YUVBuffer`
//! converter is BT.601 (0.2578/0.5039/0.0977 + 16), which decoded-as-709 is a constant hue
//! error; that's why it is NOT used here.
//! The RGB→YUV conversion is OURS, BT.709 limited range, and the SPS VUI says so
//! ([`VuiConfig::bt709`], applied in `open`). The crate's own `YUVBuffer` converter is BT.601
//! (0.2578/0.5039/0.0977 + 16), which decoded-as-709 is a constant hue error; that's why it is
//! NOT used here.
//!
//! Signalling is not optional. This used to leave the VUI unwritten and lean on decoders
//! defaulting to BT.709 limited — true of every punktfunk client (`csc_rows` falls back to 709 on
//! "unspecified"), but NOT of vendor TV decoders, which guess colorimetry from RESOLUTION: an LG
//! webOS panel reads a 4K SDR stream as BT.2020 and renders it visibly washed out.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -15,7 +19,7 @@ use super::{EncodedFrame, Encoder};
use anyhow::{bail, ensure, Context, Result};
use openh264::encoder::{
BitRate, Complexity, Encoder as Oh264, EncoderConfig, FrameRate, FrameType, IntraFramePeriod,
Profile, RateControlMode, SpsPpsStrategy, UsageType,
Profile, RateControlMode, SpsPpsStrategy, UsageType, VuiConfig,
};
use openh264::formats::YUVSlices;
use openh264::OpenH264API;
@@ -100,7 +104,10 @@ impl OpenH264Encoder {
.scene_change_detect(false) // no surprise IDRs (bitrate spikes / freeze)
.adaptive_quantization(true)
.complexity(Complexity::Low) // latency over BD-rate
.profile(Profile::Baseline); // no B-frames; the VUI carries no colour description
.profile(Profile::Baseline) // no B-frames
// video_signal_type + colour_description in the SPS VUI: BT.709 primaries/transfer/
// matrix, video_full_range_flag = 0 — exactly what `convert_bt709` below produces.
.vui(VuiConfig::bt709());
let api = OpenH264API::from_source(); // statically-bundled build (default `source` feature)
let enc = Oh264::with_api_config(api, cfg).context("openh264 Encoder::with_api_config")?;
let (w, h) = (width as usize, height as usize);
@@ -364,6 +371,144 @@ mod tests {
assert!(has_sps, "IDR must carry an SPS NAL (type 7)");
}
/// Strip Annex-B framing + emulation-prevention bytes from the first SPS NAL in `au`.
fn sps_rbsp(au: &[u8]) -> Vec<u8> {
let start = au
.windows(5)
.position(|w| w[..4] == [0, 0, 0, 1] && (w[4] & 0x1f) == 7)
.map(|p| p + 5)
.expect("an SPS NAL");
let end = au[start..]
.windows(4)
.position(|w| w[..3] == [0, 0, 1] || w == [0, 0, 0, 1])
.map_or(au.len(), |p| start + p);
let mut rbsp = Vec::new();
let nal = &au[start..end];
let mut i = 0;
while i < nal.len() {
// 00 00 03 -> the 03 is an emulation-prevention byte, not payload.
if i + 2 < nal.len() && nal[i] == 0 && nal[i + 1] == 0 && nal[i + 2] == 3 {
rbsp.extend_from_slice(&[0, 0]);
i += 3;
} else {
rbsp.push(nal[i]);
i += 1;
}
}
rbsp
}
/// The colour signalling the SPS actually carries, walked per ITU-T H.264 §7.3.2.1.1: returns
/// `(video_full_range_flag, colour_primaries, transfer_characteristics, matrix_coefficients)`.
/// `None` when the stream is unsignalled — which is what this module used to emit.
fn sps_colour(rbsp: &[u8]) -> Option<(u8, u8, u8, u8)> {
// Exp-Golomb ue(v): count leading zeros, then read that many trailing bits.
fn ue(u: &mut dyn FnMut(u32) -> u32) -> u32 {
let mut lz = 0;
while u(1) == 0 {
lz += 1;
assert!(lz < 32, "malformed Exp-Golomb");
}
if lz == 0 {
0
} else {
(1 << lz) - 1 + u(lz)
}
}
let mut pos = 0usize;
let mut u = |bits: u32| -> u32 {
let mut v = 0;
for _ in 0..bits {
v = (v << 1) | u32::from((rbsp[pos / 8] >> (7 - (pos % 8))) & 1);
pos += 1;
}
v
};
let profile_idc = u(8);
u(8); // constraint_set flags + reserved
u(8); // level_idc
ue(&mut u); // seq_parameter_set_id
assert_eq!(
profile_idc, 66,
"this encoder is pinned to Baseline — a profile change adds the chroma_format_idc \
block this walk deliberately omits"
);
ue(&mut u); // log2_max_frame_num_minus4
let poc_type = ue(&mut u);
match poc_type {
0 => {
ue(&mut u);
} // log2_max_pic_order_cnt_lsb_minus4
1 => panic!("pic_order_cnt_type 1 unhandled — openh264 emits 0 or 2"),
_ => {}
}
ue(&mut u); // max_num_ref_frames
u(1); // gaps_in_frame_num_value_allowed_flag
ue(&mut u); // pic_width_in_mbs_minus1
ue(&mut u); // pic_height_in_map_units_minus1
if u(1) == 0 {
u(1); // mb_adaptive_frame_field_flag
}
u(1); // direct_8x8_inference_flag
if u(1) == 1 {
for _ in 0..4 {
ue(&mut u); // frame_crop_*_offset
}
}
if u(1) == 0 {
return None; // vui_parameters_present_flag
}
if u(1) == 1 {
// aspect_ratio_info_present_flag
if u(8) == 255 {
u(16);
u(16);
}
}
if u(1) == 1 {
u(1); // overscan_info_present_flag -> overscan_appropriate_flag
}
if u(1) == 0 {
return None; // video_signal_type_present_flag
}
u(3); // video_format
let full_range = u(1) as u8;
if u(1) == 0 {
return None; // colour_description_present_flag
}
Some((full_range, u(8) as u8, u(8) as u8, u(8) as u8))
}
/// The SPS must SIGNAL BT.709 limited, not merely be encoded that way. `VuiConfig::bt709()`
/// is a request to a C library; this asserts it lands in the emitted bitstream.
///
/// Unsignalled was the old behaviour and it looks fine on every punktfunk client (`csc_rows`
/// defaults to BT.709 on "unspecified"), so nothing in our own stack catches a regression
/// here — but vendor TV decoders guess colorimetry from RESOLUTION, and an LG webOS panel
/// reads a 4K SDR stream as BT.2020 and renders it visibly washed out.
#[test]
fn sps_signals_bt709_limited() {
let (w, h, fps) = (1280u32, 720u32, 60u32);
let mut enc =
OpenH264Encoder::open(PixelFormat::Bgrx, w, h, fps, 8_000_000).expect("open openh264");
let frame = CapturedFrame {
width: w,
height: h,
pts_ns: 0,
format: PixelFormat::Bgrx,
payload: FramePayload::Cpu(vec![0x80u8; (w * h * 4) as usize]),
cursor: None,
};
enc.submit(&frame).expect("submit");
let au = enc.poll().expect("poll").expect("an AU");
let colour = sps_colour(&sps_rbsp(&au.data)).expect(
"the SPS must carry video_signal_type + colour_description — \
see EncoderConfig::vui in `open`",
);
// (video_full_range_flag, colour_primaries, transfer, matrix) — 0 = limited, 1 = BT.709.
assert_eq!(colour, (0, 1, 1, 1), "expected BT.709 limited signalling");
}
/// The modes the software encoder can actually serve — including the portrait orientation,
/// which a naive per-axis `w <= 3840 && h <= 2160` would wrongly reject.
#[test]
+6
View File
@@ -43,6 +43,12 @@
//! fallback + `PUNKTFUNK_AMF_FFMPEG` hatch are gone). 4:4:4 is **permanently** out: VCN hardware
//! does not encode 4:4:4.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw AMF COM-style vtable calls through `*mut AmfComponent` almost line
// for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only
// restate the signature. Clearing this file means DELETING the markers that carry no caller
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
+271 -109
View File
@@ -37,6 +37,13 @@
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) calls on borrowed hwcontext pointers
// almost line for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call
// that could only restate the signature. Clearing this file means DELETING the markers that carry
// no caller contract, not wrapping the calls — until then the lint is off HERE and enforced
// everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -50,10 +57,10 @@ use std::os::raw::{c_int, c_uint, c_void};
use std::ptr;
use windows::core::Interface;
use windows::Win32::Graphics::Direct3D11::{
ID3D11Device, ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D, D3D11_BIND_DECODER,
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_BIND_VIDEO_ENCODER,
D3D11_CPU_ACCESS_READ, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, D3D11_TEXTURE2D_DESC,
D3D11_USAGE_STAGING,
ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, ID3D11Resource, ID3D11Texture2D,
D3D11_BIND_DECODER, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
D3D11_BIND_VIDEO_ENCODER, D3D11_CPU_ACCESS_READ, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ,
D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING,
};
use windows::Win32::Graphics::Dxgi::Common::{
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
@@ -61,8 +68,8 @@ use windows::Win32::Graphics::Dxgi::Common::{
};
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_BT2020, SWS_CS_ITU709,
SWS_POINT,
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_BT2020,
SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -857,8 +864,11 @@ impl Drop for SystemInner {
// ---------------------------------------------------------------------------------------------
struct D3d11Hw {
device_ref: *mut ffi::AVBufferRef,
frames_ref: *mut ffi::AVBufferRef,
// Declared frames-BEFORE-device on purpose: these drop in declaration order, reproducing what
// the hand-written `Drop` this replaced did (the frames ctx holds its own ref on the device).
// Do not reorder these two fields.
frames_ref: AvBuffer,
device_ref: AvBuffer,
}
impl D3d11Hw {
@@ -871,30 +881,65 @@ impl D3d11Hw {
h: u32,
pool: c_int,
) -> Result<Self> {
let mut device_ref =
ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA);
if device_ref.is_null() {
bail!("av_hwdevice_ctx_alloc(D3D11VA) failed");
}
let dev_ctx = (*device_ref).data as *mut ffi::AVHWDeviceContext;
// Owned from the moment it exists: each `bail!` below drops what was built so far, so none
// of the failure branches carry cleanup of their own.
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA,
))
.context("av_hwdevice_ctx_alloc(D3D11VA) failed")?;
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
let d11 = (*dev_ctx).hwctx as *mut AVD3D11VADeviceContext;
// Turn on D3D11 multithread protection before libav sees the device.
//
// libav does this itself in `d3d11va_device_create` — but only there. We take the OTHER
// path (`d3d11va_device_init`, because we supply the capturer's device), which does not,
// and the omission bites twice:
//
// * QSV. `av_hwdevice_ctx_create_derived(QSV <- D3D11VA)` ends in
// `MFXVideoCORE_SetHandle`, and MFX rejects a device that is not multithread-protected
// with `MFX_ERR_UNDEFINED_BEHAVIOR (-16)` — logged only as "Error setting child device
// handle", which names neither the cause nor the cure. Measured on Intel UHD 750 /
// FFmpeg 7.1.5+libvpl: identical device, protection off -> derive fails; protection on
// -> derive succeeds. `D3D11_CREATE_DEVICE_VIDEO_SUPPORT` makes no difference either way.
//
// * AMF, which is the DEFAULT path and was already shipping. We deliberately leave
// `lock`/`unlock` null so libav installs its `d3d11va_default_lock`, and that lock is
// `ID3D11Multithread::Enter`/`Leave` — which are documented no-ops while protection is
// off. So the lock libav installs to serialise our capture thread against its encode
// thread has been doing nothing at all. It only starts working from here.
//
// Idempotent, and safe to apply to a device the capturer owns: it only enables the
// device's internal critical section (`was` is the previous state, reported once at debug).
match device.cast::<ID3D11Multithread>() {
Ok(mt) => {
let was = mt.SetMultithreadProtected(true);
tracing::debug!(
previously_protected = was.as_bool(),
"D3D11 multithread protection enabled for the libav hwdevice"
);
}
// Pre-11.1 runtimes have no ID3D11Multithread. Nothing to enable, so carry on and let
// the QSV derive fail with its own message rather than failing capture here.
Err(e) => tracing::warn!(
error = %e,
"no ID3D11Multithread on this device — QSV zero-copy will not derive"
),
}
// Share the capture device. FFmpeg's d3d11va teardown Releases `device`, so hand it an owned
// reference (clone = AddRef, forget = don't Release ours). init() fills
// device_context / video_device / video_context / the default lock from a non-null device.
std::mem::forget(device.clone());
(*d11).device = device.as_raw();
let r = ffi::av_hwdevice_ctx_init(device_ref);
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
if r < 0 {
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwdevice_ctx_init(D3D11VA) failed ({r})");
}
let mut frames_ref = ffi::av_hwframe_ctx_alloc(device_ref);
if frames_ref.is_null() {
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwframe_ctx_alloc(D3D11VA) failed");
}
let fc = (*frames_ref).data as *mut ffi::AVHWFramesContext;
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
.context("av_hwframe_ctx_alloc(D3D11VA) failed")?;
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_D3D11;
(*fc).sw_format = sw_format;
(*fc).width = w as c_int;
@@ -902,42 +947,42 @@ impl D3d11Hw {
(*fc).initial_pool_size = pool;
let f11 = (*fc).hwctx as *mut AVD3D11VAFramesContext;
(*f11).bind_flags = bind_flags;
let r = ffi::av_hwframe_ctx_init(frames_ref);
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
if r < 0 {
ffi::av_buffer_unref(&mut frames_ref);
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwframe_ctx_init(D3D11VA) failed ({r})");
}
Ok(D3d11Hw {
device_ref,
frames_ref,
device_ref,
})
}
}
impl Drop for D3d11Hw {
fn drop(&mut self) {
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `D3d11Hw::new` created
// (it bails before constructing `Self` if either alloc/init fails, so a live `D3d11Hw` always
// holds both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`.
// This `Drop` runs exactly once and `D3d11Hw` owns these refs exclusively → no double-free /
// use-after-free. Frames are unref'd before the device because the frames ctx internally holds
// a ref on the device (refcounted, so the order is sound either way).
unsafe {
ffi::av_buffer_unref(&mut self.frames_ref);
ffi::av_buffer_unref(&mut self.device_ref);
}
}
}
// No `Drop` for `D3d11Hw`: each `AvBuffer` field unrefs itself, in declaration order (frames, then
// device — see the field comment). The hand-written unref pair this replaced had to be kept in sync
// with every failure branch in `new`; now there is one unref path per ref and none can skip it.
struct ZeroCopyInner {
vendor: WinVendor,
/// QSV only: the QSV device + frames ctx derived from the D3D11VA ones (the encoder's real
/// input). `None` for AMF, which takes the D3D11 frames directly — the nullable raw pointers
/// these replaced said the same thing, but only in a comment.
///
/// FIELD ORDER IS LOAD-BEARING: frames before device, and BOTH before `enc`/`hw`. Fields drop
/// in declaration order, and that sequence reproduces the hand-written `Drop` this replaced,
/// which ran ahead of every field and so released the derived QSV pair before the encoder and
/// the D3D11 refs. Everything holds its own reference, so refcounting makes any order sound;
/// the ordering is pinned so a reorder cannot quietly change what ships.
qsv_frames: Option<AvBuffer>,
/// Unlike `qsv_frames` (which the send path reads to tag each mapped frame), this one is held
/// purely as an owner: the frames ctx and the encoder each took their own ref, so nothing reads
/// it again — it exists so the QSV device outlives both and is unref'd exactly once. Same
/// reasoning as the decoders' `hw_device`: removing the field would free the device early, and
/// an underscore name would hide what it holds.
#[allow(dead_code)]
qsv_device: Option<AvBuffer>,
enc: encoder::video::Encoder,
hw: D3d11Hw,
/// QSV only: the QSV device + frames ctx derived from the D3D11VA ones (the encoder's real
/// input). `None` for AMF (which takes the D3D11 frames directly).
qsv_device: *mut ffi::AVBufferRef,
qsv_frames: *mut ffi::AVBufferRef,
ctx: ID3D11DeviceContext,
/// The pool's fixed sw_format (NV12 8-bit / P010 10-bit). A captured frame whose format differs
/// (the capturer's video-processor fell back to Bgra/Rgb10a2) cannot be CopySubresourceRegion'd
@@ -971,28 +1016,27 @@ impl ZeroCopyInner {
};
let bind_flags = pool_bind_flags(vendor);
const POOL: c_int = 8;
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg an
// owned AddRef of it, balanced by FFmpeg's teardown Release) and builds an owned
// device_ref/frames_ref pair freed by `D3d11Hw::Drop`; `hw` is a local, so it is dropped (and
// both refs freed) on every early `return Err`. For QSV, `av_hwdevice_ctx_create_derived` and
// `av_hwframe_ctx_create_derived` fill the null-initialised `qsv_device`/`qsv_frames` out-params
// only on success (`r >= 0` checked); on the frames-derive failure we unref the already-created
// `qsv_device` before bailing. `open_win_encoder` internally `av_buffer_ref`s the dev/frames
// refs it is given (so ownership of `hw`'s and the derived refs stays here), and on its failure
// we unref the still-owned derived `qsv_frames`/`qsv_device` (null for AMF → skipped) and return
// — `hw` then drops its D3D11 refs. On success the derived refs are moved into `ZeroCopyInner`
// (freed in its `Drop`) and the encoder holds its own AddRef'd copies. Every `AVBufferRef` is
// unref'd exactly once across all paths — no leak, no double-free.
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg
// an owned AddRef of it, balanced by FFmpeg's teardown Release) and returns an owned
// frames_ref/device_ref pair. For QSV, `av_hwdevice_ctx_create_derived` /
// `av_hwframe_ctx_create_derived` fill their null-initialised out-params only on success
// (`r >= 0` checked) and each result is taken into an `AvBuffer` immediately. From there
// every handle in this function is owned by a local, so each early `bail!`/`?` releases
// exactly what exists at that point, in reverse order — there is no cleanup code on any
// failure branch to keep in step. `open_win_encoder` takes its OWN refs of the dev/frames
// pointers it is handed, so lending them via `as_ptr()` transfers nothing; on success the
// owners move into `ZeroCopyInner` and are released by its field drops. Every `AVBufferRef`
// is still unref'd exactly once on every path — the difference is that it is now the type
// system enforcing it rather than a comment.
unsafe {
let hw = D3d11Hw::new(device, sw_av, bind_flags, width, height, POOL)?;
let (pix_fmt, dev_ref, frames_ref, mut qsv_device, mut qsv_frames) = match vendor {
WinVendor::Amf => (
ffi::AVPixelFormat::AV_PIX_FMT_D3D11,
hw.device_ref,
hw.frames_ref,
ptr::null_mut(),
ptr::null_mut(),
),
// Own the derived QSV pair (or nothing, on AMF). Keeping ownership here and deriving the
// encoder's pointers from it below is the whole point: the tuple this replaced handed
// the SAME two pointers out twice — once as the encoder's dev/frames args and once as
// the pair moved into `Self` — which is fine for raw pointers and would be two owners
// for `AvBuffer`.
let (qsv_frames, qsv_device) = match vendor {
WinVendor::Amf => (None, None),
WinVendor::Qsv => {
// Derive a QSV device that SHARES the D3D11 device, and a QSV frames ctx derived
// from the D3D11 frames pool (auto-mapped 1:1). The encoder takes AV_PIX_FMT_QSV.
@@ -1000,34 +1044,45 @@ impl ZeroCopyInner {
let r = ffi::av_hwdevice_ctx_create_derived(
&mut qsv_device,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_QSV,
hw.device_ref,
hw.device_ref.as_ptr(),
0,
);
if r < 0 {
bail!("derive QSV device from D3D11VA: {}", ffmpeg::Error::from(r));
}
let qsv_device = AvBuffer::from_raw(qsv_device)
.context("av_hwdevice_ctx_create_derived(QSV) gave no device")?;
let mut qsv_frames: *mut ffi::AVBufferRef = ptr::null_mut();
let r = ffi::av_hwframe_ctx_create_derived(
&mut qsv_frames,
ffi::AVPixelFormat::AV_PIX_FMT_QSV,
qsv_device,
hw.frames_ref,
qsv_device.as_ptr(),
hw.frames_ref.as_ptr(),
ffi::AV_HWFRAME_MAP_DIRECT as c_int,
);
if r < 0 {
ffi::av_buffer_unref(&mut qsv_device);
// `qsv_device` drops here — the hand-written unref this replaced was the
// single easiest line in the function to forget.
bail!("derive QSV frames from D3D11VA: {}", ffmpeg::Error::from(r));
}
(
ffi::AVPixelFormat::AV_PIX_FMT_QSV,
qsv_device,
qsv_frames,
qsv_device,
qsv_frames,
)
let qsv_frames = AvBuffer::from_raw(qsv_frames)
.context("av_hwframe_ctx_create_derived(QSV) gave no frames ctx")?;
(Some(qsv_frames), Some(qsv_device))
}
};
let enc = match open_win_encoder(
// BORROWED views for the encoder — `open_win_encoder` takes its own refs of whatever it
// is handed, so ownership stays with `qsv_*`/`hw` either way.
let (pix_fmt, dev_ref, frames_ref) = match (&qsv_device, &qsv_frames) {
(Some(d), Some(f)) => (ffi::AVPixelFormat::AV_PIX_FMT_QSV, d.as_ptr(), f.as_ptr()),
_ => (
ffi::AVPixelFormat::AV_PIX_FMT_D3D11,
hw.device_ref.as_ptr(),
hw.frames_ref.as_ptr(),
),
};
// `?` is enough now: on failure `qsv_frames`/`qsv_device` and `hw` all drop on the way
// out, which is what the hand-written null-checked unref pair here used to do.
let enc = open_win_encoder(
vendor,
codec,
width,
@@ -1039,18 +1094,7 @@ impl ZeroCopyInner {
ten_bit,
dev_ref,
frames_ref,
) {
Ok(e) => e,
Err(e) => {
if !qsv_frames.is_null() {
ffi::av_buffer_unref(&mut qsv_frames);
}
if !qsv_device.is_null() {
ffi::av_buffer_unref(&mut qsv_device);
}
return Err(e);
}
};
)?;
tracing::info!(
encoder = vendor.encoder_name(codec),
"{} encode active ({width}x{height}@{fps}, zero-copy D3D11 {} path)",
@@ -1059,10 +1103,10 @@ impl ZeroCopyInner {
);
Ok(ZeroCopyInner {
vendor,
qsv_frames,
qsv_device,
enc,
hw,
qsv_device,
qsv_frames,
ctx: immediate_context(device),
pool_format,
})
@@ -1089,7 +1133,7 @@ impl ZeroCopyInner {
if d3d.is_null() {
bail!("av_frame_alloc(d3d11) failed");
}
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref, d3d, 0);
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d, 0);
if r < 0 {
ffi::av_frame_free(&mut d3d);
bail!("av_hwframe_get_buffer(D3D11) failed ({r})");
@@ -1121,8 +1165,17 @@ impl ZeroCopyInner {
ffi::av_frame_free(&mut d3d);
bail!("av_frame_alloc(qsv) failed");
}
// Always `Some` on this arm — `open` fills the pair for `WinVendor::Qsv` and
// leaves it `None` only for AMF — but say so with a bail rather than an unwrap,
// matching the null check above it. The `Option` is what the raw pointer's
// "null means AMF" convention was already encoding.
let Some(qsv_frames) = self.qsv_frames.as_ref() else {
ffi::av_frame_free(&mut qsv);
ffi::av_frame_free(&mut d3d);
bail!("QSV send path without a derived QSV frames context");
};
(*qsv).format = ffi::AVPixelFormat::AV_PIX_FMT_QSV as c_int;
(*qsv).hw_frames_ctx = ffi::av_buffer_ref(self.qsv_frames);
(*qsv).hw_frames_ctx = ffi::av_buffer_ref(qsv_frames.as_ptr());
// The map flags are a bindgen enum (no BitOr) — cast each to int before OR-ing.
let r = ffi::av_hwframe_map(
qsv,
@@ -1153,23 +1206,10 @@ impl ZeroCopyInner {
}
}
impl Drop for ZeroCopyInner {
fn drop(&mut self) {
// SAFETY: `qsv_frames`/`qsv_device` are the derived QSV `AVBufferRef`s (or null for AMF); each
// is `av_buffer_unref`'d once here (nulling the pointer through the `&mut`) — `ZeroCopyInner`
// owns these handles exclusively and this `Drop` runs once, so no double-free. The `enc` and
// `hw` fields free the encoder's AddRef'd copies and the D3D11 device/frames refs through their
// own `Drop`, so all references stay balanced.
unsafe {
if !self.qsv_frames.is_null() {
ffi::av_buffer_unref(&mut self.qsv_frames);
}
if !self.qsv_device.is_null() {
ffi::av_buffer_unref(&mut self.qsv_device);
}
}
}
}
// No `Drop` for `ZeroCopyInner`: the two `Option<AvBuffer>`s unref themselves when present and do
// nothing when `None` (AMF), which is what the hand-written null checks amounted to. Field order
// (see the struct) keeps the release sequence identical: QSV frames, QSV device, then the encoder's
// AddRef'd copies via `enc`, then the D3D11 pair via `hw`.
// ---------------------------------------------------------------------------------------------
@@ -1448,6 +1488,128 @@ impl Encoder for FfmpegWinEncoder {
mod tests {
use super::*;
/// Pick the Intel adapter, or any hardware D3D11 adapter, for the probes below.
///
/// Not `EnumAdapters1(0)`: a punktfunk host box also enumerates our own virtual-display adapter,
/// and "Microsoft Basic Render Driver" (vendor 0x1414) is the software rasterizer, which has no
/// video engine at all.
///
/// # Safety
/// Calls the DXGI enumeration FFI and `make_device`; every result is checked before use and no
/// alias to the adapter outlives the call.
#[cfg(test)]
unsafe fn test_hw_device(prefer_vendor: u32) -> Option<ID3D11Device> {
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
let mut preferred = None;
let mut fallback = None;
for i in 0.. {
let Ok(adapter) = factory.EnumAdapters1(i) else {
break; // DXGI_ERROR_NOT_FOUND — end of the list
};
let Ok(desc) = adapter.GetDesc1() else {
continue;
};
let name = String::from_utf16_lossy(&desc.Description)
.trim_end_matches('\0')
.to_string();
eprintln!("adapter {i}: vendor={:#06x} {name}", desc.VendorId);
if desc.VendorId == prefer_vendor && preferred.is_none() {
preferred = Some(adapter);
} else if desc.VendorId != 0x1414 && fallback.is_none() {
fallback = Some(adapter);
}
}
let adapter = preferred.or(fallback)?;
pf_frame::dxgi::make_device(&adapter).ok().map(|(d, _c)| d)
}
/// Construct/drop `D3d11Hw` repeatedly on real silicon — the D3D11VA half of the RAII change,
/// and the half that IS reachable on any GPU.
///
/// `D3d11Hw` owns a hwdevice + frames-pool pair through `AvBuffer`, and it is shared by both
/// Windows zero-copy vendors, so this covers the AMF path's ownership too without needing AMD
/// hardware. Looping is the point, as with `cuda_hw_alloc_drop_cycles`: a double-unref aborts in
/// the CRT, a missed one leaks a device and a pool of eight surfaces per iteration.
///
/// `#[ignore]`d (needs a real D3D11 GPU):
/// `cargo test -p pf-encode --features amf-qsv d3d11hw_alloc_drop_cycles -- --ignored --nocapture`
#[test]
#[ignore = "needs a real D3D11 GPU (run on a GPU host, not the build box)"]
fn d3d11hw_alloc_drop_cycles() {
// SAFETY: see `test_hw_device`; the returned device outlives every `D3d11Hw` built below.
let device = unsafe { test_hw_device(0x8086) }.expect("a hardware D3D11 adapter");
for i in 0..8 {
// SAFETY: `D3d11Hw::new` needs libav initialised (it is, statically, by the ffmpeg-next
// crate's first use here) and a live `ID3D11Device`, which `device` is for the whole
// loop. NV12 at 640x480 with an 8-surface pool are valid pool parameters. The handle
// drops at the end of each iteration — that release is what is under test.
let hw = unsafe {
D3d11Hw::new(
&device,
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
pool_bind_flags(WinVendor::Amf),
640,
480,
8,
)
}
.unwrap_or_else(|e| panic!("D3d11Hw::new failed on iteration {i}: {e:#}"));
assert!(!hw.device_ref.as_ptr().is_null(), "device ref went null");
assert!(!hw.frames_ref.as_ptr().is_null(), "frames ref went null");
}
eprintln!("8 D3d11Hw alloc/drop cycles completed without abort");
}
/// Construct/drop `ZeroCopyInner` on the QSV path, repeatedly, on real Intel silicon.
///
/// This is the one path in the crate where ownership was genuinely ambiguous: `open` builds a
/// `D3d11Hw` (D3D11VA device + frames pair) and then DERIVES a QSV device + frames ctx from it,
/// and the tuple it used to return handed those two derived pointers out **twice** — once as
/// the encoder's `dev_ref`/`frames_ref`, once as the pair moved into `Self`. Harmless while they
/// were raw pointers; two owners once they are `AvBuffer`. Looping construct/drop is what tells
/// the difference apart: a double-unref aborts inside the CRT, a missed one leaks an Intel
/// device per session.
///
/// Nothing else reaches it. `zerocopy_enabled` defaults QSV **off**, so the normal encode path
/// never builds one on Intel, and the native VPL backend (`enc/windows/qsv.rs`) supersedes this
/// whole file unless `PUNKTFUNK_QSV_FFMPEG=1`. Calling `open` directly sidesteps both gates so
/// the ownership itself is what gets exercised.
///
/// This test is also the regression guard for the D3D11 multithread-protection fix in
/// `D3d11Hw::new`. Without it the QSV derive dies in `MFXVideoCORE_SetHandle` with
/// `MFX_ERR_UNDEFINED_BEHAVIOR (-16)`, surfacing only as libav's "Error setting child device
/// handle" — a message that names neither the cause nor the cure. If this starts failing that
/// way again, look there first.
///
/// `#[ignore]`d (needs a real Intel QSV device):
/// `cargo test -p pf-encode --features amf-qsv zerocopy_qsv_alloc_drop_cycles -- --ignored --nocapture`
#[test]
#[ignore = "needs a real Intel QSV device (run on an Intel host, not the build box)"]
fn zerocopy_qsv_alloc_drop_cycles() {
// SAFETY: see `test_hw_device`; the device outlives every `ZeroCopyInner` built below.
let device = unsafe { test_hw_device(0x8086) }.expect("an Intel D3D11 adapter");
for i in 0..8 {
let zc = ZeroCopyInner::open(
WinVendor::Qsv,
Codec::H264,
PixelFormat::Bgrx,
640,
480,
30,
8_000_000,
8,
&device,
)
.unwrap_or_else(|e| panic!("ZeroCopyInner::open(QSV) failed on iteration {i}: {e:#}"));
// The QSV arm must have derived BOTH halves — an `Option` that came back `None` here
// would mean the AMF branch was taken and the derived-pair ownership never ran.
assert!(zc.qsv_frames.is_some(), "QSV path derived no frames ctx");
assert!(zc.qsv_device.is_some(), "QSV path derived no device");
}
eprintln!("8 ZeroCopyInner(QSV) alloc/drop cycles completed without abort");
}
/// Zero-copy default matrix: the operator override wins in both directions; unset resolves
/// AMF on (on-glass validated) and QSV off (opt-in until validated on Intel glass — the
/// probe-never-assume rule).
+136 -25
View File
@@ -33,6 +33,12 @@
//! AU completes within the same tick and `poll` picks it up); under contention completed frames
//! queue instead of stalling capture — throughput recovers up to the scheduler-granted share.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw `nvEncodeAPI` entry-table + D3D11 calls almost line for line;
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -89,6 +95,12 @@ struct EncodeApi {
*mut nv::NV_ENC_CAPS_PARAM,
*mut core::ffi::c_int,
) -> nv::NVENCSTATUS,
// The two entry points behind [`probe_codec_support`] — the driver's own list of encode GUIDs
// this chip exposes. Mandatory like every other entry: both have existed since NVENC 1.0, so a
// driver missing them is broken in ways the rest of this table would not survive either.
get_encode_guid_count: unsafe extern "C" fn(*mut c_void, *mut u32) -> nv::NVENCSTATUS,
get_encode_guids:
unsafe extern "C" fn(*mut c_void, *mut nv::GUID, u32, *mut u32) -> nv::NVENCSTATUS,
get_encode_preset_config_ex: unsafe extern "C" fn(
*mut c_void,
nv::GUID,
@@ -203,6 +215,8 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?,
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
get_encode_guid_count: list.nvEncGetEncodeGUIDCount.ok_or(MISSING)?,
get_encode_guids: list.nvEncGetEncodeGUIDs.ok_or(MISSING)?,
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?,
destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?,
@@ -1965,11 +1979,85 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE)
}
/// Query ONE NVENC capability for `codec`: creates a throwaway hardware D3D11 device + NVENC
/// session on the **selected render adapter**, reads the cap, and tears everything down. `false`
/// on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
/// Query ONE NVENC capability for `codec` on a throwaway session (see [`with_probe_session`]).
/// `false` on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
/// capability that couldn't be confirmed.
fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
with_probe_session(|enc| {
let mut param = nv::NV_ENC_CAPS_PARAM {
version: nv::NV_ENC_CAPS_PARAM_VER,
capsToQuery: cap,
reserved: [0; 62],
};
let mut val: i32 = 0;
// SAFETY: `get_encode_caps` reads one scalar cap into `val` (live locals) for the live
// session `enc` via the loaded API table (`with_probe_session` sits past `try_api`).
unsafe {
(api().get_encode_caps)(enc, codec_guid(codec), &mut param, &mut val)
.nv_ok()
.is_ok()
&& val != 0
}
})
.unwrap_or(false)
}
/// Which codecs **this GPU's** NVENC can actually encode, asked of the driver itself
/// (`nvEncGetEncodeGUIDs`) on a throwaway session — the Windows twin of the Linux
/// `nvenc_cuda::probe_support` codec half, probing the **selected render adapter** (the GPU the
/// session will really encode on) rather than CUDA device 0. Same field bug on both OSes: the
/// static `H.264 | HEVC | AV1` superset advertised HEVC on a 1st-gen Maxwell, and a client that
/// negotiated it got a dead session instead of a stream.
///
/// Every failure path returns "nothing probed", which [`crate::CodecSupport::wire_mask`] turns
/// into `None` so the caller keeps the old static superset — a broken probe must never be able to
/// narrow an NVIDIA host's advertisement to nothing. Cached per selected GPU by the caller
/// ([`crate::windows_codec_support`]).
pub(crate) fn probe_codec_support() -> crate::CodecSupport {
let unknown = crate::CodecSupport {
h264: false,
h265: false,
av1: false,
};
with_probe_session(|enc| {
// SAFETY: all NVENC calls go through the loaded API table against the live session `enc`;
// `count`/`written` are live locals, and `guids` is sized to the count the driver just
// reported, its pointer valid for that many `GUID`s (matching `guidArraySize`).
unsafe {
let mut count = 0u32;
let counted = (api().get_encode_guid_count)(enc, &mut count)
.nv_ok()
.is_ok();
let mut guids = vec![nv::GUID::default(); count as usize];
let mut written = 0u32;
let listed = counted
&& count > 0
&& (api().get_encode_guids)(enc, guids.as_mut_ptr(), count, &mut written)
.nv_ok()
.is_ok();
if !listed {
tracing::warn!(
"NVENC codec probe: driver listed no encode GUIDs — keeping the static \
advertisement"
);
return unknown;
}
guids.truncate(written as usize);
crate::CodecSupport {
h264: guids.contains(&codec_guid(Codec::H264)),
h265: guids.contains(&codec_guid(Codec::H265)),
av1: guids.contains(&codec_guid(Codec::Av1)),
}
}
})
.unwrap_or(unknown)
}
/// Open a throwaway NVENC session on a fresh hardware D3D11 device, hand it to `f`, and tear
/// everything down. `None` = no loadable NVENC / no device / failed open — the caller supplies
/// the honest "couldn't confirm" answer. Shared by [`probe_encode_cap`] and
/// [`probe_codec_support`], so every advertisement probe opens sessions exactly one way.
fn with_probe_session<T>(f: impl FnOnce(*mut c_void) -> T) -> Option<T> {
// Same exclusion as `init_session`: this opens a real (throwaway) session, so it must never
// overlap a zombie reap that could be destroying the very address the driver hands us.
let _gate = DRIVER_SESSION_GATE
@@ -1983,20 +2071,20 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
};
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
// No loadable NVENC on this box (non-NVIDIA / no driver) → the honest 4:4:4 answer is "no".
// This is also the `api()` gate for every NVENC call below.
// No loadable NVENC on this box (non-NVIDIA / no driver) → nothing to confirm.
// This is also the `api()` gate for every NVENC call below and inside `f`.
if try_api().is_err() {
return false;
return None;
}
// SAFETY: a self-contained probe owning every handle it creates. `CreateDXGIFactory1`/
// `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback).
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
// fills `device` or returns Err (→ false). `open_encode_session_ex` opens an NVENC session
// against that device's raw pointer (valid while `device` is held) or errors (→ false, after
// fills `device` or returns Err (→ None). `open_encode_session_ex` opens an NVENC session
// against that device's raw pointer (valid while `device` is held) or errors (→ None, after
// destroying any residue session the failed open left — the docs require it).
// `get_encode_caps` reads one scalar cap into `val` via the loaded API table.
// `destroy_encoder` frees the session exactly once; `device`/its context drop with the COM
// wrappers. No handle escapes this call and nothing runs concurrently.
// `destroy_encoder` frees the session exactly once, after `f` returns (so `enc` is live for
// the whole closure call); `device`/its context drop with the COM wrappers. No handle
// escapes this call and nothing runs concurrently (the gate above).
unsafe {
// Probe on the SELECTED render adapter — the GPU the session will actually encode on
// (web-console preference / PUNKTFUNK_RENDER_ADAPTER / max VRAM). The OS default adapter
@@ -2032,9 +2120,9 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
),
};
if created.is_err() {
return false;
return None;
}
let Some(device) = device else { return false };
let device = device?;
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_DIRECTX,
@@ -2051,23 +2139,14 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
if !enc.is_null() {
let _ = (api().destroy_encoder)(enc);
}
return false;
return None;
}
// Availability probe, but a real session open all the same: it proves the driver accepted
// this build's version word, which is what rules a skew out later (see `nvenc_status`).
nvenc_status::note_session_opened();
let mut param = nv::NV_ENC_CAPS_PARAM {
version: nv::NV_ENC_CAPS_PARAM_VER,
capsToQuery: cap,
reserved: [0; 62],
};
let mut val: i32 = 0;
let ok = (api().get_encode_caps)(enc, codec_guid(codec), &mut param, &mut val)
.nv_ok()
.is_ok()
&& val != 0;
let out = f(enc);
let _ = (api().destroy_encoder)(enc);
ok
Some(out)
}
}
@@ -2356,4 +2435,36 @@ mod tests {
"C:\\Users\\Public\\nvenc420_probe.h265",
);
}
/// ON-HARDWARE: the codec-advertisement probe against the real driver — the Windows twin of
/// the Linux `nvenc_codec_probe_reports_real_gpu_support` test, same invariants: every
/// NVENC-capable GPU ever made encodes H.264 (a `false` means the enumeration is broken and
/// would silently narrow the advertisement), and the answer must be stable across calls since
/// one cached answer drives every negotiation. Prints the mask so a run on an OLD card
/// (Maxwell GM107 = h264 only, the GPU this probe exists for) is self-documenting. Run:
/// cargo test -p pf-encode --features nvenc --release -- --ignored nvenc_codec_probe --nocapture
/// (`--release` is REQUIRED on Windows, and pre-dates this test: the debug lib-test link
/// fails LNK2019 because the sdk crate's unused lazy loader references the NvEncodeAPI
/// imports that runtime loading deliberately avoids — debug `/OPT:NOREF` keeps the dead
/// COMDAT, release strips it. Windows CI gates pf-encode with clippy for the same reason.)
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.173)"]
fn nvenc_codec_probe_reports_real_gpu_support() {
let caps = probe_codec_support();
eprintln!(
"NVENC (Windows) probe: h264={} h265={} av1={}",
caps.h264, caps.h265, caps.av1
);
assert!(
caps.h264,
"every NVENC generation encodes H.264 — a false here means the GUID enumeration \
failed, which would narrow the host's codec advertisement"
);
let again = probe_codec_support();
assert_eq!(
(caps.h264, caps.h265, caps.av1),
(again.h264, again.h265, again.av1),
"the probe must be stable — it is cached once and drives every later negotiation"
);
}
}
+26 -1
View File
@@ -22,6 +22,13 @@
//! The capture side (a BGRA→YUV CSC into two shareable plane textures + a shared fence, gated on the
//! pyrowave session flag) lives in `pf-capture` (`windows/idd_push.rs`); the CbCr plane + fence ride
//! the frame on [`pf_frame::dxgi::D3d11Frame::pyro`], the Y plane on `D3d11Frame::texture`.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is `pyrowave-sys` C-API plus D3D11/Vulkan interop calls almost line for
// line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only
// restate the signature. Clearing this file means DELETING the markers that carry no caller
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block in this module carries a `// SAFETY:` proof (the crate root enforces it).
use crate::pyrowave_wire;
@@ -159,6 +166,9 @@ pub struct PyroWaveEncoder {
frame_budget: usize,
/// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU.
wire_chunk: Option<usize>,
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
/// WIRE, not just the raw bitstream (see [`pyrowave_wire::WireBudget`]).
wire_budget: pyrowave_wire::WireBudget,
bitstream: Vec<u8>,
pending: VecDeque<EncodedFrame>,
}
@@ -288,6 +298,7 @@ impl PyroWaveEncoder {
hdr16,
frame_budget,
wire_chunk: None,
wire_budget: pyrowave_wire::WireBudget::new(),
bitstream: Vec::new(),
pending: VecDeque::new(),
})
@@ -441,6 +452,16 @@ impl PyroWaveEncoder {
///
/// # Safety
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
/// a promise about the wire, not the raw bitstream (see [`pyrowave_wire::WireBudget`]).
fn rate_budget(&self) -> usize {
match self.wire_chunk {
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
None => self.frame_budget,
}
}
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
// A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than
// handing null to pyrowave (see the Linux twin).
@@ -616,7 +637,7 @@ impl PyroWaveEncoder {
sync: std::mem::zeroed(),
};
let rc = pw::pyrowave_rate_control {
maximum_bitstream_size: self.frame_budget,
maximum_bitstream_size: self.rate_budget(),
};
pw_check(
pw::pyrowave_encoder_encode_gpu_synchronous(
@@ -664,6 +685,10 @@ impl PyroWaveEncoder {
}
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
if self.wire_chunk.is_some() {
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
self.wire_budget.observe(raw, au.len());
}
self.pending.push_back(EncodedFrame {
data: au,
pts_ns: frame.pts_ns,
+233 -98
View File
@@ -108,7 +108,21 @@ impl Codec {
return m & pref_ceiling;
}
}
// NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above).
// NVENC: ask the DRIVER which codecs this chip's encoder exposes, the same way the
// VAAPI arm above does — a static superset advertised HEVC on a 1st-gen Maxwell
// (HEVC needs 2nd-gen Maxwell+, AV1 needs Ada+), and a client that believed it got
// ~15 s of blank video and a disconnect instead of a stream. Fails OPEN: a probe
// that can't answer (no direct-SDK build, no CUDA, an old driver) yields `None` and
// leaves the historical superset standing, so this can only ever narrow the
// advertisement to something the GPU really encodes.
#[cfg(feature = "nvenc")]
if backend == LinuxBackend::Nvenc {
if let Some(m) = nvenc_codec_support().wire_mask() {
return m & pref_ceiling;
}
}
// NVENC without the probe (no `nvenc` feature / probe declined) — or an empty VAAPI
// probe (see above): the static superset, like GameStream.
GPU_SUPERSET & pref_ceiling
}
#[cfg(target_os = "windows")]
@@ -121,7 +135,8 @@ impl Codec {
return m;
}
}
// NVENC (static superset, like GameStream) — or an empty AMF/QSV probe (see above).
// An unprobed backend (NVENC without the `nvenc` feature) — or an empty probe
// (see above): the static superset, like GameStream.
GPU_SUPERSET
}
// The macOS dev/test host has no GPU encode backend — keep the pre-probe advertisement.
@@ -342,18 +357,27 @@ fn open_video_backend_linux(
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
// its errors crisply instead of silently trying the other).
// AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` +
// PUNKTFUNK_VULKAN_ENCODE, an HEVC session instead opens the raw Vulkan Video backend (real
// RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so the
// stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI
// the Vulkan backend imports the dmabuf and does its own 8-bit 4:2:0 CSC.
// PUNKTFUNK_VULKAN_ENCODE, an HEVC/AV1 session instead opens the raw Vulkan Video backend
// (real RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so
// the stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI
// the Vulkan backend imports the dmabuf and does its own CSC.
let open_amd_intel = || -> Result<(Box<dyn Encoder>, &'static str)> {
// An HDR session (10-bit + a PQ/BT.2020 capture format) must skip the Vulkan Video
// backend — it hardcodes an 8-bit 4:2:0 BT.709 CSC — and take the libav VAAPI path,
// which has the P010/Main10/PQ wiring. SDR sessions keep the Vulkan default.
// HDR (10-bit + a PQ/BT.2020 capture format) keeps this backend for EITHER codec, as
// long as the device says it can: the compute CSC has a BT.2020 10-bit variant, the EFC
// has a BT.2020 conversion, and the session opens Main10 / AV1-at-10. That keeps the two
// things an HDR session would otherwise lose — real RFI recovery, and the compute CSC's
// cursor blend, which is the only way a gamescope pointer reaches the stream (gamescope
// has no embedded-cursor mode to fall back to).
//
// `vulkan_encode_available_at` is the device's own answer to the same profile query the
// open will make, so this is a prediction that cannot disagree with reality — and a `no`
// routes to libav VAAPI here rather than burning a failed session open first.
#[cfg(feature = "vulkan-encode")]
let ten_bit_session = bit_depth == 10 && format.is_hdr_rgb10();
#[cfg(feature = "vulkan-encode")]
if matches!(codec, Codec::H265 | Codec::Av1)
&& vulkan_encode_enabled()
&& !(bit_depth == 10 && format.is_hdr_rgb10())
&& vulkan_encode_available_at(codec, ten_bit_session)
{
match vulkan_video::VulkanVideoEncoder::open(
codec,
@@ -987,6 +1011,33 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
}
}
/// Can this host's encode path ingest a **packed 10-bit PQ/BT.2020 CUDA payload** — i.e. may an
/// HDR capture stay zero-copy on NVIDIA?
///
/// Only the direct-SDK NVENC backend can: it registers the buffer as an `ARGB10`/`ABGR10` input
/// surface and does the BT.2020 CSC in the encoder itself. The libav fallback cannot — its HDR
/// route builds a **P010** hardware frames context and swscales the RGB into it, so handing it a
/// packed-10-bit CUDA buffer would copy 2:10:10:10 words into a P010 surface and stream garbage.
/// So when the direct path is compiled out or vetoed (`PUNKTFUNK_NVENC_DIRECT=0`), the capturer
/// must NOT build the importer for an HDR session and the frames take the CPU path instead — the
/// same route HDR took before the direct path learned 10-bit.
///
/// Resolved by the host facade into [`pf_capture::ZeroCopyPolicy`], like every other
/// encode-backend fact capture is allowed to know (the one-way capture→encode edge).
#[cfg(target_os = "linux")]
pub fn linux_hdr_cuda_ok() -> bool {
#[cfg(feature = "nvenc")]
{
// Same two terms `open_nvenc_probed` uses to take the direct arm — minus `cuda`, which is
// the very thing the caller is deciding.
nvenc_direct_enabled() && !linux_zero_copy_is_vaapi()
}
#[cfg(not(feature = "nvenc"))]
{
false
}
}
/// Whether the encode backend this session will resolve to composites [`CapturedFrame::cursor`]
/// ([`EncoderCaps::blends_cursor`]) — answered BEFORE capture opens, so the host plans cursor
/// delivery honestly instead of discovering a cursorless stream after the fact (the
@@ -997,8 +1048,9 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
///
/// `cuda_planned` is the caller's prediction of a CUDA capture payload (NVIDIA + zero-copy — the
/// prediction `SessionPlan` already makes); `ten_bit` the negotiated depth. Both shift the
/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit HDR session
/// skips Vulkan Video for libav VAAPI's P010/Main10 wiring (no blend).
/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit session keeps
/// Vulkan Video (which blends) only where the device advertises a 10-bit profile for that codec —
/// `vulkan_encode_available_at`, the same query the open makes.
#[cfg(target_os = "linux")]
pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> bool {
// A negotiated PyroWave session routes to that backend before the pref is consulted
@@ -1021,12 +1073,15 @@ pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) ->
// the device probe runs last (it opens a Vulkan instance, cached per GPU+codec).
#[cfg(feature = "vulkan-encode")]
{
// Mirrors `open_amd_intel` exactly, depth included — the device answers whether a
// 10-bit profile for this codec exists, so the prediction and the open agree.
matches!(codec, Codec::H265 | Codec::Av1)
&& vulkan_encode_enabled()
&& vulkan_encode_available(codec)
&& vulkan_encode_available_at(codec, ten_bit)
}
#[cfg(not(feature = "vulkan-encode"))]
{
let _ = ten_bit; // the depth only ever narrows the Vulkan arm
false
}
};
@@ -1035,7 +1090,7 @@ pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) ->
linux_auto_is_vaapi,
cuda_planned,
);
cursor_blend_capable_for(backend, cuda_planned, ten_bit, direct_nvenc, vulkan_csc)
cursor_blend_capable_for(backend, cuda_planned, direct_nvenc, vulkan_csc)
}
/// The dispatch-mirroring core of [`cursor_blend_capable`], device-free for the unit tests.
@@ -1046,7 +1101,6 @@ pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) ->
fn cursor_blend_capable_for(
backend: Option<LinuxBackend>,
cuda_planned: bool,
ten_bit: bool,
direct_nvenc: bool,
vulkan_csc: bool,
) -> bool {
@@ -1056,11 +1110,11 @@ fn cursor_blend_capable_for(
// Only the direct-SDK arm blends (VkSlotBlend), and it only takes CUDA payloads —
// a CPU-payload session stays on libav NVENC, which cannot blend.
Some(LinuxBackend::Nvenc) => cuda_planned && direct_nvenc,
// The Vulkan Video compute-CSC path blends; a 10-bit HDR session skips it for libav
// VAAPI (no blend). The session plan keeps a cursor-blend session off the native-NV12
// and RGB-direct shapes (`SessionPlan::output_format` / `VulkanVideoEncoder::open`),
// so CSC eligibility IS the answer.
Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => !ten_bit && vulkan_csc,
// The Vulkan Video compute-CSC path blends, at either depth. The session plan keeps a
// cursor-blend session off the native-NV12 and RGB-direct shapes
// (`SessionPlan::output_format` / `VulkanVideoEncoder::open`), so CSC eligibility IS the
// answer — including the depth term, which `vulkan_csc` already carries.
Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => vulkan_csc,
// CPU frames: the capturer composites the metadata cursor inline before the encoder
// runs, but the ENCODER blends nothing — the cursor channel's on-demand composite
// contract can't be honored. Report the encoder's truth.
@@ -1078,30 +1132,64 @@ fn cursor_blend_capable_for(
/// reports what actually did.
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
fn vulkan_encode_available(codec: Codec) -> bool {
vulkan_encode_caps(codec).supported
}
/// Can the Vulkan Video backend encode `codec` at this depth on the selected GPU? The gate that
/// decides whether an HDR session gets the good path (real RFI + the compute CSC's cursor blend)
/// or falls back to libav VAAPI — asked per codec, because a device can advertise HEVC Main10 and
/// still decline 10-bit AV1.
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
fn vulkan_encode_available_at(codec: Codec, ten_bit: bool) -> bool {
let caps = vulkan_encode_caps(codec);
caps.supported
&& if ten_bit {
caps.ten_bit
} else {
caps.eight_bit
}
}
/// The device's Vulkan Video encode capabilities for `codec`, cached per (selected GPU, codec) —
/// a web-console GPU change re-probes on the new adapter before the next Welcome, mirroring
/// `can_encode_444`/`can_encode_10bit`. The probe creates and destroys its own Vulkan instance,
/// so it is worth caching but safe to call from anywhere.
///
/// The `vulkan-encode` half of the cfg is not decoration: the return type comes from the
/// `vulkan_video` module, which only exists under that feature. Every caller is already gated;
/// leaving these two definitions on bare `target_os = "linux"` is what broke a featureless Linux
/// build (caught on Fedora 44, 2026-07-28).
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
fn vulkan_encode_caps(codec: Codec) -> vulkan_video::VulkanEncodeCaps {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
#[allow(clippy::type_complexity)]
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), vulkan_video::VulkanEncodeCaps>>> =
OnceLock::new();
let key = (pf_gpu::selection_key(), codec.label());
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(v) = cache.lock().unwrap().get(&key) {
return *v;
}
let verdict = vulkan_video::probe_encode_support(codec);
let ok = verdict.is_ok();
match &verdict {
Ok(()) => tracing::info!(
let caps = vulkan_video::probe_encode_caps(codec);
if caps.supported {
tracing::info!(
?codec,
"Vulkan Video encode probed OK — producer-native NV12 capture is eligible"
),
Err(why) => tracing::info!(
eight_bit = caps.eight_bit,
ten_bit = caps.ten_bit,
"Vulkan Video encode probed — producer-native NV12 capture is eligible, and a 10-bit \
session keeps this backend (real RFI + the compute CSC's cursor blend) where the \
device accepts the profile"
);
} else {
tracing::info!(
?codec,
why = *why,
"Vulkan Video encode unavailable — keeping the packed-RGB capture negotiation \
(the native-NV12 path has no VAAPI fallback)"
),
"Vulkan Video encode unavailable (no encode queue for this codec) — keeping the \
packed-RGB capture negotiation (the native-NV12 path has no VAAPI fallback)"
);
}
cache.lock().unwrap().insert(key, ok);
ok
cache.lock().unwrap().insert(key, caps);
caps
}
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
@@ -1263,9 +1351,31 @@ impl CodecSupport {
}
}
/// Probe the active NVIDIA GPU for its encodable codecs (cached once per process). Asks the driver
/// for this chip's encode-GUID list over the direct SDK — see [`nvenc_cuda::probe_support`] for
/// why it is not shaped like the VAAPI probe below, and for the fail-open contract. Process-wide
/// (not per selected GPU) on purpose: the direct backend opens on the shared `cuda::context()`,
/// i.e. CUDA device 0, so that is the chip whose answer applies.
#[cfg(all(target_os = "linux", feature = "nvenc"))]
pub fn nvenc_codec_support() -> CodecSupport {
use std::sync::OnceLock;
static LOGGED: OnceLock<()> = OnceLock::new();
let probed = nvenc_cuda::probe_support();
LOGGED.get_or_init(|| {
tracing::info!(
h264 = probed.codecs.h264,
h265 = probed.codecs.h265,
av1 = probed.codecs.av1,
hevc_444 = probed.hevc_444,
"NVENC encode capabilities probed"
);
});
probed.codecs
}
/// Probe the active Linux GPU backend for its encodable codecs (cached; opens a tiny encoder per
/// codec, once). Only the VAAPI (AMD/Intel) backend is probed — NVENC keeps its Moonlight-validated
/// static advertisement (callers gate on [`linux_zero_copy_is_vaapi`]).
/// codec, once). The AMD/Intel backend — NVIDIA has [`nvenc_codec_support`] (callers gate on
/// [`linux_zero_copy_is_vaapi`]).
#[cfg(target_os = "linux")]
pub fn vaapi_codec_support() -> CodecSupport {
use std::sync::OnceLock;
@@ -1320,7 +1430,29 @@ pub fn can_encode_444(codec: Codec) -> bool {
if linux_zero_copy_is_vaapi() {
vaapi::probe_can_encode_444(codec)
} else {
linux::probe_can_encode_444(codec)
// NVIDIA. On a direct-SDK host the answer comes from the driver's caps bit over
// the direct SDK ([`nvenc_cuda::probe_support`]) — the same
// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` the live session re-checks at open
// (`query_caps` → `yuv444_supported`), and the same shape the Windows NVENC arm
// uses (`nvenc::probe_can_encode_444`). It must NOT fall back to the libav
// open-probe: one ffmpeg `hevc_nvenc` FREXT open in a direct-SDK process is the
// LOG-3 field bug — it wedged every later NVENC open process-wide
// (`NV_ENC_ERR_INVALID_VERSION`) until a host restart. Only a host that will
// really serve the session over libav (PUNKTFUNK_NVENC_DIRECT=0, or a build
// without `--features nvenc`) keeps the ffmpeg probe — there it validates the
// actual session path, and ffmpeg's NVENC client runs in that process anyway.
#[cfg(feature = "nvenc")]
{
if nvenc_direct_enabled() {
nvenc_cuda::probe_support().hevc_444
} else {
linux::probe_can_encode_444(codec)
}
}
#[cfg(not(feature = "nvenc"))]
{
linux::probe_can_encode_444(codec)
}
}
}
#[cfg(target_os = "windows")]
@@ -1403,13 +1535,16 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
let supported = {
#[cfg(target_os = "linux")]
{
// NVENC (libav, the HDR P010 swscale path) or VAAPI (P010 upload / dmabuf graph),
// probed by opening a tiny real Main10 encoder — the same honesty contract as
// `can_encode_444`. Vulkan-video and the direct-SDK CUDA path stay 8-bit; a 10-bit
// session routes around them (see `open_video_backend`). NOTE: encode capability is
// only half the Linux gate — the capture side (GNOME 50+ portal monitor in HDR mode)
// is resolved separately by the host (`capturer_supports_hdr` / the GameStream RTSP
// honor), since this probe can't know what the compositor will negotiate.
// NVENC (libav, the HDR P010 swscale path — the direct-SDK CUDA path encodes the
// same GPU's Main10, so this is a truthful proxy for it too) or, on AMD/Intel, VAAPI
// OR Vulkan Video. Both of the latter are asked because either can serve the session:
// `open_amd_intel` tries Vulkan first and falls back to VAAPI, so 10-bit is available
// if EITHER says yes, and answering `false` when only one does would strand a
// perfectly encodable HDR session at 8 bits. NOTE: encode capability is only half the
// Linux gate — the capture side (a gamescope HDR output, or a GNOME 50+ portal
// monitor in HDR mode) is resolved separately by the host
// (`capture::capturer_supports_hdr_for` / the GameStream RTSP honor), since this
// probe can't know what the compositor will negotiate.
// Resolve through the SAME helper `can_encode_444` uses (and which mirrors
// `open_video`'s dispatch): `linux_auto_is_vaapi` ignores `encoder_pref`, so on a box
// that forces a backend — e.g. `encoder_pref = "vaapi"` on an NVIDIA host — this probe
@@ -1417,7 +1552,17 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
// depth (plus the HDR/SDR colour label derived from it) would describe a backend that
// never runs. That is exactly the dishonesty this probe exists to prevent.
if linux_zero_copy_is_vaapi() {
vaapi::probe_can_encode_10bit(codec)
let vulkan10 = {
#[cfg(feature = "vulkan-encode")]
{
vulkan_encode_enabled() && vulkan_encode_available_at(codec, true)
}
#[cfg(not(feature = "vulkan-encode"))]
{
false
}
};
vulkan10 || vaapi::probe_can_encode_10bit(codec)
} else {
linux::probe_can_encode_10bit(codec)
}
@@ -1624,17 +1769,20 @@ pub fn resolved_backend_ingests_rgb_444() -> bool {
}
/// True if the active Windows backend's codec advertisement comes from a **real GPU probe**
/// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the
/// ([`windows_codec_support`]) rather than the static superset. AMF always qualifies — the
/// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV qualifies
/// with either the native VPL build (`qsv`, the authoritative Query probe) or the `amf-qsv`
/// (libavcodec) build. Formerly `windows_backend_is_ffmpeg`, renamed when the
/// native AMF probe replaced the ffmpeg open-probe (design/native-amf-encoder.md §4, Phase 2).
/// (libavcodec) build, and NVENC with the `nvenc` build (the direct-SDK GUID-list probe,
/// `nvenc::probe_codec_support` — the Windows twin of the Linux probe that ended the
/// advertise-HEVC-on-a-GM107 dead sessions). Formerly `windows_backend_is_ffmpeg`, renamed when
/// the native AMF probe replaced the ffmpeg open-probe (design/native-amf-encoder.md §4, Phase 2).
#[cfg(target_os = "windows")]
pub fn windows_backend_is_probed() -> bool {
match windows_resolved_backend() {
WindowsBackend::Amf => true,
WindowsBackend::Qsv => cfg!(feature = "qsv") || cfg!(feature = "amf-qsv"),
WindowsBackend::Nvenc | WindowsBackend::Software => false,
WindowsBackend::Nvenc => cfg!(feature = "nvenc"),
WindowsBackend::Software => false,
}
}
@@ -1661,18 +1809,22 @@ fn windows_gpu_vendor() -> Option<GpuVendor> {
.or_else(|| pf_gpu::enumerate().iter().find_map(|g| by_id(g.vendor_id)))
}
/// Probe the active Windows AMF/QSV backend for its encodable codecs (cached **per (backend,
/// Probe the active Windows GPU backend for its encodable codecs (cached **per (backend,
/// selected GPU)** — a web-console preference change re-probes on the newly selected adapter
/// instead of serving the old GPU's answer for the process lifetime). Mirrors
/// [`vaapi_codec_support`]; called only when [`windows_backend_is_probed`] is true. AV1 is narrow
/// (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
/// [`vaapi_codec_support`]/[`nvenc_codec_support`]; called only when [`windows_backend_is_probed`]
/// is true. AV1 is narrow (NVIDIA Ada+, AMD RDNA3+, Intel Arc/Xe2+) and HEVC needs 2nd-gen
/// Maxwell+ on NVIDIA, so both must be probed, not assumed.
///
/// Mirrors the session dispatch (design/native-amf-encoder.md Phase 3): **AMD advertises from the
/// native AMF factory probe alone** (`amf::probe_can_encode`, on the selected adapter — the same
/// path the session opens, so the advertisement can never claim a codec the session can't emit);
/// **Intel/QSV advertises from the native VPL Query probe** (`qsv::probe_can_encode`,
/// design/native-qsv-encoder.md §4), falling back to the libavcodec probe on builds without the
/// `qsv` feature (all-`false` without either feature, matching a build that cannot open QSV).
/// `qsv` feature (all-`false` without either feature, matching a build that cannot open QSV);
/// **NVIDIA advertises from the driver's own encode-GUID list** (`nvenc::probe_codec_support`,
/// one throwaway direct-SDK session on the selected adapter — NEVER an ffmpeg open-probe, see the
/// Linux `nvenc_cuda::probe_support` doc for why).
#[cfg(target_os = "windows")]
pub fn windows_codec_support() -> CodecSupport {
use std::collections::HashMap;
@@ -1706,22 +1858,31 @@ pub fn windows_codec_support() -> CodecSupport {
false
}
}
// Callers gate on `windows_backend_is_probed` — defensively answer "nothing probed"
// (the advertisement then falls back to the static superset).
// NVENC answers below from ONE GUID-list session, not per-codec probes; Software is
// never probed. Callers gate on `windows_backend_is_probed` — defensively answer
// "nothing probed" (the advertisement then falls back to the static superset).
WindowsBackend::Nvenc | WindowsBackend::Software => false,
}
};
let caps = CodecSupport {
h264: probe_one(Codec::H264),
h265: probe_one(Codec::H265),
av1: probe_one(Codec::Av1),
let caps = match backend {
// NVIDIA: one throwaway session lists every encode GUID at once — no reason to open
// three sessions through `probe_one`. Featureless builds fall through to `probe_one`'s
// defensive all-false (= "nothing probed" → static superset), matching
// `windows_backend_is_probed`.
#[cfg(feature = "nvenc")]
WindowsBackend::Nvenc => nvenc::probe_codec_support(),
_ => CodecSupport {
h264: probe_one(Codec::H264),
h265: probe_one(Codec::H265),
av1: probe_one(Codec::Av1),
},
};
tracing::info!(
?backend,
h264 = caps.h264,
h265 = caps.h265,
av1 = caps.av1,
"Windows AMF/QSV encode capabilities probed"
"Windows encode capabilities probed"
);
// A concurrent first call may double-probe; both arrive at the same answer, last insert wins.
cache.lock().unwrap().insert(key, caps);
@@ -1973,58 +2134,32 @@ mod tests {
Some(Pyrowave),
false,
false,
false,
false
));
// NVIDIA: only the direct-SDK arm blends (VkSlotBlend), and only for CUDA payloads.
assert!(cursor_blend_capable_for(
Some(Nvenc),
true,
false,
true,
false
));
assert!(cursor_blend_capable_for(Some(Nvenc), true, true, false));
assert!(
!cursor_blend_capable_for(Some(Nvenc), false, false, true, false),
!cursor_blend_capable_for(Some(Nvenc), false, true, false),
"a CPU payload stays on libav NVENC, which cannot blend"
);
assert!(
!cursor_blend_capable_for(Some(Nvenc), true, false, false, false),
!cursor_blend_capable_for(Some(Nvenc), true, false, false),
"PUNKTFUNK_NVENC_DIRECT=0 (or a build without the feature) is the libav path"
);
// AMD/Intel: the Vulkan Video compute-CSC arm blends; VAAPI never does.
assert!(cursor_blend_capable_for(
Some(AmdIntel),
false,
false,
false,
true
));
// AMD/Intel: the Vulkan Video compute-CSC arm blends — at 8 AND 10 bits, since its CSC
// has a BT.2020 Main10 variant. VAAPI never does. The depth term lives in `vulkan_csc`
// (10-bit AV1 is the one combination that still falls through to VAAPI), so this arm is
// now exactly "did an eligible CSC arm resolve".
assert!(cursor_blend_capable_for(Some(AmdIntel), false, false, true));
assert!(
!cursor_blend_capable_for(Some(AmdIntel), false, false, false, false),
!cursor_blend_capable_for(Some(AmdIntel), false, false, false),
"no eligible Vulkan CSC arm (H.264, PUNKTFUNK_VULKAN_ENCODE=0, unsupported \
device) resolves to libav VAAPI, which cannot blend"
);
assert!(
!cursor_blend_capable_for(Some(AmdIntel), false, true, false, true),
"a 10-bit HDR session skips Vulkan Video for VAAPI's P010 wiring — no blend"
);
assert!(cursor_blend_capable_for(
Some(Vulkan),
false,
false,
false,
true
));
assert!(cursor_blend_capable_for(Some(Vulkan), false, false, true));
// Software / unknown pref: CPU frames; the encoder blends nothing.
assert!(!cursor_blend_capable_for(
Some(Software),
false,
false,
true,
true
));
assert!(!cursor_blend_capable_for(None, false, false, true, true));
assert!(!cursor_blend_capable_for(Some(Software), false, true, true));
assert!(!cursor_blend_capable_for(None, false, true, true));
}
/// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by
+3
View File
@@ -20,3 +20,6 @@ ash = { version = "0.38", features = ["loaded"] }
# Same bindgen configuration as ffmpeg-sys-next (runtime = dlopen libclang).
bindgen = { version = "0.72", features = ["runtime"], default-features = false }
pkg-config = "0.3"
[lints]
workspace = true
+3
View File
@@ -35,3 +35,6 @@ windows = { version = "0.62", features = [
"Win32_System_LibraryLoader",
"Win32_System_Threading",
] }
[lints]
workspace = true
+110 -63
View File
@@ -101,17 +101,21 @@ pub fn pack_luid(luid: LUID) -> i64 {
pub unsafe fn make_device(adapter: &IDXGIAdapter1) -> Result<(ID3D11Device, ID3D11DeviceContext)> {
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice(
adapter,
D3D_DRIVER_TYPE_UNKNOWN,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
// SAFETY: `adapter` is live for the call (caller contract). The two out-params are local
// `Option`s the callee only writes, and both are checked below before use.
unsafe {
D3D11CreateDevice(
adapter,
D3D_DRIVER_TYPE_UNKNOWN,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
}
.context("D3D11CreateDevice")?;
let device = device.context("null D3D11 device")?;
let context = context.context("null D3D11 context")?;
@@ -127,14 +131,20 @@ pub unsafe fn make_device(adapter: &IDXGIAdapter1) -> Result<(ID3D11Device, ID3D
elevate_process_gpu_priority();
if let Ok(dxgi_dev) = device.cast::<IDXGIDevice>() {
// The absolute max GPU thread priority (0x4000001E; the same value Sunshine/Apollo use); fall back to relative +7.
if dxgi_dev.SetGPUThreadPriority(0x4000_001E).is_err()
&& dxgi_dev.SetGPUThreadPriority(7).is_err()
// SAFETY: `dxgi_dev` is a live interface just obtained by a checked `cast`; both calls take
// a scalar and only report failure through their return value.
if unsafe { dxgi_dev.SetGPUThreadPriority(0x4000_001E) }.is_err()
// SAFETY: same live interface, same scalar-in/HRESULT-out contract as the call above.
// Deliberately its own block rather than one around the whole chain, which would
// destroy the short-circuit and always issue the relative-priority call too.
&& unsafe { dxgi_dev.SetGPUThreadPriority(7) }.is_err()
{
tracing::warn!("SetGPUThreadPriority failed (run as admin/SYSTEM for GPU priority)");
}
}
if let Ok(dxgi1) = device.cast::<IDXGIDevice1>() {
let _ = dxgi1.SetMaximumFrameLatency(1);
// SAFETY: `dxgi1` is a live interface from a checked `cast`; the arg is a scalar.
let _ = unsafe { dxgi1.SetMaximumFrameLatency(1) };
}
// REALTIME auto-gate (gpu-contention §5.C / latency plan T2.3) — needs the device's adapter,
// so it runs here, after creation; internally once-per-process.
@@ -177,7 +187,7 @@ fn configured_gpu_priority_mode() -> PrioMode {
/// HIGH/REALTIME GPU scheduling-priority bump on it. Held by SYSTEM/Administrators; a UAC-FILTERED
/// token does NOT have it, which is why `elevate_process_gpu_priority` may silently no-op in a
/// restricted service context.
unsafe fn enable_inc_base_priority() {
fn enable_inc_base_priority() {
use windows::core::PCWSTR;
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
use windows::Win32::Security::{
@@ -187,15 +197,24 @@ unsafe fn enable_inc_base_priority() {
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
let mut token = HANDLE::default();
if OpenProcessToken(
GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&mut token,
)
.is_ok()
{
// SAFETY: `GetCurrentProcess` returns the current-process pseudo-handle, always valid and never
// closed; `token` is a local the callee only writes, and it is only used below if this succeeded.
let opened = unsafe {
OpenProcessToken(
GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&mut token,
)
}
.is_ok();
if opened {
let mut luid = LUID::default();
if LookupPrivilegeValueW(PCWSTR::null(), SE_INC_BASE_PRIORITY_NAME, &mut luid).is_ok() {
// SAFETY: a null system name means "local system"; `SE_INC_BASE_PRIORITY_NAME` is a static
// NUL-terminated constant, and `luid` is a local the callee only writes.
let found =
unsafe { LookupPrivilegeValueW(PCWSTR::null(), SE_INC_BASE_PRIORITY_NAME, &mut luid) }
.is_ok();
if found {
let tp = TOKEN_PRIVILEGES {
PrivilegeCount: 1,
Privileges: [LUID_AND_ATTRIBUTES {
@@ -203,20 +222,25 @@ unsafe fn enable_inc_base_priority() {
Attributes: SE_PRIVILEGE_ENABLED,
}],
};
if AdjustTokenPrivileges(
token,
false,
Some(&tp as *const TOKEN_PRIVILEGES),
0,
None,
None,
)
.is_err()
{
// SAFETY: `token` is the live handle opened above; `tp` is a correctly sized local
// `TOKEN_PRIVILEGES` whose `PrivilegeCount` matches its one-element array, borrowed only
// for the duration of the call.
let adjusted = unsafe {
AdjustTokenPrivileges(
token,
false,
Some(&tp as *const TOKEN_PRIVILEGES),
0,
None,
None,
)
};
if adjusted.is_err() {
tracing::warn!("could not enable SE_INC_BASE_PRIORITY for GPU priority");
}
}
let _ = CloseHandle(token);
// SAFETY: `token` was opened above, is owned here, and is closed exactly once on this path.
let _ = unsafe { CloseHandle(token) };
}
}
@@ -231,11 +255,22 @@ unsafe fn d3dkmt_set_scheduling_priority_class(
use windows::core::s;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
let gdi32 = LoadLibraryA(s!("gdi32.dll")).ok()?;
let p = GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass"))?;
// SAFETY: both take static NUL-terminated literals; `LoadLibraryA` returns a module handle the
// process keeps for its lifetime (gdi32 is never unloaded here), and `GetProcAddress` is passed
// that live handle. Both results are checked by `?` before use.
let gdi32 = unsafe { LoadLibraryA(s!("gdi32.dll")) }.ok()?;
// SAFETY: `gdi32` is the live module handle the checked `LoadLibraryA` just returned, and the
// export name is a static NUL-terminated literal; the result is checked by `?` before use.
let p = unsafe { GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass")) }?;
type SetPrio = unsafe extern "system" fn(HANDLE, i32) -> i32;
let f: SetPrio = std::mem::transmute(p);
Some(f(process, prio))
// SAFETY: `p` is the non-null export just resolved, and `SetPrio` is its documented signature
// (`NTSTATUS D3DKMTSetProcessSchedulingPriorityClass(HANDLE, D3DKMT_SCHEDULINGPRIORITYCLASS)`,
// both arguments 4/8-byte scalars). `process` is a valid handle by this fn's own contract.
let f: SetPrio = unsafe { std::mem::transmute(p) };
// SAFETY: `f` is that export transmuted to its documented signature directly above; `process`
// is a valid handle by this fn's own contract and `prio` is a plain scalar. The call returns an
// NTSTATUS and retains nothing.
Some(unsafe { f(process, prio) })
}
/// GPU scheduling-priority hardening — the same approach as Sunshine/Apollo, independently
@@ -254,13 +289,7 @@ unsafe fn d3dkmt_set_scheduling_priority_class(
fn elevate_process_gpu_priority() {
use std::sync::Once;
static ONCE: Once = Once::new();
// SAFETY: the closure calls two of this module's `unsafe fn`s — `enable_inc_base_priority`
// (adjusts the current-process token; it has no caller precondition and builds all its FFI args
// locally) and `d3dkmt_set_scheduling_priority_class` (loads gdi32 by name and calls the export).
// The latter requires `process` to be a valid process handle; `GetCurrentProcess()` returns the
// current-process pseudo-handle, which is always valid and needs no close. Runs once via
// `Once::call_once`; no raw pointers are dereferenced here.
ONCE.call_once(|| unsafe {
ONCE.call_once(|| {
use windows::Win32::System::Threading::GetCurrentProcess;
let prio = match configured_gpu_priority_mode() {
PrioMode::Off => {
@@ -273,7 +302,10 @@ fn elevate_process_gpu_priority() {
PrioMode::Auto => 4,
};
enable_inc_base_priority();
match d3dkmt_set_scheduling_priority_class(GetCurrentProcess(), prio) {
// SAFETY: `d3dkmt_set_scheduling_priority_class` requires a valid process handle;
// `GetCurrentProcess()` returns the current-process pseudo-handle, which is always valid and
// needs no close.
match unsafe { d3dkmt_set_scheduling_priority_class(GetCurrentProcess(), prio) } {
Some(0) => tracing::info!(
priority_class = prio,
"GPU process scheduling priority class set (2=normal 4=high 5=realtime)"
@@ -316,10 +348,10 @@ const KMTQAITYPE_WDDM_2_7_CAPS: u32 = 70;
/// setter). `None` = could not determine (missing exports / query failed) — the caller treats
/// unknown as "assume the hazard exists".
///
/// # Safety
/// Calls gdi32 exports through by-name transmuted pointers with locally built, correctly sized
/// `repr(C)` argument structs; the adapter handle is closed before returning on every path.
unsafe fn hags_enabled(luid: LUID) -> Option<bool> {
/// Safe: `luid` is a plain value (no pointer a caller could get wrong), every gdi32 argument struct
/// is built locally here, and the adapter handle is closed before returning on every path — so there
/// is no precondition left for a caller to uphold. The `unsafe` that remains is internal.
fn hags_enabled(luid: LUID) -> Option<bool> {
use windows::core::s;
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
#[repr(C)]
@@ -338,19 +370,33 @@ unsafe fn hags_enabled(luid: LUID) -> Option<bool> {
private_data: *mut std::ffi::c_void,
private_data_size: u32,
}
let gdi32 = LoadLibraryA(s!("gdi32.dll")).ok()?;
let open = GetProcAddress(gdi32, s!("D3DKMTOpenAdapterFromLuid"))?;
let query = GetProcAddress(gdi32, s!("D3DKMTQueryAdapterInfo"))?;
let close = GetProcAddress(gdi32, s!("D3DKMTCloseAdapter"))?;
// SAFETY: static NUL-terminated literals; gdi32 stays loaded for the process lifetime, and each
// result is checked by `?`/`.ok()?` before the next call uses it.
let gdi32 = unsafe { LoadLibraryA(s!("gdi32.dll")) }.ok()?;
// SAFETY: `gdi32` is the live handle from the `.ok()?`-checked load above; static literal name;
// `?` checks the result.
let open = unsafe { GetProcAddress(gdi32, s!("D3DKMTOpenAdapterFromLuid")) }?;
// SAFETY: same live `gdi32` handle and static-literal name; `?` checks the result.
let query = unsafe { GetProcAddress(gdi32, s!("D3DKMTQueryAdapterInfo")) }?;
// SAFETY: same live `gdi32` handle and static-literal name; `?` checks the result.
let close = unsafe { GetProcAddress(gdi32, s!("D3DKMTCloseAdapter")) }?;
type OpenFn = unsafe extern "system" fn(*mut OpenFromLuid) -> i32;
type QueryFn = unsafe extern "system" fn(*mut QueryInfo) -> i32;
type CloseFn = unsafe extern "system" fn(*mut CloseAdapter) -> i32;
let open: OpenFn = std::mem::transmute(open);
let query: QueryFn = std::mem::transmute(query);
let close: CloseFn = std::mem::transmute(close);
// SAFETY: each pointer is the non-null export resolved just above, and each fn type mirrors that
// export's documented signature — one `*mut` to the matching `repr(C)` struct declared here,
// returning NTSTATUS.
let open: OpenFn = unsafe { std::mem::transmute(open) };
// SAFETY: `query` is the non-null export resolved above and `QueryFn` mirrors its documented
// signature — one `*mut` to the `repr(C)` struct declared here, returning NTSTATUS.
let query: QueryFn = unsafe { std::mem::transmute(query) };
// SAFETY: `close` is the non-null export resolved above and `CloseFn` mirrors its documented
// signature — one `*mut` to the `repr(C)` struct declared here, returning NTSTATUS.
let close: CloseFn = unsafe { std::mem::transmute(close) };
let mut oa = OpenFromLuid { luid, h_adapter: 0 };
if open(&mut oa) != 0 {
// SAFETY: `oa` is a live local of exactly the type the export expects; it borrows nothing.
if unsafe { open(&mut oa) } != 0 {
return None;
}
let mut caps: u32 = 0;
@@ -360,11 +406,14 @@ unsafe fn hags_enabled(luid: LUID) -> Option<bool> {
private_data: (&mut caps as *mut u32).cast(),
private_data_size: std::mem::size_of::<u32>() as u32,
};
let st = query(&mut qi);
// SAFETY: `qi` is a live local; `private_data` points at `caps`, which outlives the call, and
// `private_data_size` is that variable's exact size.
let st = unsafe { query(&mut qi) };
let mut ca = CloseAdapter {
h_adapter: oa.h_adapter,
};
let _ = close(&mut ca);
// SAFETY: `ca` is a live local holding the adapter `open` returned; closed exactly once.
let _ = unsafe { close(&mut ca) };
if st != 0 {
return None; // pre-WDDM-2.7 driver: the query type doesn't exist ⇒ HAGS can't be on
}
@@ -400,9 +449,7 @@ fn auto_priority_gate(device: &ID3D11Device) {
return;
}
};
// SAFETY: `hags_enabled` builds all its FFI arguments locally and closes the adapter
// handle before returning (see its own contract); `luid` is a plain value.
let hags = unsafe { hags_enabled(luid) };
let hags = hags_enabled(luid);
match hags {
Some(false) => {
// No HAGS ⇒ the NVENC-hang hazard cannot occur: take REALTIME outright.
+3
View File
@@ -26,3 +26,6 @@ windows = { version = "0.62", features = [
[dev-dependencies]
tempfile = "3"
[lints]
workspace = true
+3
View File
@@ -10,3 +10,6 @@ description = "Process-wide punktfunk host configuration (env-parsed HostConfig
publish = false
[dependencies]
[lints]
workspace = true
+130
View File
@@ -31,6 +31,7 @@
//! = off, anything else = on, so the old presence-style `=1` keeps working). The Linux `zerocopy`
//! module keeps its own *truthy* parser (`1|true|yes|on`) — the two are independent features that
//! share a name; do NOT conflate them.
#![forbid(unsafe_code)]
use std::sync::OnceLock;
@@ -61,6 +62,12 @@ pub fn env_on(name: &str) -> Option<bool> {
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
#[derive(Debug, Clone, Default)]
pub struct HostConfig {
/// `PUNKTFUNK_HOST_NAME` — the name this host shows up under in Moonlight (the serverinfo
/// `<hostname>` element) and in Punktfunk's own clients (the mDNS service *instance* name both
/// adverts carry). Unset/blank = the machine's own hostname, which is what it always was. Free
/// text ("Living Room PC"); the DNS-level `<label>.local.` target keeps using a sanitized
/// machine-safe label, so a spacey display name can't produce an invalid mDNS record.
pub host_name: Option<String>,
/// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
pub encoder_pref: String,
/// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty:
@@ -96,6 +103,14 @@ pub struct HostConfig {
pub perf: bool,
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
pub video_source: Option<String>,
/// `PUNKTFUNK_CAPTURE_MONITOR` — pin capture at a NAMED physical monitor (`DP-1`, `HDMI-A-2`),
/// instead of creating a virtual display or taking whichever head the portal hands back. The
/// point of the knob is an unattended host: a background `systemd --user` service has nobody to
/// answer a chooser dialog, so the monitor has to be config, not a prompt. A name that matches
/// no head is a hard error at session open (never a silent fall-back to a different screen —
/// showing the wrong monitor is worse than showing none). Linux-only today; see
/// `design/per-monitor-portal-capture.md`.
pub capture_monitor: Option<String>,
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
pub compositor: Option<String>,
@@ -123,6 +138,22 @@ pub struct HostConfig {
/// starts over (the "fresh gamescope output never delivers frames" field failure). Default ON;
/// explicit-off grammar (`=0` disables, the on-glass A/B + emergency escape hatch).
pub gamescope_splash: bool,
/// `PUNKTFUNK_GAMESCOPE_HDR` — allow HDR (10-bit BT.2020 PQ) sessions on the gamescope
/// backend. Needs the punktfunk gamescope build (`packaging/gamescope`), which teaches
/// gamescope's PipeWire node the 10-bit PQ capture formats; the host probes for it and stays
/// SDR when it isn't installed, so this knob only decides whether HDR is *attempted*.
///
/// Default OFF for the canary release, then default-on (matching `PUNKTFUNK_10BIT`'s
/// explicit-off grammar). It gates the whole feature — spawn flags included — so an operator
/// who hits a bad interaction can turn the gamescope backend back into exactly today's 8-bit
/// path with one env var and no downgrade path to trip over.
pub gamescope_hdr: bool,
/// `PUNKTFUNK_GAMESCOPE_SDR_NITS` — the luminance SDR content is mapped to inside the PQ
/// container of an HDR gamescope session (gamescope's `--hdr-sdr-content-nits`, default 400).
/// An HDR stream carries the desktop, the Steam overlay and any SDR game through the same PQ
/// encode, so this is the knob that decides how bright "white" looks on the client's panel.
/// `None` = leave gamescope's own default.
pub gamescope_sdr_nits: Option<u32>,
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
@@ -138,6 +169,38 @@ pub struct HostConfig {
/// `PUNKTFUNK_ON_DISCONNECT_CMD` — the `client.disconnected` sibling of
/// [`Self::on_connect_cmd`].
pub on_disconnect_cmd: Option<String>,
/// `PUNKTFUNK_MAX_FPS` — frame limiter for the GAME. `None` (unset, `0`, or unparseable) =
/// no limit, the default and what every existing host does.
///
/// This caps how fast the compositor lets the game render; it does **not** touch the session.
/// The client still negotiates and receives its full rate — a 120 Hz session over a game
/// limited to 60 sends 120 frames a second, 60 of them repeats of an unchanged picture, which
/// costs an almost-empty P-frame. That split is the whole point: the game stops rendering
/// frames nobody asked for, and the GPU time it gives up goes to capture and encode instead
/// (and, on a laptop or handheld, to heat and battery).
///
/// Capping the STREAM instead would be a different and mostly unwanted feature — it hands the
/// client fewer frames than it asked for and saves the game's GPU nothing.
///
/// Enforced by the compositor, so its reach is whatever that compositor offers. **gamescope**
/// takes it as `--nested-refresh`, the rate it clamps the game to; note that is the nested
/// output's rate, so everything gamescope composites moves at it, not the game alone — under
/// gamescope there is only the one output. Values are clamped into 1..=240.
pub max_fps: Option<u32>,
/// `PUNKTFUNK_VDISPLAY_HZ_MULT` — run the VIRTUAL DISPLAY at this multiple of the session's
/// frame rate while the stream stays paced at the session rate. Default 1 (off); 2 is the
/// interesting one, hence the name this shipped under.
///
/// A compositor only paints on its own vblank, so at 1× a frame can be finished just after
/// the capture sampled and then waits nearly a whole interval to be picked up — up to
/// ~16 ms of pure age at 60 Hz, and it is the jittery part of the latency, not the steady
/// part. Driving the display at 2× halves that worst case without sending a single extra
/// frame: the pacing clamp below keeps the wire at exactly the rate the client negotiated.
///
/// It is not free — the compositor and the GPU do the extra composites — so it stays opt-in.
/// Clamped to 1..=4; a backend that cannot honor the multiplied rate simply reports what it
/// achieved and the pacing follows that, exactly as it does for any other refusal.
pub vdisplay_hz_mult: u32,
}
impl HostConfig {
@@ -151,6 +214,9 @@ impl HostConfig {
// (`PUNKTFUNK_IDD_PUSH` was removed: IDD-push is the sole Windows capture path, so the knob
// only split dispatch — capture ignored it while the vdisplay manager obeyed it, and `=0`
// produced dead-swap-chain reuse on reconnect. A stale setting in an old host.env is ignored.)
host_name: val("PUNKTFUNK_HOST_NAME")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
encoder_pref: std::env::var("PUNKTFUNK_ENCODER")
.unwrap_or_default()
.to_ascii_lowercase(),
@@ -170,6 +236,11 @@ impl HostConfig {
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
perf: flag("PUNKTFUNK_PERF"),
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
// Trimmed + emptied-to-None: `PUNKTFUNK_CAPTURE_MONITOR=` in a host.env means "not
// set", not "match the monitor named empty string".
capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
compositor: val("PUNKTFUNK_COMPOSITOR"),
gamepad: val("PUNKTFUNK_GAMEPAD"),
vdisplay: val("PUNKTFUNK_VDISPLAY"),
@@ -188,10 +259,40 @@ impl HostConfig {
// Default ON, explicit-off grammar: the splash is what makes a fresh bare spawn deliver
// its first frames at all; `=0` is the A/B + escape hatch.
gamescope_splash: env_on("PUNKTFUNK_GAMESCOPE_SPLASH").unwrap_or(true),
// Default OFF for one canary release (design §4 rollout), then flip the `unwrap_or`.
gamescope_hdr: env_on("PUNKTFUNK_GAMESCOPE_HDR").unwrap_or(false),
gamescope_sdr_nits: val("PUNKTFUNK_GAMESCOPE_SDR_NITS")
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|n| (1..=10_000).contains(n)),
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
.filter(|s| !s.trim().is_empty()),
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
on_disconnect_cmd: val("PUNKTFUNK_ON_DISCONNECT_CMD").filter(|s| !s.trim().is_empty()),
// 0 means "no limit" rather than "stream nothing" — it is the natural way to spell
// "off" in a config file, and a 0 fps session is not a thing anyone wants.
max_fps: val("PUNKTFUNK_MAX_FPS")
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|&f| f > 0)
.map(|f| f.clamp(1, 240)),
vdisplay_hz_mult: val("PUNKTFUNK_VDISPLAY_HZ_MULT")
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(1)
.clamp(1, 4),
}
}
}
impl HostConfig {
/// The rate to hand the compositor as the GAME's refresh: the session's rate, capped by
/// [`Self::max_fps`]. Only the compositor's game-facing rate goes through here — the session's
/// own mode, the encoder and the wire never do (see the field docs for why).
///
/// `0` in means `0` out. A zero rate is rejected upstream, and quietly turning it into a real
/// one here would hide that.
pub fn game_fps(&self, session_hz: u32) -> u32 {
match self.max_fps {
Some(cap) if session_hz > cap => cap,
_ => session_hz,
}
}
}
@@ -201,3 +302,32 @@ pub fn config() -> &'static HostConfig {
static CFG: OnceLock<HostConfig> = OnceLock::new();
CFG.get_or_init(HostConfig::from_env)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(max_fps: Option<u32>) -> HostConfig {
HostConfig {
max_fps,
..Default::default()
}
}
#[test]
fn game_fps_caps_only_above_the_limit() {
// Unset: every session rate passes through untouched — the default, and every existing
// host. The game keeps rendering at the session's rate, exactly as it always did.
for hz in [24, 30, 60, 120, 144, 240] {
assert_eq!(cfg(None).game_fps(hz), hz);
}
// Set: capped above, exact at, untouched below. A session BELOW the limit keeps its own
// rate — the knob is a ceiling on the game, not a target to render up to.
let c = cfg(Some(60));
assert_eq!(c.game_fps(120), 60);
assert_eq!(c.game_fps(60), 60);
assert_eq!(c.game_fps(30), 30);
// An invalid rate stays invalid rather than being laundered into a real one.
assert_eq!(c.game_fps(0), 0);
}
}
+7
View File
@@ -59,6 +59,10 @@ windows = { version = "0.62", features = [
"Win32_Devices_Enumeration_Pnp",
# SwDeviceCreate's SW_DEVICE_CREATE_INFO references DEVPROPKEY (Properties).
"Win32_Devices_Properties",
# The channel proof: HidD_GetIndexedString + GUID_DEVINTERFACE_HID (the pads/mouse leg) and
# CreateFileW to open the device interface the proof is asked over.
"Win32_Devices_HumanInterfaceDevice",
"Win32_Storage_FileSystem",
"Win32_System_Memory",
"Win32_System_IO",
"Win32_System_StationsAndDesktops",
@@ -71,3 +75,6 @@ windows = { version = "0.62", features = [
"Win32_UI_Input_Pointer",
"Win32_UI_WindowsAndMessaging",
] }
[lints]
workspace = true
+253 -20
View File
@@ -20,6 +20,7 @@
//! key events) is enough.
use super::{gs_button_to_evdev, vk_to_evdev, InputInjector};
use crate::AbsoluteAnchor;
use anyhow::{anyhow, Result};
use ashpd::desktop::{
remote_desktop::{
@@ -374,25 +375,98 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<(UnixStream, Opti
}
/// One EI device and its emulation state.
/// Pick the region to map absolute coordinates into: the one whose logical size matches the
/// streamed mode (the session's virtual output). The device advertises one region per logical
/// monitor, and blindly taking `first()` next to a physical monitor put the pointer — and every
/// Pick the region to map absolute coordinates into. The device advertises one region per logical
/// monitor and blindly taking `first()` next to a physical monitor put the pointer — and every
/// click — on whichever output the compositor happened to announce first (on-glass: GNOME with a
/// dummy HDMI beside the virtual primary; the seat cursor never entered the streamed monitor, so
/// neither embedded nor metadata cursor capture could see it). Size is the only key available
/// today: regions carry no output name, and matching the screencast stream's `mapping_id` needs
/// the stream id plumbed across crates (follow-up). Two same-sized monitors stay ambiguous.
fn region_for_mode(
regions: &[reis::event::Region],
/// neither embedded nor metadata cursor capture could see it).
///
/// The ladder, most identifying first:
///
/// 1. **`mapping_id`** from the session's [`AbsoluteAnchor`] — the protocol's own key for
/// correlating a region with a video stream.
/// 2. **origin** from the anchor — two outputs can share a size, never a top-left. This is what
/// makes a *mirrored physical monitor* land correctly (`design/per-monitor-portal-capture.md`
/// §7.2): its region is not the client's size, so the size rung can't find it.
/// 3. **size** — the streamed mode. Correct for a client-sized virtual output, ambiguous the
/// moment two heads share a mode, which is exactly why the rungs above exist.
/// 4. `first()`.
///
/// An anchor that matches nothing falls through rather than failing: the region set is the truth
/// and the anchor is our belief about it. The caller logs that miss ([`anchor_missed`]).
fn region_for_mode<'a>(
regions: &'a [reis::event::Region],
w: f32,
h: f32,
) -> Option<&reis::event::Region> {
anchor: Option<&AbsoluteAnchor>,
) -> Option<&'a reis::event::Region> {
if let Some(a) = anchor {
if let Some(id) = a.mapping_id.as_deref() {
if let Some(r) = regions.iter().find(|r| r.mapping_id.as_deref() == Some(id)) {
return Some(r);
}
}
if let Some((x, y)) = a.origin {
// EI region offsets are unsigned; a compositor places every output at a non-negative
// origin in its own global space, so a negative anchor simply matches nothing.
if x >= 0 && y >= 0 {
if let Some(r) = regions.iter().find(|r| r.x == x as u32 && r.y == y as u32) {
return Some(r);
}
}
}
}
regions
.iter()
.find(|r| r.width as f32 == w && r.height as f32 == h)
.or_else(|| regions.first())
}
/// Report which region absolute coordinates actually landed in, once per distinct answer.
///
/// The ladder above is only *observable* through where the pointer ends up, which on a two-head box
/// is precisely the thing that is hard to see and easy to get wrong — the anchor exists because it
/// already resolved wrong on-glass once, silently. `warn_anchor_miss` covers the anchor naming
/// nothing; this covers the other half, an anchor that matched *something*, by saying which. Once
/// per distinct region so a live session logs one line, not one per motion event.
fn note_abs_region(region: &reis::event::Region, anchor: Option<&AbsoluteAnchor>) {
static LAST: std::sync::Mutex<Option<(u32, u32, u32, u32)>> = std::sync::Mutex::new(None);
let key = (region.x, region.y, region.width, region.height);
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
if *last == Some(key) {
return;
}
*last = Some(key);
tracing::info!(
region = %format!("{}x{}+{}+{}", region.width, region.height, region.x, region.y),
mapping_id = ?region.mapping_id,
anchor_origin = ?anchor.and_then(|a| a.origin),
anchor_mapping_id = ?anchor.and_then(|a| a.mapping_id.clone()),
"libei: absolute input maps into this output"
);
}
/// Did an anchor name an output this region set doesn't have? Drives the one-shot warning — a
/// silently mis-mapped pointer is the failure this whole ladder exists to prevent, so when the
/// anchor can't be honored that has to be visible in the log rather than inferred from "clicks land
/// on the wrong screen".
fn anchor_missed(regions: &[reis::event::Region], anchor: Option<&AbsoluteAnchor>) -> bool {
let Some(a) = anchor else {
return false;
};
if let Some(id) = a.mapping_id.as_deref() {
if regions.iter().any(|r| r.mapping_id.as_deref() == Some(id)) {
return false;
}
}
if let Some((x, y)) = a.origin {
if x >= 0 && y >= 0 && regions.iter().any(|r| r.x == x as u32 && r.y == y as u32) {
return false;
}
}
true
}
struct DeviceSlot {
device: reis::event::Device,
/// The device is resumed (allowed to emit). Devices arrive paused and may pause again.
@@ -430,6 +504,30 @@ struct EiState {
output_hint: Option<(u32, u32)>,
}
/// The anchor whose miss we last warned about, so a persistently unmatchable anchor logs once per
/// *change* instead of once per pointer sample (absolute motion arrives at client frame rate).
static LAST_WARNED_ANCHOR: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// Warn — once per distinct anchor — that the session's anchor names an output this EIS doesn't
/// advertise, so absolute coordinates fell back to size matching. See [`region_for_mode`].
fn warn_anchor_miss(anchor: &AbsoluteAnchor, regions: &[reis::event::Region]) {
let key = format!("{anchor:?}");
let mut last = LAST_WARNED_ANCHOR.lock().unwrap_or_else(|e| e.into_inner());
if last.as_deref() == Some(key.as_str()) {
return;
}
*last = Some(key);
tracing::warn!(
?anchor,
regions = ?regions
.iter()
.map(|r| (r.x, r.y, r.width, r.height, r.mapping_id.clone()))
.collect::<Vec<_>>(),
"libei: the session's absolute-coordinate anchor matches no EIS region — falling back to \
size matching, so the pointer may land on the wrong monitor"
);
}
/// Is this EIS region a plausible OUTPUT geometry — something to map normalized coordinates
/// into? gamescope advertises a degenerate `(0,0,INT32_MAX,INT32_MAX)` "everything" region on
/// its virtual input device, meaning "absolute coordinates are raw"; normalizing into it
@@ -763,13 +861,23 @@ impl EiState {
// raw client pixels as the last resort.
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
let (x, y) = match region_for_mode(slot.regions(), w, h)
let anchor = crate::absolute_anchor();
if let Some(a) = anchor
.as_ref()
.filter(|a| anchor_missed(slot.regions(), Some(a)))
{
warn_anchor_miss(a, slot.regions());
}
let (x, y) = match region_for_mode(slot.regions(), w, h, anchor.as_ref())
.filter(|r| sane_region(r))
{
Some(region) => (
region.x as f32 + nx * region.width as f32,
region.y as f32 + ny * region.height as f32,
),
Some(region) => {
note_abs_region(region, anchor.as_ref());
(
region.x as f32 + nx * region.width as f32,
region.y as f32 + ny * region.height as f32,
)
}
// Degenerate/absent region: scale into the relay-file output hint
// (correct even when the client streams at a different resolution
// than the session runs); raw client pixels as the last resort.
@@ -847,14 +955,20 @@ impl EiState {
Some(t) if w > 0.0 && h > 0.0 => {
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
// Same region-selection + degenerate fallback ladder as MouseMoveAbs.
let (x, y) = match region_for_mode(slot.regions(), w, h)
// Same region-selection + degenerate fallback ladder as MouseMoveAbs
// (including the session anchor — touch must land on the same monitor the
// pointer does, or a mirrored session's taps go to a different screen).
let anchor = crate::absolute_anchor();
let (x, y) = match region_for_mode(slot.regions(), w, h, anchor.as_ref())
.filter(|r| sane_region(r))
{
Some(region) => (
region.x as f32 + nx * region.width as f32,
region.y as f32 + ny * region.height as f32,
),
Some(region) => {
note_abs_region(region, anchor.as_ref());
(
region.x as f32 + nx * region.width as f32,
region.y as f32 + ny * region.height as f32,
)
}
None => match self.output_hint {
Some((ow, oh)) => (nx * ow as f32, ny * oh as f32),
None => (ev.x as f32, ev.y as f32),
@@ -913,3 +1027,122 @@ impl EiState {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn region(x: u32, y: u32, w: u32, h: u32, mapping_id: Option<&str>) -> reis::event::Region {
reis::event::Region {
x,
y,
width: w,
height: h,
scale: 1.0,
mapping_id: mapping_id.map(str::to_string),
}
}
/// The case the anchor exists for: two heads at the SAME size, so the size rung is a coin
/// flip. The origin picks the right one.
#[test]
fn the_origin_disambiguates_two_same_size_monitors() {
let regions = [
region(0, 0, 1920, 1080, None),
region(1920, 0, 1920, 1080, None),
];
let anchor = AbsoluteAnchor {
origin: Some((1920, 0)),
mapping_id: None,
};
let picked = region_for_mode(&regions, 1920.0, 1080.0, Some(&anchor)).unwrap();
assert_eq!((picked.x, picked.y), (1920, 0));
// Without the anchor the same call takes the first same-sized region — the old behavior,
// preserved deliberately for the client-sized virtual-output path.
let picked = region_for_mode(&regions, 1920.0, 1080.0, None).unwrap();
assert_eq!((picked.x, picked.y), (0, 0));
}
/// `mapping_id` outranks the origin: it is the protocol's own stream↔region correlation, so a
/// stale/rounded origin can't override it.
#[test]
fn mapping_id_outranks_the_origin() {
let regions = [
region(0, 0, 1920, 1080, Some("head-a")),
region(1920, 0, 1920, 1080, Some("head-b")),
];
let anchor = AbsoluteAnchor {
origin: Some((0, 0)),
mapping_id: Some("head-b".into()),
};
let picked = region_for_mode(&regions, 1920.0, 1080.0, Some(&anchor)).unwrap();
assert_eq!(picked.mapping_id.as_deref(), Some("head-b"));
}
/// A mirrored monitor's region is NOT the client's streamed size, so the size rung would miss
/// it entirely — the origin is what makes this land.
#[test]
fn the_anchor_finds_a_monitor_the_streamed_size_does_not_match() {
let regions = [
region(0, 0, 1920, 1080, None),
region(1920, 0, 3840, 2160, None),
];
// Client streams 1280x720 of a 4K head parked to the right.
let anchor = AbsoluteAnchor {
origin: Some((1920, 0)),
mapping_id: None,
};
let picked = region_for_mode(&regions, 1280.0, 720.0, Some(&anchor)).unwrap();
assert_eq!((picked.width, picked.height), (3840, 2160));
}
/// An anchor that names nothing present must not strand input: fall back down the ladder
/// (size, then first) — and say so, which `anchor_missed` drives.
#[test]
fn an_unmatched_anchor_falls_back_and_is_reported() {
let regions = [region(0, 0, 1920, 1080, None)];
let anchor = AbsoluteAnchor {
origin: Some((5000, 5000)),
mapping_id: None,
};
assert!(anchor_missed(&regions, Some(&anchor)));
let picked = region_for_mode(&regions, 1920.0, 1080.0, Some(&anchor)).unwrap();
assert_eq!((picked.x, picked.y), (0, 0), "fell back to the size match");
// A matched anchor is never reported as missed.
let ok = AbsoluteAnchor {
origin: Some((0, 0)),
mapping_id: None,
};
assert!(!anchor_missed(&regions, Some(&ok)));
assert!(!anchor_missed(&regions, None), "no anchor is not a miss");
}
/// EI region offsets are unsigned; a negative anchor origin can only ever match nothing, and
/// must not be cast into a huge u32 that accidentally matches something.
#[test]
fn a_negative_origin_matches_nothing_rather_than_wrapping() {
let regions = [region(0, 0, 1920, 1080, None)];
let anchor = AbsoluteAnchor {
origin: Some((-1920, 0)),
mapping_id: None,
};
assert!(anchor_missed(&regions, Some(&anchor)));
let picked = region_for_mode(&regions, 1920.0, 1080.0, Some(&anchor)).unwrap();
assert_eq!((picked.x, picked.y), (0, 0));
}
/// An empty anchor is the same as no anchor — so a caller can build one unconditionally and
/// let the setter drop it.
#[test]
fn an_empty_anchor_is_dropped_by_the_setter() {
crate::set_absolute_anchor(Some(AbsoluteAnchor::default()));
assert_eq!(crate::absolute_anchor(), None);
crate::set_absolute_anchor(Some(AbsoluteAnchor {
origin: Some((1920, 0)),
mapping_id: None,
}));
assert_eq!(crate::absolute_anchor().unwrap().origin, Some((1920, 0)));
crate::set_absolute_anchor(None);
assert_eq!(crate::absolute_anchor(), None);
}
}
@@ -551,7 +551,7 @@ pub fn deck_unit_id(index: u8) -> u32 {
/// serial passes, so we keep the PunktFunk marker one slot in (`"FVPF"`) — still distinct from a
/// real Deck's `"FVZZ"` for the self-detection below while satisfying Steam's format check.
/// Derived from [`deck_unit_id`] so the `0xAE` serial reply and the `0x83` unit-id attrs stay
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-dualsense lib.rs.)
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-gamepad lib.rs.)
pub fn deck_serial(index: u8) -> String {
format!("FVPF{:08X}", deck_unit_id(index))
}

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