Compare commits

..
Author SHA1 Message Date
sassycrownandOpenAI Codex 22936bbc89 fix(vaapi): use FFmpeg bt2020nc matrix name
FFmpeg rejects bt2020 as an out_color_matrix value. Use its canonical bt2020nc name for non-constant-luminance BT.2020, matching the existing AVCOL_SPC_BT2020_NCL encoder VUI.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-07-29 00:26:57 +02:00
enricobuehlerandClaude Opus 5 f5de661c9e feat(client/windows): overridden rows are marked and resettable, and the speed test targets the right layer
The two remaining pieces of P1 on Windows, both closing the same kind of gap the Linux
client closed earlier.

Overridden rows in profile scope now say so — an accent-marked caption — and carry the
only way back to inheriting. Without it a profile is a one-way door here too, because
overrides are recorded on touch and never inferred from comparing values. Reset calls the
shared `SettingsOverlay::clear`, so the list of what can be cleared stays in one place
across both shells. It also bumps a revision counter: the catalog changes behind the
controls and nothing the page reads as state does, and root state compares before
re-rendering, so without the bump the row would keep showing the value it just dropped.

The speed test writes to the layer the TESTED host reads bitrate from, not the global —
measuring one host used to re-tune every other one. Resolved exactly the way a connect
resolves it: the one-off the test was started with (a pinned tile carries one), else the
host's binding, else the global; a dangling binding falls back. The button names its
target, so it says where it will write before you press it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:21:13 +02:00
enricobuehler 60a85a1344 refactor(encode/windows): fourth fence off — ffmpeg_win.rs, and D3d11Hw::new joins VaapiHw
47 sites, 25 of them raw pointer dereferences: the same libav shape as `vaapi.rs`, so the same
verdict — a proof here carries an argument rather than restating a call.

`D3d11Hw::new` loses its marker for exactly the reason `VaapiHw::new` did, and the trio is now the
clearest statement of the rule in the tree: `CudaHw::new` KEEPS it (handed a `CUcontext`),
`VaapiHw::new` and `D3d11Hw::new` do not (a borrowed COM wrapper and scalars, opening what they
need themselves). Three near-identical libav context builders, sorted by whether a caller can hand
them something broken.

Also unmarked, all for the same reason — no parameter a caller can get wrong: `immediate_context`
and `ensure_staging` (`&ID3D11Device` IS the live-device guarantee), `send` and `ensure_sws` (only
scalars, operating on the `AVFrame`/`SwsContext` the struct owns from its constructor to `Drop`),
and `test_hw_device`, whose `# Safety` section described its own body — "calls the DXGI enumeration
FFI and `make_device`" — while taking a single `u32`.

`open_win_encoder` keeps its marker (raw `*mut AVBufferRef` pair) and gains the proof it lacked,
including the null case the system path relies on: both refs may be null, the `is_null` guards keep
`av_buffer_ref` off that path, and the refs it does take are new ones the codec context adopts.
`D3d11Hw::new`'s proof records the ordering the multithread-protection fix depends on — the device
store must precede `av_hwdevice_ctx_init`, which reads it.

14 fenced files -> 10. Verified on the Intel box .47: `-p pf-encode --features nvenc,amf-qsv,qsv`
clippy `-D warnings` rc=0, the full Windows CI clippy set rc=0, pf-capture's 18 tests pass, and —
because this is the live D3D11VA construct path, not dead code — `d3d11hw_alloc_drop_cycles` passes
on real Intel silicon: 8 construct/drop cycles, no abort. Linux .21: fmt + both CI clippy steps rc=0.
2026-07-29 00:20:35 +02:00
enricobuehler 8e7ba00d2d refactor(encode/linux): third fence off — vaapi.rs, and VaapiHw::new needed no marker at all
Same criterion as the last two: `vaapi.rs`'s sites are pointer dereferences and libav ctx calls,
not ash, so a proof here carries an argument. Three regions, three arguments.

`VaapiHw::new` also loses its `unsafe fn` outright, and the contrast with its CUDA twin is the whole
point: `CudaHw::new` keeps the marker because it is HANDED a `CUcontext` the caller must vouch for,
while this one takes four scalars and opens the VAAPI device itself. Two functions of near-identical
shape, opposite answers, decided by whether a caller can supply something broken.

The other two regions keep their markers (`open_vaapi_encoder`/`_mode` are handed raw
`*mut AVBufferRef`s) and gain the proofs they lacked. The encoder-config block now records the fact
that makes it sound rather than obvious: `av_buffer_ref` returns a NEW reference the codec context
adopts, so the callee shares the caller's device/frames buffers instead of consuming them — which is
also why the low-power entrypoint ladder can retry with the same two pointers after a failed attempt.
The frames-pool block reuses the `CudaHw::new` argument: alloc returns null-or-initialized and
`AvBuffer::from_raw` rejects null, so the `?` leaves before any field store can run.

One call site dropped its `unsafe {}` and its comment with it — the comment argued that libav was
initialized, which is a real precondition of the call but not one a caller can violate, so it is now
a note rather than a contract.

14 fenced files -> 11. Verified on .21 (fmt + both CI clippy steps rc=0) and, because this is the
live VAAPI construct path rather than dead code, ON THE AMD 780M (.116, pf-build distrobox):
`vaapi_cpu_encode_smoke`, `dmabuf_inner_alloc_drop_cycles` and `vaapi_probe_smoke` all pass —
3 passed / 0 failed, H265 + AV1 probes still true in both 8- and 10-bit.
2026-07-29 00:20:35 +02:00
enricobuehler ee29b3c3a9 refactor(client-core): three decoder lift methods that took nothing to get wrong
`D3d11Decoder::lift`, `VaapiDecoder::map_dmabuf` and `VulkanDecoder::extract` each take `&mut self`
and NOTHING else. They dereference `self.frame`, the `*mut AVFrame` the decoder allocates in its own
constructor and owns until `Drop` — a pointer no caller supplies, can replace, or can invalidate.
None carried a `# Safety` section, and each body was already one `unsafe {}` with its proof, so the
marker was pure ceremony and its removal moves nothing.

All three live in files the workspace `deny` already covers (`video_vulkan.rs` since the fence came
off it), so they stay fully enforced.

Worth recording why this is where the marker sweep STOPS for these two crates: every remaining
candidate in pf-encode and pf-client-core sits inside a fenced file, and there removing a marker is
not free. The fence excuses `unsafe_op_in_unsafe_fn`, which only applies to `unsafe fn` BODIES — a
safe fn always needs explicit blocks — so unmarking an ash-dense helper forces exactly the
whole-body `unsafe {}` this program rejected when it rejected `cargo fix`. The way to reach those is
to reclaim the file first, not to unmark inside it.

Verified on .21: fmt + `clippy --workspace --all-targets -- -D warnings` + the feature-gated
`-p pf-encode` step, all rc=0.
2026-07-29 00:20:35 +02:00
enricobuehler d72822ced7 refactor(encode/linux): second fence off — CudaHw::new's pointer walk gets its proof
`linux/mod.rs`'s fifteen sites are the same kind as `video_vulkan.rs`'s, not the ash kind: nine raw
pointer dereferences and six libav calls, all inside `CudaHw::new`, which had no `unsafe` block and
therefore no proof of the one thing worth proving here — that the pointer chain it walks is live.

The marker STAYS (`cu_ctx: *mut c_void` is a `CUcontext` the caller must supply valid). The body is
now two blocks, one per phase, because there are two distinct arguments to make. Both turn on the
same non-obvious fact: `av_hwdevice_ctx_alloc`/`av_hwframe_ctx_alloc` return null or a ref whose
`data` libav has ALREADY initialized, and `AvBuffer::from_raw` rejects null — so the `?` leaves
before any of the field stores below it can run. That is what makes the `(*dev_ctx)`/`(*fc)` writes
in-bounds stores on live allocations rather than a hope, and it is exactly the reasoning that was
missing. The device block also records the ordering constraint that was implicit: `cuda_ctx` must be
stored BEFORE `av_hwdevice_ctx_init`, which reads it.

Two files now need no exemption: 14 fenced -> 12. Both were removable for the same reason — their
sites are pointer dereferences, where a proof carries an argument, unlike the ash backends where it
could only restate the call. That is the criterion for which fence to attack next, not file size.

Verified on .21: fmt + `clippy --workspace --all-targets -- -D warnings` + the feature-gated
`-p pf-encode --features nvenc,vulkan-encode,pyrowave` step, all rc=0 with no allow in either file.
2026-07-29 00:20:35 +02:00
enricobuehler ed4bbc6b0b refactor(client-core): the first fence comes off — video_vulkan.rs is at zero
`video_vulkan.rs` was fenced with the other thirteen GPU/FFI backends, but it never belonged with
them: its four sites are not ash calls, they are RAW POINTER DEREFERENCES inside the two FFmpeg
`lock_queue`/`unlock_queue` callback trampolines — exactly the case where the lint pays, and where a
proof carries an argument instead of restating a signature. Both bodies had none at all.

They keep `unsafe extern "C"`: FFmpeg invokes them with a `*mut AVHWDeviceContext`, which is a real
contract, now written down. What was missing is why the dereference chain is sound, and it is worth
stating because it is not obvious — the trampoline casts between pf_ffvk's `AVHWDeviceContext` and
ffmpeg-sys's (the same C struct declared twice), reads `user_opaque`, and dereferences it as a
`QueueLock`. That pointer borrows `VkCtxStorage::_queue_lock`, an `Arc<QueueLock>` whose field doc
already says it exists to outlive every call the context can make. The proof now connects those two
facts, so a future edit to the storage's lifetime has something to contradict.

With that, the file needs no exemption and the workspace `deny` covers it: 14 fenced files -> 13.
This is what removing a fence is supposed to look like — the sites that are worth narrowing get
narrowed, and the exemption disappears rather than being renewed.

Verified on .21: fmt + `clippy --workspace --all-targets -- -D warnings` + the feature-gated
`-p pf-encode` step, all rc=0 with no allow in the file.
2026-07-29 00:20:35 +02:00
enricobuehler 5b2be889f9 refactor(capture/windows): two # Safety sections that stated no contract
The previous commit took the markers with no `# Safety` at all. These two HAVE one, and that is
what makes them worth naming: a section can exist and still describe nothing a caller can violate.

`resolve_render_adapter` takes **no arguments**. Its section read "calls DXGI factory/adapter
enumeration; returns owned COM objects or an error" — a summary of the body. With no parameters and
no globals touched, there is no way to call it wrongly.

`create_nv12` asked that "`device` must be a live D3D11 device". It takes `&ID3D11Device`, a
borrowed reference-counted COM wrapper, so the borrow itself is that guarantee — safe Rust cannot
produce a dangling one. The remainder ("the returned texture is owned by the caller") is an
ownership note, the same distinction `service::open_log_handle` already draws in its doc. Every
other parameter is a plain scalar.

Both bodies already had their explicit block, so again nothing moved. `create_nv12`'s proof said
"on the live `device` borrow (per the contract above)" and now says the borrow is what keeps it
live — a proof that pointed at a contract being deleted had to stop pointing at it.

The other four were read and KEPT, and they are the shape of a real one: `channel::send` and
`duplicate_and_deliver` take raw `HANDLE`s; `prepare_blend_scratch` requires the caller to hold the
slot's keyed mutex before it copies; `pyro_fence_signal` must run on the thread owning the immediate
context, which is load-bearing precisely because `IddPushCapturer` is `unsafe impl Send` and so CAN
be moved. `verify_is_wudfhost` (raw `HANDLE`, and the driver-channel security check) keeps its
marker too.

pf-capture: 21 `unsafe fn` -> 8, every survivor carrying an obligation a caller can actually break.
Verified on .47: pf-capture clippy `-D warnings` rc=0 + 18 Windows tests pass, host/pf-encode/
pf-vdisplay green; Linux .21 fmt + both CI clippy steps rc=0.
2026-07-29 00:20:35 +02:00
enricobuehler 9e8473b46a refactor(capture/windows): eleven D3D11 helpers had no caller contract
The DXGI converters (`HdrP010Converter`, `BgraToYuvPlanes`, `VideoConverter`), the cursor blend
pass, and `create_ring_slots` declared their methods `unsafe fn` because their bodies are D3D11
FFI. That is a property of the body, not an obligation on the caller, and their own proofs said so:
"`?`-checked D3D11 methods on the live `device` borrow". Every parameter is either a borrowed
windows-rs COM wrapper (`&ID3D11Device`, `&ID3D11DeviceContext`, `&ID3D11Texture2D`, the views) or
a plain `u32`/`bool`/`DXGI_FORMAT`; each body builds its own descriptors from those and every
interface it creates owns its reference. There is nothing a caller can pass that makes them
unsound.

Not one of the eleven carried a `# Safety` section — the marker had no stated contract to inherit,
which is the tell. Each body was already one `unsafe {}` with its proof, so this is purely
subtractive: no block moved and no proof was written. The four call sites lost `unsafe {}` wrappers
whose own comments gave the game away — "`X::new` is `unsafe` (it compiles D3D11 shaders…)",
"`create_ring_slots` is an `unsafe fn` (it makes D3D11/DXGI COM calls)" — comments explaining that
the marker described the callee's body rather than anything the caller had to guarantee.

`compile_shader` KEEPS its `unsafe fn`: it takes `PCSTR`, a raw pointer the caller must guarantee
points at a NUL-terminated literal. That is the line — a contract a caller can actually break.
pf-zerocopy was surveyed the same way and deliberately left alone: its CUDA/Vulkan helpers look
identical by signature but state real obligations ("the shared context is current", "single-threaded
use of handles this bridge owns"), so signature screening finds candidates and only reading decides.

pf-capture already denies both `unsafe_op_in_unsafe_fn` and `undocumented_unsafe_blocks`, so the
crate stays fully enforced; the reduction is in obligations, not in warnings. It now has exactly one
`unsafe fn` left. Verified on the Intel box .47: pf-capture clippy `-D warnings` rc=0 and its 18
Windows tests pass, with host/pf-encode/pf-vdisplay clippy still green; Linux .21 fmt + both CI
clippy steps rc=0.
2026-07-29 00:20:35 +02:00
enricobuehler 50531c8e9e fix(host/display): a screen picker that cannot stream a screen now says so instead of saving
The Windows console's "Streamed screen" picker SAVED and then did nothing. `capture_monitor` is
platform-neutral, so the PUT persisted; every consumer of it is Linux-gated, so a virtual display
was still created on connect. The operator got a control that acknowledged the click and changed
nothing — the worst of the three possible behaviours.

The root cause is not a missing gate, it is a missing BACKEND. Per-monitor capture is Linux/portal
only: `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]` because `pf-capture` has no
Windows entry point that can capture an arbitrary head. Its sole Windows entry point is
`open_idd_push`, a frame channel pushed by our OWN IddCx virtual display; DXGI Desktop Duplication
was deliberately REMOVED (`windows/dxgi.rs` keeps the GPU-preference hook only to stop DXGI
reparenting the virtual display off the pinned adapter, and says so). So there is nothing for a pin
to aim at, and exposing the picker got ahead of that.

Decision of record (user, this session): mark it unsupported now rather than build the Windows
backend here. A Windows mirror backend is a real feature gap and a project of its own — it needs a
duplication capturer plus everything the IDD-push path carries today (cursor sidechannel, 444,
10-bit, PyroWave, HDR) re-plumbed through it — not a follow-on to an enumeration commit.

Three places, so the answer is consistent wherever it is asked:

* `MonitorsResponse.pin_supported` — a CAPABILITY, reported by the build that would have to honor
  the pin rather than sniffed from the OS client-side. When a Windows mirror backend lands this
  flips and the console needs no change. `pinned` stays `None` off-Linux deliberately, and now says
  why: it is what the console highlights as "sessions stream this", and highlighting a head nothing
  will capture is the same lie in a different place.
* `enforced` drops `capture_monitor` off Linux. That list is exactly the "which controls are live vs.
  coming soon" contract, and claiming this one unconditionally is what let the picker ship enabled.
* The PUT drops a non-Linux `capture_monitor` instead of storing it, and logs that it did. COERCED,
  not rejected with a 400: this PUT is WHOLE-OBJECT, so a host that already stored a pin would have
  every later settings save rejected over a field the operator cannot see — taking the other axes
  down with it. This way such a policy self-heals on the next write.

Console: the picker renders read-only with an explanation, reusing the `envLocked` shape rather than
inventing a second one (`locked = envLocked || !pinSupported`). Managed heads were ALREADY excluded
from selection here (`mon.enabled && !mon.managed`), which is the "grey out, never filter blindly"
rule working as intended — the heads stay listed and explicable. `pin_supported` defaults to TRUE
when absent so an older host, which only ever shipped this picker where it worked, is not
retroactively locked out.

⚠️ `api/openapi.json` is hand-patched — punktfunk-host does not build on macOS, so the spec could not
be regenerated from the binary. The shape was verified by running the real generator over it: orval
emits `pin_supported: boolean` (required, no `?`) on `MonitorsResponse`.

Verified: `bun run codegen` (orval + paraglide + i18n parity: 434 messages, en + de), `bun run build`,
`bun run lint` (tsc --noEmit) all clean. Rust verified separately on .173 — see the next commit's
note if that lands, since neither xcheck target covers punktfunk-host.
2026-07-29 00:18:27 +02:00
enricobuehler ed021a13ee test(vdisplay): give the 3.2 case eyes — a subscriber, and the probe that splits the two candidates
§5 3.2 reproduces on glass and the shipped fix is not sufficient: the panel stays dark after
teardown. Two candidates were left undistinguished by the first on-glass run — the adopted snapshot
was already poisoned, or the dark-desk backstop never fired — because the case had no way to tell
them apart. This adds that way.

**The subscriber.** The adoption arm and `restore_displays_ccd`'s backstop announce themselves ONLY
through `tracing`, and a bare `cargo test` harness installs no subscriber, so both events went
nowhere. `tracing-subscriber` becomes a DEV-dependency of pf-vdisplay (the shipped host's closure
through this crate is unchanged) and `init_test_tracing` wires it: `try_init` so a second live case
in the same binary is a no-op rather than a panic, `with_test_writer` so it interleaves with the
harness rather than racing `println!`, `RUST_LOG` still winning over the `debug` default.

`live_inplace_resize` gets it too — its comment claimed tracing-subscriber "is not a dep of this
crate, run the host binary for traced runs", which was the same blindness written down as a
limitation. It no longer holds.

**The probe is the actual discriminator, and it needs no logs at all.** Member 1's isolate is
INJECTED to fail, so nothing of ours deactivates anything there. Sampling `active_physicals()`
immediately after member 1's create therefore answers the question directly:

* empty  -> the arriving IddCx monitor took the desktop on its own. Every snapshot from that instant
  on records "panel off", so member 2's adopted snapshot is POISONED AT BIRTH and restoring it
  faithfully restores darkness. Adoption works; the snapshot SOURCE is the defect.
* non-empty -> poisoning is excluded and the break is downstream: no adoption line means teardown's
  restore was never gated on, a backstop line with a non-zero force-EXTEND rc means the remedy
  itself failed.

The failing assertion now reads that verdict out instead of asserting one cause, so the next on-glass
run reports which link broke rather than only that one did.

⚠️ Still UNRUN on glass — this is the instrument, not the measurement. Needs .173's CONSOLE session
(ssh is session 0 and CCD sees nothing there), and it can leave the desk dark: recover with
`SDC_USE_DATABASE_CURRENT|SDC_APPLY`, not EXTEND, which returns rc=31 against a single display.

Checked while writing this, and recorded so it is not re-derived: the "backstop is structurally
inert on a one-display box" theory does NOT survive. Both real call sites restore BEFORE the virtual
is REMOVEd, so two displays are connected and the preset applies — `live_force_extend_*` measured
exactly that (1 -> 1 -> 2). The documented residual is narrower: a restore that fails once the
virtual is already gone.

Verified: `xcheck.sh windows clippy` clean (it passes `--all-targets`, so this test code really is
compiled), `cargo fmt --all --check` clean.

⚠️ `Cargo.lock` moves with this commit and must not be split from it. CI runs
`cargo clippy --workspace --all-targets --locked`, so a new dependency edge whose lockfile entry is
missing does not degrade to a re-resolve — it FAILS the build outright. The dev-dependency adds one
line (pf-vdisplay gains a `tracing-subscriber` edge; the package itself was already in the graph via
punktfunk-host and pf-encode, so nothing new is vendored).
2026-07-29 00:18:27 +02:00
enricobuehler 8381fd73ef test(vdisplay): bound the 3.2 case's creates, and what it found on glass
A hang must fail the case, not wedge the box. The first attempt at this case hung inside
`create`; killing the harness skipped every `Drop` and leaked an IddCx monitor, and a few of
those exhaust the driver's slot pool — after which every later run wedges too and only a reboot
clears it. Both creates now run on a worker thread with a 45 s budget, so the harness exits
NORMALLY, which is what lets the driver reap the session. Re-run with matched host+drivers: no
hang, no leak, and the box came back with the panel lit.

 With that guard in, §5 3.2 REPRODUCED ON GLASS for the first time (.173, LG TV attached, host
0.21.0 + drivers 9.9.728.2241, first isolate injected to fail):

    physicals before                        : [(4352, "LG TV SSCR2 [HDMI]")]
    after member 1 (isolate INJECTED to fail): [(260, "punktfunk [punktfunk-virtual]")]
    after member 2 (isolate REAL)            : [(260, …), (264, …)]
    physicals during                         : []
    physicals after teardown                 : []   <-- the operator's panel, still dark

So the shipped 3.2 fix — adopt the first SUCCESSFUL isolate as the group's restore snapshot — is
necessary but NOT sufficient. Two candidate reasons, both worth checking before changing code:

* The adopted snapshot is already POISONED. The panel went dark at member 1's create even though
  our isolate never ran (the injected failure), because the arriving IddCx monitor took the
  desktop on its own. Member 2's isolate then snapshotted a topology in which the physical was
  ALREADY inactive, so restoring it faithfully restores "TV off". This is the same
  poisoned-snapshot chain §5 already records from the field logs.
* `restore_displays_ccd`'s dark-desk backstop did not rescue it either. Worth its own look, since
  21eda37a is what first made that condition evaluable at all (before it, our own display counted
  as a lit physical and the guard could never fire).

⚠️ Not yet distinguished, because this case installs no tracing subscriber: whether the adoption
arm fired at all, and whether the backstop ran and failed or never ran. That is the next
diagnostic — `live_inplace_resize` shows the pattern for wiring a subscriber into a live case.

Also confirms 21eda37a in the field: the virtual displays report `tech=punktfunk-virtual` and are
correctly absent from the physicals list, which is what makes "physicals after teardown: []" mean
what it says rather than counting our own output as the operator's panel.
2026-07-29 00:18:27 +02:00
enricobuehler 22f2686680 feat(vdisplay/windows): the console can finally show the operator's real screens
`GET /display/monitors` returned an empty list on every Windows host, plus a LINUX error string:
`monitors::list` is a per-compositor dispatch whose non-Linux arm bails, and it never even got
that far because `detect()` was not cfg-gated — on Windows it fell through to an
`XDG_CURRENT_DESKTOP` sniff and failed with advice about setting `PUNKTFUNK_COMPOSITOR`, which the
handler puts VERBATIM into the response. So the console showed no physical screen and its only
explanation was Linux troubleshooting (sweep §13.17).

The data was there all along. `target_inventory()` already walks the CCD database; it now also
reports the geometry that walk had in hand — the driving source's position and mode, the GDI
device name, the refresh rational, and `primary`. No second CCD walker.

* `monitors::list_windows()` maps that onto `PhysicalMonitor`. Two fields cannot mean on Windows
  what they mean on Linux and are reported honestly rather than invented: `scale` is always 1.0
  (Windows scaling is per-monitor DPI applied per application, not a compositor-global logical
  scale — so the geometry is PIXELS), and an INACTIVE head gets zeroed geometry because the CCD
  mode indices are only valid for active paths. Inactive heads are still listed, per `list`'s own
  contract, so "why can't I pick it?" keeps an answer.
* `managed` is finally truthful. The doc says only KWin can tell a virtual output from a real head;
  Windows can too, via our own EDID manufacturer id in the device path (21eda37a).
* `detect()` gets an honest non-Linux arm, the operator pin gated with it — naming a Wayland
  compositor on Windows could never be honoured either.
* The handler reports `compositor: "windows"` instead of null-plus-a-Linux-error.

ON GLASS (.173, host 0.21.0, TV attached):

    connector=\\.\DISPLAY1  enabled=true  managed=false  primary=true
    3840x2160 @165Hz  pos=(0,0)  "LG TV SSCR2"

Pinned by an `#[ignore]`d hardware case that asserts at least one NON-managed head is visible —
the exact thing that was missing. Read-only, so it is safe against a live host.

This also gives `capture_monitor` a picker on Windows, where the handler currently hardcodes
`pinned: None`; wiring that is left for the per-monitor-capture work rather than smuggled in here.

Verified on .173: `clippy -p punktfunk-host -p pf-vdisplay -p pf-win-display --all-targets
-- -D warnings` clean; both xcheck targets clean.
2026-07-29 00:18:27 +02:00
enricobuehler 735cd99e8f fix(ci/windows): the web install was the one CI site still running lifecycle scripts
The Windows host job died in "Build + smoke-boot web console (bun)":

    $ bun2nix -o bun.nix
    error: bun is not installed in %PATH%
    error: postinstall script from "punktfunk-web" exited with 255
    bun install failed (255)

`web`'s `postinstall` is `bun2nix -o bun.nix` — Nix codegen that shells out to `bun` on PATH. This
job runs a fetched PORTABLE bun by absolute path (`$env:BUN_EXE`), so PATH has none and the script
aborted the install, taking the installer, the driver bundle and the publish steps with it.

Every other web install in CI already passes `--ignore-scripts` — ci.yml (twice),
web-screenshots.yml, sdk-publish.yml, plugin-kit-publish.yml, and the SDK install 25 lines further
down THIS SAME FILE. This one line was the lone exception: the same N-1-of-N seam asymmetry the
pf-vdisplay sweep keeps turning up. Nothing here needs those scripts — `bun run build` re-runs its
own `prebuild` codegen, and ci.yml proves the build works after an `--ignore-scripts` install —
and `bun.nix` is a Nix artifact this job neither consumes nor commits. deb.yml still regenerates
it (bun on PATH there), so the self-regenerating contract is unchanged.

⚠️ Honest about the evidence: I could NOT reproduce the failure on .173. Invoking the portable bun
by absolute path with `where bun` empty, the postinstall SUCCEEDED there — bun made itself
resolvable to its own lifecycle script, which the SYSTEM-account runner evidently does not. So this
is not a reproduced-then-fixed A/B. It is justified instead by construction: `--ignore-scripts`
removes the dependency on bun2nix (and on how bun2nix locates bun) altogether, and aligns the step
with every sibling. If the job goes green, that is the confirmation.

⚠️ Related trap for whoever deploys the console by hand: rebuilding `web/.output` does NOT take
effect until the `PunktfunkWeb` SCHEDULED TASK is restarted — restarting the host service does not
touch it. A server left running from before a rebuild serves HTML referencing the OLD asset hashes
while disk holds the new ones, so every asset 500s while `/login` still returns 200. Hit on .173
today; `Stop-ScheduledTask PunktfunkWeb; Start-ScheduledTask PunktfunkWeb` fixes it.
2026-07-29 00:18:27 +02:00
enricobuehlerandClaude Opus 5 3d25482469 feat(client/windows): pinned host+profile tiles render in the grid
The menu could pin since the last commit but nothing appeared, which is worse than not
offering it. Each host's pins now render right after it, sharing its live status because
they read the same record, with the profile named on an accent chip. A pin whose profile
was deleted simply doesn't render.

A pinned tile carries no menu of its own: it is a shortcut, not a second host, and
pin/unpin already live on the primary tile's menu — the one place you decide it. Its
hover key is `<fp>#<profile-id>` so two tiles for one host don't light up together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:18:12 +02:00
enricobuehlerandClaude Opus 5 2a088e13a4 feat(client/windows): bind a host to a profile, connect with one, pin one, copy its link
The WinUI half of §5.2: `Target` carries a one-off profile into the session's `--profile`,
the host editor gains the binding picker, and the tile names its bound profile on a chip so
what a plain click will do is visible without opening anything.

Shape notes, both forced by this toolkit rather than chosen:

- The flyout has no submenus, so "Connect with", "Pin as card" and "Unpin card" are flat
  items, one per profile, matched by prefix. The prefixes end in ": " so a profile named
  "Copy link" can't collide with a fixed entry.
- The binding picker commits on change rather than at Save. The rest of that sheet is text
  boxes with draft refs, which need a Save; a ComboBox doesn't, and making it wait would be
  the odd one out.

Semantics stay identical to the Linux client, because they are the design's, not the
platform's: "Connect with" never rebinds, "Default settings" is `Some("")` so it really
does override a bound host for one session, and a binding whose profile was deleted reads
as Default settings and resolves to it.

`pf_client_core::clipboard::set_text` is the small addition the crate needed for "Copy
link" — the OS clipboard plumbing was already there, just private to the streaming bridge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:17:15 +02:00
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
259 changed files with 25220 additions and 4276 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.
+34 -3
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
@@ -271,7 +294,15 @@ jobs:
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
}
Push-Location web
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
# `--ignore-scripts` like every other web install in CI (ci.yml, web-screenshots.yml,
# sdk/plugin-kit-publish, and the SDK install further down this same file). This step was
# the one site that ran lifecycle scripts, and web's `postinstall` is `bun2nix -o bun.nix`
# — a NIX codegen step that shells out to `bun` on PATH. CI runs a fetched PORTABLE bun by
# absolute path (`$env:BUN_EXE`), so PATH has none, and bun2nix aborted the install:
# error: bun is not installed in %PATH% ... postinstall script exited with 255
# Nothing here needs those scripts — `build` re-runs its own `prebuild` codegen — and
# bun.nix is a Nix artifact this job neither consumes nor commits.
& $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
Generated
+2
View File
@@ -2863,6 +2863,7 @@ dependencies = [
"pipewire",
"punktfunk-core",
"pyrowave-sys",
"rand 0.9.4",
"rustls",
"sdl3",
"serde",
@@ -3050,6 +3051,7 @@ dependencies = [
"sha2",
"tokio",
"tracing",
"tracing-subscriber",
"utoipa",
"wayland-backend",
"wayland-client",
+29
View File
@@ -56,6 +56,35 @@ 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"
+22 -2
View File
@@ -3912,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",
@@ -5823,7 +5831,8 @@
"type": "object",
"description": "The host's physical monitors + which one capture is pinned to.",
"required": [
"monitors"
"monitors",
"pin_supported"
],
"properties": {
"compositor": {
@@ -5847,6 +5856,10 @@
},
"description": "The heads, ordered left-to-right by desktop position."
},
"pin_supported": {
"type": "boolean",
"description": "Whether this build can actually STREAM one of these monitors.\n\nEnumeration and capture are separate capabilities, and on Windows only the first exists: the\nheads below are real and worth showing (they explain the topology, and `/display/state`\ncross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a\nframe channel pushed by our OWN IddCx virtual display. There is no desktop-duplication\ncapturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so\n`vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.\n\nThe console renders the picker read-only on `false`. Reported as a capability rather than\nsniffed client-side from the OS so the answer comes from the build that would have to honor\nit — when a Windows mirror backend lands, this flips and the UI needs no change."
},
"pinned": {
"type": [
"string",
@@ -6355,6 +6368,7 @@
"audio_streaming",
"pin_pending",
"paired_clients",
"native_paired_clients",
"active_sessions",
"games"
],
@@ -6376,10 +6390,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": {
@@ -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
+50 -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;
@@ -238,6 +239,7 @@ fn connect_spawn(
let target = target.clone();
// The closure owns `target`/`fp_hex`; the call itself borrows copies.
let (addr, port, fp_arg) = (target.addr.clone(), target.port, fp_hex.clone());
let profile_arg = target.profile.clone();
let spawned = crate::spawn::spawn_session(
&addr,
port,
@@ -245,6 +247,7 @@ fn connect_spawn(
opts.connect_timeout.as_secs(),
fullscreen,
opts.launch.as_deref(),
profile_arg.as_deref(),
child,
move |event| {
use crate::spawn::SpawnEvent;
@@ -272,9 +275,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 +421,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 +445,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 +469,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));
}
});
}
+192 -1
View File
@@ -20,6 +20,13 @@ const MENU_WAKE: &str = "Wake host";
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
/// that matter.
const MENU_EDIT: &str = "Edit\u{2026}";
/// Dynamic menu-item prefixes: this flyout has no submenus, so the profile entries are flat
/// items matched by prefix. The trailing space keeps them readable AND keeps a profile named
/// e.g. "Copy link" from colliding with a fixed entry.
const MENU_WITH: &str = "Connect with: ";
const MENU_PIN: &str = "Pin as card: ";
const MENU_UNPIN: &str = "Unpin card: ";
const MENU_COPY_LINK: &str = "Copy link";
const MENU_FORGET: &str = "Forget\u{2026}";
/// Whether the console (gamepad) UI is available in this build: the session binary ships
@@ -163,6 +170,19 @@ pub(crate) struct Hover {
/// The status row at the bottom of a tile: presence dot + Online/Offline, plus the trust chip.
fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
status_row_with(online, badge, kind, None)
}
/// [`status_row`] plus the profile chip: what a plain click on THIS tile will use — its own
/// profile on a pinned tile, the host's binding on the primary one. A binding whose profile
/// was deleted shows nothing and resolves as the defaults, which is what will happen on
/// connect (design §6).
fn status_row_with(
online: Option<bool>,
badge: &str,
kind: Pill,
profile: Option<(&str, Pill)>,
) -> Element {
let mut items: Vec<Element> = Vec::new();
if let Some(online) = online {
items.push(
@@ -183,6 +203,13 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
if let Some((name, kind)) = profile {
items.push(
pill(name, kind)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
}
hstack(items)
.spacing(6.0)
.margin(edges(0.0, 12.0, 0.0, 0.0))
@@ -250,6 +277,43 @@ fn edit_editor(
se.call(None);
}
};
// The profile binding: what a plain click on this tile will use. It commits on change
// rather than at Save — it is a picker with no draft ref, and the rest of the sheet's
// fields are text boxes that genuinely need one.
let profile_picker = {
let catalog = pf_client_core::profiles::ProfilesFile::load();
let stored = KnownHosts::load()
.hosts
.iter()
.find(|h| h.fp_hex == fp)
.and_then(|h| h.profile_id.clone());
let mut names = vec!["Default settings".to_string()];
let mut ids: Vec<String> = vec![String::new()];
for p in &catalog.profiles {
names.push(p.name.clone());
ids.push(p.id.clone());
}
// A binding whose profile is gone reads as Default settings — the same "dangling
// resolves as none" rule the connect path follows — and is cleaned up on the next pick.
let current = stored
.as_ref()
.and_then(|id| ids.iter().position(|i| i == id))
.unwrap_or(0);
let fp = fp.to_string();
ComboBox::new(names)
.header("Profile")
.selected_index(current as i32)
.on_selection_changed(move |i: i32| {
let Some(id) = ids.get(i.max(0) as usize) else {
return;
};
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.profile_id = (!id.is_empty()).then(|| id.clone());
let _ = known.save();
}
})
};
let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
vstack((
text_block(label)
@@ -281,6 +345,18 @@ fn edit_editor(
"auto-filled when known",
mac_draft,
),
vstack((
profile_picker,
text_block(
"The settings a plain click on this host uses. \u{201c}Connect with\u{201d} \
in the tile\u{2019}s menu overrides it for one session without changing it.",
)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.wrap()
.horizontal_alignment(HorizontalAlignment::Left),
))
.spacing(4.0),
vstack((
ToggleSwitch::new(clip0)
.header("Share clipboard with this host")
@@ -481,6 +557,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
if !known.hosts.is_empty() {
body.push(section("SAVED HOSTS"));
let mut tiles: Vec<Element> = Vec::new();
// One catalog read per render, shared by every tile's menu and chip.
let profiles: Vec<(String, String)> = pf_client_core::profiles::ProfilesFile::load()
.profiles
.into_iter()
.map(|p| (p.id, p.name))
.collect();
for k in &known.hosts {
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
@@ -504,6 +586,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: Some(k.fp_hex.clone()),
pair_optional: false,
mac: k.mac.clone(),
profile: None,
};
// Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter
// covers a routed/Tailscale host that never advertises — the display companion to
@@ -525,6 +608,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let (svc, target) = (props.svc.clone(), target.clone());
let (sf, sr) = (set_forget.clone(), set_rename.clone());
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
let menu_profiles = profiles.clone();
let (link_host, link_profile) = (k.clone(), None::<String>);
button("")
.icon(Symbol::More)
.subtle()
@@ -532,6 +617,24 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.automation_name("More options")
.menu_flyout({
let mut items = vec![menu_item(MENU_CONNECT)];
// One-off connects, flat: this flyout has no submenus, so each
// profile is its own item. "Connect with" NEVER rebinds — the default
// is changed in the host editor (design §5.2).
for (_, name) in profiles.iter() {
items.push(menu_item(format!("{MENU_WITH}{name}")));
}
if !profiles.is_empty() {
items.push(menu_item(format!("{MENU_WITH}Default settings")));
for (id, name) in profiles.iter() {
let label = if k.pinned_profiles.iter().any(|p| p == id) {
format!("{MENU_UNPIN}{name}")
} else {
format!("{MENU_PIN}{name}")
};
items.push(menu_item(label));
}
}
items.push(menu_item(MENU_COPY_LINK));
// The library surfaces — mouse/KB page and the gamepad console UI —
// for paired hosts only (the mgmt API needs the paired identity);
// the page additionally sits behind the experimental toggle, the
@@ -550,6 +653,52 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
items
})
.on_item_clicked(move |item: String| match item.as_str() {
// The profile items are dynamic, so they are matched by prefix before
// the fixed ones.
_ if item.starts_with(MENU_WITH) => {
let name = item.trim_start_matches(MENU_WITH);
let mut target = target.clone();
// `Some("")` — not `None` — so "Default settings" really does
// override a bound host for this one connect.
target.profile = Some(
menu_profiles
.iter()
.find(|(_, n)| n == name)
.map(|(id, _)| id.clone())
.unwrap_or_default(),
);
initiate(&svc.ctx, target, &svc.set_screen, &svc.set_status)
}
_ if item.starts_with(MENU_PIN) || item.starts_with(MENU_UNPIN) => {
let pin = item.starts_with(MENU_PIN);
let name = item
.trim_start_matches(MENU_PIN)
.trim_start_matches(MENU_UNPIN);
let Some((id, _)) =
menu_profiles.iter().find(|(_, n)| n == name).cloned()
else {
return;
};
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.pinned_profiles.retain(|p| p != &id);
if pin {
h.pinned_profiles.push(id);
}
let _ = known.save();
}
// The tile list re-reads the store on the next render; nudge it.
sr.call(None);
}
MENU_COPY_LINK => {
let url = pf_client_core::deeplink::DeepLink::for_host(
&link_host,
None,
link_profile.as_deref(),
)
.to_url();
pf_client_core::clipboard::set_text(&url);
}
MENU_CONNECT => {
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
}
@@ -575,15 +724,20 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
})
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let pinned_base = target.clone();
tiles.push(host_tile(
&k.fp_hex,
&hover,
&k.name,
&format!("{}:{}", k.addr, k.port),
status_row(
status_row_with(
Some(online),
if k.paired { "Paired" } else { "Trusted" },
if k.paired { Pill::Good } else { Pill::Info },
k.profile_id
.as_ref()
.and_then(|id| profiles.iter().find(|(pid, _)| pid == id))
.map(|(_, name)| (name.as_str(), Pill::Neutral)),
),
Some(menu),
Some(Box::new(move || {
@@ -598,6 +752,41 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
}
})),
));
// …then this host's pinned host+profile tiles, in the order they were pinned
// (design §5.2a). They share the host's live status because they read the same
// record, and a pin whose profile is gone simply doesn't render. No menu of their
// own: a pinned tile is a shortcut, not a second host, and pin/unpin already live
// on the primary tile's menu — the one place you decide it.
for id in &k.pinned_profiles {
let Some((id, name)) = profiles.iter().find(|(pid, _)| pid == id) else {
continue;
};
let (ctx3, ss3, st3) = (ctx.clone(), set_screen.clone(), set_status.clone());
let mut pinned_target = pinned_base.clone();
pinned_target.profile = Some(id.clone());
tiles.push(host_tile(
// Its own hover key: two tiles for one host must not light up together.
&format!("{}#{id}", k.fp_hex),
&hover,
&k.name,
&format!("{}:{}", k.addr, k.port),
status_row_with(
Some(online),
if k.paired { "Paired" } else { "Trusted" },
if k.paired { Pill::Good } else { Pill::Info },
Some((name.as_str(), Pill::Info)),
),
None,
Some(Box::new(move || {
if can_wake {
initiate_waking(&ctx3, pinned_target.clone(), &ss3, &st3);
} else {
initiate(&ctx3, pinned_target.clone(), &ss3, &st3);
}
})),
));
}
}
body.push(tile_grid(tiles, cols, TILE_GAP));
}
@@ -634,6 +823,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
pair_optional: h.pair == "optional",
mac: h.mac.clone(),
profile: None,
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let (badge, kind) = if h.pair == "required" {
@@ -716,6 +906,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: None,
pair_optional: false,
mac: Vec::new(),
profile: None,
},
&ss,
&st,
+22
View File
@@ -80,6 +80,11 @@ pub(crate) struct Target {
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a
/// magic packet before connecting to an offline host. Empty when none is known.
pub(crate) mac: Vec<String>,
/// A ONE-OFF settings profile for this connect ("Connect with"): `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 default changes only through
/// the picker in the host editor (design/client-settings-profiles.md §5.2).
pub(crate) profile: Option<String>,
}
/// Stable app services handed to the page components as props. Each routed screen that uses
@@ -243,6 +248,17 @@ 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);
// Bumped when a settings edit changes what the page should SHOW without changing any state
// it already reads — resetting an override, which rewrites the catalog behind the controls.
// Root state comparison makes same-value calls free, so a counter is what forces the pass.
let (settings_rev, set_settings_rev) = cx.use_async_state(0u64);
// 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 +505,12 @@ 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,
settings_rev,
&set_settings_rev,
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);
+508 -69
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,171 @@ 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");
}
}
/// Which tier-P rows the profile in scope overrides. Plain bools rather than a lookup so the
/// call sites read as `over.codec` — the row and its flag stay visibly paired.
#[derive(Default)]
struct OverrideFlags {
resolution: bool,
refresh_hz: bool,
render_scale: bool,
bitrate_kbps: bool,
codec: bool,
hdr_enabled: bool,
compositor: bool,
audio_channels: bool,
mic_enabled: bool,
touch_mode: bool,
mouse_mode: bool,
invert_scroll: bool,
inhibit_shortcuts: bool,
gamepad: bool,
stats_verbosity: bool,
fullscreen_on_stream: bool,
}
impl OverrideFlags {
fn of(profile: Option<&StreamProfile>) -> OverrideFlags {
let Some(o) = profile.map(|p| &p.overrides) else {
return OverrideFlags::default();
};
OverrideFlags {
// One control drives the width/height/match-window tri-state, so any of the three
// marks the row.
resolution: o.width.is_some() || o.height.is_some() || o.match_window.is_some(),
refresh_hz: o.refresh_hz.is_some(),
render_scale: o.render_scale.is_some(),
bitrate_kbps: o.bitrate_kbps.is_some(),
codec: o.codec.is_some(),
hdr_enabled: o.hdr_enabled.is_some(),
compositor: o.compositor.is_some(),
audio_channels: o.audio_channels.is_some(),
mic_enabled: o.mic_enabled.is_some(),
touch_mode: o.touch_mode.is_some(),
mouse_mode: o.mouse_mode.is_some(),
invert_scroll: o.invert_scroll.is_some(),
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
gamepad: o.gamepad.is_some(),
stats_verbosity: o.stats_verbosity.is_some(),
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
}
}
}
/// 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 +289,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));
})
}
@@ -162,6 +311,52 @@ fn setting_toggle(
/// but a caption under it reads as a caption, which is how every Windows Settings page and
/// the Apple client both do it. Width-capped for the same reason Apple caps at 360pt: a
/// full-width caption runs into the control column and the whole cell reads as one block.
/// [`described`], plus the override marker and reset a profile-scope row carries: the caption
/// says the profile changes this one, and the button is the only way back to inheriting —
/// overrides are recorded on touch and never inferred from a value comparison, so "not
/// overridden" needs an explicit act.
fn described_overridable(
rev: (u64, &AsyncSetState<u64>),
scope: &str,
field: &'static str,
overridden: bool,
control: impl Into<Element>,
caption: &str,
) -> Element {
if scope.is_empty() || !overridden {
return described(control, caption);
}
let (rev, set_rev) = (rev.0, rev.1.clone());
let scope = scope.to_string();
vstack((
control.into(),
hstack((
text_block(format!("\u{25cf} Overridden here \u{00b7} {caption}"))
.font_size(12.0)
.foreground(ThemeRef::AccentText)
.wrap()
.max_width(360.0)
.horizontal_alignment(HorizontalAlignment::Left),
button("Reset").on_click(move || {
let mut catalog = ProfilesFile::load();
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) {
p.overrides.clear(field);
if let Err(e) = catalog.save() {
tracing::warn!(error = %format!("{e:#}"), "clearing an override");
}
}
// The catalog changed behind the controls, and nothing the page reads as
// state did — bump the revision so the row re-renders showing the inherited
// value again.
set_rev.call(rev + 1);
}),
))
.spacing(8.0),
))
.spacing(5.0)
.into()
}
fn described(control: impl Into<Element>, caption: &str) -> Element {
vstack((
control.into(),
@@ -220,14 +415,41 @@ 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>>,
rev: u64,
set_rev: &AsyncSetState<u64>,
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();
// Which rows this profile overrides — the marker + reset each of them carries. In the
// defaults scope nothing is marked, and `described_overridable` degrades to `described`.
let over = OverrideFlags::of(active.as_ref());
let _ = rev; // read via the closures below; the value itself only forces the re-render
// 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 +475,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 +493,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 +507,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 +528,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 +542,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 +551,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 +567,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 +622,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 +650,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 +679,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,
@@ -463,14 +704,22 @@ pub(crate) fn settings_page(
let mut out = group(
Some("Resolution"),
vec![
described(
described_overridable(
(rev, set_rev),
scope,
"resolution",
over.resolution,
res_combo,
"The host drives a real virtual output at exactly this size \u{2014} true \
pixels, no scaling. \u{201C}Native display\u{201D} follows the monitor this \
window is on; \u{201C}Match window\u{201D} keeps the picture pixel-exact \
(1:1) through every resize.",
),
described(
described_overridable(
(rev, set_rev),
scope,
"refresh_hz",
over.refresh_hz,
hz_combo,
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
connect.",
@@ -481,24 +730,40 @@ pub(crate) fn settings_page(
out.extend(group(
Some("Quality"),
vec![
described(
described_overridable(
(rev, set_rev),
scope,
"render_scale",
over.render_scale,
scale_combo,
"Above native supersamples for sharpness; below renders lighter on the \
host and the link. This device resamples the result to the window.",
),
described(
described_overridable(
(rev, set_rev),
scope,
"bitrate_kbps",
over.bitrate_kbps,
bitrate_box,
"0 lets the host decide (its default, clamped to what it supports). A \
host card\u{2019}s context menu has a network speed test.",
),
described(
described_overridable(
(rev, set_rev),
scope,
"codec",
over.codec,
codec_combo,
"A preference \u{2014} the host falls back if it can\u{2019}t encode it. \
PyroWave is the low-latency wavelet codec for a WIRED link: it trades \
bitrate (hundreds of Mb/s) for near-zero decode time, so it wants \
gigabit Ethernet.",
),
described(
described_overridable(
(rev, set_rev),
scope,
"hdr_enabled",
over.hdr_enabled,
hdr_toggle,
"HDR10, when the host has HDR content and this display supports it. \
HEVC only; otherwise the stream stays SDR.",
@@ -506,9 +771,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 \
@@ -528,7 +796,11 @@ pub(crate) fn settings_page(
));
out.extend(group(
Some("Host output"),
vec![described(
vec![described_overridable(
(rev, set_rev),
scope,
"compositor",
over.compositor,
comp_combo,
"The backend the host uses for its virtual output (Linux hosts only). A \
specific choice falls back to auto-detection when that backend \
@@ -542,7 +814,11 @@ pub(crate) fn settings_page(
"input" => {
let mut out = group(
Some("Touch & pointer"),
vec![described(
vec![described_overridable(
(rev, set_rev),
scope,
"touch_mode",
over.touch_mode,
touch_combo,
"How a touchscreen drives the host: Trackpad moves the host cursor like a \
laptop trackpad (tap to click), Direct pointer jumps the cursor to wherever \
@@ -553,19 +829,31 @@ pub(crate) fn settings_page(
out.extend(group(
Some("Keyboard & mouse"),
vec![
described(
described_overridable(
(rev, set_rev),
scope,
"mouse_mode",
over.mouse_mode,
mouse_combo,
"Capture locks the pointer to the stream and sends relative motion — \
best for games. Desktop leaves the pointer free to enter and leave \
the stream and sends absolute positions best for remote desktop \
work. Ctrl+Alt+Shift+M switches live.",
),
described(
described_overridable(
(rev, set_rev),
scope,
"inhibit_shortcuts",
over.inhibit_shortcuts,
shortcuts_toggle,
"Alt+Tab, the Windows key and friends reach the host while the stream \
has input captured. Off, they act on this machine instead.",
),
described(
described_overridable(
(rev, set_rev),
scope,
"invert_scroll",
over.invert_scroll,
invert_scroll_toggle,
"Reverses the wheel and trackpad scroll direction sent to the host.",
),
@@ -578,21 +866,32 @@ 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_overridable(
(rev, set_rev),
scope,
"gamepad",
over.gamepad,
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."),
),
),
@@ -601,12 +900,20 @@ pub(crate) fn settings_page(
group(
None,
vec![
described(
described_overridable(
(rev, set_rev),
scope,
"audio_channels",
over.audio_channels,
channels_combo,
"The speaker layout requested from the host. It downmixes if its own \
output has fewer channels.",
),
described(
described_overridable(
(rev, set_rev),
scope,
"mic_enabled",
over.mic_enabled,
mic_toggle,
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
),
@@ -626,24 +933,36 @@ 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_overridable(
(rev, set_rev),
scope,
"fullscreen_on_stream",
over.fullscreen_on_stream,
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(
Some("Statistics"),
vec![described(
vec![described_overridable(
(rev, set_rev),
scope,
"stats_verbosity",
over.stats_verbosity,
hud_combo,
"Live session stats in a corner overlay \u{2014} Compact is a one-line pill, \
Detailed adds the latency stage breakdown. Ctrl+Alt+Shift+S cycles the \
@@ -651,13 +970,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 +1022,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 +1079,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 +1091,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 +1170,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(),
}
}
+48 -9
View File
@@ -5,6 +5,8 @@
use super::style::*;
use super::{Screen, Svc};
use crate::probe::run_speed_probe;
use crate::trust::KnownHosts;
use pf_client_core::profiles::ProfilesFile;
use windows_reactor::*;
/// Speed-test lifecycle. Held as ROOT state (the probe worker completes it via
@@ -122,17 +124,54 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element {
recommended_kbps,
} => {
let recommended_mbps = f64::from(*recommended_kbps) / 1000.0;
// A measured bitrate belongs in the layer the TESTED host actually reads it from
// (design/client-settings-profiles.md §5.3) — writing the global here is what made
// measuring one host re-tune every other one. Resolved the way a connect resolves
// it: the one-off this test was started with, else the host's binding.
let target = ctx.shared.target.lock().unwrap().clone();
let bound = KnownHosts::load()
.hosts
.iter()
.find(|h| h.addr == target.addr && h.port == target.port)
.and_then(|h| h.profile_id.clone());
let profile = match target.profile.as_deref() {
Some("") => None,
Some(id) => Some(id.to_string()),
None => bound,
}
.and_then(|reference| ProfilesFile::load().resolve(&reference).0.cloned());
let apply_btn = {
let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps);
button(format!("Use {recommended_mbps:.0} Mb/s"))
.accent()
.icon(Symbol::Accept)
.on_click(move || {
let mut s = ctx.settings.lock().unwrap();
s.bitrate_kbps = kbps;
s.save();
ss.call(Screen::Hosts);
})
let profile = profile.clone();
button(match &profile {
Some(p) => format!(
"Set {recommended_mbps:.0} Mb/s in \u{201c}{}\u{201d}",
p.name
),
None => format!("Use {recommended_mbps:.0} Mb/s"),
})
.accent()
.icon(Symbol::Accept)
.on_click(move || {
match &profile {
Some(p) => {
let mut catalog = ProfilesFile::load();
if let Some(slot) = catalog.profiles.iter_mut().find(|x| x.id == p.id) {
slot.overrides.bitrate_kbps = Some(kbps);
if let Err(e) = catalog.save() {
tracing::warn!(error = %format!("{e:#}"),
"saving the measured bitrate");
}
}
}
None => {
let mut s = ctx.settings.lock().unwrap();
s.bitrate_kbps = kbps;
s.save();
}
}
ss.call(Screen::Hosts);
})
};
let results = card(
vstack((
+6
View File
@@ -101,6 +101,7 @@ pub(crate) fn spawn_session(
connect_timeout_secs: u64,
fullscreen: bool,
launch: Option<&str>,
profile: Option<&str>,
slot: SessionChild,
on_event: impl FnMut(SpawnEvent) + Send + 'static,
) -> Result<(), String> {
@@ -117,6 +118,11 @@ pub(crate) fn spawn_session(
if let Some(id) = launch {
cmd.arg("--launch").arg(id);
}
// Only a ONE-OFF pick rides the flag: without it the session resolves the host's own
// binding through the same helper this shell would have used, so the two can't disagree.
if let Some(reference) = profile {
cmd.arg("--profile").arg(reference);
}
add_window_pos(&mut cmd);
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
}
+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,
+16 -11
View File
@@ -387,11 +387,20 @@ pub(crate) struct HdrP010Converter {
cbuf: ID3D11Buffer,
}
// The three converters' methods below are SAFE fns. They were `unsafe fn` because their bodies are
// D3D11 FFI, which is not the same thing as having a caller contract: every parameter is a borrowed
// windows-rs COM wrapper (`&ID3D11Device`, `&ID3D11DeviceContext`, `&ID3D11Texture2D`, the views) or
// a plain `u32`/`bool`, each body builds its own descriptors from those, and every created interface
// owns its reference. There is nothing a caller can pass that makes them unsound, and the proofs the
// markers carried said exactly that — "`?`-checked D3D11 methods on the live `device` borrow" — which
// is a description of the body, not an obligation. `unsafe` now marks the FFI inside them, where the
// blocks and their proofs already were. `compile_shader` KEEPS its marker: it takes `PCSTR`, a raw
// pointer the caller must guarantee is a NUL-terminated literal.
impl HdrP010Converter {
/// `w`/`h` are the SOURCE dimensions this converter's chroma pass will sample, baked into the
/// immutable constant buffer. Rebuild the converter if they change (the IDD capturer's
/// `recreate_ring` already drops it).
pub(crate) unsafe fn new(device: &ID3D11Device, w: u32, h: u32) -> Result<Self> {
pub(crate) fn new(device: &ID3D11Device, w: u32, h: u32) -> Result<Self> {
// SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over
// fully-initialized stack descriptors and live `Option` out-params; `compile_shader` receives
// `s!()` literals (its contract). Each created COM interface owns its own reference, and no
@@ -458,7 +467,7 @@ impl HdrP010Converter {
/// Called ONCE PER OUT-RING SLOT by the owner of the P010 textures, not per frame — see
/// [`Self::convert`]. Fails when the driver rejects a planar RTV, which is the one hard
/// requirement of this whole path (a D3D11.3+ runtime plus driver support).
pub(crate) unsafe fn plane_rtv(
pub(crate) fn plane_rtv(
device: &ID3D11Device,
dst: &ID3D11Texture2D,
format: DXGI_FORMAT,
@@ -498,7 +507,7 @@ impl HdrP010Converter {
/// ring slot's keyed-mutex hold, i.e. time the DRIVER spent blocked on that slot. Both are
/// lifetime-of-mode facts: the views belong to the out-ring slot (built in `ensure_out_ring`),
/// the buffer to this converter, which is already rebuilt on every mode change.
pub(crate) unsafe fn convert(
pub(crate) fn convert(
&self,
ctx: &ID3D11DeviceContext,
src_srv: &ID3D11ShaderResourceView,
@@ -693,7 +702,7 @@ pub(crate) struct BgraToYuvPlanes {
}
impl BgraToYuvPlanes {
pub(crate) unsafe fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
pub(crate) fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
// SAFETY: as `HdrP010Converter::new` — `?`-checked D3D11 shader creation on the live
// `device` borrow, with `s!()` literals into `compile_shader` and live out-params.
unsafe {
@@ -731,7 +740,7 @@ impl BgraToYuvPlanes {
/// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque
/// passes; `w`/`h` are the full luma dims (even for 4:2:0).
#[allow(clippy::too_many_arguments)]
pub(crate) unsafe fn convert(
pub(crate) fn convert(
&self,
ctx: &ID3D11DeviceContext,
src_srv: &ID3D11ShaderResourceView,
@@ -828,7 +837,7 @@ impl VideoConverter {
/// linear (the HDR ring, used by a PyroWave session that tone-maps the HDR desktop down to the
/// 8-bit wavelet stream). The output is always studio-range BT.709 NV12 — the P010/BT.2020 HDR
/// path is [`HdrP010Converter`]'s job, never this one.
pub(crate) unsafe fn new(
pub(crate) fn new(
device: &ID3D11Device,
context: &ID3D11DeviceContext,
width: u32,
@@ -897,11 +906,7 @@ impl VideoConverter {
/// Convert `input` (BGRA, or scRGB FP16 for a converter built with `scrgb_input`) → `output`
/// (NV12, BT.709 studio-range — see the type doc: never P010) on the video engine. Views are
/// created per call (cheap relative to the Blt) so the input texture can vary frame to frame.
pub(crate) unsafe fn convert(
&self,
input: &ID3D11Texture2D,
output: &ID3D11Texture2D,
) -> Result<()> {
pub(crate) fn convert(&self, input: &ID3D11Texture2D, output: &ID3D11Texture2D) -> Result<()> {
// SAFETY: both view creations are `?`-checked calls on `self.vdev` with fully-initialized
// stack descriptors and live out-params. `stream.pInputSurface` is a `ManuallyDrop` of the
// input view just created: `VideoProcessorBlt` only BORROWS it (a COM in-param never transfers
+47 -39
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`.
@@ -627,11 +648,7 @@ impl IddPushCapturer {
// nothing downstream could detect the mismatch, because every field it would compare against
// had already been moved. Nothing below this line fails.
let fmt = Self::ring_format_for(new_display_hdr);
// SAFETY: `create_ring_slots` is an `unsafe fn` (it makes D3D11/DXGI COM calls); we pass a live
// borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain
// `u32`/`DXGI_FORMAT` values, and `?` propagates any failure before the slots are used. Every
// returned slot's texture + keyed mutex belongs to that same `self.device`.
let new_slots = unsafe { Self::create_ring_slots(&self.device, new_w, new_h, fmt)? };
let new_slots = Self::create_ring_slots(&self.device, new_w, new_h, fmt)?;
self.display_hdr = new_display_hdr;
self.width = new_w;
self.height = new_h;
@@ -770,13 +787,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 {
@@ -953,11 +965,11 @@ impl IddPushCapturer {
/// mid-session mode swap.
fn ensure_pyro_conv(&mut self) -> Result<()> {
if self.pyro_conv.is_none() {
// SAFETY: `BgraToYuvPlanes::new` compiles D3D11 shaders on `self.device`; `?` propagates
// failure before it is stored.
self.pyro_conv = Some(unsafe {
BgraToYuvPlanes::new(&self.device, self.display_hdr, self.want_444)?
});
self.pyro_conv = Some(BgraToYuvPlanes::new(
&self.device,
self.display_hdr,
self.want_444,
)?);
}
Ok(())
}
@@ -968,22 +980,22 @@ impl IddPushCapturer {
fn ensure_converter(&mut self) -> Result<()> {
if self.display_hdr {
if self.hdr_p010_conv.is_none() {
// SAFETY: `HdrP010Converter::new` is `unsafe` (it compiles D3D11 shaders + creates
// resources); we pass a live borrow of `self.device`, the device the converter's resources
// belong to, and `?` propagates any failure before the converter is stored.
self.hdr_p010_conv =
Some(unsafe { HdrP010Converter::new(&self.device, self.width, self.height)? });
self.hdr_p010_conv = Some(HdrP010Converter::new(
&self.device,
self.width,
self.height,
)?);
}
} else if self.want_444 {
// Full-chroma passthrough — no conversion resources to build.
} else if self.video_conv.is_none() {
// SAFETY: `VideoConverter::new` is `unsafe` (it sets up the D3D11 VIDEO processor); we pass live
// borrows of `self.device` + its immediate `self.context` (single-threaded, this thread) plus
// plain `u32` dimensions, and `?` propagates any failure before it is stored. The converter's
// resources belong to that same device/context.
self.video_conv = Some(unsafe {
VideoConverter::new(&self.device, &self.context, self.width, self.height, false)?
});
self.video_conv = Some(VideoConverter::new(
&self.device,
&self.context,
self.width,
self.height,
false,
)?);
}
Ok(())
}
@@ -1108,9 +1120,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,
@@ -1809,9 +1819,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,
@@ -56,7 +56,7 @@ pub(super) struct CursorBlendPass {
}
impl CursorBlendPass {
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
pub(super) fn new(device: &ID3D11Device) -> Result<Self> {
// SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over
// fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
// literals (its contract).
@@ -116,11 +116,7 @@ impl CursorBlendPass {
}
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
unsafe fn ensure_shape(
&mut self,
device: &ID3D11Device,
ov: &pf_frame::CursorOverlay,
) -> Result<()> {
fn ensure_shape(&mut self, device: &ID3D11Device, ov: &pf_frame::CursorOverlay) -> Result<()> {
// SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live
// `device` borrow. `init.pSysMem` points into `ov.rgba`, which the length check above proves
// holds at least `ov.w * ov.h * 4` bytes for the declared `SysMemPitch = ov.w * 4`, and which
@@ -170,7 +166,7 @@ impl CursorBlendPass {
/// composition) — linearize and scale to the target's SDR white. The quad is placed purely
/// via the viewport (the fullscreen-triangle VS fills whatever viewport is set), clipped by
/// the target automatically.
pub(super) unsafe fn blend(
pub(super) fn blend(
&mut self,
device: &ID3D11Device,
ctx: &ID3D11DeviceContext,
@@ -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))
@@ -85,7 +85,7 @@ impl IddPushCapturer {
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an
/// UNNAMED NT handle (nothing to open by name — the sealed channel); the driver reaches it only via
/// the duplicate the [`ChannelBroker`] sends after the ring is published.
pub(super) unsafe fn create_ring_slots(
pub(super) fn create_ring_slots(
device: &ID3D11Device,
w: u32,
h: u32,
@@ -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,
@@ -137,9 +137,10 @@ impl Capturer for SyntheticNv12Capturer {
/// Resolve the same render adapter the encoder will pick (`PUNKTFUNK_RENDER_ADAPTER` / preference /
/// max-VRAM LUID), falling back to adapter 0.
///
/// # Safety
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
/// Safe: it takes no arguments, so there is nothing a caller could get wrong — it creates the
/// factory itself and returns an owned adapter. The `# Safety` section it used to carry ("calls
/// DXGI enumeration; returns owned COM objects") described the body, which is not a contract.
fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
// SAFETY: three `?`/`Ok`-checked DXGI enumeration calls over owned locals — the factory is
// created here and the adapters it returns own their own COM references. No raw pointers.
unsafe {
@@ -155,9 +156,10 @@ unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
///
/// # Safety
/// `device` must be a live D3D11 device; the returned texture is owned by the caller.
unsafe fn create_nv12(
/// Safe: its old `# Safety` asked for "a live D3D11 device", which `&ID3D11Device` — a borrowed,
/// reference-counted COM wrapper — already guarantees; the rest ("the returned texture is owned by
/// the caller") is an ownership note, not a soundness obligation. Every flag is a plain scalar.
fn create_nv12(
device: &ID3D11Device,
width: u32,
height: u32,
@@ -181,8 +183,8 @@ unsafe fn create_nv12(
..Default::default()
};
let mut tex: Option<ID3D11Texture2D> = None;
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow (per the contract
// above), with a fully-initialized stack descriptor and a live `Option` out-param.
// SAFETY: one `?`-checked `CreateTexture2D` on the `&ID3D11Device` borrow, which the borrow
// itself keeps live, with a fully-initialized stack descriptor and a live `Option` out-param.
unsafe {
device
.CreateTexture2D(&desc, None, Some(&mut tex))
+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
+10
View File
@@ -260,6 +260,16 @@ fn handle_event(client: &NativeClient, state: &mut State, ev: ClipEventCore) {
}
}
/// Put plain text on this machine's clipboard — the "Copy link" affordance, which has nothing
/// to do with the streaming bridge above but wants the same OS plumbing. Best-effort: a
/// clipboard another process is holding open is a transient nuisance, never worth failing a
/// user action over. A no-op where this build has no OS clipboard.
pub fn set_text(text: &str) {
if let Err(e) = os::set(MIME_TEXT, text.as_bytes()) {
tracing::warn!(error = %format!("{e:#}"), "copying to the clipboard");
}
}
#[cfg(windows)]
mod os {
//! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and
+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);
}
}
+23 -18
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 {
@@ -605,7 +610,7 @@ impl D3d11vaDecoder {
/// BGRA8) under its keyed mutex and describe the hand-off. The mutex acquire also
/// back-pressures against the presenter still reading this slot (only possible if the
/// stream runs `RING_SLOTS` ahead of present).
unsafe fn lift(&mut self) -> Result<D3d11Frame> {
fn lift(&mut self) -> Result<D3d11Frame> {
use ffmpeg::ffi;
unsafe {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
@@ -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;
+16 -8
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 {
@@ -131,7 +139,7 @@ impl VaapiDecoder {
/// single-plane texture with the chroma dropped, painting the screen green. The fix:
/// derive the COMBINED fourcc from the decoder's software pixel format (NV12 →
/// `DRM_FORMAT_NV12`) and flatten every plane across every layer in order (Y then UV).
unsafe fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
use ffmpeg::ffi;
unsafe {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
@@ -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.
}
}
}
+52 -19
View File
@@ -6,7 +6,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 +19,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
@@ -53,25 +61,45 @@ struct VkCtxStorage {
/// [`VkCtxStorage`], which outlives the context). Replaces FFmpeg's internal default,
/// which only serializes FFmpeg against itself — the presenter submits to the same
/// graphics queue from another thread and holds this same lock around its calls.
///
/// # Safety
/// FFmpeg calls this with the `AVHWDeviceContext` it owns, whose `user_opaque` we set to a
/// `*const QueueLock` before handing the context over.
unsafe extern "C" fn ffvk_lock_queue(
ctx: *mut pf_ffvk::AVHWDeviceContext,
_queue_family: u32,
_index: u32,
) {
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
let lock = (*dev).user_opaque as *const QueueLock;
(*lock).lock();
// SAFETY: `ctx` is the live context FFmpeg passes to its own callback, and the two
// `AVHWDeviceContext` declarations (pf_ffvk's and ffmpeg-sys's) describe the same C struct, so
// the cast reads the same `user_opaque` field. That field holds the pointer we stored, which
// borrows `VkCtxStorage::_queue_lock` — an `Arc<QueueLock>` the storage keeps alive for as long
// as the hw device context can fire this trampoline (see its field doc), so the lock outlives
// every call FFmpeg can make.
unsafe {
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
let lock = (*dev).user_opaque as *const QueueLock;
(*lock).lock();
}
}
/// The matching `unlock_queue` trampoline — see [`ffvk_lock_queue`].
///
/// # Safety
/// As [`ffvk_lock_queue`]; additionally, FFmpeg only calls this after a matching `lock_queue`, so
/// the lock it releases is one this pair took.
unsafe extern "C" fn ffvk_unlock_queue(
ctx: *mut pf_ffvk::AVHWDeviceContext,
_queue_family: u32,
_index: u32,
) {
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
let lock = (*dev).user_opaque as *const QueueLock;
(*lock).unlock();
// SAFETY: as `ffvk_lock_queue` — same live context from FFmpeg, same `user_opaque` pointer into
// the `Arc<QueueLock>` that `VkCtxStorage` keeps alive for the context's whole lifetime.
unsafe {
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
let lock = (*dev).user_opaque as *const QueueLock;
(*lock).unlock();
}
}
impl VulkanDecoder {
@@ -187,6 +215,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 +232,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 +252,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 {
@@ -292,7 +320,7 @@ impl VulkanDecoder {
/// guard — keeps the image + frames context alive through present) and ship the
/// POINTERS; the presenter reads the live sync state under the frames-context lock
/// at its own submit time.
unsafe fn extract(&mut self) -> Result<VkVideoFrame> {
fn extract(&mut self) -> Result<VkVideoFrame> {
use ffmpeg::ffi;
unsafe {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VULKAN as i32 {
@@ -358,7 +386,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 +424,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.
+114 -56
View File
@@ -22,7 +22,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 +61,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 +76,61 @@ 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;
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);
if r < 0 {
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwdevice_ctx_init failed ({r})");
}
// 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 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;
(*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);
if r < 0 {
ffi::av_buffer_unref(&mut frames_ref);
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwframe_ctx_init failed ({r})");
}
// SAFETY: `av_hwdevice_ctx_alloc` returns either null — which `AvBuffer::from_raw` rejects,
// so the `?` returns before anything below runs — or a fresh ref whose `data` libav has
// already initialized as an `AVHWDeviceContext`. For a CUDA device that context's `hwctx`
// is an `AVCUDADeviceContext` (our repr(C) mirror of libav's layout), so writing
// `cuda_ctx` is an in-bounds field store on a live allocation, and `cu_ctx` is a valid
// `CUcontext` by this fn's contract. `av_hwdevice_ctx_init` then takes the same live ref;
// it must see `cuda_ctx` already set, which is why the store precedes it.
let device_ref = unsafe {
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.as_ptr());
if r < 0 {
bail!("av_hwdevice_ctx_init failed ({r})");
}
device_ref
};
// SAFETY: the same shape one level up — `av_hwframe_ctx_alloc` is handed the live,
// now-initialized device ref and returns null (rejected by `from_raw`, so the `?` leaves
// before the writes) or a ref whose `data` is a live `AVHWFramesContext`. Every store below
// is an in-bounds field write on that allocation, all plain scalars, done before
// `av_hwframe_ctx_init` reads them.
let frames_ref = unsafe {
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.as_ptr());
if r < 0 {
bail!("av_hwframe_ctx_init failed ({r})");
}
frames_ref
};
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 +353,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 +392,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 +429,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 +855,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 +1005,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 +1064,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:
@@ -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::{
@@ -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.
+193 -163
View File
@@ -36,7 +36,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
@@ -156,7 +157,7 @@ struct Vui {
}
/// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010 the
/// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020 on the zero-copy
/// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020nc on the zero-copy
/// path); the client decoder auto-detects PQ from the VUI. SDR: we hand the encoder BT.709
/// *limited* NV12 (swscale CSC on the CPU path; `scale_vaapi` pinned to
/// `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range RGB input
@@ -203,7 +204,7 @@ fn async_depth(raw: Option<&str>) -> u32 {
/// matrix untouched.)
fn scale_vaapi_args(ten_bit: bool) -> &'static CStr {
if ten_bit {
c"format=p010:out_color_matrix=bt2020:out_range=limited"
c"format=p010:out_color_matrix=bt2020nc:out_range=limited"
} else {
c"format=nv12:out_color_matrix=bt709:out_range=limited"
}
@@ -267,17 +268,23 @@ unsafe fn open_vaapi_encoder(
let modes: &[bool] = entrypoint_ladder(low_power_override(), cached);
let mut first_err = None;
for &lp in modes {
match open_vaapi_encoder_mode(
codec,
width,
height,
fps,
bitrate_bps,
device_ref,
frames_ref,
ten_bit,
lp,
) {
// SAFETY: `device_ref`/`frames_ref` are forwarded unchanged from this fn's own contract —
// valid `AVBufferRef`s — and the callee only `av_buffer_ref`s them, so retrying another
// entrypoint below reuses the same still-owned buffers rather than consuming them.
let attempt = unsafe {
open_vaapi_encoder_mode(
codec,
width,
height,
fps,
bitrate_bps,
device_ref,
frames_ref,
ten_bit,
lp,
)
};
match attempt {
Ok(enc) => {
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
m.insert(key.clone(), latched_mode(lp));
@@ -335,16 +342,24 @@ unsafe fn open_vaapi_encoder_mode(
video.set_format(if ten_bit { Pixel::P010LE } else { Pixel::NV12 });
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
apply_low_latency_rc(&mut video, fps, bitrate_bps);
let raw = video.as_mut_ptr();
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
let vui = vui_for(ten_bit);
(*raw).colorspace = vui.colorspace;
(*raw).color_range = vui.range;
(*raw).color_primaries = vui.primaries;
(*raw).color_trc = vui.trc;
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
// SAFETY: `as_mut_ptr` hands back the `AVCodecContext` behind the `video` encoder allocated
// just above, which outlives every write here (it is moved into the return value). The
// colour/gop/pix_fmt stores are in-bounds scalar field writes on that live context. The two
// `av_buffer_ref` calls take `device_ref`/`frames_ref`, valid `AVBufferRef`s by this fn's
// contract, and each returns a NEW reference that the codec context adopts and unrefs when it
// is freed — so this shares the caller's buffers rather than taking them over.
unsafe {
let raw = video.as_mut_ptr();
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
let vui = vui_for(ten_bit);
(*raw).colorspace = vui.colorspace;
(*raw).color_range = vui.range;
(*raw).color_primaries = vui.primaries;
(*raw).color_trc = vui.trc;
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
}
let mut opts = Dictionary::new();
if let Some(profile) = explicit_profile(codec, ten_bit) {
@@ -396,8 +411,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 +450,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,62 +478,70 @@ 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 {
unsafe fn new(sw_format: ffi::AVPixelFormat, w: u32, h: u32, pool: c_int) -> Result<Self> {
/// Safe: unlike its CUDA twin ([`super::CudaHw::new`], which is handed a `CUcontext` the caller
/// must vouch for) this takes only scalars and opens the device itself, so there is no caller
/// contract — the `unsafe` below is the libav FFI, not an obligation.
fn new(sw_format: ffi::AVPixelFormat, w: u32, h: u32, pool: c_int) -> Result<Self> {
let mut device_ref: *mut ffi::AVBufferRef = ptr::null_mut();
let node = render_node();
let r = ffi::av_hwdevice_ctx_create(
&mut device_ref,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
node.as_ptr(),
ptr::null_mut(),
0,
);
// SAFETY: `device_ref` is a live local out-param; `node` is a NUL-terminated `CString` that
// outlives the call, and the remaining arguments are the documented "no options" pair. On
// success libav writes ONE owned reference into `device_ref`.
let r = unsafe {
ffi::av_hwdevice_ctx_create(
&mut device_ref,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
node.as_ptr(),
ptr::null_mut(),
0,
)
};
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;
(*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);
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})");
}
// `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.
// SAFETY: `r >= 0`, so `device_ref` is that owned reference and this is its only owner.
let device_ref = unsafe { AvBuffer::from_raw(device_ref) }
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
// SAFETY: the same shape as `CudaHw::new` — `av_hwframe_ctx_alloc` is handed the live device
// ref and returns null (rejected by `from_raw`, so the `?` leaves before the writes below)
// or a ref whose `data` libav has already initialized as an `AVHWFramesContext`. Every store
// is then an in-bounds scalar write on that live allocation, done before
// `av_hwframe_ctx_init` reads them.
let frames_ref = unsafe {
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.as_ptr());
if r < 0 {
bail!("av_hwframe_ctx_init(VAAPI) failed ({r})");
}
frames_ref
};
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,
@@ -549,12 +572,11 @@ impl CpuInner {
ffi::AVPixelFormat::AV_PIX_FMT_NV12
};
const POOL: c_int = 16;
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the
// only path here is `VaapiEncoder::open` → `ensure_inner` → `CpuInner::open`, and `open` ran
// `ffmpeg::init()`. The args are valid: an NV12/P010 sw_format, the validated positive
// `width`/`height`, pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s
// on drop.
let hw = unsafe { VaapiHw::new(staging_av, width, height, POOL)? };
// `VaapiHw::new` is safe now; it returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s
// on drop. (libav is initialized on every path here `VaapiEncoder::open` → `ensure_inner`
// → `CpuInner::open`, and `open` ran `ffmpeg::init()` — which the call relies on but cannot
// itself be broken by a caller, so it is a note rather than a contract.)
let hw = VaapiHw::new(staging_av, width, height, POOL)?;
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
// non-null (`VaapiHw::new` guarantees it) and from the `hw` just built above, which is a live
// local that outlives this synchronous call. The fn `av_buffer_ref`s them into the encoder, so
@@ -567,8 +589,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 +720,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 +768,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 +801,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 +871,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 +943,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 +966,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 +989,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 +1005,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 +1090,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 +1170,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 +1389,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.
@@ -1535,7 +1565,7 @@ mod tests {
let args = scale_vaapi_args(true).to_str().unwrap();
for needle in [
"format=p010",
"out_color_matrix=bt2020",
"out_color_matrix=bt2020nc",
"out_range=limited",
] {
assert!(
+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;
+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)]
+413 -186
View File
@@ -50,10 +50,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 +61,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
@@ -330,29 +330,40 @@ unsafe fn open_win_encoder(
video.set_format(Pixel::from(sw_pix_fmt));
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
apply_low_latency_rc(&mut video, fps, bitrate_bps);
let raw = video.as_mut_ptr();
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
if ten_bit {
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from
// the HEVC VUI; the static mastering metadata also rides the 0xCE datagram out-of-band.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
} else {
// We hand the encoder BT.709 *limited* NV12 (video-processor or swscale CSC), so signal that
// VUI — else the client decoder washes the picture out.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
}
(*raw).pix_fmt = pix_fmt;
if !device_ref.is_null() {
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
}
if !frames_ref.is_null() {
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
// SAFETY: `as_mut_ptr` hands back the `AVCodecContext` behind the `video` encoder allocated just
// above, which outlives every write here (it is opened and returned below). The gop/colour/
// pix_fmt stores are in-bounds scalar field writes on that live context. `device_ref` and
// `frames_ref` are valid `AVBufferRef`s by this fn's contract OR null — the system path passes
// null for both — and the `is_null` guards keep `av_buffer_ref` off the null case; each call
// returns a NEW reference that the codec context adopts and unrefs when freed, so this shares
// the caller's buffers rather than taking them over.
let raw = unsafe { video.as_mut_ptr() };
// SAFETY: as above — `raw` is that live `AVCodecContext` and every store below is an in-bounds
// field write on it, with the two `av_buffer_ref` calls guarded against null.
unsafe {
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
if ten_bit {
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from
// the HEVC VUI; the static mastering metadata also rides the 0xCE datagram out-of-band.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
} else {
// We hand the encoder BT.709 *limited* NV12 (video-processor or swscale CSC), so signal that
// VUI — else the client decoder washes the picture out.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
}
(*raw).pix_fmt = pix_fmt;
if !device_ref.is_null() {
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
}
if !frames_ref.is_null() {
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
}
}
// Low-latency tuning — the per-vendor contract lives in `vendor_opts` (pure, test-pinned).
@@ -427,12 +438,19 @@ pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
}
/// The immediate context of an `ID3D11Device` (for `CopyResource`/`CopySubresourceRegion`).
unsafe fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
///
/// Safe: `&ID3D11Device` is a borrowed, reference-counted COM wrapper, so the borrow itself is the
/// "live device" guarantee, and the returned context owns its own reference.
fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
// windows-rs 0.62: the inherent method takes no args and returns the context (the OutRef form is
// only on the `_Impl` trait, for implementing the interface). Every D3D11 device has one.
device
.GetImmediateContext()
.expect("ID3D11Device always has an immediate context")
// SAFETY: a `?`-free COM call on the live `device` borrow; it takes no pointers and every
// D3D11 device has an immediate context, so the `expect` is unreachable in practice.
unsafe {
device
.GetImmediateContext()
.expect("ID3D11Device always has an immediate context")
}
}
// ---------------------------------------------------------------------------------------------
@@ -531,11 +549,10 @@ impl SystemInner {
}
/// Lazily (re)build the staging texture matching `dxgi_fmt` on the captured device.
unsafe fn ensure_staging(
&mut self,
device: &ID3D11Device,
dxgi_fmt: DXGI_FORMAT,
) -> Result<()> {
///
/// Safe: `&ID3D11Device` is the live-device guarantee and `dxgi_fmt` is a plain enum; the
/// texture it creates is owned by `self`.
fn ensure_staging(&mut self, device: &ID3D11Device, dxgi_fmt: DXGI_FORMAT) -> Result<()> {
if self.staging.is_some() {
return Ok(());
}
@@ -555,25 +572,38 @@ impl SystemInner {
MiscFlags: 0,
};
let mut t: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, None, Some(&mut t))
.context("CreateTexture2D(staging readback)")?;
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow, over a
// fully-initialized stack descriptor and a live `Option` out-param.
unsafe {
device
.CreateTexture2D(&desc, None, Some(&mut t))
.context("CreateTexture2D(staging readback)")?;
}
self.staging = t;
self.ctx = Some(immediate_context(device));
Ok(())
}
/// Send the reusable `sw_frame` to the encoder with the given pts / IDR flag.
unsafe fn send(&mut self, pts: i64, idr: bool) -> Result<()> {
(*self.sw_frame).pts = pts;
(*self.sw_frame).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame);
if r < 0 {
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
///
/// Safe: both arguments are scalars, and `sw_frame`/`enc` are allocations `self` owns from its
/// constructor until `Drop` — no caller supplies or can invalidate them.
fn send(&mut self, pts: i64, idr: bool) -> Result<()> {
// SAFETY: `self.sw_frame` is the `AVFrame` this struct allocated and owns, so the two field
// stores are in-bounds writes on a live allocation; `avcodec_send_frame` then takes that
// frame and `self.enc`'s own context, both live for the call and neither retained by libav
// (it references the frame's buffers itself).
unsafe {
(*self.sw_frame).pts = pts;
(*self.sw_frame).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame);
if r < 0 {
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
}
}
Ok(())
}
@@ -800,7 +830,10 @@ impl SystemInner {
/// Lazily build the swscale context (src → NV12/P010, limited range, the given colorspace). A
/// SystemInner uses exactly one src→dst conversion for its lifetime (8-bit RGB→NV12 BT.709, or
/// 10-bit RGB10→P010 BT.2020), so caching a single context is sound.
unsafe fn ensure_sws(
///
/// Safe: every argument is a plain libav enum/int, and the context it caches belongs to `self`
/// (freed once in `Drop`).
fn ensure_sws(
&mut self,
src_av: ffi::AVPixelFormat,
dst_av: ffi::AVPixelFormat,
@@ -809,25 +842,33 @@ impl SystemInner {
if !self.sws.is_null() {
return Ok(());
}
let sws = ffi::sws_getContext(
self.width as c_int,
self.height as c_int,
src_av,
self.width as c_int,
self.height as c_int,
dst_av,
SWS_POINT,
ptr::null_mut(),
ptr::null_mut(),
ptr::null(),
);
if sws.is_null() {
bail!("sws_getContext(RGB→YUV) failed");
}
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI we
// signal). For RGB input the src coefficient table is unused; pass the dst table for both.
let coeff = ffi::sws_getCoefficients(cs);
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
// SAFETY: `sws_getContext` takes only scalars plus the documented "no filters, no params"
// null trio, and returns an owned context or null — which is checked before use, so
// `sws_setColorspaceDetails` and the store below only ever see a live one.
// `sws_getCoefficients` returns a pointer into libav's own static tables, valid for the
// process, and the call only reads it.
let sws = unsafe {
let sws = ffi::sws_getContext(
self.width as c_int,
self.height as c_int,
src_av,
self.width as c_int,
self.height as c_int,
dst_av,
SWS_POINT,
ptr::null_mut(),
ptr::null_mut(),
ptr::null(),
);
if sws.is_null() {
bail!("sws_getContext(RGB→YUV) failed");
}
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI
// we signal). For RGB input the src coefficient table is unused; pass dst for both.
let coeff = ffi::sws_getCoefficients(cs);
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
sws
};
self.sws = sws;
Ok(())
}
@@ -857,13 +898,19 @@ 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 {
/// Wrap the capturer's `ID3D11Device` as a D3D11VA hwdevice and build an NV12/P010 frames pool.
unsafe fn new(
/// Safe: like [`super::super::linux::VaapiHw::new`] and unlike its CUDA counterpart, this is
/// handed no raw pointer — `&ID3D11Device` is a borrowed, reference-counted COM wrapper and the
/// rest are scalars — so there is no caller contract; the `unsafe` below is the libav/D3D11 FFI.
fn new(
device: &ID3D11Device,
sw_format: ffi::AVPixelFormat,
bind_flags: u32,
@@ -871,73 +918,131 @@ 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");
// 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.
// SAFETY: `av_hwdevice_ctx_alloc` returns null — rejected by `AvBuffer::from_raw`, so the
// `?` leaves before anything below runs — or a ref whose `data` libav has already
// initialized as an `AVHWDeviceContext`; for a D3D11VA device that context's `hwctx` is an
// `AVD3D11VADeviceContext`, so `d11` addresses a live, correctly-typed struct.
let (device_ref, d11) = unsafe {
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;
(device_ref, d11)
};
// 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) => {
// SAFETY: a COM call on the live `ID3D11Multithread` just obtained by a checked
// `cast` of the borrowed device; it takes a BOOL and returns the previous state.
let was = unsafe { 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"
),
}
let dev_ctx = (*device_ref).data as *mut ffi::AVHWDeviceContext;
let d11 = (*dev_ctx).hwctx as *mut AVD3D11VADeviceContext;
// 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);
// SAFETY: `d11` is the live `AVD3D11VADeviceContext` from above, so storing the device
// pointer is an in-bounds field write; the `forget(clone())` on the line above is what
// makes that pointer an OWNED reference, matching the Release libav does at teardown.
// `av_hwdevice_ctx_init` then reads that field, which is why the store precedes it.
let r = unsafe {
(*d11).device = device.as_raw();
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;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_D3D11;
(*fc).sw_format = sw_format;
(*fc).width = w as c_int;
(*fc).height = h as c_int;
(*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);
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})");
}
// SAFETY: same shape one level up — `av_hwframe_ctx_alloc` takes the live, now-initialized
// device ref and returns null (rejected by `from_raw`, so the `?` leaves before the writes)
// or a ref whose `data` is a live `AVHWFramesContext` whose `hwctx` is an
// `AVD3D11VAFramesContext`. Every store is an in-bounds field write on those, all done
// before `av_hwframe_ctx_init` reads them.
let frames_ref = unsafe {
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;
(*fc).height = h as c_int;
(*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.as_ptr());
if r < 0 {
bail!("av_hwframe_ctx_init(D3D11VA) failed ({r})");
}
frames_ref
};
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 +1076,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 +1104,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 +1154,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 +1163,10 @@ impl ZeroCopyInner {
);
Ok(ZeroCopyInner {
vendor,
qsv_frames,
qsv_device,
enc,
hw,
qsv_device,
qsv_frames,
ctx: immediate_context(device),
pool_format,
})
@@ -1089,7 +1193,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 +1225,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 +1266,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 +1548,133 @@ 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.
///
/// Safe: `prefer_vendor` is a plain id and every DXGI object is created here and owned by the
/// returned device; the old `# Safety` section described the body, not a caller obligation.
#[cfg(test)]
fn test_hw_device(prefer_vendor: u32) -> Option<ID3D11Device> {
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
// SAFETY: DXGI factory/adapter enumeration over owned locals — the factory is created here,
// each adapter it yields owns its own COM reference, and every call is `.ok()`-checked
// before use. `GetDesc1` fills a fully-initialized stack descriptor.
let (factory, mut preferred, mut fallback): (IDXGIFactory1, _, _) =
(unsafe { CreateDXGIFactory1() }.ok()?, None, None);
for i in 0.. {
// SAFETY: a COM call on the live `factory` created above; it takes an index and
// yields an owned adapter, and the `Ok` binding is what proves one came back.
let Ok(adapter) = (unsafe { factory.EnumAdapters1(i) }) else {
break; // DXGI_ERROR_NOT_FOUND — end of the list
};
// SAFETY: a COM call on the adapter just enumerated, filling a fully-initialized
// stack descriptor it returns by value.
let Ok(desc) = (unsafe { 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)?;
// SAFETY: `make_device` requires a live `IDXGIAdapter1`; `adapter` is one of the adapters
// enumerated above, still owned here and borrowed only for this synchronous call.
unsafe { 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() {
let device = test_hw_device(0x8086).expect("a hardware D3D11 adapter");
for i in 0..8 {
// `D3d11Hw::new` is safe now; it still needs libav initialised, which the ffmpeg-next
// crate does statically on first use here. 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 = 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() {
let device = 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"
);
}
}
@@ -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;
+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
+117
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:
@@ -131,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
@@ -146,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 {
@@ -159,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(),
@@ -201,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,
}
}
}
@@ -214,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
@@ -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))
}
@@ -0,0 +1,512 @@
//! Ask a devnode which process is serving it — the host half of
//! [`pf_driver_proto::gamepad::ChannelProof`].
//!
//! This is what the pad channel trusts instead of the bootstrap 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 the system
//! `WUDFHost.exe` — which is world-executable. So through gamepad proto v2 any LocalService
//! principal, notably the deliberately de-privileged plugin runner, could spawn its own WUDFHost,
//! publish that pid, and be handed the pad's live DATA section: forged HID input into the interactive
//! desktop and a read of the remote user's controller state (security-review 2026-07-28).
//!
//! No host-side check could tell the two apart. 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, and both are session-0 LocalService processes running the same image,
//! so token, session and image checks all discriminate nothing. The one thing an attacker cannot
//! forge is **being the driver bound to the devnode we created**: I/O sent to that device reaches
//! only the driver PnP bound to it, and we look the device up by the instance id `SwDeviceCreate`
//! handed back, so a planted look-alike devnode is not in the running either.
//!
//! Three transports, one per driver shape — see [`ProofTransport`] for what each of them cost
//! and why the obvious ones did not survive contact with hidclass.
use anyhow::{anyhow, bail, Context, Result};
use pf_driver_proto::gamepad::{
ChannelProof, DECK_PROOF_CMD, HID_FEATURE_REPORT_CHANNEL_PROOF, HID_STRING_INDEX_CHANNEL_PROOF,
IOCTL_PF_GET_CHANNEL_PROOF,
};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows::core::{GUID, PCWSTR};
use windows::Win32::Devices::DeviceAndDriverInstallation::{
CM_Get_Child, CM_Get_Device_IDW, CM_Get_Device_Interface_ListW,
CM_Get_Device_Interface_List_SizeW, CM_Get_Sibling, CM_Locate_DevNodeW,
CM_GET_DEVICE_INTERFACE_LIST_PRESENT, CM_LOCATE_DEVNODE_NORMAL, CR_SUCCESS,
};
use windows::Win32::Devices::HumanInterfaceDevice::{
HidD_GetFeature, HidD_GetIndexedString, HidD_GetProductString, HidD_GetSerialNumberString,
HidD_SetFeature, GUID_DEVINTERFACE_HID,
};
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Storage::FileSystem::{
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
};
use windows::Win32::System::IO::DeviceIoControl;
/// `GUID_DEVINTERFACE_XUSB` {EC87F1E3-C13B-4100-B5F7-8B84D54260CB} — the interface `pf_xusb`
/// registers on its own devnode (and what `xinput1_4` enumerates).
const GUID_DEVINTERFACE_XUSB: GUID = GUID::from_u128(0xEC87F1E3_C13B_4100_B5F7_8B84D54260CB);
/// How a driver answers the proof — one variant per driver shape, because what Windows actually
/// carries to each shape differs (all three measured on .173 / Win11 26200, 2026-07-28).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProofTransport {
/// `pf_xusb`: a private IOCTL on `GUID_DEVINTERFACE_XUSB`, the interface it registers on its own
/// devnode. Works because it is NOT a HID minidriver — nothing sits above it, so it owns both
/// `IRP_MJ_CREATE` and its own IOCTL dispatch.
XusbIoctl,
/// `pf_mouse`: the proof arrives as the HID **serial-number string**. The one transport that
/// survived measurement against a UMDF HID minidriver — `HidD_GetSerialNumberString` on a
/// zero-access handle, verified end to end (`PFCP:3:0:7296`, and 7296 was a real
/// service-spawned `WUDFHost.exe`). Safe here because nothing reads the virtual mouse's serial.
HidSerialString,
/// `pf_gamepad` (DualSense / DualShock 4 / Edge / Deck): a HID **feature report**.
///
/// The pads cannot use the serial string — it is what SDL and Steam dedup controllers on, and
/// Steam is known to mangle a pad's displayed name over serial FORMAT alone. They get a feature
/// report instead, and it costs NO report-descriptor change, because the captured descriptors
/// already declare far more feature ids than the driver ever served: `0x85` is declared as a
/// Feature report on DualSense, DualShock 4 and Edge alike and used to fail with
/// `STATUS_INVALID_PARAMETER`. The Deck declares one UNNUMBERED feature report driven as
/// command→response, so its proof rides that existing contract via a private two-byte command.
HidFeatureReport,
}
/// Ask the devnode `instance_id` (as `SwDeviceCreate` reported it) which process serves it, and
/// return that pid — already checked against `expect_pad_index` and this build's protocol version.
///
/// Every failure is a refusal to deliver, so the errors say exactly which step failed: an operator
/// staring at a dead gamepad needs to know whether the devnode is missing, the interface has not
/// appeared yet, or a driver answered something we did not mint.
pub(super) fn query(
instance_id: &str,
transport: ProofTransport,
expect_pad_index: u32,
) -> Result<u32> {
let paths = match transport {
ProofTransport::XusbIoctl => interface_paths(&GUID_DEVINTERFACE_XUSB, instance_id)
.with_context(|| format!("enumerate the XUSB interface on {instance_id}"))?,
ProofTransport::HidSerialString | ProofTransport::HidFeatureReport => {
// hidclass publishes the collection interface on a CHILD PDO, not on our devnode.
let children = child_device_ids(instance_id)
.with_context(|| format!("enumerate the HID children of {instance_id}"))?;
let mut all = Vec::new();
for child in &children {
all.extend(interface_paths(&GUID_DEVINTERFACE_HID, child).unwrap_or_default());
}
all
}
};
if paths.is_empty() {
bail!(
"no device interface for {instance_id} yet — the driver has not finished starting (or \
is not bound to this devnode at all)"
);
}
// A devnode can publish more than one collection; ask each and take the first well-formed proof.
let mut last_err = None;
for path in &paths {
let r = match transport {
ProofTransport::XusbIoctl => ask_ioctl(path, expect_pad_index),
ProofTransport::HidSerialString => ask_hid_path(path, expect_pad_index),
ProofTransport::HidFeatureReport => ask_feature_path(path, expect_pad_index),
};
match r {
Ok(pid) => return Ok(pid),
Err(e) => last_err = Some(e.context(format!("ask {path}"))),
}
}
Err(last_err.unwrap_or_else(|| anyhow!("no interface answered a channel proof")))
}
/// Open `path` and put the proof IOCTL to it (the private pad-control interface, and `pf_xusb`'s own).
fn ask_ioctl(path: &str, expect_pad_index: u32) -> Result<u32> {
let handle = open_device(path)?;
let proof = ask_xusb(HANDLE(handle.as_raw_handle()))?;
proof
.check(expect_pad_index)
.map_err(|why| anyhow!("{why}"))
}
/// Open a HID collection and read the proof out of a FEATURE report — the pad transport.
fn ask_feature_path(path: &str, expect_pad_index: u32) -> Result<u32> {
let handle = open_device(path)?;
let proof = ask_feature(HANDLE(handle.as_raw_handle()))?;
proof
.check(expect_pad_index)
.map_err(|why| anyhow!("{why}"))
}
/// The PS identities answer on the declared-but-unserved report `0x85`; the Deck answers its
/// unnumbered report after a private SET_FEATURE command. Both are tried — one driver binary serves
/// four identities and the host does not know which one this devnode became until the DATA section
/// is attached, which is precisely what we are trying to earn the right to do.
fn ask_feature(h: HANDLE) -> Result<ChannelProof> {
// Feature buffers are sized by the descriptor; the largest of these reports is 64 bytes, and
// Windows accepts a buffer at least that big.
const BUF: usize = 64;
// PS identities: GET_FEATURE 0x85.
let mut buf = [0u8; BUF];
buf[0] = HID_FEATURE_REPORT_CHANNEL_PROOF;
// SAFETY: `h` is the live HID interface handle; `buf` is a valid BUF-sized in/out buffer.
if unsafe { HidD_GetFeature(h, buf.as_mut_ptr().cast(), BUF as u32) } {
if let Some(p) = ChannelProof::from_feature_report(&buf) {
return Ok(p);
}
}
// Deck: SET the private command, then GET the reply. Byte 0 is the (unnumbered) report id 0.
let mut cmd = [0u8; BUF];
cmd[1..1 + DECK_PROOF_CMD.len()].copy_from_slice(&DECK_PROOF_CMD);
// SAFETY: `h` is live; `cmd` is a valid BUF-sized buffer.
let set_ok = unsafe { HidD_SetFeature(h, cmd.as_mut_ptr().cast(), BUF as u32) };
if set_ok {
let mut reply = [0u8; BUF];
// SAFETY: as above.
if unsafe { HidD_GetFeature(h, reply.as_mut_ptr().cast(), BUF as u32) }
&& reply.starts_with(&DECK_PROOF_CMD)
{
if let Some(p) = ChannelProof::from_bytes(&reply[DECK_PROOF_CMD.len()..]) {
return Ok(p);
}
}
}
bail!(
"this HID collection carries no channel proof (feature 0x{:02x}: no; Deck command: {}) — \
the driver predates the proof (reinstall: punktfunk-host.exe driver install --gamepad)",
HID_FEATURE_REPORT_CHANNEL_PROOF,
if set_ok {
"no matching reply"
} else {
"SET_FEATURE failed"
},
)
}
/// Open a HID collection and read the proof out of a string.
fn ask_hid_path(path: &str, expect_pad_index: u32) -> Result<u32> {
let handle = open_device(path)?;
let proof = ask_hid(HANDLE(handle.as_raw_handle()))?;
proof
.check(expect_pad_index)
.map_err(|why| anyhow!("{why}"))
}
/// [`query`] for the `channel-proof-probe` subcommand — same call the delivery path makes, so the
/// probe reports the production answer rather than a re-implementation of it.
pub fn probe_pid(
instance_id: &str,
transport: ProofTransport,
expect_pad_index: u32,
) -> Result<u32> {
query(instance_id, transport, expect_pad_index)
}
/// A human-readable walk of the same lookup [`query`] does, reporting each step and — for the HID
/// leg — which of the two IOCTLs Windows actually forwarded to the minidriver.
///
/// This exists because exactly one thing in this design could not be settled by reading: whether
/// hidclass forwards `IOCTL_HID_GET_INDEXED_STRING` (and/or an arbitrary-index `IOCTL_HID_GET_STRING`)
/// down to a UMDF HID minidriver. Both are wired up so the answer only has to be "at least one", but
/// a maintainer should be able to find out which on a real box in one command rather than by
/// inference — hence `punktfunk-host channel-proof-probe`.
pub fn diagnose(instance_id: &str, transport: ProofTransport, expect_pad_index: u32) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "devnode : {instance_id}");
let _ = writeln!(
out,
"transport: {transport:?}, expected pad index {expect_pad_index}"
);
let paths = match transport {
ProofTransport::XusbIoctl => match interface_paths(&GUID_DEVINTERFACE_XUSB, instance_id) {
Ok(p) => p,
Err(e) => {
let _ = writeln!(out, " XUSB interface lookup FAILED: {e:#}");
return out;
}
},
ProofTransport::HidSerialString | ProofTransport::HidFeatureReport => {
let children = child_device_ids(instance_id).unwrap_or_default();
let _ = writeln!(out, " hidclass children: {}", children.len());
let mut all = Vec::new();
for c in &children {
let p = interface_paths(&GUID_DEVINTERFACE_HID, c).unwrap_or_default();
let _ = writeln!(out, " {c} -> {} HID interface(s)", p.len());
all.extend(p);
}
all
}
};
if paths.is_empty() {
let _ = writeln!(
out,
" NO device interface — the driver has not started, or is not bound to this devnode"
);
return out;
}
for path in &paths {
let _ = writeln!(out, " interface {path}");
let handle = match open_device(path) {
Ok(h) => h,
Err(e) => {
let _ = writeln!(out, " open FAILED: {e:#}");
continue;
}
};
let h = HANDLE(handle.as_raw_handle());
match transport {
ProofTransport::XusbIoctl => match ask_xusb(h) {
Ok(p) => {
let _ = writeln!(out, " IOCTL_PF_GET_CHANNEL_PROOF -> {p:?}");
let _ = writeln!(out, " check: {:?}", p.check(expect_pad_index));
}
Err(e) => {
let _ = writeln!(out, " IOCTL_PF_GET_CHANNEL_PROOF FAILED: {e:#}");
}
},
ProofTransport::HidFeatureReport => match ask_feature(h) {
Ok(p) => {
let _ = writeln!(out, " feature proof -> {p:?}");
let _ = writeln!(out, " check: {:?}", p.check(expect_pad_index));
}
Err(e) => {
let _ = writeln!(out, " feature proof FAILED: {e:#}");
}
},
ProofTransport::HidSerialString => {
// Report each path separately — this is the whole point of the probe.
let (indexed, serial, control) = ask_hid_both(h);
let _ = writeln!(out, " HidD_GetSerialNumberString -> {serial}");
let _ = writeln!(out, " HidD_GetIndexedString(proof) -> {indexed}");
let _ = writeln!(out, " HidD_GetProductString -> {control}");
}
}
}
out
}
/// The probe's raw material: the proof index alongside a KNOWN-GOOD control, so a failure can be
/// told apart from "user mode cannot reach this driver at all".
///
/// The control is `HidD_GetProductString`, which on-glass succeeds against a UMDF HID minidriver on
/// the very same 0-access handle where `HidD_GetIndexedString` fails for every index — the
/// measurement that established the indexed-string transport is unusable (see [`ask_hid`]).
fn ask_hid_both(h: HANDLE) -> (String, String, String) {
let text = |ok: bool, buf: &[u16]| -> String {
if !ok {
let e = std::io::Error::last_os_error();
return format!("call failed ({e})");
}
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
String::from_utf16_lossy(&buf[..end])
};
let mut a = [0u16; 128];
let bytes = (a.len() * 2) as u32;
// SAFETY: `h` is the live HID interface handle; `a` is a valid `bytes`-sized out-buffer.
let ok_a = unsafe {
HidD_GetIndexedString(
h,
HID_STRING_INDEX_CHANNEL_PROOF,
a.as_mut_ptr().cast(),
bytes,
)
};
let indexed = match (ok_a, decode_proof(&a)) {
(true, Some(p)) => format!("{p:?}"),
(true, None) => format!("answered, but not a proof: {:?}", text(true, &a)),
(false, _) => text(false, &a),
};
let mut c = [0u16; 128];
// SAFETY: as above — live handle, valid out-buffer.
let ok_c = unsafe { HidD_GetSerialNumberString(h, c.as_mut_ptr().cast(), bytes) };
let serial = match (ok_c, decode_proof(&c)) {
(true, Some(p)) => format!("{p:?}"),
(true, None) => format!("plain serial, no proof: {:?}", text(true, &c)),
(false, _) => text(false, &c),
};
let mut b = [0u16; 128];
// SAFETY: as above — live handle, valid out-buffer.
let ok_b = unsafe { HidD_GetProductString(h, b.as_mut_ptr().cast(), bytes) };
let control = format!(
"{} (control: proves user mode reaches this driver)",
text(ok_b, &b)
);
(indexed, serial, control)
}
/// `CreateFileW` with **no** access rights: enough to send `FILE_ANY_ACCESS` IOCTLs and to run the
/// `HidD_*` helpers, and the only thing that works on a HID mouse/keyboard collection, which Windows
/// refuses to open for read from user mode.
fn open_device(path: &str) -> Result<OwnedHandle> {
let wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
// SAFETY: `wide` is a valid NUL-terminated UTF-16 path for the duration of the call; the returned
// handle is owned solely by the `OwnedHandle` built from it.
let h = unsafe {
CreateFileW(
PCWSTR(wide.as_ptr()),
0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES(0),
None,
)
.with_context(|| format!("CreateFileW({path})"))?
};
// SAFETY: `h` is the fresh handle just opened, moved into a single owner that closes it on drop.
Ok(unsafe { OwnedHandle::from_raw_handle(h.0 as _) })
}
/// `pf_xusb`: a private METHOD_BUFFERED IOCTL returning the 16 proof bytes.
fn ask_xusb(h: HANDLE) -> Result<ChannelProof> {
let mut buf = [0u8; 16];
let mut returned = 0u32;
// SAFETY: `h` is the live interface handle; `buf` is a valid 16-byte out-buffer and `returned`
// a valid out-param. METHOD_BUFFERED, so the I/O manager copies — no pointer escapes.
unsafe {
DeviceIoControl(
h,
IOCTL_PF_GET_CHANNEL_PROOF,
None,
0,
Some(buf.as_mut_ptr().cast()),
buf.len() as u32,
Some(&mut returned),
None,
)
.context("IOCTL_PF_GET_CHANNEL_PROOF")?;
}
ChannelProof::from_bytes(&buf[..returned as usize]).ok_or_else(|| {
anyhow!("the XUSB interface returned {returned} bytes, too short for a channel proof")
})
}
/// `pf_gamepad` / `pf_mouse`: the reserved HID string index.
///
/// ⚠️ **ON-HARDWARE FINDING, .173 (Win11 26200), 2026-07-28 — this transport DOES NOT WORK.**
/// Probed against a live `pf_mouse` devnode with the installed driver: on the SAME 0-access handle,
/// `HidD_GetManufacturerString` / `GetProductString` / `GetSerialNumberString` all succeeded and
/// returned the driver's OWN strings (so user mode does reach a UMDF HID minidriver, and hidclass
/// does translate the named string IOCTLs down to its `IOCTL_HID_GET_STRING` handler) — while
/// `HidD_GetIndexedString` failed for EVERY index tried: 1, 2, 4, 0x0E and 0x5046. Indices 2 and
/// 0x0E are ones the driver demonstrably serves through the named wrappers, so this is not our
/// reserved index being out of range: hidclass does not carry an arbitrary indexed-string request to
/// a UMDF HID minidriver at all.
///
/// The call is kept because it costs one failed IOCTL and is the correct thing to ask first if a
/// later Windows starts forwarding it. What was REMOVED here is a second "fallback" that issued
/// `IOCTL_HID_GET_STRING` directly: that IOCTL is METHOD_NEITHER and **kernel-facing** — hidclass
/// sends it DOWN to the minidriver, user mode never sends it up — so it could not have worked, and
/// on-glass it returned `ERROR_INVALID_FUNCTION` for a control index the device definitely serves.
/// Do not re-add it.
///
/// The HID pads/mouse therefore still need a working proof transport; see the module docs.
fn ask_hid(h: HANDLE) -> Result<ChannelProof> {
let mut buf = [0u16; 128];
let bytes = (buf.len() * 2) as u32;
// SAFETY: `h` is the live HID interface handle; `buf` is a valid `bytes`-sized out-buffer.
if unsafe { HidD_GetSerialNumberString(h, buf.as_mut_ptr().cast(), bytes) } {
if let Some(p) = decode_proof(&buf) {
return Ok(p);
}
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
bail!(
"this HID collection's serial is {:?}, not a channel proof — an old driver is installed \
(reinstall: punktfunk-host.exe driver install --gamepad)",
String::from_utf16_lossy(&buf[..end])
);
}
bail!("HidD_GetSerialNumberString failed on this HID collection")
}
/// Decode a NUL-terminated UTF-16 HID string answer into a proof.
fn decode_proof(buf: &[u16]) -> Option<ChannelProof> {
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
let s = String::from_utf16(&buf[..end]).ok()?;
ChannelProof::from_hid_string(&s)
}
/// Every present device-interface path of `class` on `device_id`.
fn interface_paths(class: &GUID, device_id: &str) -> Result<Vec<String>> {
let wide: Vec<u16> = device_id.encode_utf16().chain(std::iter::once(0)).collect();
let mut len = 0u32;
// SAFETY: `len` is a valid out-param; `wide` is a NUL-terminated id valid for the call.
let cr = unsafe {
CM_Get_Device_Interface_List_SizeW(
&mut len,
class,
PCWSTR(wide.as_ptr()),
CM_GET_DEVICE_INTERFACE_LIST_PRESENT,
)
};
if cr != CR_SUCCESS || len == 0 {
return Ok(Vec::new());
}
let mut buf = vec![0u16; len as usize];
// SAFETY: `buf` is `len` UTF-16 units as the size call just reported; same id/class as above.
let cr = unsafe {
CM_Get_Device_Interface_ListW(
class,
PCWSTR(wide.as_ptr()),
&mut buf,
CM_GET_DEVICE_INTERFACE_LIST_PRESENT,
)
};
if cr != CR_SUCCESS {
bail!("CM_Get_Device_Interface_ListW failed (CONFIGRET {})", cr.0);
}
// A REG_MULTI_SZ-shaped list: NUL-separated, double-NUL terminated.
Ok(buf
.split(|&c| c == 0)
.filter(|s| !s.is_empty())
.map(String::from_utf16_lossy)
.collect())
}
/// The instance ids of `instance_id`'s immediate children — for a HID minidriver, the collection
/// PDOs hidclass created under our devnode.
fn child_device_ids(instance_id: &str) -> Result<Vec<String>> {
let wide: Vec<u16> = instance_id
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut devinst = 0u32;
// SAFETY: `devinst` is a valid out-param; `wide` is a NUL-terminated id valid for the call.
let cr = unsafe {
CM_Locate_DevNodeW(
&mut devinst,
PCWSTR(wide.as_ptr()),
CM_LOCATE_DEVNODE_NORMAL,
)
};
if cr != CR_SUCCESS {
bail!(
"CM_Locate_DevNodeW({instance_id}) failed (CONFIGRET {})",
cr.0
);
}
let mut out = Vec::new();
let mut child = 0u32;
// SAFETY: `devinst` is the devnode just located; `child` is a valid out-param.
if unsafe { CM_Get_Child(&mut child, devinst, 0) } != CR_SUCCESS {
return Ok(out); // no children yet — hidclass has not enumerated the collections
}
loop {
let mut buf = [0u16; 512];
// SAFETY: `child` is a live devnode handle from CM_Get_Child/CM_Get_Sibling; `buf` is a
// valid out-buffer whose length the binding passes along.
if unsafe { CM_Get_Device_IDW(child, &mut buf, 0) } == CR_SUCCESS {
let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
out.push(String::from_utf16_lossy(&buf[..end]));
}
let mut next = 0u32;
// SAFETY: as above — `child` is live, `next` is a valid out-param.
if unsafe { CM_Get_Sibling(&mut next, child, 0) } != CR_SUCCESS {
break;
}
child = next;
}
Ok(out)
}

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