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
enricobuehlerandClaude Opus 5 d08893383a chore(release): bump workspace version to 0.21.0
42 commits since v0.20.0 (v0.20.1 was tagged but never announced;
superseded by this release rather than repointed, since real new
functionality landed after it was cut). Minor, not patch: the
headline is streaming one of the machine's own physical monitors
instead of always creating a virtual display — pick it from a new
console card or pin it in host.env, on KWin, Mutter, sway and
Hyprland, in both the Punktfunk app and Moonlight.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Dense (non-chunked) sessions are untouched.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Plenty of rows will never have art — an operator-typed command has no catalog
entry behind it, a custom entry may carry none, and nothing does until
`/library` has loaded. The slot keeps its fixed size so rows stay aligned when
only some titles have covers; it just holds a gamepad glyph instead of nothing.
2026-07-27 00:47:05 +02:00
enricobuehlerandClaude Opus 5 f57fc85d34 chore(release): bump workspace version to 0.20.0
117 commits since v0.19.2. Minor rather than patch: the headline is the
session⇄game lifetime binding on both planes and both platforms — the
stream ends when the game does, and optionally the reverse — plus
library metadata, winget packaging for the Windows host, a 64-bit ARM
Linux client, headless `--pair` enrolment, DRM card selection for the
compositor-less presenter, and the ABR/latency de-escalation work, which
changes runtime behaviour on every session.

Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows
virtual-display driver protocol at 6, so 0.18/0.19/0.20 hosts and
clients mix freely and no embedder rebuild is required. The new GameMeta
library fields are optional and flat on the wire.

Notes authored ahead of the tag per docs/releases/README.md, so the
release is born with a body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:33:45 +02:00
enricobuehlerandClaude Fable 5 0323158a3e fix(vdisplay/gamescope): a fresh dedicated launch streams from the first second
A freshly spawned headless gamescope never delivered a single PipeWire
buffer when a game was launched: gamescope only composites (and only on a
composite pushes a capture buffer) when a client paints, and a nested
Steam bootstrap paints nothing until its UI's first frame — far longer
than the 10 s first-frame budget. The native plane's retry ladder then
killed the half-booted Steam on every attempt and started from zero; the
GameStream plane died on its single wait. Reused live displays worked,
which pointed away from the real cause (root-caused on .41 with a raw
pw_stream probe: `sleep infinity` nested → 0 buffers ever, `vkcube`
nested → 60/s immediately).

Every bare spawn now backgrounds the host's own splash client
(`gamescope-splash`, hidden subcommand) beside the nested app: a dark
window with a subtle breathing bar painted at ~2.5 Hz — damage from the
first second, so capture gets its first frame within the budget on both
planes. In `--steam` mode gamescope composites only windows whose appid
is in the root `GAMESCOPECTRL_BASELAYER_APPID` list (live-proven: even a
painting vkcube gets zero composites without it), so the splash declares
itself as the Steam UI (STEAM_GAME=769) and seeds the baselayer iff
unset; Steam's own rewrite at game launch hands composite focus to the
game with no action on our side. PUNKTFUNK_GAMESCOPE_SPLASH=0 is the
escape hatch.

The dedicated Steam launch is also shaped with `-gamepadui` instead of
`-silent`: the nested Steam is Big Picture — the identity gamescope's
`--steam` integration is built around — so the boot shows the gamepad UI
instead of the desktop client window flashing through the stream, and
gamescope's focus rules (game outranks the Steam UI appid) cover what
`-silent` was working around.

On-glass on .41 (Bazzite, RTX 5070 Ti, gamescope c31743d): cold connect +
Balatro launch delivers frames on the first attempt on both a cold and a
torn-down-then-fresh spawn; Big Picture boot → game handover confirmed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 00:28:59 +02:00
enricobuehler 980b399ea8 test(session/gamelease): the live-process test waits out the shim window
Its child is `sh` running a script that `exec`s a binary outside the install
dir, so neither the image path nor the command line carries the directory — the
host's own child is the only signal there is. Which is exactly the case the shim
window now gates: a live child that might still turn out to be a launcher is not
called the game until that window passes.

So "running" legitimately arrives up to SHIM_WINDOW later than it used to, and
the assertion at 1.5 s was asserting the old behavior. Waiting it out is the
point now, not an accident of timing.

The delay is the deliberate cost of the fix: until the window passes there is no
way to tell "the child IS the game" from "the child is about to hand off". It
shows as a few seconds of `launching` in the console, and only for a title whose
store gave us signals.

.25: the ignored live test passes (Child -> Running -> terminate via the
SIGTERM->SIGKILL group ladder -> Exited, session-end action correctly NOT fired),
full suite 305 passed.
2026-07-26 22:33:42 +02:00
enricobuehler d5857abd91 fix(session/gamelease): a launcher handing off is not the game exiting
On glass (.41), launching any Steam title over the compat plane ended the
session ~7 s later, before the game had started:

  launched app  command=steam steam://rungameid/2379780  pid=12699
  watching the launched game  kind="child"
  the launched game is running  kind="child"  procs=0      <- 3 ms in, zero game procs
  the launched game exited  ->  session ended

Linux launches are `Child` leases: the host spawns the launcher itself. The
watcher counts "my child is alive" as the game running, so the lease left the
waiting phase on its FIRST poll — and the shim reclassification lives in that
phase, so it never got to run. When `steam` handed off and exited, that read as
the game exiting.

The design always called for the shim window ("never fires B from a shim exit")
and the code implements it; the lease had simply moved past where it applies. So
when the store gave us signals to recognize the real game by, a bare live child
no longer counts as the game until that window has passed. With no signals the
child is all we have and still counts immediately — custom commands are
unchanged.

Windows could not reproduce this: it holds no child, so its leases are always
`Matched`, and the whole .173 pass was structurally blind to it. Both planes on
Linux were affected — which is most of the audience for this feature.

Also fixes the live-process fixture, which was CI-red for an unrelated reason:
it copied `/bin/sleep` to a file named `game`, and modern coreutils (uutils on
Ubuntu 25.10+, busybox elsewhere) is a MULTI-CALL binary dispatching on argv[0]
— under any other name it exits instantly, leaving nothing in /proc and looking
exactly like a broken matcher. Verified by A/B: the same test fails identically
on a tree predating these changes.

.25 (Ubuntu 26.04): 305 passed, 0 failed.
2026-07-26 21:24:12 +02:00
enricobuehler 0c261de636 ci(plugin-kit): quote the step name that contains a colon
`- name: Repair the file: dependency (…)` is not valid YAML — the unquoted
`file: ` reads as a second mapping key — so the workflow has been unparseable
since it was added, which is why re-pushing the tag never triggered a run and a
manual dispatch 500'd. I had put that down to a Gitea tag quirk; it was this.

The failure is invisible from the repo side: nothing validates workflow YAML on
push, and an unparseable workflow simply never runs.
2026-07-26 20:45:58 +02:00
enricobuehler 6e07d063c3 fix(session/gamelease): Windows had no launch reference, so it adopted old games
On glass (.173), with a copy of Buckshot Roulette already running: a session
launched the same title, Steam focused the existing instance rather than
starting one, the lease adopted THAT process — and a deliberate stop closed the
player's pre-existing game.

That is the one thing the design says must never happen. The rule was written
and the matcher implements it; the reference instant it filters against just
never arrived. `procscan::launch_stamp` supports Windows, but the wrapper every
launch site calls was gated `#[cfg(target_os = "linux")]` and answered `None`
everywhere else — and `None` doesn't fail, it turns the filter OFF (`find` skips
it), so every process under the install dir became adoptable.

Windows is where this matters most: the host runs as SYSTEM and can see every
process on the box, which is why `procscan/windows.rs` calls rule 1
load-bearing rather than a nicety. Both planes were affected.

No unit test could have caught it — the Linux fixtures pass an explicit
`min_start`, and the Windows live test passes `None` deliberately. So the guard
added here asserts the reference EXISTS wherever processes can be matched, which
is the shape of the failure: silent, and invisible from downstream.

.133: 282 passed (+1), same 4 environmental native::tests failures.
2026-07-26 20:30:20 +02:00
enricobuehlerandClaude Fable 5 dff63b2a29 fix(encode/nvenc): a visible cursor no longer serializes submit — the blend goes stream-ordered
Report: iPad on a gamescope/NVIDIA 120 fps session capped at ~80 fps with
repeat_fps 0, zero loss, capture 0 µs, ASIC 15 µs — and submit p50 at
10.2 ms, ~81 % of the loop period. Under gamescope the host composites
the live pointer into EVERY frame, and a cursor-bearing frame forced the
CPU-synced submit path: a blocking CUDA copy plus a fence-waited Vulkan
blend, both exposed to the running game's GPU load. The "games hide the
cursor" assumption the gate relied on does not hold under gamescope.

The blend is now stream-ordered end to end. VkSlotBlend exports a
timeline semaphore (VK_KHR_timeline_semaphore + external_semaphore_fd)
into CUDA (cuImportExternalSemaphore, new dlopen entries): the enqueued
copy signals it on the encode thread's copy stream, the blend submission
waits for and advances it on the Vulkan queue, and a CUDA-side wait
orders the encode after the blend on the session's bound IO stream — no
CPU sync anywhere, so cursor frames keep the stream-ordered fast path.
Each ring slot gets its own command buffer + descriptor set (written
once) so several ordered blends can be in flight; cursor-bitmap uploads
and teardown quiesce through the timeline. Drivers without the timeline
export keep the previous CPU-synced blend, and any bring-up or per-frame
failure still degrades to "no cursor", never a dropped frame.

Also: the blocking multi-plane copies (the escalated/pipelined mode and
the non-stream-ordered fallback) now enqueue every plane and pay ONE
stream sync instead of one per plane (NV12 2→1, YUV444 3→1) — each
exposed wait costs scheduling latency under GPU contention, which is
what makes the escalation's blocking copies self-reinforcing.

Verified on the RTX 5070 Ti box (driver 610.43.03): all 12 nvenc_cuda
on-hardware smokes green, including the new
nvenc_cuda_cursor_blend_stream_ordered (6 cursor AUs, all ordered,
across a bitmap-serial flip); host suite 301/301; clippy --all-targets
-D warnings clean; struct layouts of the hand-flattened cuda.h params
asserted in tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:05:27 +02:00
enricobuehler 02b94b828d fix(gamestream): the termination type comes from the version we advertise
Still -1 on glass. The message was going out; the client was ignoring it.

Which packet-type table a client reads is decided by exactly one thing —
moonlight-common-c sets `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1,
431)` — and we advertise 7.1.431, so every client reads the ENCRYPTED table,
where termination is 0x0109. We were sending 0x0100.

Deriving that from the nonce scheme was the error: `NonceKind` describes how the
GCM nonce is built and nothing else. The two look like the same "is this
encrypted" axis and are not. The HDR message could never have caught it either,
since 0x010e is identical in both tables — so the only symptom was the silence
this whole chain has been about.

Pinned with a test asserting APP_VERSION still clears 7.1.431, because if it
ever drops below, the correct type silently becomes 0x0100 again and the stream
goes back to ending as an error with nothing failing to explain why.

.133: 281 passed (+3), same 4 environmental native::tests failures. The Linux
box is off the network, so the full Linux suite is owed.
2026-07-26 20:04:54 +02:00
enricobuehler e44f2b1024 fix(gamestream): say "the app quit", not just goodbye
Disconnecting the ENet peer stopped the freeze but the client then reported the
session as error -1: a bare disconnect is indistinguishable from the host
falling over, so Moonlight called it a connection failure. Which, from its side,
was a fair reading — we never said otherwise.

The protocol has a word for this. Send the TERMINATION control message with
`NVST_DISCONN_SERVER_TERMINATED_CLOSED` first, then disconnect; the client maps
that to a graceful termination (given it has seen a frame, true of any real
session) and lands the player back in the app list.

Constants verified against moonlight-common-c `ControlStream.c` rather than
recalled, because every way of getting this wrong is silent — the client ignores
an unparseable control message, which reads on glass as the same freeze:

* the type comes from the table the CLIENT chose, so it is derived from the
  negotiated nonce scheme (plain 0x0100 / encrypted 0x0109) instead of guessed,
* the reason is BIG-endian (the >=6-byte branch; the short branch is a
  little-endian u16, which is GFE's older shape).

`disconnect_later` rather than `disconnect`, so the queued message actually
reaches the wire instead of racing the teardown — which would land us back at -1.

The session's GCM key is cached per tick because ending a session clears the
launch state the key lives in, and this is precisely the message that has to be
sealed after that.
2026-07-26 19:58:25 +02:00
enricobuehler ddc28de32a fix(gamestream): tell the client when the HOST ends the session
On-glass on .173: a launched game exited, the host tore the session down
correctly — and the client froze on its last frame instead of returning to its
app list.

The host's half was right; it just never said anything. On the native plane
ending a session closes the QUIC connection with APP_EXITED, which every client
renders as "game ended". The compat plane has no such channel: `end_session`
stops the media threads, and stopping a UDP sender is silence, not a signal.
Moonlight holds the ENet control stream for the whole session, so with no word
from us it sat there until its own timeout eventually fired.

The control loop already reacted to the CLIENT disconnecting; it had no path for
the reverse. It now watches for its session being cleared out from under it and
disconnects the peer, which is what Moonlight reads as "the session is over" —
landing the player back in the app list, the same place quitting puts them.

This is the signal every host-side end needed, not just a game exiting: the
management stop and a `/cancel` racing another connection had the same silence.
2026-07-26 19:44:55 +02:00
enricobuehlerandClaude Opus 5 1839d7566b feat(packaging/winget): install the Windows host with winget, from our own source
`winget install unom.PunktfunkHost` after adding the source, and `winget upgrade`
from then on. Windows had no update path at all before this — no self-update, no
package manager — so keeping a host current meant noticing a release and
re-running an installer by hand.

Ships the manifest trio in winget-pkgs' own format (so submitting upstream later
is a copy, not a rewrite) plus a release-time generator that substitutes only
version, URL, hash and release-notes link. Everything reviewable — the silent
switches, the agreements, the installation notes — stays in the checked-in
templates rather than buried in a generator.

Silent installs deliberately take the SAME task defaults the wizard shows: a
per-channel default is a support trap. The disclosures the wizard puts on screen
travel as manifest Agreements instead, shown before install and requiring
acceptance — VB-Audio's bundling grant wants the user to see VB-CABLE's origin
and donationware status, and a silent install shows them nothing otherwise. The
console password can only be pointed at, never printed: it is generated per
install and the notes are fixed at publish time.

Self-hosted rather than the community repo, which gates on Defender/SmartScreen
validation the self-signed installer cert would not clear today. The source is
three endpoints — /information, /manifestSearch, /packageManifests/{id}. The
reference implementation's other twenty are its admin API for mutating a
CosmosDB; a catalogue generated at release time has nothing to mutate.

It runs on unom-1 as a stock bun image with the two .mjs files bind-mounted, the
same shape as the flatpak server, so there is no image to build or pull. The
catalogue is derived from the RELEASES rather than local files: winget resolves
--version and upgrade against the version list, so a source that only knew the
newest release could neither pin an older one nor show an upgrade path from it.
That also leaves no state to drift — re-running the build reproduces it exactly.

NormalizedPackageNameAndPublisher is declared unsupported on purpose. winget
derives it client-side with its own normalization, and a near-miss silently
mis-correlates an installed host; ProductCode is exact and Inno gives us one.

28 checks drive the handler directly, and CI gates on them before anything
reaches the box — a wrong response shape does not fail loudly, it just makes
winget report "no package found".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:09:13 +02:00
enricobuehlerandClaude Opus 5 3d89301336 fix(installer/windows): a silent install can't hang on a prompt, and an upgrade keeps your choices
Three faults that only bite when nobody is watching the wizard — which is how a
package manager always runs us, and how a re-run by hand half-behaves today.

The conflicting-host warning used a plain MsgBox. That ignores /SUPPRESSMSGBOXES
and displays even under /VERYSILENT, so an unattended install onto a box already
running Sunshine/Apollo would sit on a modal dialog nobody can see or click.
SuppressibleMsgBox with an IDNO default aborts instead, which is the honest
answer for a combination the message itself calls unsupported.

The GameStream and Public-firewall choices were re-applied on every run. A silent
run has no wizard, so every task falls back to its script default and the
installer confidently re-asserted it: a user who enabled GameStream had it turned
off again, and one who opened the firewall on Public networks had it quietly
revoked (that task is default-unchecked, so the reset ran the other way). Both
params are now fresh-install-only, keyed off whether host.env already exists.
`service install` already read an absent --gamestream as "keep host.env as-is";
--allow-public-network becomes tri-state the same way, resolving an absent flag
from the marker the previous install recorded. It is strict on the value: a
typo'd =of must not fall through to a marker that may say true, because that
turns a mistyped opt-OUT into leaving Public open.

Also brands the installer "Punktfunk Host" in the strings a user actually sees —
Add/Remove Programs, the Start Menu group, the wizard task text. Paths are
untouched; renaming those would relocate installs and orphan existing config.

Verified on the windows-amd64 runner: ISCC compiles the script (with WithWeb
defined, so the password page parses too), and cargo check + clippy -D warnings
pass for punktfunk-host with nvenc,amf-qsv,qsv.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:09:13 +02:00
enricobuehlerandClaude Opus 5 1ee06defa6 feat(library): descriptive metadata on every entry — platform, year, genres, and friends
Emulation-and-beyond libraries need more than a title and a poster. Every
library shape (GameEntry, CustomEntry, CustomInput, ProviderEntryInput)
now carries a shared, flattened GameMeta: platform, description,
developer, publisher, release_year, genres, tags, region, players. All
fields are optional and flat on the wire, so existing library.json files,
provider plugins, and clients keep working unchanged.

- Installed-store scanners (Steam, Lutris, Heroic, Epic, GOG, Xbox) stamp
  platform=PC; custom/provider entries carry whatever was authored.
- GET /library grows a ?platform= filter (case-insensitive) beside
  ?provider=.
- Console: the add/edit form gets a Details section (round-tripping every
  field, since update replaces the entry), the poster tile a platform
  badge (non-PC only — the store badge already implies PC) and the year.
- plugin-kit: ProviderEntry accepts the same fields (new GameMeta schema,
  spread flat); SDK + OpenAPI spec regenerated.
- pf-client-core decodes platform for future client badges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:00:30 +02:00
enricobuehler d8b7a86366 ci(plugin-kit): repair the file: dependency bun 1.3 links to itself
The publish workflow has been failing at Typecheck with "cannot find module
'@punktfunk/host'" — every import of the SDK, in a job whose previous step built
that very SDK successfully.

bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking
each top-level FILE to itself: `node_modules/@punktfunk/host/package.json ->
package.json`, a dangling self-reference. So `dist/index.d.ts` arrives intact
while the manifest that points at it does not, and resolution dies at the first
step it takes. The types were never missing; nothing could find the front door.

Replacing that tree with a real copy is the whole fix, and the step no-ops
itself the moment bun links `file:` deps correctly again.

Not a regression from anything in the kit — it has been broken since bun
updated, and went unnoticed because nothing has been published since 0.1.4.
Verified from a clean node_modules: install → repair → typecheck → 20 tests →
build, all green.
2026-07-26 18:58:49 +02:00
enricobuehler f0e71e928a fix(gamestream): the resolved launch command is what the backend nests
A Windows/macOS build compiled the resolved-launch parameter out entirely and
warned about it, because the two uses left were both Linux-gated. Both platforms
want the same value: off Linux `set_launch_command` is a backend no-op and a
library title resolves to no command at all (the interactive-session spawner
launches it by id), so the cfg split was carrying an older distinction that no
longer exists.

Also covers the Windows half of `process_name` with a test — the pure name match
plus a live scan for this test process, since `find`'s early return would
otherwise skip the signal entirely and the test would pass vacuously.

Gates: .133 check + clippy --all-targets clean with no warnings, 5/5 procscan
(incl. live); .21 299 tests + fmt.
2026-07-26 18:28:13 +02:00
enricobuehler ee722c2160 docs: what happens when a game ends, and when a session does
The two behaviors are opt-in-shaped in different directions — one on by
default, one off — so the page says which is which, what `always` costs, and
that the keep-alive above it is a different clock governing a different thing.
The precedence rule (a display kept forever stays up regardless) is spelled out
rather than left to be discovered.

The dedicated-game-session blurb claimed game-exit-ends-the-session as its own
feature; that is now true everywhere, so it moves to where it belongs.

Automation gains the `game.running` / `game.exited` rows and a short section on
reacting to a game rather than a stream — the two are usually the same moment
but not always, and anyone who has been polling the host to find out when a game
finished can stop.

docs-site: build + tsc clean.
2026-07-26 18:28:13 +02:00
enricobuehler 6a2c127ce9 feat(mgmt/web): show the running game, and who decides when it ends
The host knows what it launched and, after a disconnect, that it is counting
down to closing it. None of that was visible: the console showed a stream with
no game, and a game on its way to being closed was something you found out about
afterwards.

The Dashboard gains a running-game card above the session card — box art matched
against the catalog it already fetches, so no new endpoint and no request on the
2 s status poll. "End now" means the two different things the row's state
implies: a live game ends by stopping its session (what then happens to the game
follows the policy — stopping a session is not licence to close a game), while
one already waiting out its reconnect window has no session left to stop and is
ended directly.

The settings card sits next to the display keep-alive policy because that is the
same question one step out: keep-alive decides how long a *display* outlives a
disconnect, this decides whether the *game* does. The copy says plainly what
`always` costs, that a drop is not someone pressing Stop, and that a display kept
forever is unaffected either way — the precedence rule that would otherwise
surprise someone. Where a build enforces nothing (macOS, no launch path) the
controls are shown disabled rather than hidden: "does nothing here" is
information.

Also fixes the story fixtures, which had gone stale against the `games[]` field
Phase 1 added, and adds a story for the state the card exists for — a game whose
client walked away.

web: build + tsc clean, biome-formatted; en/de messages complete.
2026-07-26 18:28:13 +02:00
enricobuehler 110eabf281 feat(library/providers): let a provider say how to recognize its games
A plugin's titles launch through the provider's own client, which hands off and
exits — so the host had nothing left to watch, and both lifetime behaviors went
quiet for exactly the entries a provider contributes. A `ProviderEntry` (and a
manual custom entry) may now carry an optional `detect` hint: install dir, exe,
or process name.

It is deliberately a subset of what the host tracks internally. A Steam appid or
a launcher's environment marker are things the host discovers for itself and
would be meaningless — or dangerous — to take on someone's word; where a title
is installed is something only the provider knows. The host's own findings win
where both exist, so a stale export can never redirect the matcher, and a blank
field is treated as absent rather than as "match everything" — an empty install
dir would otherwise prefix-match every process on the box, and this feature can
end processes.

`process_name` is the weakest of the three and the only one typed by hand, so it
is matched case-insensitively against the image's file name and nothing else:
`retroarch` finds RetroArch, not a helper whose name merely starts the same way,
and not a script that happens to live in a `retroarch/` directory. The
never-adopt-a-pre-existing-process rule still bounds it.

Also: the tray summary gains the running-game row (with the closing-in countdown
for a game whose client is gone — visible at the machine without opening the
console), the SDK mirrors the `game.*` events, and its generated client catches
up with the endpoints Phase 1 added.

Gates on .21: check + clippy --all-targets clean, 299 tests, fmt CI-parity,
openapi regenerated (GameEntry still carries no `detect` outbound); SDK tsc +
54 tests green.
2026-07-26 18:28:13 +02:00
enricobuehler 98121ccd53 feat(session/gamestream): the compat plane learns a game's lifetime too
Moonlight sessions had neither direction of the session⇄game binding: a game
exiting left the player looking at a desktop, and a session ending never touched
the game. The machinery for both landed with the native plane; this wires it up.

The compat plane had no way to say "this is over" — RTSP carries no close code —
so `/cancel`, the management stop and a game exiting all looked exactly like a
client vanishing. They now set a session quit flag, which is what lets the
virtual display skip its keep-alive linger for a real stop and what the
end-game-on-session-end policy reads to tell a decision from a network blip. A
drop still lingers, and still gets its reconnect window.

Moonlight can't resume a session, so a client coming back for a game it left
behind does it by launching the title again — that reclaims the waiting game
before anything starts, rather than letting the old window close on the copy the
player is now holding.

Both planes now resolve a launch through one lookup (`resolve_launch`), so a
GameStream title arrives with the same identity and the same detect signals a
native one does; `resolve_session_launch`/`launch_command` were the older,
identity-less half of that and are gone. A Moonlight game also has no
live-session entry to hang off, so the status surface gains a slot for it —
without it the Dashboard would show a stream with no game while one was running.

Gates on .21: check + clippy --all-targets clean, 296 tests (293 + 3), fmt
CI-parity.
2026-07-26 18:28:13 +02:00
enricobuehlerandClaude Fable 5 ed4e3d3ab6 feat(session): end-game/end-session lifetime on Windows
Phase 2. The lease, the settings, the events and the status surface were already
platform-neutral; what Windows lacked was a way to see processes and a way to ask
one to close.

`procscan` splits per-OS behind one contract. The Windows matcher is a Toolhelp
snapshot plus each process's full image path (`QueryFullProcessImageNameW`), with
creation times from `GetProcessTimes` enforcing the two rules the module exists
for: never adopt a process that predates the launch, never trust a bare pid. Both
matter more here than on Linux — the host is SYSTEM, so it can see and signal
everything, and Windows recycles pids briskly.

Path matching is case-insensitive and separator-aware, and normalizes the `\\?\`
prefix `canonicalize` adds, without which a store-derived path would never compare
equal to a live image path.

There is no reaper argv and no readable environment on Windows, so a spec carrying
only a Steam appid or an env marker matches nothing rather than falling through to
an empty predicate. Steam's appid instead feeds a **veto**: its per-app `Running`
flag in the user's hive can't be trusted to say a game IS running (Steam sets it
around updates too, and leaves it stale after a crash), but it is exactly right for
refusing to declare one gone. If the matcher can't see a game whose exe sits
outside its manifest's install dir, ending the session would be a false positive
the player feels immediately; honoring the veto only ever leaves a stream up.

Termination asks before it insists, which on Windows needs a detail that fails
silently if missed: `EnumWindows` only sees the calling thread's desktop, and the
host's is session 0, which holds none of the user's windows. So the terminating
thread binds to the input desktop first (the pattern `pf-inject`'s `sendinput.rs`
uses), posts `WM_CLOSE` to the game's visible top-level windows, waits, and only
then calls `TerminateProcess` on freshly re-verified pids. Without the desktop
bind the polite pass finds nothing and every game dies unsaved.

Job Objects, planned as WP2.3, are deferred with the reasoning in the design doc:
every Windows launch either hands off through a launcher or the shell — where a job
would wrap a shim that exits immediately — or is a direct exe whose path we already
know, and wiring one in would touch the `CreateProcessAsUserW` token path the UAC
and secure-desktop work depends on.

Also: the watcher's steady-state poll now re-verifies known pids and only re-scans
the whole table once they all vanish (which still catches a game that re-execs).
On Windows a full scan is an `OpenProcess` per process on the box, so this is the
difference between a negligible and a noticeable poll.

Gates: Linux .21 check/clippy/fmt/293 tests green; Windows .133 check + clippy
--all-targets clean, and the 4 procscan tests pass — including the live one that
finds the test process in the real process table and rejects a wrong creation time.
On-glass Windows (Steam/Epic/GOG/Xbox titles, the unsaved-progress check) still
owed; it needs a box with games on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:28:13 +02:00
enricobuehlerandClaude Fable 5 10902f57d0 test(session/gamelease): prove it against real processes, not just fixtures
The fixture tests establish that the parsing is right; they cannot establish that
it describes this kernel. Two tests close that gap.

procscan now scans the real /proc for a process it just started, which is the
only version that would catch wrong `stat` field ordering, a broken uid check, or
a mis-read uptime clock. Writing it found something worth keeping: a wrapper
script that `exec`s a binary outside the install dir leaves no trace of that
directory in either the image path or the command line, so the install-dir recipe
cannot see it. A real game's binary lives under its install dir, so the fixture
now models that instead — and the note is in the test for whoever wonders.

The gamelease test drives a real child from Running through a host-requested end
to Exited, and asserts the session-ending action does NOT fire for it — the
difference between "the player quit" (end the session) and "we closed it" (do
not). It needs ~12s to outlive the shim window and the exit confirmation, so it
is #[ignore]d; run it with `-- --ignored gamelease`. Verified on .21.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:28:13 +02:00
enricobuehlerandClaude Fable 5 a17aa61c5a fix(session/gamelease): four things the first pass got wrong
Found reviewing the riskiest paths of the previous commit.

- An install dir of `/games/x` matched a process running out of `/games/xyz`.
  The image-path check was already component-wise and fine; the command-line
  check compared raw bytes, so it needed the separator. Two library folders
  where one name prefixes the other is not a contrived case.
- Ending a nested game force-releases kept displays, which is not per-display —
  so with another client streaming it could retire a display that was never
  ours. Skip it while anyone else has a live session: the failure mode becomes a
  game that keeps running, which is the default everywhere else anyway.
- A lease nothing polls (no detect signals, or a platform without a matcher yet)
  sat at "launching" forever in the console. Report it as running: the host did
  just launch it, and with no watcher it is claiming nothing about the exit.
- A game ended after its session was already gone reported no `game.exited` at
  all — its watcher had stopped with the session, so nothing was left to emit
  it. That is every grace expiry and every `POST /game/end`, i.e. exactly the
  ones an operator most wants to see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:28:13 +02:00
enricobuehlerandClaude Fable 5 64c0ff96bc feat(session): bind a session's life to its game's, in both directions (Linux)
Two of the most-asked-for behaviors, and until now the host could only manage a
sliver of one of them: end the streaming session when the launched game exits,
and — when the operator asks — end the game when the session ends.

Ending the session on game exit already worked, but only for a Steam title under
a bare-spawn gamescope. Everywhere else the host spawned a launch and forgot it:
the pid was logged and dropped, so on KWin, Mutter, wlroots or gamescope-attach a
finished game left the client staring at a desktop. Ending a game was not
possible at all — there was no primitive for it.

The missing piece was never plumbing, it was knowledge: a launch says what to
run, not what the game looks like once a launcher has handed off. So each store
now also reports how to recognize its game (DetectSpec, previous commit's
subject matter, folded in here), procscan turns that into live pids on Linux, and
gamelease turns pids into a lifetime — with four kinds covering how the game got
started:

  nested    gamescope owns it; its display teardown ends the game
  child     the host spawned it, in its own process group
  matched   a launcher owns it; recognized by its store's signals
  untracked nothing identifies it — both behaviors stay off, and the host
            says so once rather than guessing

A child that exits successfully within 5s was a launcher handing off, not the
game: the lease re-resolves to matched (or to untracked) instead of reporting an
exit. That single rule is what keeps steam://, epic:// and playnite:// launches
from ending a session the moment they start.

Ending a game is destructive, so three rules bound it. It is opt-in
(game_on_session_end defaults to keep). A drop is not a decision — `always`
waits out a reconnect window (5 min) that a returning client cancels by
reclaiming its own game, matched on fingerprint and title. And only ever this
session's game: a pid is adopted only if it started after the launch, so a copy
the player already had open is never touched, and every pid is re-verified
against its start time immediately before being signalled, so a recycled pid
never is. Termination asks first (SIGTERM to the group, 10s) and only then
insists.

Also here, because they are the same decision seen from other angles:

- A management stop is now a deliberate stop. `DELETE /session` sets quit before
  stop, so it behaves like a client pressing Stop — the display skips its
  keep-alive linger instead of lingering for a session nobody is coming back to.
- `/status` reports what is running (games[]), including a game whose session has
  gone and which is waiting out its window, so the console can show it and offer
  `POST /game/end`.
- New game.running / game.exited events, filterable like every other kind, which
  is the whole polling loop of the community plugin this replaces.
- session_status::register takes a named struct: eleven positional arguments,
  half of them same-typed Arc<Atomic…>, is a transposition waiting to happen.

Gates on Linux (.21): check, clippy --all-targets, fmt, and 291 tests green.
Windows (Job Objects, Toolhelp matching) and the GameStream plane are phases 2
and 3; both are inert here, as is macOS, which has no launch path at all.

Design + plan: punktfunk-planning/design/session-game-lifetime{,-implementation-plan}.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:28:13 +02:00
enricobuehlerandClaude Opus 5 0d9d78398c fix(drivers/windows): name each virtual pad for what it is — one shared description read as "the setting did nothing"
`pf_dualsense.inx` gave all four hardware ids a single %DeviceDesc%, so Device
Manager labelled an emulated DualShock 4, DualSense Edge and Steam Deck pad
"punktfunk Virtual DualSense". The HID layer was always per-type — device_type
picks the PID (09CC for DS4), the report descriptor and the product string — but
the one place a user goes to check said DualSense for every choice, which reads
exactly like the controller-type setting being ignored.

Split into four model lines over the same install section, one description each.
No binding, service or descriptor change; stampinf's 9.9.MMdd.HHmm DriverVer
increments on every build, so pnputil takes the update. InfVerif on the WDK
runner: INF is VALID.

Also correct the Slot.pref comment from the previous commit: emulating a
DualShock 4 gives up adaptive triggers by construction. HidOutput::Trigger is
emitted only by dualsense_proto, and a DS4 has no trigger-effect reports — the
host never generates any to send. Rumble and the lightbar remain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:56:44 +02:00
enricobuehlerandClaude Opus 5 6edd700910 fix(client/gamepad): honor an explicit controller type — the per-pad arrival was re-declaring the physical pad
The "Controller type" setting reached the Hello and stopped there. Every pad then
opened with a GamepadArrival carrying its DETECTED kind, and the host builds each
virtual device from that arrival — the session default is only the fallback for a
pad that never declares one. So "emulate my DualSense as a DualShock 4" put a
DualSense on the host the instant the controller connected, and no amount of
reconnecting helped. Apple and the native clients both; Android already applied
the setting per pad.

The setting now rides the declaration too: explicit wins for every slot, Automatic
keeps per-pad detection so a mixed session stays honest. The physical kind still
drives the LOCAL feedback paths — a DualSense emulated as a DualShock 4 keeps its
lightbar and adaptive triggers, and a Deck keeps its rumble keep-alive.

Regressed in 97c67b26 / 76be4c3e (multi-controller support); ships in 0.19.x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:56:44 +02:00
enricobuehlerandClaude Fable 5 8362d57001 fix(vdisplay/windows): exclusive topology gets re-asserted — a verified isolate is not durable
The CCD isolate verifies "sole active desktop" at commit time and is never
checked again. On a hybrid Intel+NVIDIA box the internal panel is re-activated
moments later (the isolated topology is deliberately not saved to the CCD
database so teardown can restore the user's layout, and the post-isolate
resize/HDR churn — or the panel's own driver, or display-poller software —
re-resolves back to the stored layout). Proven on-glass: seconds after the
verify, CCD showed the panel ACTIVE beside the virtual display while the host
still believed it was exclusive — cursor and windows can land off-stream, and
the lock screen can land on the physical panel.

A group-scoped watchdog now re-queries every PUNKTFUNK_EXCLUSIVE_REASSERT_MS
(default 2 s, 0 disables) while an EXCLUSIVE isolate is live and re-issues the
isolate when a non-managed display crept back, logging who-is-fighting
escalation (3×WARN → 1×ERROR → DEBUG). Gated on a new GroupState.ccd_exclusive
flag so a Primary group's deliberately-lit panels are never "fixed". Cycles
take the state lock via try_lock with a sliced sleep, so teardown's stop+join
under the state lock cannot deadlock and is bounded by ~250 ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:53:19 +02:00
enricobuehlerandClaude Fable 5 6c2c8b6eab feat(mgmt/web): surface the host.env encoder pin so a conflicting GPU choice isn't invisible
listGpus gains encoder_pin (PUNKTFUNK_ENCODER when it actually pins something —
unset/auto stay null), and the console's GPU card shows it: a muted note when
the pin is compatible, an amber warning naming the pinned vendor and the next
session's GPU when it contradicts the selection (the host overrides such a pin
at session open — without this field the selection just looked broken). Docs
updated to say the adapter wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:53:19 +02:00
enricobuehlerandClaude Fable 5 751d1de506 fix(encode/windows): a stale PUNKTFUNK_ENCODER pin no longer wedges a conflicting GPU selection
On a hybrid box, picking a GPU in the web console whose vendor contradicts a
host.env PUNKTFUNK_ENCODER pin produced an unrecoverable session: the pin won
backend selection while the adapter followed the console, the wrong-vendor
encoder failed deterministically at submit, the reset ladder burned its 5
in-place rebuilds on it, and the client reconnected into the identical wall
forever (~10 s per cycle, no visible reason).

Three legs:
- windows_resolved_backend() now reconciles: a hardware pin whose vendor
  contradicts the selected GPU is overridden by the adapter-derived backend
  (capture + encode share one adapter, so honoring the pin can only fail);
  open_video warns loudly when a pin loses. The reconciliation is a pure,
  unit-tested table (resolve_windows_backend).
- the QSV wrong-adapter bind is typed TerminalEncoderError, and the stream
  loop's reset ladder ends the session immediately on it instead of feeding a
  deterministic config error 5 futile rebuilds.
- the un-pinned path was already correct (verified on-glass: NVIDIA preference
  + no pin = stable AV1 10-bit HDR on NVENC), so the override simply takes the
  conflict case onto that path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:53:19 +02:00
enricobuehlerandClaude Opus 5 36e566052f fix(apple/cursor): a pointer whose bitmap has not landed must not vanish
`resetCursorRects` wore the host shape only when `hostCursors[st.serial]` hit,
and fell through to `invisibleCursor` on a miss. But a miss is the ROUTINE case,
not a degenerate one, and it is not a reason to hide the pointer:

  * State (0xD0) is a per-frame datagram and announces the new serial the moment
    the host QUEUES the bitmap on the reliable control stream, so on every single
    shape change the client knows a serial before it holds the pixels.
  * The shape ring drops the NEWEST under burst (CURSOR_SHAPE_QUEUE = 8) and the
    host never re-sends it — it only sends on a serial CHANGE — so a dropped
    serial stays un-backed until the pointer next changes shape. Crossing a
    toolbar flips arrow/I-beam/hand/resize several times a second, and each flip
    mints a fresh serial and a fresh bitmap, so bursts are ordinary.

Either way the pointer BLINKED OUT rather than lagging, and in the dropped case
it stayed gone for as long as the pointer held that shape — reported on glass as
"the I-beam never appears over text fields, every other cursor is fine".

Hold the last worn shape across the gap: only `st.visible == false` (the host
says the pointer is hidden) may hide it now, never a missing bitmap. Worst case
is a briefly stale pointer. This also makes two comments that already claimed
this behaviour true — the shape-rejected warning's "keeping the previous cursor"
and CURSOR_SHAPE_QUEUE's healing claim, both of which were fiction.

Host side is exonerated: on .221 the poller sees the I-beam handle (0x10005,
CURSOR_SHOWING set), and the monochrome AND-over-XOR conversion renders a correct
glyph — black beam, white outline — so the bitmap that goes on the wire is good.

swift build PunktfunkKit + cargo check punktfunk-core green; on-glass owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:13:08 +02:00
enricobuehlerandClaude Opus 5 6a9b3b0f28 fix(windows/cursor): a re-rendered pointer keeps its handle — re-probe the extent
The GDI shape poller rasterises only when the HCURSOR VALUE changes. But Windows
rebuilds the system cursors at a new size whenever the display scale under the
pointer changes, and it does that behind a handle that never moves — the shared
arrow is 0x10003 for the whole session. So the cache latched whatever size the
pointer happened to have when the poller started and never let go.

That window is not rare, it is the norm: a fresh virtual display is created at
Windows' RECOMMENDED scale and only picks up the client's saved
PerMonitorSettings override a beat later, while the poller starts within a
second of the monitor appearing. Sample inside it and the session forwarded —
and composited — a 96 px pointer over a 100 % desktop for its entire life, which
reads on the client as a pointer 3x too large while every other thing on the
streamed desktop is correctly sized. Scaling on the client cannot undo it: the
bitmap is proportional to the video, it is just proportional to the WRONG scale.

Re-read the bitmap's extent on a slow cadence whenever the handle is unchanged —
dimensions only, no pixel copy, 4 Hz — and drop the cache when it moved, so the
next tick re-rasterises and publishes a new serial. Observing the extent itself
rather than a DPI proxy also covers the accessibility pointer-size slider and any
other cause of a same-handle re-render.

Windows-side clippy -D warnings green via scripts/wincheck.sh; on-glass owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:22:53 +02:00
enricobuehlerandClaude Opus 5 1421e235f2 fix(host): hdr-p010-selftest silently ignored its size argument
`args` starts AT the subcommand (main builds it with `env::args().skip(1)`), so a subcommand's own
optional arguments begin at index 1 — cf. `args.get(1)` in the `service` arm. This arm read
`skip(2)`, swallowing the first optional argument.

The documented invocation `hdr-p010-selftest 1920x1080 nvidia` therefore picked up the vendor (it is
in the second slot) and ran at the 64x64 DEFAULT. A size-only `hdr-p010-selftest 1920x1080` parsed
nothing whatsoever and still printed PASS. Nothing warned, because an unrecognised token is the only
thing that errors and no token was ever inspected.

The size is the entire point of the flag: the arm's own comment says heights like 1080 are not
16-aligned and exercise a different driver path. So the self-test has only ever validated the one
geometry that exercises the least, and the sweep's W7 gate — 1920x1080 and 5120x2880 on the RTX box
— could not actually run as written.

Found by running that gate: both sizes printed "HDR P010 self-test (64x64 ...)" with byte-identical
plane layouts (row_pitch=128, expected_total=12288 — those are 64x64's).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:12:20 +02:00
enricobuehlerandClaude Opus 5 28bbaa5250 tools: check and lint Windows-only Rust from a Linux dev box (scripts/wincheck.sh)
Linux CI never compiles `#[cfg(target_os = "windows")]` code, so a Windows-side edit was
unverifiable until the Windows CI job ran or someone tested on a real box. The pf-capture sweep's
Phases 3–6 used an ad-hoc version of this harness in a scratch directory; committing it means the
next Windows change does not have to rediscover the recipe.

The obvious in-tree command fails, and not because of the Windows code:

    cargo check --target x86_64-pc-windows-msvc -p pf-capture

dies in build scripts that compile C for the target — audiopus_sys (cmake wants a VS generator),
then ring (needs an MSVC C compiler plus the Windows SDK headers). Both enter through
punktfunk-core's `quic` feature.

So the script generates a workspace whose members are Cargo.toml copies with `src` SYMLINKED at the
real crate directories — nothing to keep in sync, no copies to go stale — plus a ~30-line stub
punktfunk-core carrying only `Mode` and `quic::HdrMeta`, which is provably all the Windows capture
stack uses. rustls, ring and opus never enter the graph. Every path dep that is not a generated
member points at the real crate.

Covers pf-frame, pf-win-display and pf-capture with --all-targets, so the Windows `#[cfg(test)]`
modules are type-checked too. ~10 s cold, ~1 s warm, into target/wincheck (gitignored).

Verified non-vacuous: a deliberate type error planted in a windows-only file
(idd_push/stall.rs) is caught and fails the run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:07:22 +02:00
enricobuehlerandClaude Opus 5 0d69ebf60b ci(windows): run pf-capture's tests instead of only type-checking them
Phase 0 of the sweep added `clippy -p pf-capture --all-targets` to this workflow, which type-checks
all 19 Windows `#[test]`s and executes none of them. They were decorative.

The workflow's own comment explains why pf-encode's tests cannot run here — nvenc link-imports
NvEncodeAPICreateInstance, which resolves only against the driver's import lib. That reasoning does
not extend to pf-capture: it has no encoder dependency at all (`cargo tree -p pf-capture` lists no
nvidia/ffmpeg/libvpl/pyrowave), so its test binary links against nothing the runner lacks.

Confirmed empirically rather than reasoned: `cargo test --release -p pf-capture` was run on a
Windows dev box against a workspace checkout — it linked, executed and passed, building in ~51 s off
an existing release target dir. --release keeps it in the one C:\t\release tree, so it does not
trip the C1069 disk exhaustion a second debug dep tree causes.

18 of the 19 run here (StallWatch + ring-generation masking, the cursor shape→wire truth table, and
`f32_to_f16`'s rounding-carry/saturation edges); all are pure — no Win32, no device, no desktop. The
19th, `hdr_p010_selftest_intel_1080_live`, is `#[ignore]`d because it needs a real Intel adapter.

This is Windows-only code no other job can execute, so it was the cheapest coverage still on the
table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:07:22 +02:00
enricobuehlerandClaude Opus 5 1423b63333 fix(gamestream): the HDR SDR-downgrade latch gated the capture offer but not the negotiation (X6)
`pf_capture::hdr_capture_failed()` had exactly one consumer: `open_portal_monitor`, which uses it to
drop the HDR offer (`want_hdr && !hdr_capture_failed()`). The RTSP negotiation consulted only
`gnome_hdr_monitor_active()`. So once the latch was set — an HDR negotiation timed out, the monitor
left HDR mode between probe and negotiation, NVIDIA EGL not listing LINEAR for XR30, a pre-50 Mutter
— the host kept telling the client HDR while capturing and encoding SDR: `cfg.hdr` flowed to
`open_portal_monitor`, which then silently opened the SDR offer.

The client renders an SDR stream as PQ. Washed out, wrong gamut, and no error anywhere. The latch is
sticky until host restart, so every reconnect repeated it.

Consulted at RTSP honor time rather than folded into `host_hdr_capable()` for the same reason as the
colour-mode probe next to it: that fn is the STATIC serverinfo capability (it decides whether to
advertise SCM_HEVC_MAIN10 at all), and this is a live per-session fact.

The native plane needs no equivalent — `capturer_supports_hdr()` is hard-false on Linux, and the
latch is a fact about the portal capturer, which that plane never uses. Noted in handshake.rs so it
isn't re-derived.

Listed in the sweep's confirmed-defect register as cross-cutting/medium, then absent from every
phase of its implementation plan. Verification is on-glass (force the latch, reconnect a client that
requests HDR, confirm the Welcome/SDP reports SDR).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:07:04 +02:00
enricobuehlerandClaude Opus 5 59e33ad11d Merge origin/main into the pf-capture sweep
Upstream landed `fc335b39` (negotiate the cursor around what the encoder can blend) on the same
files this sweep restructured. Merged rather than rebased: the sweep MOVED ~2,000 lines out of
`linux/mod.rs` into `pipewire.rs`/`portal.rs`/`pw_*.rs`, so a rebase would have re-fought the same
conflict in up to nine commits; merging resolves it once with both sides in view. The repo already
carries merge commits (`land/sweep-all`).

Two conflicts, both resolved by keeping BOTH changes:

* `linux/mod.rs` — `PortalCapturer::open` gains upstream's `want_metadata_cursor` AND keeps the
  sweep's `PortalSession` teardown, so the portal threads now take `(setup_tx, quit_rx,
  want_metadata_cursor)`. The second conflict was upstream editing inside the region Phase 5.1/5.3
  moved out; the split wins and upstream's change is ported to where the code now lives:
  `choose_cursor_mode`'s new `want_metadata` ladder (4 arms — prefer Embedded when the encoder can't
  blend, and warn-then-take Metadata when Embedded isn't advertised) is now in `portal.rs`, taken
  verbatim.
* `gamestream/stream.rs` — the pooled-capturer slot keeps upstream's third reuse key
  (`metadata_cursor`, beside HDR-ness) AND the sweep's `result.is_ok() && capturer.is_alive()`
  re-pool gate. Both guard the same slot against different failures: theirs against reusing a
  session negotiated for the wrong cursor mode, the sweep's (L2) against reusing a dead one.

Everything else auto-merged, including the `want_metadata_cursor` thread through
`host/capture.rs`'s facade and `native/stream.rs`'s `session_plan::cursor_blend_for`.

Verified after the merge: workspace `cargo test` green, `clippy --workspace --all-targets` clean on
Linux AND on x86_64-pc-windows-msvc, `cargo fmt --check --all` clean, pf-capture 38/38.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:44:22 +02:00
enricobuehlerandClaude Opus 5 14914bc72f test(pf-capture): the suite the splits enable — 38 Linux + 19 Windows tests (Phase 6)
The plan's priority order was "every producer- or OS-controlled parser, blend, geometry guard or
negotiation decision", none of which had a test before this sweep (X3). Phase 5's splits are what
made most of them reachable without a compositor or a driver.

**6.1 `bitmap_extent`** — extracted from `update_cursor_meta`, which is the guard whose own SAFETY
proof says a missing bound "SIGSEGVs inside the PipeWire `.process` callback (a segfault
`catch_unwind` cannot catch)". Every input except the region size is producer-written. 5 tests:
a bitmap that fits (with and without header/pixel offsets); the last-row rule (`stride·(bh−1) + row`,
so a bitmap ending flush against the region is accepted rather than rejected by padding that is
never read — and one byte short is rejected); anything past the region; degenerate geometry; and four
distinct overflow vectors including the near-`i32::MAX` stride the SAFETY comment calls out. One
assertion I first wrote was wrong, not the code — with a single row `stride·0 == 0`, so even a
`usize::MAX`-wide row is arithmetically in range; the test now exercises the ≥2-row case that really
overflows and says why the other is fine (the caller has already capped `bw` at 1024).

**6.2 the composite blits** — 8 tests over `composite_cursor` / `composite_cursor_rgb10`: every
packed `PixelFormat` arm lands the colour in its OWN channels (so a byte-order slip cannot pass);
clipping off all four edges plus six fully-outside positions; zero-alpha, `visible: false` and
no-bitmap-yet all drawing nothing; the integer alpha blend; NV12/Yuv444 declined rather than
mis-blitted; and for the 10-bit path a bit-exact round trip of an untouched pixel (including the two
alpha bits the repack must preserve), the R-at-bit-20 vs R-at-bit-0 distinction between `X2Rgb10` and
`X2Bgr10`, and the same clipping. Plus `decode_bitmap_pixel`'s four byte orders.

**6.4 the pod builders** — 4 tests. The `dataType` bitmask is pinned per Buffers pod, because each
bit is load-bearing in a different direction: the mappable pod MUST include DmaBuf (or gamescope's
modifier-bearing format pod wins and the buffer intersection is empty — a link silently stuck in
"negotiating"), the SHM-only pod MUST exclude it (or Mutter hands dmabufs and the race-free download
path is not), and the dmabuf pod MUST exclude the mappable types (or an HDR session can be handed a
MemFd buffer, which Mutter paints 8-bit ARGB32 regardless of the negotiated 10-bit format). Also:
every pod parses back through `Pod::from_bytes`; the HDR pods carry MANDATORY PQ + BT.2020 +
LINEAR-modifier; and only the NV12 offer pins the colour matrix/range. The `dataType` reader parses
the SPA property layout literally (`key, flags, size, type, value`) — a "find the first
plausible-looking int" heuristic read the `size` word, which is also 4, and reported the wrong mask.

**6.5 `FrameToken` generation masking (W14)** — 2 tests, with `IDD_GENERATION` parked two below the
24-bit boundary so the WRAP is what gets exercised: every minted generation is non-zero, fits the
token's field, and survives the pack/unpack round trip `try_consume` performs; and the cleared-
`latest` 0 sentinel never matches a live generation.

**6.6 the cursor conversion (Windows)** — `convert`'s pixel logic extracted from its GDI plumbing
into `mono_planes_to_rgba` / `apply_and_mask_alpha` / `alpha_is_empty`, which is what makes it
testable at all (the caller needs a live `HCURSOR` and a screen DC). 6 tests: the four-state AND/XOR
truth table in one row; transparency surviving without an invert neighbour; the invert outline
covering all eight neighbours, overwriting only TRANSPARENT ones, and clipping at the edges; the
alpha-less colour cursor taking alpha from the AND mask; and a short mask not panicking.

**6.3 / 6.7** landed with the fixes they guard (`negotiation_plan` in 2.3, `f32_to_f16` in 3.5).

38 Linux tests, up from 6 at the start of the sweep — ci.yml already runs them. 19 `#[test]`s on the
Windows side; Phase 0.1's `--all-targets` lint type-checks them all, but note it does not RUN them
(the workflow lints only), so the Windows ones execute on a Windows box. clippy --all-targets clean
on both targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:31:34 +02:00
enricobuehlerandClaude Opus 5 306f4a514d refactor(pf-capture): structural splits + collapse the restated signal set (Phase 5)
Refactors LAST, after every defect fix, so no behaviour change hides in a move diff — and so the
newly-exposed review surface was already clean when it landed (the WP7 discipline). 28f8fc71's
recorded trap (an inline `use super::X` inside a moved body silently changing meaning) was audited
first: every `use super::` in the moved code is either at module level — where `super` still means
`linux` — or inside a `mod tests` that moved with its parent.

5.1 `mod pipewire` → `linux/pipewire.rs`. It was 1,900 of that file's lines. `mod.rs` is a directory
module, so a plain `mod pipewire;` resolves with no `#[path]`.

5.2 out of that, `linux/pw_pods.rs` (the 7 param-pod builders + `serialize_pod` + the PQ constant
and its test) and `linux/pw_cursor.rs` (`CursorState`, `decode_bitmap_pixel`, `update_cursor_meta`,
`dst_offsets`, both `composite_cursor*`). The pods are the crate's WIRE surface — a missing property
is not a compile error but a link that stalls in `negotiating`; the cursor half is producer-driven
and bounds-critical. Both are now pure enough to unit-test without a compositor, which is what
Phase 6 needs.

5.3 `linux/portal.rs`: the ScreenCast/RemoteDesktop handshake, the cursor-mode choice and GNOME's
BT.2100 probe — the async/tokio/zbus control plane, separated from the realtime half. Zero
re-exports changed: `mod.rs` re-exports `gnome_hdr_monitor_active` at its old path.

5.4 `idd_push/open.rs` (the whole one-time construction path + `SharedObjectSa` + `AttachTexFail`)
and `idd_push/compose_kick.rs`. Read `open.rs` when a session will not START and the parent when one
stops flowing; the steady state stays with the parent by decision. Also moved the ~65-line stall
REPORTING block out of `try_consume` (the hot loop) into `StallWatch::report`, taking its two
correlation counters with it — they were capturer fields nothing else touched.

5.5 `windows/dxgi/selftest.rs`: `p010_reference`, both self-tests, `hdr_p010_convert_bars_on_luid`,
`f32_to_f16` and the two test modules — the validation path, none of which runs in a session. The
two `pub` entry points are re-exported, so `main.rs`'s subcommand and pf-encode's live e2e keep
their paths. (An explicit `#[path]`, like `idd_push`'s children: this file is itself reached through
a `#[path]`, so a bare `mod` would resolve to `windows/selftest.rs`.)

5.6 `CaptureSignals`. The seven shared flags/slots were created in `spawn_pipewire`, `_cb`-cloned one
by one, passed as seven of `pipewire_thread`'s parameters, and then re-declared as fields on
`PwHandles`, `PortalCapturer` AND `UserData` — the same list written four times, each with its own
drifting copy of the doc comment. One `#[derive(Clone)]` struct instead (a refcount bump per field),
which also makes it structurally obvious that producer and consumer share ONE set. And `CaptureOpts`
for the four trailing `bool`s: four adjacent same-typed positional arguments are a
silent-transposition footgun — swap `want_444` and `want_hdr` and it compiles, negotiates the wrong
pod family, and surfaces as a black screen ten seconds later.

5.7 trait shape, targeted: `set_active(&self)` → `&mut self` (it took `&self` only because the flag
happened to be an `Arc<AtomicBool>` — an implementation detail leaking into the contract);
`capture_target_id` ⇒ `resize_output`'s pairing rule stated on both methods and in a cluster note;
one-line cluster headers over the 13 methods.

NOT done from 5.7: taking the gamescope targets as an `open_*` option instead of the trait method.
It would put a Linux-only parameter on the cross-platform `open_virtual_output` and on the host's
`capture_virtual_output` facade — a `#[cfg]`-shaped wart in two shared signatures to delete one
already-`cfg`'d defaulted method whose lifecycle rule 2.5c had already made harmless. Wrong trade
under 5.7's own "no churn for no defect fixed" principle.

File sizes: `linux/mod.rs` 2,778 → 770 (target ≤900 ✓), `windows/dxgi.rs` 1,374 → 954, and
`windows/idd_push.rs` 2,694 → 1,922. The last two miss the plan's ≤850/≤1,300 targets, which were
computed against the ORIGINAL line counts — Phases 1–4 added several hundred lines of SAFETY proofs
and defect rationale to exactly these files before the split. The moves themselves are the ones the
plan specifies; closing the rest would mean inventing new splits it does not call for.

Zero behaviour delta. pf-capture 20/20; workspace clippy --all-targets clean on Linux and on
windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:25:39 +02:00
enricobuehlerandClaude Opus 5 530909a154 perf(pf-capture): take three per-frame costs off the Windows HDR hot path (Phase 4)
**4.1 (W10) — the HDR convert allocated two views and mapped a constant buffer per frame, inside
the ring slot's keyed-mutex hold.** So the driver's publisher sat blocked on that slot while the
host created `CreateRenderTargetView`s and Mapped/Unmapped a 16-byte buffer whose contents never
change. Both are lifetime-of-mode facts, not per-frame ones:
  - the two P010 plane RTVs now belong to the out-ring SLOT, built once in `ensure_out_ring`
    (`out_ring` becomes `Vec<OutSlot>`). A driver that rejects planar RTVs — the one hard
    requirement of this path — therefore fails at ring build, where such a failure belongs, instead
    of on the first frame.
  - `inv_src` becomes an IMMUTABLE constant buffer filled in `HdrP010Converter::new(device, w, h)`.
    The converter is already dropped by `recreate_ring` on every mode change, so it cannot go
    stale. This also deletes the `Map`'s silently-ignored failure: it could leave the chroma pass
    sampling at a garbage texel size with no error anywhere.

**4.2 (W15) — `sdr_white_level_scale` ran inside the keyed-mutex hold too.** It is a CCD query that
contends the display-config lock — precisely the contention `DescriptorPoller`'s doc says must stay
off the frame path — and `prepare_blend_scratch` called it while holding the slot. Moved to
`refresh_sdr_white_scale`, called at open and after each ring recreate (where the scratch is
invalidated anyway). "Only on scratch rebuilds" is not the same as harmless when the thing being
blocked is the producer.

**4.3 (W11) — `VideoProcessorSetStreamAutoProcessingMode(vp, 0, false)`.** The comment claimed "no
interpolation/auto-processing" while only setting the frame format; auto-processing's documented
default is ENABLED, so vendor denoise/edge-enhancement was free to run inside every SDR
`VideoProcessorBlt` — altering the pixels we encode and spending video-engine time on the
desktop-capture hot path. Now actually disabled, with each call's purpose stated separately.

**4.4 (the Linux CPU-path map cache) is deliberately NOT done.** The plan gates it on measurement
("measure first, and only if the CPU path still matters") and this box cannot measure a live capture
session; it also needs a frame-buffer return path through `FramePayload::Cpu`, which is a larger
change than the rest of this phase. Left for Phase 7's measured pass.

Compile-verified for windows-msvc locally; pf-capture 20/20 on Linux; workspace clippy
--all-targets clean on both targets. The GPU-capture check and the measured `cap_us` delta are owed
to Phase 7.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:11:46 +02:00
enricobuehlerandClaude Opus 5 bc45287dc6 fix(pf-capture): Windows IDD-push defects — HDR pin, cursor, recreate, handles, f16 (Phase 3)
Compile-verified for x86_64-pc-windows-msvc locally (a scratch workspace symlinking the real
sources against a stub punktfunk-core — the `quic` feature's ring/opus C builds are what blocks an
in-tree cross-check). Behaviour is owed the on-glass validation in sweep Phase 7.3.

**3.1 (W1/F3) — the HDR pin asserted a flip it never verified.** `poll_display_hdr` discarded
`set_advanced_color`'s `bool` and then wrote `now.hdr = self.client_10bit` — the DESIRED state in
place of the observed one. On a display that cannot be flipped (the state this file already logs as
"Downgrade point D" at open) that broke in both directions: wanting HDR, the fabricated `true`
differed from `current`, so two poller samples drove `recreate_ring(true, …)` and rebuilt the ring
FP16 while the driver composed 8-bit BGRA — every publish dropped by the driver's format guard,
`recovering_since` expiring, `try_consume` bailing: a permanent 3-second reconnect loop. Wanting
SDR, the fabricated `false` MATCHED `current`, so no recreate ever fired and the ring stayed BGRA
against an FP16 composition — the same dropped-publish outcome, silently. Now it re-reads
`advanced_color_enabled` and follows what the display actually composes, with a one-shot error
naming want/observed/returned. A not-yet-settled read costs one debounce cycle, never a wrong ring,
which is why this does not block the frame path on a settle poll the way `open_on` does. Downgrade
point D's error now carries the same pair, so `Some(false)` (display says no) and `None` (the CCD
read failed) are distinguishable.

**3.2 (W2, W3, W6) — cursor correctness.**
  - W2: the poller's desktop rect was captured once at open and used forever, for BOTH the
    desktop→frame offset and the `in_rect` visibility test — while both mid-session mode-change
    paths (`resize_output`, `poll_display_hdr` → `recreate_ring`) keep the same poller. After an
    in-place resize the pointer was clipped to the old rect and offset by a stale origin. It is now
    a SEED: the poll thread re-queries on its existing 250 ms reattach cadence, keeping the last
    good value on `None` (a transient CCD failure must not park the rect at zero and report every
    position invisible), which keeps the CCD call off the encode thread as `DescriptorPoller`
    demands.
  - W3: `composite_forced` tested `cursor_sender.is_none()`, but §8.6's rationale is "no cursor
    CHANNEL" — and the delivery just above it is explicitly allowed to fail non-fatally, which is
    precisely the state needing the rescue. It was the one state that skipped it: a negotiated
    channel that failed to create or deliver left a cursor-excluded target with NO pointer at all.
    Now `cursor_shared.is_none()`, evaluated after that binding.
  - W6: `cursor()` degraded poller→shm correctly, but the BLEND path — the only consumer that
    matters in the composite model, since the Windows encode loop never attaches `frame.cursor` —
    read the poller directly with no `alive()` check and no fallback, so the documented fallback and
    the spawn-failure warning were both untrue for exactly those sessions (a dead poller meant
    pointer-less frames, not a degraded pointer). One `live_cursor()` now serves all three
    consumers and LATCHES the source, because the two keep independent serial namespaces and
    interleaving them poisons the client's shape cache.

**3.3 (W4, W5, W14) — recreate hardening.**
  - W4: `recreate_ring` committed `display_hdr`/`width`/`height` BEFORE the fallible
    `create_ring_slots` (VRAM pressure at a large new mode — exactly when resizes happen), leaving
    a failed recreate emitting frames stamped with the new geometry against the old ring, the old
    generation and an unchanged header. Slots are built first; nothing after the commit point fails.
  - W5: a recreate never cleared the driver's status words, and `wait_for_attach` — the only
    classifier of TEX_FAIL/BIND_FAIL and the only source of the LUID rebind — runs at open ONLY. A
    stale `OPENED` therefore made a failed re-attach look healthy while the recover-or-drop bail
    reported nothing. Now cleared before the Release generation store (plus `status_logged`), and
    the 3 s bail prints the live `(driver_status, detail, render_luid)` the way `next_frame`'s 20 s
    bail already did. The four-field read is one `driver_diag()` helper instead of four copies of
    the same unsafe block.
  - W14: `IDD_GENERATION` is a full `u32` but the publish token carries 24 bits and `unpack` masks
    what it reads, so past 2²⁴ recreates `tok.generation != self.generation` would be permanently
    true — every frame rejected. Masked at the single mint point, and 0 skipped (it is also the
    cleared-`latest` sentinel).

**3.4 (W8, W9, W12) — handle hygiene.** `shared_object_sa`'s security descriptor is a `LocalAlloc`
nobody freed: leaked twice per open and once per ring recreate. It is now an RAII `SharedObjectSa`
whose `Drop` `LocalFree`s it and whose `as_ptr()` only lends a borrow — which also makes the
"descriptor must outlive the attributes" rule structural instead of a comment. The PyroWave fence's
shared NT handle, created per capturer and never closed, becomes an `OwnedHandle` (the encoder holds
its own duplicate, so closing ours is safe). `cursor_blend`'s `cbuf_scale` is cached only on a
successful `Map` — caching unconditionally wedged the HDR/SDR cursor scale for the session after one
transient failure.

**3.5 (W7) — `f32_to_f16` swallowed the rounding carry.** `sign | half_exp | (half_mant + round)`
ORs a mantissa carry into bit 10, so for every ODD biased exponent (bit 10 already set) the carry
vanished and the result came back ~2× low: `1.9998779 → 1.0`, `0.49996948 → 0.25`. Only values one
ULP below a power of two are affected — precisely what a gradient test pattern is full of — so this
made `hdr-p010-selftest` FAIL a correct shader. Composed additively, with 4 tests (18 asserted bit
patterns, a round-trip property over the self-test's scRGB values, saturation) — all verified
numerically against a standalone reference on this box, including a scan confirming old-vs-new
diverges ONLY on the carry cases. Phase 0.1's `--all-targets` lint is what lets these compile in CI
at all.

pf-capture 20/20 on Linux; workspace clippy --all-targets clean on Linux and windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:06:26 +02:00
enricobuehlerandClaude Opus 5 9a7b3a46d0 fix(pf-capture): gamescope cursor — auth without setenv, frame-space coords, live targets (2.5)
**2.5a (L4) — stop `set_var`ing `XAUTHORITY` from a live multithreaded host.** The old connect
swapped the process-global var around each `RustConnection::connect` under a mutex. That lock
serialises this source against itself and nothing else: `getenv` takes no lock, so every OTHER
thread's read raced it — and the concurrency is by construction, since `attach_gamescope_cursor`
runs while the PipeWire thread is starting up (libspa plugin load, EGL/CUDA init). The primary path
now parses the MIT-MAGIC-COOKIE-1 entry out of the given file itself and hands it to
`DefaultStream::connect` + `RustConnection::connect_to_stream_with_auth_info` — the same two steps
`RustConnection::connect` performs internally, minus its env-derived auth lookup — so nothing in
this process touches the environment. The env swap survives only as a fallback for a file we cannot
parse, and a wrong cookie pick cannot do damage: the server rejects it and the fallback (which does
libxcb's full family/address match) takes over. Sharing `pf_vdisplay`'s process-wide env lock was
not the fix — wrong layer, and it would still not fix `getenv`.

**2.5b (L6) — publish the pointer in FRAME coordinates.** `QueryPointer` answers in the nested
root's space, but `CursorOverlay::x/y`'s contract is frame pixels, and gamescope's `-w/-h` (nested
root) and `-W/-H` (output + PipeWire node) are independent knobs — at `-W 1280 -H 720 -w 640 -h 360`
they differ by 2×, so the pointer drew at a fraction of its real position. The root size comes free
off the setup reply we already parse; the negotiated frame size arrives from the PipeWire thread's
`param_changed` through a new `Arc<AtomicU64>` (`0` = not negotiated ⇒ pass through, as before).
Scaling is computed in `i64` — a 5K coordinate times a 5K width overflows `i32`. Position only: the
bitmap stays at root scale, warned once so a mismatched session is visible.

**2.5c (L7) — the targets are a PROVIDER, not a snapshot.** The list was discovered once, before
the game launched. gamescope creates the game's Xwayland at launch and advertises only the FIRST in
any child's environ (verified on this box: `--xwayland-count 2` makes `:2` and `:3`, only `:2` is
advertised), so the game's display was invisible — and when the connected Big Picture display then
reported "gamescope is not drawing the pointer here", the source blanked the cursor for the whole
game session, which is the exact regression the module doc says it fixed. The worker now re-runs the
provider every 2 s: it adopts new Xwaylands, reconnects dead ones, and `spawn` no longer returns
`None` on an empty list — a stream that starts before the game converges instead of staying
cursorless. The provider is a host-facade closure, same one-way-edge shape as `FrameChannelSender`.

**2.5d (L8) — bound the teardown join.** `Drop` joined the worker unbounded while the worker blocks
in `RustConnection` replies with NO read timeout, so a peer that stops answering but keeps its
socket open hung capturer teardown — on the session path. Now: a completion channel,
`recv_timeout(250 ms)`, then detach with a warning (the thread only touches its own X connections
and an `Arc`'d slot).

7 new tests, all host-runnable: the cookie reader against a hostile `.Xauthority` (wrong protocol,
wrong display, wildcard entry, truncation mid-length-prefix, an over-long length prefix, a missing
file), display-string parsing, and the root→frame mapping incl. the 5K overflow. pf-capture 20/20;
workspace clippy --all-targets clean on Linux and windows-msvc. Phase 7.2 owes the on-glass
nested-gamescope validation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:56:05 +02:00
enricobuehlerandClaude Fable 5 3f4ad08869 test(encode/ffmpeg_win): extract the decision logic and pin it with unit tests
The QSV open-failure fallback (1,400 lines, 23 unsafe, 0 tests) follows the
vaapi.rs treatment: the device-free decisions now live in named functions
with their contracts pinned — the per-vendor zero-copy default matrix (AMF
on-glass-validated on, QSV opt-in), the PUNKTFUNK_FFWIN_POLL_MS
clamp-before-µs-conversion (the 27.7-hour-spin class), the readback routing
table with its mid-stream depth-change guard, the swscale source map, the
QSV display-remoting latency contract (async_depth=1/low_power=1/
look_ahead=0/forced_idr=1/scenario) and the AMF no-B-frames contract, and
the per-vendor zero-copy pool bind flags. A probe smoke rides along
#[ignore]d for the runner. No FFI plumbing chased; no behavior changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:50:33 +02:00
enricobuehlerandClaude Fable 5 fc335b39e9 fix(host/encode): negotiate the cursor around what the encoder can blend
EncoderCaps::blends_cursor's contract said the HOST must fall back to
capturer-side compositing when a cursor-as-metadata session lands on an
encoder that can't composite — but that host half was never built: open_video
warned and the session streamed WITHOUT a pointer (confirmed on the VAAPI
dmabuf and libav-NVENC CUDA paths; latent on vulkan RGB-direct/native-NV12).
The negotiation is now caps-aware, ahead of capture, on both planes:

* pf-encode grows cursor_blend_capable() — the pre-open dispatch mirror
  (sibling of linux_native_nv12_ok) answering whether the resolved backend
  composites frame.cursor; its pure core is test-pinned arm by arm.
* Native plane: handshake::cursor_forward grants the cursor channel only
  where the resolved backend can blend (the capture-mouse flip makes the host
  draw the pointer on demand); denied sessions keep the pre-channel path —
  the compositor EMBEDS the pointer, never cursorless, never doubled. The
  Welcome's HOST_CAP_CURSOR bit is computed once and read back at both
  session-wiring sites instead of recomputed. SessionPlan::output_format
  additionally keeps every cursor-blend session off producer-native NV12 (the
  arm with no CSC to fold a cursor into), and vulkan RGB-direct now yields to
  a cursor-blend session even when pinned (EFC cannot composite; the open
  logs the override). Windows plans cursor_blend=false via the new shared
  cursor_blend_for() rule — the IDD capturer composites the pointer itself,
  and asking the encoder anyway fired the blends-cursor warn spuriously on
  every cursor-channel session.
* GameStream plane: the hardcoded cursor_blend=true is gone. The portal
  source asks for cursor-as-metadata only when the resolved backend blends,
  otherwise negotiates an Embedded pointer (choose_cursor_mode's new ladder);
  the capturer pool now also keys on that mode. The virtual-output source
  passes false — its capture embeds the pointer where it can.

The per-arm warns in vulkan_video (RGB-direct, native-NV12) are now
structurally unreachable and removed. open_video's post-open check stays as
the single backstop for what planning cannot see: a Vulkan-open falling back
to VAAPI mid-session, and the gamescope residual (no embedded mode exists
there, so a never-blending backend — H.264-on-AMD VAAPI, software — still
streams cursorless; fixing that needs a compositing stage, deliberately not
built in this pass). Zero-copy is preserved throughout — every fallback is a
capture-negotiation change, never a readback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:50:33 +02:00
enricobuehlerandClaude Opus 5 759fdf767c fix(pf-capture): buffer-geometry guards + a genuine drop-oldest hand-off (sweep 2.4, 2.6)
**2.4 — buffer geometry correctness on the Linux CPU path.**

L5: the self-mmap de-pad mapped the fd from offset 0 and then indexed by `chunk.offset()` ALONE,
ignoring `spa_data.mapoffset` — where that spa_data's region actually begins inside the fd. For a
pooled-memfd producer (every buffer a slice of one fd) it therefore read the WRONG buffer, and the
`needed > avail` guard could not catch it: `avail` came from the whole-fd mapping, so it was ample
for any single buffer's span. PipeWire's own MAP_BUFFERS slice (the fallback) was always correct
because it already starts at `mapoffset` — only the hand-rolled "fix" path was wrong, so the two
paths now compute their base separately (checked add; both halves are producer-controlled).

L15: when `fstat` failed, the mmap length was invented as `offset + needed` — producer-controlled
geometry. That defeats the entire point of the fstat (the "buffer smaller than the frame span"
guard then compares against the number the producer just supplied) and can map, and read, past the
end of the object — a SIGBUS, not an `Err`. Without a real length we now decline to self-map and
let PipeWire's own slice serve, which is bounded by construction.

L12: `bytes_per_pixel()`'s catch-all answers 4 for NV12, so a producer-native NV12 buffer computed
`row = 4w` against a stride of ~w and ALWAYS tripped the stride guard — reporting "chunk stride <
row", i.e. blaming the producer for a host limitation. NV12 cannot be de-padded on this path at all
(plane 1 is not even in `datas[0]`'s span), so reject it with a message that says which side is at
fault and under what condition the NV12 offer is valid.

L14: `spa_meta_bitmap` was read field-by-field through an aligned `*const` at a
PRODUCER-controlled offset, with a SAFETY comment asserting an alignment the code never
established. One `read_unaligned` of the `Copy` POD instead.

**2.6 — hand-off semantics (L9, L10).** The frame channel was `sync_channel(8)` + `try_send`, i.e.
drop-NEWEST, while the trait documented drop-oldest. Neither remedy the plan offered works: a
`SyncSender` cannot pop, so "drain one and retry" is not expressible, and shrinking the bound makes
staleness WORSE, not better (with bound N the queue holds the first N frames of a stall and
everything after is discarded, so a larger N actually yields a fresher frame — the defect is the
discarding, not the depth). Replaced with a one-deep OVERWRITING mailbox plus a depth-1 wakeup
channel: publishing overwrites, so a stalled consumer loses the intermediate frames and is always
handed the freshest one. `next_frame` used to return the OLDEST of eight stale frames.

Falls out of it:
  - `wait_arrival` now honours its documented "must NOT consume" contract by peeking the slot. The
    `pending` field existed only to stash a frame the un-peekable channel forced it to consume;
    it is gone.
  - a queued `CapturedFrame` can own a dup'd dmabuf fd or a CUDA buffer, so the old 8-deep backlog
    could pin eight compositor buffers. One-deep by construction now.
  - L10: `set_active(false)` flushes the mailbox, so a reused capturer no longer opens the next
    stream with the previous session's last frame (wrong content, and a `pts_ns` from the old
    clock). Two lines, where flushing a queue + a `pending` stash was fiddlier.
  - the producer-liveness signal moves to the wakeup channel's `Disconnected`, and a final frame
    left in the slot as the thread exits is still served before the error.

pf-capture 13/13; workspace clippy --all-targets clean on Linux and windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:48:18 +02:00
enricobuehlerandClaude Fable 5 f495b201e1 fix(encode): delete the write-only EncoderCaps::supports_hdr_metadata
A caps field nothing reads is a contract nobody honors — and this one shipped
write-only: its single reader anywhere in the workspace was a hardware-gated
assertion inside pf-encode's own AMF smoke test. Both planes send the static
HDR grade out-of-band unconditionally (the native 0xCE datagram per keyframe,
the GameStream 0x010e control message), every first-party client reads
exclusively that path, and none parse in-band SEI — so the host decision the
field was reserved for (suppress out-of-band when the encoder embeds) can
never validly exist. The field's doc contract had also rotted in two
directions: it claimed set_hdr_meta no-ops when false (native AMF and QSV
consume it regardless) and that only Windows direct-NVENC attaches in-band
metadata (AMF and QSV do too). The in-band SEI/OBU emission itself is
untouched — it stays a bonus for stock decoders, documented at the emit
sites; the trait docs now describe the real routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:48:07 +02:00
enricobuehlerandClaude Opus 5 17eb8100a3 fix(pf-capture): portal teardown, observable death, one negotiation resolver (sweep 2.1–2.3)
**2.1 (L1) — the portal thread leaked a live screencast session.** It parked on
`future::pending()` and `Drop` stopped only the PipeWire thread, so every dropped portal capturer
permanently leaked a thread + a 2-worker tokio runtime + a zbus connection — and because dropping
that connection is what ends the compositor's cast (ashpd's `Session` has no `Drop`), the
COMPOSITOR-side session leaked too. GameStream drops the pooled capturer on every HDR↔SDR
reconnect flip and opens a fresh one, so the sessions accumulated: exactly the "second conflicting
screencast" the pooling exists to prevent. Both portal threads now park on a `oneshot::Receiver`
and `PortalSession::drop` fires it, then waits — BOUNDED (750 ms, then detach with a warning) —
for a completion signal the thread sends after its runtime has been dropped. Bounded because
teardown runs on the session path and the thread may be inside a D-Bus round-trip: an unbounded
`join()` there would hang the host rather than leak. Every early-return path in `open` drops the
session too, so a failed PipeWire spawn no longer strands a fresh cast.

**2.2 (L2) — a dead capturer was pooled forever.** All three of the portal backend's terminal
states are sticky and observable only by consuming a frame, and `Capturer` exposed no health
predicate, so GameStream re-pooled unconditionally — after `result` was already an `Err`. One
zerocopy-worker death or one compositor restart therefore wedged GameStream portal video at 10 s
per reconnect attempt, permanently (that path has no rebuild closure). Adds
`Capturer::is_alive()` (default `true`), implemented for `PortalCapturer` as
`!broken && streaming && !thread.is_finished()` — the third term catches a dead PipeWire thread,
which is otherwise indistinguishable from an idle desktop. A static desktop stays `Streaming`, so
an idle-but-healthy capture is never reported dead. The host re-pools only on
`result.is_ok() && capturer.is_alive()` and logs which of the two retired it. Both halves ship
together: either alone leaves the wedge reachable.

**2.3 (L3/F1) — a "VAAPI" downgrade latch was really a global zero-copy kill switch.**
`spawn_pipewire` hand-mirrored the thread's `vaapi_passthrough` decision and the copy omitted
`raw_dmabuf_import_disabled`, so once that latch fired the capturer still believed it had made the
passthrough offer while the thread had already fallen back — and its timeout branch then latched a
downgrade for an offer nobody made. That downgrade was `note_vaapi_dmabuf_failed`, which fed
`pf_zerocopy::enabled()`, so ONE failed negotiation dropped every later session on the host — the
NVENC EGL→CUDA path included — to CPU capture until restart. And since the raw-passthrough offer
is also the PyroWave one on ANY vendor, a single PyroWave negotiation timeout was enough to do it
on an NVIDIA box.

Two fixes, both structural:
  - `negotiation_plan(NegotiationInputs) -> NegotiationPlan` is now the ONE resolver, consumed by
    the thread AND by `spawn_pipewire` — the mirror is deleted, not re-synced (WP7.6's shape), so
    the drift class is unrepresentable. It is pure (every env/latch read is an input), which is
    what makes it testable; the runtime-dependent half stays a method (`want_dmabuf` needs the
    modifier list the importer's construction actually yielded). The redundant `importer.is_none()`
    term goes: `build_importer` already excludes the passthrough, and that term is what made the
    decision look impure enough to "need" a mirror. `PUNKTFUNK_FORCE_SHM` was ALSO read in both
    places; now once.
  - the capture-side downgrade becomes `pf_zerocopy::note_raw_dmabuf_negotiation_failed`, which
    latches `RAW_DMABUF_DISABLED` — gating only the raw-passthrough decision. `enabled()` is now
    the env var alone, and `VAAPI_DMABUF_FAILED` is deleted.

Also (L13, needed by 2.3's plan inputs): `PUNKTFUNK_PIPEWIRE_NV12` honoured only the exact string
`"0"`, so `"0 "` or `"false"` read as force-ON — the bug class of ed525c4c. pf-host-config's
explicit-off grammar is now exported as `env_on()` and the four in-crate copies (`PUNKTFUNK_
ZEROCOPY`/`_10BIT`/`_444`/`_CHACHA20`) plus this new caller share it.

7 new tests pin the four invariants that were prose-only comments, plus the latch property that
made the mirror wrong. pf-capture 13/13; workspace clippy --all-targets clean on Linux and on
x86_64-pc-windows-msvc. On-glass validation of 2.1/2.2/2.3 is owed (sweep Phase 7.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:41:53 +02:00
enricobuehlerandClaude Fable 5 232b6d6be2 test(encode/vaapi): extract the open-time decision logic and pin it with unit tests
The fallback backend under Vulkan Video — and the only AMD/Intel H.264 and
10-bit/HDR encoder — had 1,300 lines, 26 unsafe blocks, and zero tests. The
device-free decisions now live in named functions with their contracts pinned:
the entrypoint ladder + LP_MODE latch round-trip (the cross-GPU session-killer
and the 8-bit-pins-10-bit under-advertisement are both key'd tests now), the
PUNKTFUNK_VAAPI_LOW_POWER / _ASYNC_DEPTH grammars, the VUI ↔ scale_vaapi
colour agreement (the Mesa-BT.601 hue-shift pin), the honest-downgrade depth
table, the HEVC-Main10-only explicit profile, and the 10-bit probe gate.
Probe + CPU-path encode round-trip ride along as #[ignore]d hardware smokes
in the house style. No FFI plumbing was chased; no behavior changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:29:31 +02:00
enricobuehlerandClaude Opus 5 54a37aeb46 docs(pf-capture): truth pass over comments, docs and log strings (sweep Phase 1)
Every item here is prose that actively misdirects a maintainer or an operator. Landing it before
the defect fixes means those diffs get reviewed against accurate comments.

1.1 (X5) — split two merged doc blocks that documented the item ABOVE them: `hdr_meta`'s whole
contract was glued onto `fn cursor()` in lib.rs (leaving `hdr_meta` undocumented), and
`hdr_p010_selftest_at`'s was glued onto `hdr_p010_convert_bars_on_luid` in dxgi.rs. rustdoc for
the trait's central HDR contract was simply wrong.

1.2 (L9 doc half) — the `Capturer` trait doc advertised a "bounded drop-oldest channel". The
Linux portal's `sync_channel(8)` + `try_send` is drop-NEWEST: under a consumer stall the arriving
frame is discarded and the encoder gets the stalest queued one. Say so. (Phase 2.6 changes the
behaviour; this commit only stops the doc from lying about today's code.)

1.3 — delete the DONT_FIXATE claim from the dmabuf offer comment. `build_dmabuf_format` emits a
plain MANDATORY `ChoiceEnum::Enum` and `param_changed` re-emits nothing, so there is no two-step
DMA-BUF handshake here; libspa 0.9's `ChoiceFlags` cannot even express DONT_FIXATE. Recorded as a
real follow-up instead of an implemented thing.

1.4 — `mainloop.run()` no longer "blocks until process exit": the quit channel attached above it
stops the loop from `PortalCapturer::drop`.

1.5 (L11) — the 6-arm negotiation log ladder told a fully zero-copy PyroWave session "VAAPI
encode with the CPU capture path — zero-copy was disabled", one line after logging that it had
advertised the PyroWave device's dmabuf modifiers: the passthrough arm was gated on
`pyrowave_modifiers.is_empty()`, so the pyrowave case fell through to the CPU warning. Ungate it;
the CPU-path arm is now reachable only when no dmabuf is advertised at all, which is what it
claims. Its parenthetical also gains the third real cause (the session asked for CPU frames).
Control-flow-neutral for every other combination.

1.6 (W13) — `kick_dwm_compose` claimed a "sub-millisecond" round trip while its
cursor-on-a-sibling-display branch sleeps 35 ms on the CALLER's thread. State the cost on the
function and at both call sites, including which one is on the live frame path.

1.7 (W16) — DDA-era residue, DDA having been removed:
  - retarget the win32u hook's rationale + all four log strings: its job is no longer "keep DDA on
    one adapter" but "keep the virtual display on the adapter SET_RENDER_ADAPTER pinned" (a DXGI
    reparent surfaces as the driver's TEX_FAIL render-adapter mismatch). The hook stays installed.
  - the per-monitor-v2 DPI awareness rationale likewise: not DuplicateOutput1's E_ACCESSDENIED any
    more, but keeping cursor/window coordinates in the PHYSICAL pixels the host's CCD geometry
    (`source_desktop_rect`, `desktop_bounds`) is already in.
  - `HYBRID_HOOK_HITS` was write-only. Surface it as `hybrid_hook_hits()` on the IDD-push open
    line, which is the first point where DXGI has actually been exercised — the patch-readback
    check proves the bytes landed, only this proves DXGI reaches the export.
  - delete `hdr_p010_selftest()`: an unreachable 64×64 wrapper (main.rs calls
    `hdr_p010_selftest_at`); its description moves onto the function that survives.
  - `VideoConverter`'s docs promised a P010/BT.2020 output and a live scRGB input path. It pins
    `YCBCR_STUDIO_G22_LEFT_P709` unconditionally and its only caller always passes
    `scrgb_input: false`.

1.8 (X7) — the native handshake's 4:4:4 gate comment stated the OPPOSITE of what
`pf_capture::capturer_supports_444` returns on Windows ("delivers subsampled NV12/P010 today, so
it returns false there" — it forwards `resolved_backend_ingests_rgb_444()` and the IDD ring passes
BGRA through for an SDR 4:4:4 session).

No `.rs` behaviour delta: comments, doc comments and log strings, plus 1.5's control-flow-neutral
ladder and 1.7's counter accessor. Linux tests 6/6; clippy --all-targets clean on both targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:27:06 +02:00
enricobuehlerandClaude Opus 5 cc848479c4 test(pf-encode): re-point the cpu_img size-change smoke at the CSC guard's contract
vulkan_cpu_img_survives_a_source_size_change drove MISMATCHED source
sizes through the then-lenient CSC arm as its vehicle for the staging
cache hazard (format-only-keyed cpu_img → OOB copy while submit said
Ok). e3354b6d's guard — correctly the equality check every sibling arm
always had, against the MODE, not the coded extent, so the padded
render-vs-coded tolerance in the direct arms is untouched — makes that
scenario unrepresentable through submit and broke the test on main
(.25 layers baseline read 13/13 instead of 14/12).

Replaced by vulkan_csc_refuses_a_mismatched_source: refusal pinned in
BOTH directions, plus the property that actually needs proving — a
refused submit does not WEDGE the session (the bail lands after step
1's frame-type bookkeeping; the next well-sized frame must still
encode, and an AU must come out). Verified on the 780M under
validation layers: 8/8 vulkan tests, full-suite baseline restored to
14/12.

The WP4.2 size-keyed staging stays as belt-and-braces; the hazard it
fixed is now structurally unreachable through submit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:20:37 +02:00
enricobuehlerandClaude Opus 5 6a2a153c0b chore(pf-capture): make the crate verifiable — Windows CI lint + two denies (sweep Phase 0)
Gate for the rest of design/pf-capture-sweep.md: today half this crate is compiled by no CI job
at all, so the Windows-side findings can't even be type-checked.

0.1 (X1) — windows-host.yml lints `-p pf-capture --all-targets`. The host lint above it builds
pf-capture only as a DEPENDENCY, so its `#[cfg(test)]` modules were never compiled anywhere: the
5 StallWatch tests, the DXGI HDR self-tests and the cursor-conversion tables were dead code in
CI. The workflow's own comment already diagnosed this blind spot and fixed it for pf-encode;
pf-capture never got the same treatment. No features on this crate, so no feature juggling and
no extra dep tree.

0.2 (X2) — `#![deny(unsafe_op_in_unsafe_fn)]` beside the existing
`deny(clippy::undocumented_unsafe_blocks)`. The crate declares "every unsafe block carries a
SAFETY proof; enforce it" in ten file headers, but in edition 2021 that lint has nothing to fire
on inside an `unsafe fn` — so the hardest FFI in the crate was exempt from its own program: the
ring/slot construction, the fence signal, the blend scratch, and every D3D converter ctor/convert.
119 operations across 16 functions now carry a proof. `shared_object_sa` loses its `unsafe`
marker instead (it states no precondition — only its RESULT is a raw pointer, which is the
caller's business).

0.3 (X4) — drop the crate-wide `#![allow(dead_code)]`. Verified: zero warnings on either target
without it, so it was hiding nothing the lint can see (W16's dead items are `pub`/written-once,
so they need Phase 1.7's deletion instead).

No behaviour change: only lint attributes, `unsafe { }` scoping and comments.

Linux `cargo test -p pf-capture` 6/6, clippy --all-targets clean on both targets (Windows
verified by cross-checking x86_64-pc-windows-msvc locally).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:18:48 +02:00
367 changed files with 49101 additions and 10611 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
+38
View File
@@ -99,3 +99,41 @@ jobs:
mkdir -p ~/unom-flatpak/site/repo
cd ~/unom-flatpak
docker compose -f compose.production.yml up -d
winget:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Sync winget source compose + server
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
# Land all three flat in ~/unom-winget/ (drop the packaging/winget/server/ prefix).
source: "packaging/winget/server/compose.production.yml,packaging/winget/server/server.mjs,packaging/winget/server/handler.mjs"
target: "~/unom-winget"
strip_components: 3
overwrite: true
- name: Start winget REST source
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
# ./data/data.json is NOT shipped by this workflow — windows-host.yml rsyncs it on each
# stable tag (same content/config split as the flatpak repo). Ensure the bind-mount
# source exists so the container starts; it serves 503 until the first catalogue lands.
mkdir -p ~/unom-winget/data
cd ~/unom-winget
docker compose -f compose.production.yml up -d
# Surface a missing catalogue here rather than letting winget report "no package found".
sleep 3
curl -fsS http://127.0.0.1:3240/healthz || echo "NOTE: no catalogue yet - publish a stable tag to populate it"
+19
View File
@@ -39,6 +39,25 @@ jobs:
working-directory: plugin-kit
run: bun install --frozen-lockfile --ignore-scripts
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
# dangling self-reference. `dist/` therefore arrives intact while the manifest that points at
# it does not, so module resolution dies at the first step and every `@punktfunk/host` import
# reads as "cannot find module". Replacing the tree with a real copy is the whole fix; drop
# this step once bun links `file:` deps correctly again.
- name: "Repair the file: dependency (bun 1.3 self-symlink)"
working-directory: plugin-kit
run: |
# -f follows the link, so this is true only when the manifest actually resolves.
if test -f node_modules/@punktfunk/host/package.json; then
echo "bun linked it correctly — this step can go"
else
rm -rf node_modules/@punktfunk/host
cp -R ../sdk node_modules/@punktfunk/host
fi
test -f node_modules/@punktfunk/host/package.json
test -f node_modules/@punktfunk/host/dist/index.d.ts
- name: Typecheck
working-directory: plugin-kit
run: bun run typecheck
+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.
+165 -14
View File
@@ -172,24 +172,80 @@ jobs:
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
# pf-vkhdr-layer's clippy below runs --release.
#
# pf-encode is linted SEPARATELY with --all-targets so its Windows `#[cfg(test)]` modules
# are type-checked — the 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, and the
# PyroWave-Windows smoke test. The host lint above cannot cover them: `-p punktfunk-host`
# only builds pf-encode as a dependency, so its test targets are never compiled, and that
# blind spot is what let the Linux twin's tests rot to the wrong arity unnoticed.
# NOTE: clippy (a check, no link step) is deliberately the vehicle here — `cargo test`
# with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk link-imports
# NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve only against
# the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can and does
# run the tests there.) Running them here would need an `--features amf-qsv,qsv` build
# without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 — not
# worth it while ci.yml executes the same tests.
# 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
# cannot cover them: `-p punktfunk-host` only builds those crates as dependencies, so their
# test targets are never compiled anywhere, and that blind spot is what let the Linux twin's
# tests rot to the wrong arity unnoticed. pf-capture has no cargo features, so it needs no
# feature juggling and pulls in no extra dep tree.
# NOTE: for the HOST and pf-encode, clippy (a check, no link step) is deliberately the
# vehicle — `cargo test` with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk
# link-imports NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve
# only against the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can
# and does run the tests there.) Running them here would need an `--features amf-qsv,qsv`
# build without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 —
# not worth it while ci.yml executes the same tests.
#
# That reasoning does NOT extend to pf-capture: it has no encoder dependency at all
# (`cargo tree -p pf-capture` lists no nvidia/ffmpeg/libvpl/pyrowave), so its test binary
# links against nothing this runner lacks, and it reuses the release artifacts the steps
# above already built. Its Windows `#[test]`s — StallWatch, the f16 conversions, the cursor
# truth table, the IDD generation masking — are Windows-only code that NO other job can
# execute, so linting them was leaving real coverage on the table. See the run step below.
run: |
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)
shell: pwsh
# The only Rust tests that RUN on Windows CI. pf-capture's `#[cfg(target_os = "windows")]`
# test modules cover code no Linux job compiles, let alone executes: 19 declared, of which
# 18 execute here — the IDD-push StallWatch state machine and ring-generation masking
# (idd_push.rs), the cursor shape→wire truth table (idd_push/cursor_poll.rs), and
# `f32_to_f16` including the rounding-carry / saturation edges the HDR P010 path depends on
# (dxgi/selftest.rs). All 18 are pure — no Win32, no device, no desktop. The 19th,
# `hdr_p010_selftest_intel_1080_live`, is `#[ignore]`d because it needs a real Intel
# adapter; it stays a manual `-- --ignored` run on the validation boxes. Until this step
# the whole set was type-checked by the clippy line above and nothing more.
#
# --release for the same reason as the clippy step: it reuses C:\t\release instead of
# spawning a second debug dep tree (the C1069 disk-exhaustion trigger). If this step ever
# starts tripping C1069 anyway, record THAT here rather than quietly dropping the step.
#
# The link question this step turns on was settled empirically before it was added: the same
# command was run on a Windows dev box against a workspace checkout and linked + executed
# cleanly, building in ~51 s off an existing release target dir.
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
@@ -238,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"
@@ -326,3 +390,90 @@ jobs:
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
# winget manifests for the release just attached above. Runs AFTER the attach step so the
# InstallerUrl the manifest pins is already live — winget validates the URL + hash, and a
# manifest published ahead of its artifact is a hard 404 for every client that picks it up.
# Stable tags only: winget pins one immutable artifact per version, so the rolling `canary/`
# alias has nothing it could point at.
- name: Emit + attach winget manifests (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
& scripts/ci/winget-manifest.ps1 `
-Version $env:HOST_VERSION -InstallerPath $env:HOST_SETUP_PATH -OutDir C:\t\out\winget
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in (Get-ChildItem C:\t\out\winget -Filter *.yaml)) {
Upsert-GiteaAsset -ReleaseId $rid -File $f.FullName
}
# Republish the winget REST source on unom-1 once the release above carries its manifests.
#
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
# reads the manifests from the release, so it must not run before they are attached.
winget-source:
needs: package
if: startsWith(gitea.ref, 'refs/tags/v')
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
# so the result cannot drift and re-running any tag reproduces it byte for byte.
- name: Build + test the source catalogue
working-directory: packaging/winget/server
run: |
set -euo pipefail
npm install --no-audit --no-fund
node build-data.mjs --out data/data.json
# A wrong response SHAPE does not fail loudly — winget just reports "no package found".
# Gate on the suite before anything reaches the box.
node test.mjs
# Content only. server.mjs/handler.mjs/compose land via deploy-services.yml, matching how the
# flatpak repo's content and config deploy on separate paths.
- name: Ship the catalogue to unom-1
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "packaging/winget/server/data/data.json"
target: "~/unom-winget/data"
strip_components: 4
overwrite: true
# No restart: server.mjs reloads on mtime change. This only proves the new catalogue is the
# one actually being served, and fails the release if it is not.
- name: Verify the served catalogue
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
curl -fsS http://127.0.0.1:3240/healthz
echo
curl -fsS -X POST http://127.0.0.1:3240/manifestSearch \
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' \
| grep -q "${GITHUB_REF_NAME#v}" \
|| { echo "served catalogue does not contain ${GITHUB_REF_NAME#v}"; exit 1; }
echo "winget source serving ${GITHUB_REF_NAME#v}"
# `env:` below populates the RUNNER's environment; this action runs `script` on the
# REMOTE host, which inherits nothing from it. `envs:` is the action's OWN input —
# it must live under `with:` (matching docker.yml/deploy-services.yml's REGISTRY_TOKEN
# forwarding) — naming the variables to forward into the remote shell. A prior fix put
# it as a step-level sibling of `with:`/`env:` instead: that key is not part of the
# step schema, so appleboy/ssh-action never received it as an input and the step kept
# failing ("GITHUB_REF_NAME: unbound variable") on every tag after 24d2f97e too.
envs: GITHUB_REF_NAME
env:
GITHUB_REF_NAME: ${{ gitea.ref_name }}
Generated
+37 -27
View File
@@ -1020,6 +1020,13 @@ dependencies = [
"subtle",
]
[[package]]
name = "display-disturb"
version = "0.21.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
@@ -2194,7 +2201,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.19.2"
version = "0.21.0"
[[package]]
name = "lazy_static"
@@ -2299,7 +2306,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"bindgen",
"cmake",
@@ -2334,7 +2341,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"punktfunk-core",
]
@@ -2823,7 +2830,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2844,7 +2851,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -2856,6 +2863,7 @@ dependencies = [
"pipewire",
"punktfunk-core",
"pyrowave-sys",
"rand 0.9.4",
"rustls",
"sdl3",
"serde",
@@ -2868,7 +2876,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2886,7 +2894,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -2907,7 +2915,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -2931,7 +2939,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"ash",
"bindgen",
@@ -2940,7 +2948,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"libc",
@@ -2952,7 +2960,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2966,11 +2974,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.19.2"
version = "0.21.0"
[[package]]
name = "pf-inject"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2999,14 +3007,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -3021,7 +3029,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3043,16 +3051,18 @@ dependencies = [
"sha2",
"tokio",
"tracing",
"tracing-subscriber",
"utoipa",
"wayland-backend",
"wayland-client",
"wayland-scanner",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"x11rb",
]
[[package]]
name = "pf-win-display"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3064,7 +3074,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -3272,7 +3282,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"android_logger",
"jni",
@@ -3288,7 +3298,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3304,7 +3314,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3319,7 +3329,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3338,7 +3348,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3370,7 +3380,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3454,7 +3464,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3468,7 +3478,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ksni",
@@ -3491,7 +3501,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"bindgen",
"cmake",
+31 -1
View File
@@ -28,6 +28,7 @@ members = [
"clients/session",
"clients/windows",
"clients/android/native",
"tools/display-disturb",
"tools/latency-probe",
"tools/loss-harness",
]
@@ -48,13 +49,42 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.19.2"
version = "0.21.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
authors = ["unom"]
repository = "https://git.unom.io/unom/punktfunk"
# The `unsafe` discipline the `packaging/windows/drivers/*` crates already run, extended to the
# workspace. `unsafe fn` marks a CONTRACT the caller must uphold; it is not a licence for the whole
# body to skip checking. Without this lint an `unsafe fn` body is unchecked end to end, so a 600-line
# function hides which handful of lines are actually the unsafe ones — exactly the reviewer-hostile
# shape we are working down. (This is the Rust 2024 default; adopting it early also pays off the
# edition migration.)
#
# `deny`, not `warn`. `warn` was never actually a softer setting: CI runs `cargo clippy … -D
# warnings`, which promotes it to a hard error anyway — that is how adopting this lint turned main
# red on every platform for a day without the level in this file ever saying `deny`. A level that
# lies about its own severity is worse than a strict one, so this now states what CI already does,
# and the exemptions are written down per file instead of hiding in a 689-warning wall nobody reads.
#
# THE EXEMPTIONS. Fourteen GPU/FFI backend files carry `#![allow(unsafe_op_in_unsafe_fn)]` with a
# one-line reason each. They are not "not done yet" — they are where this lint stops paying:
# their bodies are ash/CUDA/AMF/libav calls almost line for line (measured: 64% of the sites are a
# single third-party FFI call, and of the 44 `unsafe fn`s in them only 4 have a body containing no
# unsafe operation at all). Narrowing them means one `unsafe {}` per line plus, since pf-encode also
# denies `clippy::undocumented_unsafe_blocks`, one hand-written SAFETY comment per line that could
# only ever restate "an ash call on a live device" — the precise noise that made `unsafe` stop
# meaning anything here before (see the header of `pf-win-display/src/win_display.rs`).
#
# Everything else in the workspace is at zero and enforced. Removing one of those allows, file by
# file, is real work with a real payoff; blanket-narrowing all fourteen is not. Prefer DELETING an
# `unsafe fn` marker over wrapping its body: keep the marker only where a caller can actually break
# something (a raw pointer, a borrowed HANDLE, a GPU object that must not be in flight).
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
[profile.release]
opt-level = 3
lto = "thin"
+1 -1
View File
@@ -96,7 +96,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
+847 -138
View File
File diff suppressed because it is too large Load Diff
@@ -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
@@ -990,7 +990,9 @@ struct ContentView: View {
rawValue: UInt32(clamping: gamepadType)) ?? .auto)
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
let g = PunktfunkConnection.GamepadType(name: name) {
pad = g
// Back through resolveType so the lever is adopted as the session's setting: the
// per-pad arrivals declare it too, which is what the host actually builds from.
pad = GamepadManager.shared.resolveType(setting: g)
}
var bitrate = UInt32(clamping: bitrateKbps)
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
@@ -55,7 +55,13 @@ public final class GamepadCapture {
/// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every
/// event this controller sends the low byte of `flags`.
let pad: UInt32
/// The controller KIND declared to the host (GamepadArrival) when the slot opened.
/// The controller KIND declared to the host (GamepadArrival) when the slot opened the
/// user's explicit "Controller type" setting when they picked one, else the detected
/// kind (`GamepadManager.declaredKind(for:)`). NOT the physical pad's kind: local feedback
/// keys off the live `GCController` subclass instead, so whatever the host DOES send is
/// applied natively to the pad in the user's hands. What the host sends is bounded by the
/// emulated type, though a virtual DualShock 4 has no adaptive-trigger reports in its
/// protocol, so emulating one gives those up by construction (rumble + lightbar remain).
let pref: PunktfunkConnection.GamepadType
var buttons: UInt32 = 0
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
@@ -166,7 +172,7 @@ public final class GamepadCapture {
private func openSlot(_ dc: GamepadManager.DiscoveredController) {
guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return }
let c = dc.controller
let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind)
let slot = Slot(controller: c, pad: UInt32(pad), pref: manager.declaredKind(for: dc))
slots.append(slot)
ext.valueChangedHandler = { [weak self, weak slot] g, _ in
@@ -192,9 +198,12 @@ public final class GamepadCapture {
}
}
// Declare this pad's controller KIND before any of its input, so the host builds a
// matching virtual device (mixed types pad 0 a DualSense, pad 1 an Xbox pad). The core
// re-sends it a few times against datagram loss; an older host ignores it and uses the
// session-default kind. Then wake the host pad (pads are created lazily from the first
// matching virtual device the user's chosen type when they picked one, else per-pad
// detection (mixed types pad 0 a DualSense, pad 1 an Xbox pad). This declaration is
// what the host actually builds from, so it MUST carry an explicit setting; the
// handshake's session default is only the fallback for a pad that never declares. The
// core re-sends it a few times against datagram loss; an older host ignores it and uses
// the session-default kind. Then wake the host pad (pads are created lazily from the first
// event; a DualSense's UHID handshake + initial lightbar write only start then).
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
@@ -131,13 +131,40 @@ public final class GamepadManager: ObservableObject {
GCController.stopWirelessControllerDiscovery()
}
/// The user's controller-type choice AS CHOSEN (not resolved) for the session being dialed
/// adopted by `resolveType` and read back by `declaredKind(for:)`. `.auto` = detect per pad.
public private(set) var typeSetting: PunktfunkConnection.GamepadType = .auto
/// The kind to DECLARE to the host for one forwarded controller (its `GamepadArrival`).
/// An explicit setting wins for every pad the handshake's session default alone does NOT
/// stick, because a current host honors the per-pad arrival over it (punktfunk-host's
/// `Pads::set_kind`), so a client that declared only the detected kind here would silently
/// undo the user's choice. `.auto` keeps per-pad detection, which is what makes a mixed
/// session (pad 0 a DualSense, pad 1 an Xbox pad) honest.
public func declaredKind(
for controller: DiscoveredController
) -> PunktfunkConnection.GamepadType {
Self.declaredKind(setting: typeSetting, detected: controller.kind)
}
/// The pure fold behind `declaredKind(for:)` (pf-client-core's `declared_kind`).
nonisolated static func declaredKind(
setting: PunktfunkConnection.GamepadType,
detected: PunktfunkConnection.GamepadType
) -> PunktfunkConnection.GamepadType {
setting == .auto ? detected : setting
}
/// Connect-time resolution of the user's controller-type setting: an explicit choice
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense
/// DualSense, DualShock 4 DualShock 4, an Xbox pad Xbox One, anything else Xbox
/// 360); no controller at all defers to the host.
/// 360); no controller at all defers to the host. Called once per dial with the RAW setting,
/// which it also adopts for `declaredKind(for:)` so the handshake default and every pad's
/// arrival can never disagree about an explicit choice.
public func resolveType(
setting: PunktfunkConnection.GamepadType
) -> PunktfunkConnection.GamepadType {
typeSetting = setting
guard setting == .auto else { return setting }
// Refresh from the LIVE controller list first. `active` is otherwise only populated by the
// async `.GCControllerDidConnect` notification, so at connect time it can still be nil even
@@ -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
}
@@ -236,6 +236,13 @@ public final class StreamLayerView: NSView {
let hotY: Int
}
private var hostCursors: [UInt32: HostCursorShape] = [:]
/// The last shape actually worn. State (`0xD0`, a per-frame datagram) announces a new serial the
/// moment the host QUEUES its bitmap on the reliable control stream, so the client routinely
/// knows a serial before it holds the pixels and the shape ring drops the NEWEST under burst
/// (`CURSOR_SHAPE_QUEUE`), which the host never re-sends because it only sends on a serial
/// CHANGE. Both leave `hostCursors[serial]` empty; wearing the previous pointer through that
/// gap degrades it to a briefly-stale shape instead of blinking the pointer out of existence.
private var lastWornShape: HostCursorShape?
private var cursorState: PunktfunkConnection.CursorStateEvent?
/// Last `CursorRenderMode.clientDraws` told to the host (the §8 mid-stream render flip);
/// nil = nothing sent yet. Edge-detected by [`reconcileCursorRender`] from the live mouse
@@ -509,10 +516,19 @@ public final class StreamLayerView: NSView {
override public func resetCursorRects() {
if captured && desktopMouse {
// Cursor channel active: wear the HOST's pointer shape (it is no longer in the
// video); hidden host pointer (or no shape yet) = invisible. Without the channel,
// M1 behavior: invisible local cursor, the composited host cursor is the visible one.
// video); a HIDDEN host pointer (or nothing seen yet at all) = invisible. Without the
// channel, M1 behavior: invisible local cursor, the composited host cursor is the
// visible one.
//
// A visible pointer whose announced serial has no bitmap yet falls back to the last
// worn shape (see `lastWornShape`) rather than to `invisibleCursor`. That case is
// routine, not degenerate state outruns its bitmap on every single shape change
// and treating it as "hide the pointer" made the pointer VANISH over anything whose
// shape arrived late or got dropped, with no recovery until the next change. Only
// `st.visible == false` may hide the pointer; a missing bitmap may not.
if cursorChannelActive, let st = cursorState, st.visible,
let shape = hostCursors[st.serial] {
let shape = hostCursors[st.serial] ?? lastWornShape {
lastWornShape = shape
addCursorRect(bounds, cursor: scaledCursor(shape))
} else {
addCursorRect(bounds, cursor: Self.invisibleCursor)
@@ -583,6 +599,8 @@ public final class StreamLayerView: NSView {
private func applyCursorShape(_ ev: PunktfunkConnection.CursorShapeEvent) {
guard let shape = Self.makeShape(ev) else {
// Truthful only because `resetCursorRects` falls back to `lastWornShape`: before that,
// a rejection here left the announced serial with no bitmap and HID the pointer.
streamInputLog.warning("cursor shape rejected (\(ev.width)x\(ev.height)) — keeping the previous cursor")
return
}
@@ -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 {
@@ -119,6 +119,25 @@ final class GamepadWireTests: XCTestCase {
XCTAssertEqual(GamepadWire.maxPads, 16)
}
func testAnExplicitControllerTypeIsWhatEveryPadDeclares() {
// The regression this pins: the "Controller type" setting reached the Hello only, and each
// pad's arrival then re-declared the DETECTED kind which the host honors over the
// session default, so "emulate my DualSense as a DualShock 4" produced a DualSense.
// (pf-client-core's `declared_kind` test is the same table.)
XCTAssertEqual(
GamepadManager.declaredKind(setting: .dualShock4, detected: .dualSense), .dualShock4)
// Every physical pad in a mixed session follows the one explicit choice.
for detected: PunktfunkConnection.GamepadType in [
.dualSense, .xbox360, .switchPro, .dualSenseEdge,
] {
XCTAssertEqual(
GamepadManager.declaredKind(setting: .xbox360, detected: detected), .xbox360)
}
// Automatic keeps per-pad detection otherwise a mixed session collapses to one type.
XCTAssertEqual(GamepadManager.declaredKind(setting: .auto, detected: .dualSense), .dualSense)
XCTAssertEqual(GamepadManager.declaredKind(setting: .auto, detected: .switchPro), .switchPro)
}
func testTouchpadConversionCorners() {
// GC ±1 with +y up wire 0...65535 with origin top-left, +y down.
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
+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
}
+31 -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);
@@ -620,6 +644,7 @@ fn mock_library() -> (
store: store.to_string(),
title: title.to_string(),
art: crate::library::Artwork::default(),
platform: None,
};
let games = vec![
game("steam:570", "steam", "Dota 2"),
+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() {
+42 -7
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],
@@ -204,9 +221,17 @@ mod session_main {
mode,
compositor: CompositorPref::from_name(&settings.compositor)
.unwrap_or(CompositorPref::Auto),
gamepad: match GamepadPref::from_name(&settings.gamepad) {
Some(GamepadPref::Auto) | None => gamepad.auto_pref(),
Some(explicit) => explicit,
gamepad: {
// The setting AS CHOSEN goes to the pad service too, not just the Hello: the host
// builds each virtual pad from that pad's arrival and only falls back to this
// session default for a pad that never declares one, so an explicit choice that
// stopped here would be undone the moment a controller connected.
let chosen = GamepadPref::from_name(&settings.gamepad).unwrap_or(GamepadPref::Auto);
gamepad.set_kind_override(chosen);
match chosen {
GamepadPref::Auto => gamepad.auto_pref(),
explicit => explicit,
}
},
bitrate_kbps: settings.bitrate_kbps,
audio_channels: settings.audio_channels,
@@ -238,6 +263,7 @@ mod session_main {
identity,
connect_timeout: connect_timeout(),
force_software,
profile,
}
}
@@ -423,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."
);
@@ -445,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
@@ -515,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((
+17 -1
View File
@@ -13,8 +13,17 @@
// console behind the couch UI looks like a crash.
#![cfg_attr(windows, windows_subsystem = "windows")]
// The shared client log sink (std-only): a couch launch has no console, so the session's
// stderr would otherwise evaporate — same reasoning as the shell's tee. This shim only
// initializes + forwards; the module's subscriber-side surface stays unused here.
#[cfg(windows)]
#[path = "../logfile.rs"]
#[allow(dead_code)]
mod logfile;
#[cfg(windows)]
fn main() {
logfile::init();
// The session binary ships beside us in the package; fall back to PATH for a dev run.
let session = std::env::current_exe()
.ok()
@@ -27,7 +36,14 @@ fn main() {
if !std::env::args().any(|a| a == "--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(_) => std::process::exit(1),
}
+98
View File
@@ -0,0 +1,98 @@
//! Persistent client log file: `%LOCALAPPDATA%\punktfunk\logs\client.log`.
//!
//! The shell is a `windows_subsystem` binary and spawns `punktfunk-session` with
//! `CREATE_NO_WINDOW` — a normal GUI/MSIX launch has NO console, so before this module every
//! log line (the shell's and, worse, the session's whole receive/decode/present forensic
//! trail) evaporated exactly when a user hit a problem worth reporting. The 2026-07 PyroWave
//! latency-sawtooth field report had to be triaged from host logs alone because the client
//! side had nowhere to land.
//!
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
/// Rotate at the next start once the file exceeds this (the host's cap).
const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
fn log_dir() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
}
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
pub(crate) fn path() -> Option<PathBuf> {
Some(log_dir()?.join("client.log"))
}
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
/// subscriber installs; every later [`tee`] shares the handle.
pub(crate) fn init() {
SINK.get_or_init(|| {
let dir = log_dir()?;
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join("client.log");
if std::fs::metadata(&path).is_ok_and(|m| m.len() > ROTATE_BYTES) {
let old = dir.join("client.log.old");
// Windows `rename` refuses an existing destination — drop the old generation first.
let _ = std::fs::remove_file(&old);
let _ = std::fs::rename(&path, &old);
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()?;
Some(Arc::new(Mutex::new(file)))
});
}
/// A writer that duplicates onto stderr (dev runs from a terminal keep their interleaved
/// output) and the log file (GUI runs finally keep anything at all). The tracing subscriber's
/// `with_writer` factory and the session-stderr forwarder both use it.
pub(crate) struct Tee;
/// `with_writer` factory (`fn() -> Tee` satisfies `MakeWriter`).
pub(crate) fn tee() -> Tee {
Tee
}
impl Write for Tee {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let _ = io::stderr().write_all(buf);
if let Some(Some(f)) = SINK.get() {
let _ = f.lock().unwrap().write_all(buf);
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
let _ = io::stderr().flush();
if let Some(Some(f)) = SINK.get() {
let _ = f.lock().unwrap().flush();
}
Ok(())
}
}
/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never
/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the
/// pipe (child exit).
pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
let _ = std::thread::Builder::new()
.name("punktfunk-session-log".into())
.spawn(move || {
let mut reader = io::BufReader::new(stderr);
let mut line = String::new();
let mut tee = Tee;
while matches!(reader.read_line(&mut line), Ok(n) if n > 0) {
let _ = tee.write_all(line.as_bytes());
line.clear();
}
});
}
+21 -1
View File
@@ -26,6 +26,8 @@ mod discovery;
#[cfg(windows)]
mod gpu;
#[cfg(windows)]
mod logfile;
#[cfg(windows)]
mod probe;
#[cfg(windows)]
mod shell_window;
@@ -49,11 +51,20 @@ fn main() {
}
set_app_user_model_id();
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
// a GUI/MSIX launch has no console, so without the file the client side of any field report
// simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean.
logfile::init();
tracing_subscriber::fmt()
.with_ansi(false)
.with_writer(logfile::tee)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
if let Some(p) = logfile::path() {
tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)");
}
let args: Vec<String> = std::env::args().collect();
let flag = |name: &str| args.iter().any(|a| a == name);
@@ -88,7 +99,16 @@ fn main() {
if !flag("--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
// Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch
// (Start-menu tile, Steam shortcut) has no console to inherit either.
cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(e) => {
eprintln!("could not start the console UI: {e}");
+13 -1
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)
}
@@ -169,13 +175,19 @@ fn spawn_with(
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
// Piped through the log tee: dev-terminal runs keep the interleaved stderr they always
// had, and GUI runs — which have no console — finally keep the session's whole
// receive/decode/present log in the client log file.
.stderr(Stdio::piped())
.creation_flags(CREATE_NO_WINDOW);
let mut child = cmd
.spawn()
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
tracing::info!(host = %host_label, "session binary spawned");
if let Some(stderr) = child.stderr.take() {
crate::logfile::forward_child_stderr(stderr);
}
let stdout = child.stdout.take().expect("piped stdout");
// Park the child where the kill handle (and the reader, for the final reap) reach it.
*slot.0.lock().unwrap() = Some(child);
+3
View File
@@ -15,3 +15,6 @@ links = "vpl"
cmake = "0.1"
# Same bindgen configuration as pyrowave-sys (runtime = dlopen libclang).
bindgen = { version = "0.72", features = ["runtime"], default-features = false }
[lints]
workspace = true
+3
View File
@@ -58,3 +58,6 @@ windows = { version = "0.62", features = [
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
] }
[lints]
workspace = true
+201 -51
View File
@@ -7,10 +7,12 @@
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
//! orchestrator).
// Scaffold: trait defaults + synthetic sources are defined ahead of the backends that use them.
#![allow(dead_code)]
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
// …and that program only covers a whole `unsafe fn` body once the body needs its own block: in
// edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, which exempted the crate's hardest FFI
// (the ring/slot construction, the channel broker, every D3D converter ctor) from the deny above.
#![deny(unsafe_op_in_unsafe_fn)]
use anyhow::Result;
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
@@ -19,9 +21,16 @@ use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
#[cfg(target_os = "linux")]
use pf_frame::DmabufFrame;
/// Produces frames from a captured output. Lives on its own thread, feeding the encoder
/// over a bounded drop-oldest channel (never block the compositor).
/// Produces frames from a captured output. Lives on its own thread, handing frames over without
/// ever blocking the compositor — the Linux portal publishes into a one-deep OVERWRITING slot
/// (drop-oldest), so a stalled consumer costs the intermediate frames and is still handed the
/// freshest one.
pub trait Capturer: Send {
// ---- Frames -----------------------------------------------------------------------------
// `next_frame` blocks for one; `try_latest` is the steady-state non-blocking read;
// `wait_arrival` + `supports_arrival_wait` are the frame-driven trigger that replaces a
// free-running tick.
fn next_frame(&mut self) -> Result<CapturedFrame>;
/// [`next_frame`](Self::next_frame) with a caller-chosen first-frame budget instead of the
@@ -52,23 +61,47 @@ pub trait Capturer: Send {
/// Block until a FRESH frame is available via [`try_latest`](Self::try_latest) or
/// `deadline` passes — the encode loop's frame-driven wait (latency plan T1.1): waking on
/// the compositor's publish instead of sampling at a free-running tick deletes the
/// sample-and-hold (~half a frame interval on average). Must NOT consume the frame (the
/// loop's `try_latest` call does); backends buffer internally where the arrival channel
/// can't be peeked. Only called when [`supports_arrival_wait`](Self::supports_arrival_wait)
/// is `true`; errors surface at the following `try_latest`.
/// sample-and-hold (~half a frame interval on average). Must NOT consume the frame the
/// loop's `try_latest` call does that — so a backend implements this by waiting on a wakeup
/// and then PEEKING its hand-off slot. Only called when
/// [`supports_arrival_wait`](Self::supports_arrival_wait) is `true`; errors surface at the
/// following `try_latest`.
fn wait_arrival(&mut self, _deadline: std::time::Instant) {}
// ---- Lifecycle --------------------------------------------------------------------------
// Whether the capturer is being used right now, and whether it can still be used at all.
/// Gate expensive per-frame work so the capturer can be kept alive (reused) between
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive;
/// the default is a no-op (synthetic sources are produced on demand). Set `true` for the
/// duration of a stream, `false` when it ends.
fn set_active(&self, _active: bool) {}
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive and
/// flushes its frame mailbox on `false`; the default is a no-op (synthetic sources are produced
/// on demand). Set `true` for the duration of a stream, `false` when it ends.
///
/// `&mut self`: it mutates capturer state, and every caller owns the capturer. It took `&self`
/// only because the flag happened to be an `Arc<AtomicBool>` — an implementation detail leaking
/// into the contract, and one the mailbox flush this now also does would not have shared.
fn set_active(&mut self, _active: bool) {}
/// Whether this capturer can still produce frames — the gate a caller that POOLS capturers
/// across streams must consult before reusing one.
///
/// Some backends have TERMINAL states that are only observable by trying to consume a frame:
/// the Linux portal capturer's zero-copy poison flag, a dead PipeWire thread, and a source that
/// never returns to `Streaming` are all sticky, and each makes every subsequent
/// [`next_frame`](Self::next_frame) / [`try_latest`](Self::try_latest) fail — for that backend
/// an `Err` from either is terminal, never transient. A pool that re-admits such a capturer
/// wedges the next session permanently (it re-fails at the same point, every reconnect), which
/// is why this predicate exists rather than leaving callers to infer liveness from an error they
/// have often already discarded.
///
/// `true` (the default) for backends with no such state: the synthetic sources, and the Windows
/// IDD-push capturer, whose failures already end the session through its own rebuild path.
fn is_alive(&self) -> bool {
true
}
// ---- Cursor -----------------------------------------------------------------------------
// The out-of-band pointer: where it is, who draws it, and (Linux/gamescope) where to read it.
/// 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).
/// 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.
/// The capture source's LIVE cursor state, when it arrives out-of-band from the frames
/// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and
/// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves
@@ -90,13 +123,22 @@ pub trait Capturer: Send {
/// Attach a gamescope cursor source (remote-desktop-sweep Phase C). gamescope paints no
/// `SPA_META_Cursor`, so [`cursor`](Self::cursor)'s slot stays empty — this hands the Linux
/// portal capturer gamescope's nested Xwayland `(DISPLAY, XAUTHORITY)` targets (it may run
/// several — one per `--xwayland-count`) so it reads the pointer shape/position over X11
/// (XFixes + QueryPointer), following whichever display is focused, and publishes it into that
/// same slot. Called once, after the capturer is built, only for gamescope sessions. Default
/// no-op: every non-gamescope capturer already has a cursor source.
fn attach_gamescope_cursor(&mut self, _targets: Vec<(String, Option<String>)>) {}
/// portal capturer a way to reach gamescope's nested Xwaylands (it may run several — one per
/// `--xwayland-count`) so it reads the pointer shape/position over X11 (XFixes +
/// QueryPointer), following whichever display is focused, and publishes it into that same slot.
/// Called once, after the capturer is built, only for gamescope sessions. Default no-op: every
/// non-gamescope capturer already has a cursor source.
#[cfg(target_os = "linux")]
fn attach_gamescope_cursor(&mut self, _targets: GamescopeCursorTargets) {}
// ---- 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`), 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> {
None
}
@@ -111,10 +153,18 @@ pub trait Capturer: Send {
1
}
// ---- Host-initiated resize --------------------------------------------------------------
// These two are ONE operation split in half and must be implemented together: a backend that
// returns `Some` from `capture_target_id` is promising `resize_output` works, and one that
// implements `resize_output` without the identity leaves the caller no way to check that the
// display it just reconfigured is still this capturer's. Both defaults decline.
/// The OS display-target id this capturer is bound to (Windows IDD-push), so the resize path
/// can verify the display it just reconfigured is STILL the one this capturer serves (an
/// in-place resize keeps the target; a re-arrival fallback mints a new one, which needs a
/// fresh capturer). `None` = the backend has no such identity (every non-IDD backend).
///
/// PAIRED with [`resize_output`](Self::resize_output) — see the cluster note above.
fn capture_target_id(&self) -> Option<u32> {
None
}
@@ -125,9 +175,25 @@ pub trait Capturer: Send {
/// EXTERNAL changes only) and no teardown: the capture pipeline and its send thread survive;
/// only the encoder is swapped by the caller once the first new-size frame arrives. Returns
/// `true` when handled; `false` (the default) routes the caller to the full-rebuild path.
///
/// PAIRED with [`capture_target_id`](Self::capture_target_id) — see the cluster note above.
fn resize_output(&mut self, _width: u32, _height: u32) -> bool {
false
}
/// Recreate the delivery ring at the CURRENT mode and re-run the driver attach handshake —
/// the recovery half of a swap-chain bounce the descriptor poller cannot see: an
/// exclusive-topology eviction (the vdisplay re-assert watchdog) is a real topology change,
/// so the OS drives COMMIT_MODES on the live virtual display too and the driver's swap-chain
/// is recreated while this capturer keeps waiting on the old ring attachment — frames stop
/// with an unchanged descriptor (same mode, same HDR), so the two-strike debounce never
/// trips. Arms the same recover-or-drop window as a real resize, so a driver that cannot
/// re-attach still fails the session cleanly. Returns `true` when handled; `false` (the
/// default) means the backend has no in-place ring recovery and the caller should treat the
/// pipeline as unrecoverable in place.
fn recreate_ring_in_place(&mut self) -> bool {
false
}
}
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
@@ -297,23 +363,52 @@ 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
/// `--xwayland-count` — for [`Capturer::attach_gamescope_cursor`].
///
/// A CLOSURE, not the `Vec` it used to be, and re-run on a slow cadence by the cursor worker. The
/// snapshot was taken once, before the game launched: gamescope creates a second Xwayland for the
/// game but only advertises the FIRST in any child's environ, so the game's display was invisible to
/// discovery — and when the connected (Big Picture) display then reported "gamescope is not drawing
/// the pointer here", the source blanked the cursor for the whole game session, which is the exact
/// regression the module doc says it fixed. A provider also lets the worker retry a display that
/// died, and lets a stream that starts BEFORE the game converge instead of staying cursorless.
///
/// Built by the host facade (it wraps `pf_vdisplay::gamescope_xwayland_cursor_targets`), exactly
/// like [`FrameChannelSender`] — so the capture→host edge stays one-way.
#[cfg(target_os = "linux")]
pub type GamescopeCursorTargets =
std::sync::Arc<dyn Fn() -> Vec<(String, Option<String>)> + Send + Sync>;
#[cfg(target_os = "linux")]
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
true
}
/// 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
@@ -328,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")]
@@ -447,21 +581,35 @@ pub mod synthetic_nv12;
/// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR
/// set — pass it only when the mirrored monitor is actually in HDR mode (the host probes
/// DisplayConfig) or the negotiation runs into its 10 s timeout and latches the SDR downgrade.
/// The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge).
/// `want_metadata_cursor` asks for cursor-as-metadata (`SPA_META_Cursor`) — pass it only when
/// the session's encode path composites `CapturedFrame::cursor` (the host consults
/// `pf-encode`'s `cursor_blend_capable`); otherwise the portal EMBEDS the pointer so it is
/// never silently lost. The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts
/// (the one-way edge).
#[cfg(target_os = "linux")]
pub fn open_portal_monitor(
anchored: bool,
want_hdr: bool,
want_metadata_cursor: bool,
policy: ZeroCopyPolicy,
) -> Result<Box<dyn Capturer>> {
linux::PortalCapturer::open(anchored, want_hdr && !hdr_capture_failed(), policy)
.map(|c| Box::new(c) as Box<dyn Capturer>)
linux::PortalCapturer::open(
anchored,
want_hdr && !hdr_capture_failed(HdrSource::PortalMonitor),
want_metadata_cursor,
policy,
)
.map(|c| Box::new(c) as Box<dyn Capturer>)
}
/// 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(
@@ -471,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>> {
@@ -481,6 +630,7 @@ pub fn open_virtual_output(
keepalive,
allow_zerocopy,
want_444,
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
policy,
expect_exact_dims,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+419
View File
@@ -0,0 +1,419 @@
//! The portal CONTROL PLANE: the xdg ScreenCast / RemoteDesktop handshake (async, `ashpd` over
//! zbus, on its own tokio runtime), the cursor-mode choice, and GNOME's BT.2100 colour-mode probe.
//!
//! Split out of `linux/mod.rs` (sweep Phase 5.3) to separate the async control plane from the
//! realtime half: nothing here runs per frame — the handshake happens once, then the thread parks
//! until `PortalSession`'s `Drop` (in the parent) releases it, and DROPPING the zbus
//! connection is what ends the compositor's cast. The probe is likewise a one-shot D-Bus round-trip
//! for a control-plane caller.
use anyhow::{anyhow, Context, Result};
use std::os::fd::OwnedFd;
/// Whether the monitor this host would mirror is currently in BT.2100 (HDR) colour mode — the
/// precondition for Mutter's monitor screencast advertising the 10-bit PQ formats (GNOME 50+;
/// Mutter only appends the HDR formats while the mirrored monitor's colour state is BT.2020+PQ).
/// Queried over the session bus: `DisplayConfig.GetCurrentState`, monitor property
/// `"color-mode" == 1` (`META_COLOR_MODE_BT2100`). `false` on any error — not GNOME, a pre-48
/// Mutter without colour modes, no monitors — so callers fall back to the honest SDR offer.
/// Blocking (one D-Bus round-trip on a fresh connection); call from control-plane threads only.
///
/// **Scoped to `PUNKTFUNK_CAPTURE_MONITOR` when it is set** (`design/per-monitor-portal-capture.md`
/// §7.4). Without a pin this asks "is ANY monitor in HDR mode", which was a fair heuristic while the
/// capture path took whatever monitor it was handed — but once the operator names the head, an
/// HDR-capable *neighbour* must not talk this host into offering PQ formats for an SDR panel.
pub fn gnome_hdr_monitor_active() -> bool {
use ashpd::zbus;
// GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is
// (spec(ssss), modes a(siiddada{sv}), properties a{sv}) — "color-mode" lives in the monitor
// properties.
type Mode = (
String,
i32,
i32,
f64,
f64,
Vec<f64>,
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
);
type Monitor = (
(String, String, String, String),
Vec<Mode>,
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
);
type LogicalMonitor = (
i32,
i32,
f64,
u32,
bool,
Vec<(String, String, String, String)>,
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
);
type State = (
u32,
Vec<Monitor>,
Vec<LogicalMonitor>,
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
);
let probe = || -> Result<bool> {
// zbus is built async-only here (ashpd's tokio integration) — run the one round-trip on
// a throwaway current-thread runtime; this is a control-plane call, never per-frame.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build tokio runtime")?;
rt.block_on(async {
let conn = zbus::Connection::session().await.context("session bus")?;
let reply = conn
.call_method(
Some("org.gnome.Mutter.DisplayConfig"),
"/org/gnome/Mutter/DisplayConfig",
Some("org.gnome.Mutter.DisplayConfig"),
"GetCurrentState",
&(),
)
.await
.context("DisplayConfig.GetCurrentState")?;
let (_serial, monitors, _logical, _props): State = reply
.body()
.deserialize()
.context("parse GetCurrentState")?;
// `spec.0` is the connector; "color-mode" 1 is META_COLOR_MODE_BT2100.
let heads: Vec<(&str, bool)> = monitors
.iter()
.map(|(spec, _modes, props)| {
let hdr = props
.get("color-mode")
.and_then(|v| u32::try_from(v).ok())
.is_some_and(|mode| mode == 1);
(spec.0.as_str(), hdr)
})
.collect();
Ok(hdr_offer_for(
&heads,
pf_host_config::config().capture_monitor.as_deref(),
))
})
};
match probe() {
Ok(hdr) => hdr,
Err(e) => {
tracing::debug!(error = %format!("{e:#}"), "GNOME HDR colour-mode probe failed — SDR");
false
}
}
}
/// Should this host offer the HDR (10-bit PQ) formats, given each head as `(connector, is_bt2100)`
/// and the `PUNKTFUNK_CAPTURE_MONITOR` pin?
///
/// Pinned: only that head's colour mode counts — an HDR-capable neighbour must not talk the host
/// into offering PQ for the SDR panel it is actually streaming. A pin naming no live head reports
/// SDR rather than falling back to "any": the session is about to fail on that same missing
/// monitor, and an over-claimed HDR offer would be a second, quieter wrong answer.
/// Unpinned: the pre-existing "any monitor is in HDR mode" heuristic, unchanged.
fn hdr_offer_for(heads: &[(&str, bool)], pinned: Option<&str>) -> bool {
match pinned {
Some(want) => heads
.iter()
.find(|(connector, _)| connector.eq_ignore_ascii_case(want))
.is_some_and(|(_, hdr)| *hdr),
None => heads.iter().any(|(_, hdr)| *hdr),
}
}
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`).
/// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap
/// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position +
/// an occasional bitmap), which the consumer composites itself — avoiding the producer burning the
/// cursor into every frame (`Embedded`), which on gamescope would defeat its HW cursor plane.
/// Without it — the session's encode path has no compositing stage for a metadata cursor
/// (`pf-encode`'s `cursor_blend_capable` said the resolved backend can't blend) — the ladder
/// prefers `Embedded`, so the pointer is in the pixels instead of in metadata nothing would draw.
/// Both ladders fall through to the other mode, then `Hidden`; a failed property query (an older
/// portal) keeps the prior `Embedded` behavior so the cursor is never silently lost.
async fn choose_cursor_mode(
proxy: &ashpd::desktop::screencast::Screencast,
want_metadata: bool,
) -> ashpd::desktop::screencast::CursorMode {
use ashpd::desktop::screencast::CursorMode;
match proxy.available_cursor_modes().await {
Ok(avail) if want_metadata && avail.contains(CursorMode::Metadata) => {
tracing::info!(
?avail,
"ScreenCast: requesting cursor-as-metadata (SPA_META_Cursor)"
);
CursorMode::Metadata
}
Ok(avail) if avail.contains(CursorMode::Embedded) => {
if want_metadata {
tracing::info!(
?avail,
"ScreenCast: cursor metadata unavailable — requesting Embedded cursor"
);
} else {
tracing::info!(
?avail,
"ScreenCast: requesting Embedded cursor (this session's encoder does not \
composite a metadata cursor)"
);
}
CursorMode::Embedded
}
Ok(avail) if avail.contains(CursorMode::Metadata) => {
// Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture
// path composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer.
tracing::warn!(
?avail,
"ScreenCast: Embedded cursor not advertised — requesting cursor-as-metadata \
(only CPU-path frames will composite it)"
);
CursorMode::Metadata
}
Ok(avail) => {
tracing::warn!(
?avail,
"ScreenCast: neither Metadata nor Embedded cursor advertised — cursor will be hidden"
);
CursorMode::Hidden
}
Err(e) => {
tracing::warn!(
error = %e,
"ScreenCast: AvailableCursorModes query failed — defaulting to Embedded cursor"
);
CursorMode::Embedded
}
}
}
/// The portal handshake: connect ScreenCast, select a single monitor, start, open the
/// PipeWire remote, hand the fd + node id back, then keep the session alive until `quit_rx`
/// resolves (the capturer's `Drop` — see [`PortalSession`]).
pub(super) fn portal_thread(
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
quit_rx: tokio::sync::oneshot::Receiver<()>,
want_metadata_cursor: bool,
) {
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
// Multi-thread runtime: the zbus connection's background reader must be pumped
// continuously across the create_session → select_sources → start handshake, or the
// portal reports "Invalid session". (A current-thread runtime starves it.)
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
return;
}
};
let err_tx = setup_tx.clone();
rt.block_on(async move {
let result: Result<()> = async {
let proxy = Screencast::new()
.await
.context("connect ScreenCast portal")?;
let session = proxy
.create_session(Default::default())
.await
.context("create_session")?;
let cursor_mode = choose_cursor_mode(&proxy, want_metadata_cursor).await;
proxy
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(cursor_mode)
// Only MONITOR is offered by the wlroots backend
// (AvailableSourceTypes=1); requesting unsupported types
// invalidates the session.
.set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::DoNot),
)
.await
.context("select_sources")?
.response()
.context("select_sources rejected (unsupported source type / cursor mode?)")?;
let streams = proxy
.start(&session, None, Default::default())
.await
.context("start cast")?
.response()
.context("start response (chooser cancelled? portal misconfigured?)")?;
let stream = streams
.streams()
.first()
.context("portal returned no streams")?
.clone();
let node_id = stream.pipe_wire_node_id();
let fd = proxy
.open_pipe_wire_remote(&session, Default::default())
.await
.context("open_pipe_wire_remote")?;
setup_tx
.send(Ok((fd, node_id)))
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
// Keep `proxy` + `session` (and the underlying zbus connection) alive for the
// capture; the cast is torn down when the connection drops (ashpd's `Session`
// has no `Drop`) — which now happens when this park returns, not at process exit.
let _keep_alive = (&proxy, &session);
let _ = quit_rx.await;
Ok(())
}
.await;
if let Err(e) = result {
let _ = err_tx.send(Err(format!("{e:#}")));
}
});
// Drop the runtime HERE, before the caller signals completion: shutting the 2 workers down is
// what finishes releasing the zbus connection, so a `done` signal sent after this means the
// compositor-side session is really gone (see `PortalSession::drop`).
drop(rt);
}
/// Combined RemoteDesktop+ScreenCast portal setup (KWin/GNOME). ScreenCast sources are selected
/// on a session created via RemoteDesktop, so a single RemoteDesktop `start` grant —
/// pre-authorized headlessly via the `kde-authorized` permission, exactly like the libei input
/// path — also covers screen capture, with no separate ScreenCast dialog (which has no such
/// bypass). Yields the same PipeWire fd + node id as the standalone path; the consumer is
/// identical, as is the `quit_rx` teardown park (see [`PortalSession`]).
pub(super) fn portal_thread_remote_desktop(
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
quit_rx: tokio::sync::oneshot::Receiver<()>,
want_metadata_cursor: bool,
) {
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions};
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
return;
}
};
let err_tx = setup_tx.clone();
rt.block_on(async move {
let result: Result<()> = async {
let remote = RemoteDesktop::new()
.await
.context("connect RemoteDesktop portal")?;
let screencast = Screencast::new()
.await
.context("connect ScreenCast portal")?;
let session = remote
.create_session(Default::default())
.await
.context("create RemoteDesktop session")?;
// RemoteDesktop requires a device selection; we never connect_to_eis on this session
// (input injection runs its own), but selecting devices is what makes `start` the
// RemoteDesktop grant the kde-authorized bypass covers.
remote
.select_devices(
&session,
SelectDevicesOptions::default()
.set_devices(DeviceType::Keyboard | DeviceType::Pointer)
.set_persist_mode(PersistMode::DoNot),
)
.await
.context("select_devices")?
.response()
.context("select_devices rejected")?;
let cursor_mode = choose_cursor_mode(&screencast, want_metadata_cursor).await;
screencast
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(cursor_mode)
.set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::DoNot),
)
.await
.context("select_sources")?
.response()
.context("select_sources rejected (unsupported source type?)")?;
let streams = remote
.start(&session, None, Default::default())
.await
.context("start RemoteDesktop+ScreenCast")?
.response()
.context("start response (grant not pre-authorized / headless dialog?)")?;
let stream = streams
.streams()
.first()
.context("portal returned no screencast streams")?
.clone();
let node_id = stream.pipe_wire_node_id();
let fd = screencast
.open_pipe_wire_remote(&session, Default::default())
.await
.context("open_pipe_wire_remote")?;
setup_tx
.send(Ok((fd, node_id)))
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
// Keep the proxies + session (and their zbus connection) alive for the capture, until
// the capturer's `Drop` fires the quit channel.
let _keep_alive = (&remote, &screencast, &session);
let _ = quit_rx.await;
Ok(())
}
.await;
if let Err(e) = result {
let _ = err_tx.send(Err(format!("{e:#}")));
}
});
// See `portal_thread`: drop the runtime before the caller's completion signal.
drop(rt);
}
#[cfg(test)]
mod hdr_offer_tests {
use super::hdr_offer_for;
#[test]
fn unpinned_keeps_the_any_monitor_heuristic() {
assert!(hdr_offer_for(&[("DP-1", false), ("HDMI-A-1", true)], None));
assert!(!hdr_offer_for(&[("DP-1", false)], None));
}
/// The regression this exists to prevent: an HDR TV on HDMI while the pinned head is an SDR
/// desk monitor. Before scoping, the host offered PQ formats for a panel that can't show them.
#[test]
fn a_pin_ignores_an_hdr_neighbour() {
let heads = [("DP-1", false), ("HDMI-A-1", true)];
assert!(!hdr_offer_for(&heads, Some("DP-1")));
assert!(hdr_offer_for(&heads, Some("HDMI-A-1")));
}
#[test]
fn a_pin_matches_case_insensitively_like_the_resolver() {
assert!(hdr_offer_for(&[("HDMI-A-1", true)], Some("hdmi-a-1")));
}
#[test]
fn a_pin_naming_no_live_head_reports_sdr() {
assert!(!hdr_offer_for(&[("DP-1", true)], Some("DP-9")));
}
}
+634
View File
@@ -0,0 +1,634 @@
//! Cursor-as-metadata: the `SPA_META_Cursor` parser and the CPU-path composite blits.
//!
//! Split out of `linux/pipewire.rs` (sweep Phase 5.2). Both halves are producer-driven and
//! bounds-critical: [`update_cursor_meta`] reads a bitmap at offsets the COMPOSITOR chose (its own
//! SAFETY proof notes that a missing bound SIGSEGVs inside the PipeWire `.process` callback, where
//! `catch_unwind` cannot help), and the `composite_cursor*` blits clip a caller-positioned bitmap
//! into a frame buffer. Separating them from the stream machinery is what makes them testable
//! without a compositor.
use super::PixelFormat;
use pipewire as pw;
use pw::spa;
use std::sync::Arc;
/// Latest cursor state parsed from `SPA_META_Cursor` (cursor-as-metadata mode). Position is
/// refreshed every buffer that carries the meta (including Mutter's cursor-only "corrupted"
/// buffers we otherwise skip for their stale frame); the RGBA bitmap is cached and only
/// replaced when the compositor sends a fresh one (`bitmap_offset != 0`).
#[derive(Default)]
pub(super) struct CursorState {
/// True when the compositor reports a visible pointer (`spa_meta_cursor.id != 0`).
visible: bool,
/// Top-left where the bitmap is drawn = reported position hotspot.
x: i32,
y: i32,
/// Cached straight-alpha RGBA pixels (`bw*bh*4`, bytes R,G,B,A). `Arc` so the overlay handed
/// to each GPU frame is a refcount bump, not a copy. Empty until the first bitmap arrives.
rgba: Arc<Vec<u8>>,
bw: u32,
bh: u32,
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
/// so the GPU encoder re-uploads its cursor texture only on change.
serial: u64,
/// The compositor-reported hotspot — carried on the overlay for the cursor-forward
/// channel (the blend path uses the pre-adjusted `x`/`y` and never reads it).
hot_x: i32,
hot_y: i32,
}
impl CursorState {
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
/// client's relative-mode hint (M3) — which is a different fact from "no cursor yet".
/// The encode loop strips invisible overlays before any blend path sees the frame.
/// Cheap: clones an `Arc` + a few scalars.
pub(super) fn overlay(&self) -> Option<pf_frame::CursorOverlay> {
if self.rgba.is_empty() {
return None;
}
Some(pf_frame::CursorOverlay {
x: self.x,
y: self.y,
w: self.bw,
h: self.bh,
rgba: self.rgba.clone(),
serial: self.serial,
hot_x: self.hot_x.max(0) as u32,
hot_y: self.hot_y.max(0) as u32,
visible: self.visible,
})
}
}
/// Extract straight (R,G,B,A) from one 4-byte cursor-bitmap pixel, honoring the bitmap's SPA
/// video format (portals emit RGBA or BGRA; ARGB/ABGR handled for completeness). Unknown
/// 4-byte formats are read as RGBA.
pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
match vfmt {
x if x == spa::sys::SPA_VIDEO_FORMAT_RGBA => (s[0], s[1], s[2], s[3]),
x if x == spa::sys::SPA_VIDEO_FORMAT_BGRA => (s[2], s[1], s[0], s[3]),
x if x == spa::sys::SPA_VIDEO_FORMAT_ARGB => (s[1], s[2], s[3], s[0]),
x if x == spa::sys::SPA_VIDEO_FORMAT_ABGR => (s[3], s[2], s[1], s[0]),
_ => (s[0], s[1], s[2], s[3]),
}
}
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) {
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
// `spa_buffer_find_meta` returns the `spa_meta` (type + byte `size` + `data` pointer) for
// `SPA_META_Cursor`, or null. We take `find_meta` rather than `find_meta_data` specifically
// to obtain the region's real `size`: the bitmap offset, pixel offset and stride read below
// are ALL producer-written, and without a bound against the actual region they drive
// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot
// catch). Every offset below is validated against `region_size` with checked arithmetic,
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr.
let meta = unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor) };
if meta.is_null() {
return;
}
// SAFETY: `meta` is non-null and points into the held buffer's metadata array.
let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) };
if data.is_null() || region_size < std::mem::size_of::<spa::sys::spa_meta_cursor>() {
return;
}
let cur = data as *const spa::sys::spa_meta_cursor;
// SAFETY: `region_size >= size_of::<spa_meta_cursor>()` checked above, so every field is in bounds.
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
(
(*cur).id,
(*cur).position.x,
(*cur).position.y,
(*cur).hotspot.x,
(*cur).hotspot.y,
(*cur).bitmap_offset,
)
};
if id == 0 {
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
return;
}
cursor.visible = true;
cursor.x = pos_x - hot_x;
cursor.y = pos_y - hot_y;
cursor.hot_x = hot_x;
cursor.hot_y = hot_y;
if bmp_off == 0 {
// Position-only update — keep the cached bitmap.
return;
}
let bmp_off = bmp_off as usize;
// The `spa_meta_bitmap` header must fit entirely inside the region before we read it —
// `bitmap_offset` is producer-controlled and otherwise reads past the metadata.
match bmp_off.checked_add(std::mem::size_of::<spa::sys::spa_meta_bitmap>()) {
Some(end) if end <= region_size => {}
_ => return,
}
// SAFETY: `bmp_off + size_of::<spa_meta_bitmap>() <= region_size` (checked directly above),
// so the header is fully in bounds for a read of that many bytes. `read_unaligned` is
// REQUIRED, not defensive: `bmp_off` is producer-written and nothing in the SPA contract or
// in this function establishes that `data + bmp_off` meets `spa_meta_bitmap`'s alignment —
// the previous field reads through an aligned `*const` asserted an invariant the code never
// proved. The struct is `Copy` POD, so one unaligned read yields an owned, aligned local.
let bmp = unsafe { (data.add(bmp_off) as *const spa::sys::spa_meta_bitmap).read_unaligned() };
let (vfmt, bw, bh, stride, pix_off) = (
bmp.format,
bmp.size.width,
bmp.size.height,
bmp.stride.max(0) as usize,
bmp.offset as usize,
);
// Ignore empty or implausibly large bitmaps (the meta-size request covers <= 1024×1024;
// real cursors are ≤96px — the cursor channel downscales >120px for the wire anyway).
if bw == 0 || bh == 0 || bw > 1024 || bh > 1024 {
return;
}
let row = bw as usize * 4;
let stride = if stride < row { row } else { stride };
let Some(extent) = bitmap_extent(bmp_off, pix_off, stride, row, bh as usize, region_size)
else {
return;
};
// SAFETY: `bitmap_extent` returned `Some`, which means (see its contract) the whole range
// `[bmp_off + pix_off, +len)` lies inside `region_size` and `len` is EXACTLY the extent the
// strided loop below reads. `data` is the producer's meta-region base, live for this callback.
let src = unsafe { std::slice::from_raw_parts(data.add(extent.start), extent.len()) };
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
for y in 0..bh as usize {
for x in 0..bw as usize {
let so = y * stride + x * 4;
let (r, g, b, a) = decode_bitmap_pixel(vfmt, &src[so..so + 4]);
let d = (y * bw as usize + x) * 4;
rgba[d] = r;
rgba[d + 1] = g;
rgba[d + 2] = b;
rgba[d + 3] = a;
}
}
cursor.rgba = Arc::new(rgba);
cursor.bw = bw;
cursor.bh = bh;
cursor.serial = cursor.serial.wrapping_add(1);
}
/// The byte range inside the producer's cursor-meta region that a `bh`-row, `row`-wide,
/// `stride`-strided bitmap at `bmp_off + pix_off` occupies — or `None` when it does not fit, or when
/// any of the arithmetic overflows.
///
/// THE bound on `update_cursor_meta`. Every input except `region_size` is producer-written, and
/// `region_size` is the real byte size of the meta region libspa handed us (which is why the caller
/// takes `spa_buffer_find_meta` rather than `find_meta_data`). Without this check the offsets drive
/// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read that
/// SIGSEGVs inside the PipeWire `.process` callback, where `catch_unwind` cannot help. A stride near
/// `i32::MAX` is enough to overflow the multiply on its own, so every step is checked.
///
/// `len()` of the returned range is EXACTLY `stride·(bh1) + row`: the last row contributes only its
/// `row` visible bytes, not a full stride, so a bitmap that ends flush against the region's end is
/// accepted rather than rejected by a padding byte that is never read.
///
/// Extracted (sweep Phase 6.1) purely so it can be tested — the caller is unreachable without a live
/// compositor.
fn bitmap_extent(
bmp_off: usize,
pix_off: usize,
stride: usize,
row: usize,
bh: usize,
region_size: usize,
) -> Option<std::ops::Range<usize>> {
if bh == 0 || row == 0 || stride < row {
return None;
}
let span = stride.checked_mul(bh - 1)?.checked_add(row)?;
let start = bmp_off.checked_add(pix_off)?;
let end = start.checked_add(span)?;
(end <= region_size).then_some(start..end)
}
/// Destination channel byte offsets (R,G,B) and bytes-per-pixel for a packed-RGB `PixelFormat`,
/// or `None` for a layout the CPU cursor blit doesn't handle (YUV/10-bit — those never reach
/// the CPU de-pad path anyway).
pub(super) fn dst_offsets(fmt: PixelFormat) -> Option<(usize, usize, usize, usize)> {
Some(match fmt {
PixelFormat::Bgrx | PixelFormat::Bgra => (2, 1, 0, 4),
PixelFormat::Rgbx | PixelFormat::Rgba => (0, 1, 2, 4),
PixelFormat::Rgb => (0, 1, 2, 3),
PixelFormat::Bgr => (2, 1, 0, 3),
_ => return None,
})
}
/// Alpha-blend the cached cursor bitmap into a packed 10-bit (`X2Rgb10`/`X2Bgr10`) CPU frame:
/// unpack each u32, blend the 8-bit cursor channels scaled to 10 bits (`v<<2 | v>>6`), repack.
/// The frame samples are PQ-encoded, so like the 8-bit gamma-space blend this is a display-
/// referred approximation — fine for a cursor. `r_shift` is the R channel's bit offset (20 for
/// x:R:G:B, 0 for x:B:G:R); G is always at 10 and B mirrors R.
pub(super) fn composite_cursor_rgb10(
tight: &mut [u8],
w: usize,
h: usize,
r_shift: u32,
cursor: &CursorState,
) {
let b_shift = 20 - r_shift; // 0 or 20 — the opposite end from R
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
for cy in 0..bh {
let dy = cursor.y + cy;
if dy < 0 || dy as usize >= h {
continue;
}
for cx in 0..bw {
let dx = cursor.x + cx;
if dx < 0 || dx as usize >= w {
continue;
}
let s = ((cy * bw + cx) as usize) * 4;
let a = cursor.rgba[s + 3] as u32;
if a == 0 {
continue;
}
// 8-bit cursor channel → 10-bit (replicate the top bits into the bottom).
let up10 = |v: u8| ((v as u32) << 2) | ((v as u32) >> 6);
let (sr, sg, sb) = (
up10(cursor.rgba[s]),
up10(cursor.rgba[s + 1]),
up10(cursor.rgba[s + 2]),
);
let di = (dy as usize * w + dx as usize) * 4;
let px = u32::from_le_bytes(tight[di..di + 4].try_into().unwrap());
let blend = |dst: u32, src: u32| (src * a + dst * (255 - a)) / 255;
let dr = blend((px >> r_shift) & 0x3ff, sr);
let dg = blend((px >> 10) & 0x3ff, sg);
let db = blend((px >> b_shift) & 0x3ff, sb);
let out = (px & 0xc000_0000) | (dr << r_shift) | (dg << 10) | (db << b_shift);
tight[di..di + 4].copy_from_slice(&out.to_le_bytes());
}
}
}
/// Alpha-blend the cached cursor bitmap into the tightly-packed CPU frame at its latched
/// position. Cheap: a straight-alpha blit over at most ~256×256 pixels, clipped to the frame —
/// the whole point of cursor-as-metadata (no forced full-frame composite on the producer).
pub(super) fn composite_cursor(
tight: &mut [u8],
w: usize,
h: usize,
fmt: PixelFormat,
cursor: &CursorState,
) {
if !cursor.visible || cursor.rgba.is_empty() {
return;
}
// The packed 10-bit HDR layouts blend via bit unpack/repack, not byte offsets.
match fmt {
PixelFormat::X2Rgb10 => return composite_cursor_rgb10(tight, w, h, 20, cursor),
PixelFormat::X2Bgr10 => return composite_cursor_rgb10(tight, w, h, 0, cursor),
_ => {}
}
let Some((ri, gi, bi, bpp)) = dst_offsets(fmt) else {
return;
};
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
for cy in 0..bh {
let dy = cursor.y + cy;
if dy < 0 || dy as usize >= h {
continue;
}
for cx in 0..bw {
let dx = cursor.x + cx;
if dx < 0 || dx as usize >= w {
continue;
}
let s = ((cy * bw + cx) as usize) * 4;
let a = cursor.rgba[s + 3] as u32;
if a == 0 {
continue;
}
let (sr, sg, sb) = (
cursor.rgba[s] as u32,
cursor.rgba[s + 1] as u32,
cursor.rgba[s + 2] as u32,
);
let di = (dy as usize * w + dx as usize) * bpp;
let blend = |dst: u8, src: u32| ((src * a + dst as u32 * (255 - a)) / 255) as u8;
tight[di + ri] = blend(tight[di + ri], sr);
tight[di + gi] = blend(tight[di + gi], sg);
tight[di + bi] = blend(tight[di + bi], sb);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A solid-colour cursor state at `(x, y)`, `w`×`h`, alpha `a`.
fn cursor(x: i32, y: i32, w: u32, h: u32, rgb: (u8, u8, u8), a: u8) -> CursorState {
let mut px = Vec::with_capacity((w * h * 4) as usize);
for _ in 0..w * h {
px.extend_from_slice(&[rgb.0, rgb.1, rgb.2, a]);
}
CursorState {
visible: true,
x,
y,
rgba: Arc::new(px),
bw: w,
bh: h,
serial: 1,
hot_x: 0,
hot_y: 0,
}
}
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
#[test]
fn bitmap_extent_accepts_a_bitmap_that_fits() {
// 4×2 RGBA, tightly packed: 32 bytes at offset 0.
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 32), Some(0..32));
// …and the same bitmap behind a header + pixel offset.
assert_eq!(bitmap_extent(24, 8, 16, 16, 2, 64), Some(32..64));
}
#[test]
fn bitmap_extent_charges_the_last_row_only_its_visible_bytes() {
// stride 32, row 16, 3 rows ⇒ 32*2 + 16 = 80, NOT 96. A bitmap ending flush against the
// region must be accepted: the trailing stride padding is never read.
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 80), Some(0..80));
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 79), None, "one byte short");
}
#[test]
fn bitmap_extent_rejects_anything_past_the_region() {
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 31), None);
// An offset alone can push it out.
assert_eq!(bitmap_extent(1, 0, 16, 16, 2, 32), None);
assert_eq!(bitmap_extent(0, 1, 16, 16, 2, 32), None);
// A region of zero accepts nothing.
assert_eq!(bitmap_extent(0, 0, 16, 16, 1, 0), None);
}
/// The producer picks `stride` and both offsets, so each is an overflow vector on its own.
#[test]
fn bitmap_extent_survives_hostile_arithmetic() {
// stride × (bh-1) overflows.
assert_eq!(bitmap_extent(0, 0, usize::MAX, 16, 3, usize::MAX), None);
// span + row overflows. Needs ≥2 rows so `stride·(bh1)` is already at the ceiling: with a
// SINGLE row `stride·0 == 0`, and even a `usize::MAX`-wide row is then arithmetically in
// range — which is correct rather than a miss, since the caller has already capped `bw` at
// 1024 and `row` is therefore ≤ 4096.
assert_eq!(
bitmap_extent(0, 0, usize::MAX, usize::MAX, 2, usize::MAX),
None
);
// bmp_off + pix_off overflows.
assert_eq!(bitmap_extent(usize::MAX, 1, 16, 16, 1, usize::MAX), None);
// start + span overflows.
assert_eq!(
bitmap_extent(usize::MAX - 8, 0, 16, 16, 1, usize::MAX),
None
);
// A near-i32::MAX stride — the case the SAFETY comment calls out — must not wrap.
assert_eq!(bitmap_extent(0, 0, i32::MAX as usize, 16, 1024, 4096), None);
}
#[test]
fn bitmap_extent_rejects_degenerate_geometry() {
assert_eq!(bitmap_extent(0, 0, 16, 16, 0, 4096), None, "zero rows");
assert_eq!(bitmap_extent(0, 0, 16, 0, 2, 4096), None, "zero-width row");
assert_eq!(bitmap_extent(0, 0, 8, 16, 2, 4096), None, "stride < row");
}
// ---- composite_cursor: clipping, alpha, and every layout --------------------------------
/// Read pixel `(x, y)`'s (R, G, B) out of a packed frame, honouring the layout's byte order.
fn px_rgb(buf: &[u8], w: usize, x: usize, y: usize, fmt: PixelFormat) -> (u8, u8, u8) {
let (ri, gi, bi, bpp) = dst_offsets(fmt).expect("packed layout");
let i = (y * w + x) * bpp;
(buf[i + ri], buf[i + gi], buf[i + bi])
}
#[test]
fn every_packed_layout_lands_the_colour_in_its_own_channels() {
for fmt in [
PixelFormat::Bgrx,
PixelFormat::Bgra,
PixelFormat::Rgbx,
PixelFormat::Rgba,
PixelFormat::Rgb,
PixelFormat::Bgr,
] {
let bpp = dst_offsets(fmt).unwrap().3;
let (w, h) = (4usize, 4usize);
let mut buf = vec![0u8; w * h * bpp];
// Opaque pure red at (1, 1).
composite_cursor(&mut buf, w, h, fmt, &cursor(1, 1, 1, 1, (255, 0, 0), 255));
assert_eq!(px_rgb(&buf, w, 1, 1, fmt), (255, 0, 0), "{fmt:?}");
// Nothing else moved.
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (0, 0, 0), "{fmt:?}");
assert_eq!(px_rgb(&buf, w, 2, 1, fmt), (0, 0, 0), "{fmt:?}");
}
}
#[test]
fn a_cursor_hanging_off_every_edge_is_clipped_not_wrapped() {
let (w, h, fmt) = (4usize, 4usize, PixelFormat::Bgrx);
// Top-left: only the bottom-right quarter of a 2×2 lands, at (0, 0).
let mut buf = vec![0u8; w * h * 4];
composite_cursor(
&mut buf,
w,
h,
fmt,
&cursor(-1, -1, 2, 2, (10, 20, 30), 255),
);
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (10, 20, 30));
assert_eq!(px_rgb(&buf, w, 1, 0, fmt), (0, 0, 0));
assert_eq!(px_rgb(&buf, w, 0, 1, fmt), (0, 0, 0));
// Bottom-right: only the top-left quarter lands, at (3, 3).
let mut buf = vec![0u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &cursor(3, 3, 2, 2, (10, 20, 30), 255));
assert_eq!(px_rgb(&buf, w, 3, 3, fmt), (10, 20, 30));
assert_eq!(px_rgb(&buf, w, 2, 3, fmt), (0, 0, 0));
// Fully outside in each direction: the frame is untouched.
for pos in [(-2, 0), (0, -2), (4, 0), (0, 4), (-9, -9), (99, 99)] {
let mut buf = vec![0u8; w * h * 4];
composite_cursor(
&mut buf,
w,
h,
fmt,
&cursor(pos.0, pos.1, 2, 2, (255, 255, 255), 255),
);
assert!(buf.iter().all(|&b| b == 0), "drew something at {pos:?}");
}
}
#[test]
fn transparent_and_hidden_cursors_draw_nothing() {
let (w, h, fmt) = (2usize, 2usize, PixelFormat::Bgrx);
// Alpha 0 — every pixel skipped.
let mut buf = vec![0u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 2, 2, (255, 255, 255), 0));
assert!(buf.iter().all(|&b| b == 0));
// `visible: false` — the whole blit is skipped.
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
c.visible = false;
let mut buf = vec![0u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &c);
assert!(buf.iter().all(|&b| b == 0));
// No bitmap yet — likewise.
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
c.rgba = Arc::new(Vec::new());
let mut buf = vec![0u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &c);
assert!(buf.iter().all(|&b| b == 0));
}
#[test]
fn half_alpha_blends_toward_the_destination() {
let (w, h, fmt) = (1usize, 1usize, PixelFormat::Bgrx);
// dst = white, src = black at 50% ⇒ mid grey (integer blend: (0*128 + 255*127)/255 = 127).
let mut buf = vec![255u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 1, 1, (0, 0, 0), 128));
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (127, 127, 127));
}
/// A layout the CPU blit cannot address must be declined, not mis-blitted.
#[test]
fn unsupported_layouts_are_declined() {
assert!(dst_offsets(PixelFormat::Nv12).is_none());
assert!(dst_offsets(PixelFormat::Yuv444).is_none());
let (w, h) = (2usize, 2usize);
let mut buf = vec![0u8; w * h * 4];
composite_cursor(
&mut buf,
w,
h,
PixelFormat::Nv12,
&cursor(0, 0, 2, 2, (255, 255, 255), 255),
);
assert!(buf.iter().all(|&b| b == 0), "NV12 must not be blitted");
}
// ---- composite_cursor_rgb10: the 10-bit unpack/repack round trip -------------------------
/// Pack an `x:R:G:B` (`X2Rgb10`) pixel — R at bit 20, G at 10, B at 0 — the way a producer does.
fn pack_x2rgb10(r: u32, g: u32, b: u32) -> [u8; 4] {
(0xC000_0000 | (r << 20) | (g << 10) | b).to_le_bytes()
}
#[test]
fn the_10bit_path_round_trips_an_untouched_pixel() {
// Alpha 0 ⇒ the blend is skipped entirely, so the packed pixel must come back bit-identical
// (including the top two bits, which are alpha and must survive the repack).
for (r, g, b) in [(0, 0, 0), (1023, 1023, 1023), (940, 64, 512), (1, 2, 3)] {
let src = pack_x2rgb10(r, g, b);
let mut buf = src.to_vec();
composite_cursor(
&mut buf,
1,
1,
PixelFormat::X2Rgb10,
&cursor(0, 0, 1, 1, (255, 255, 255), 0),
);
assert_eq!(buf, src, "({r},{g},{b}) was modified by a zero-alpha blend");
}
}
#[test]
fn the_10bit_path_writes_the_right_channel_at_the_right_shift() {
// Opaque pure red, 8-bit 255 → 10-bit 1023 (the `v<<2 | v>>6` expansion).
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
composite_cursor(
&mut buf,
1,
1,
PixelFormat::X2Rgb10,
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
);
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
assert_eq!((v >> 20) & 0x3ff, 1023, "R");
assert_eq!((v >> 10) & 0x3ff, 0, "G");
assert_eq!(v & 0x3ff, 0, "B");
assert_eq!(v & 0xc000_0000, 0xc000_0000, "alpha bits preserved");
// X2Bgr10 puts R at bit 0 and B at 20 — the SAME cursor must land in the other end.
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
composite_cursor(
&mut buf,
1,
1,
PixelFormat::X2Bgr10,
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
);
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
assert_eq!(v & 0x3ff, 1023, "R at bit 0 for x:B:G:R");
assert_eq!((v >> 20) & 0x3ff, 0, "B untouched");
}
#[test]
fn the_10bit_path_clips_like_the_8bit_one() {
let (w, h) = (2usize, 2usize);
let mut buf: Vec<u8> = (0..w * h).flat_map(|_| pack_x2rgb10(0, 0, 0)).collect();
let before = buf.clone();
// Entirely off-frame.
composite_cursor(
&mut buf,
w,
h,
PixelFormat::X2Rgb10,
&cursor(-5, -5, 2, 2, (255, 255, 255), 255),
);
assert_eq!(buf, before);
// Straddling the top-left corner: only (0, 0) is written.
composite_cursor(
&mut buf,
w,
h,
PixelFormat::X2Rgb10,
&cursor(-1, -1, 2, 2, (255, 255, 255), 255),
);
let p0 = u32::from_le_bytes(buf[0..4].try_into().unwrap());
let p1 = u32::from_le_bytes(buf[4..8].try_into().unwrap());
assert_eq!((p0 >> 20) & 0x3ff, 1023);
assert_eq!((p1 >> 20) & 0x3ff, 0);
}
// ---- decode_bitmap_pixel: the producer's byte order ------------------------------------
#[test]
fn each_bitmap_format_is_decoded_to_straight_rgba() {
let s = [1u8, 2, 3, 4];
assert_eq!(
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_RGBA, &s),
(1, 2, 3, 4)
);
assert_eq!(
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_BGRA, &s),
(3, 2, 1, 4)
);
assert_eq!(
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ARGB, &s),
(2, 3, 4, 1)
);
assert_eq!(
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ABGR, &s),
(4, 3, 2, 1)
);
// An unknown 4-byte format reads as RGBA rather than being rejected — documented behaviour.
assert_eq!(decode_bitmap_pixel(0xdead_beef, &s), (1, 2, 3, 4));
}
}
+515
View File
@@ -0,0 +1,515 @@
//! The PipeWire `EnumFormat` / `Buffers` / `Meta` param pods the capture stream offers, and the
//! `Pod` serializer they all end in.
//!
//! Split out of `linux/pipewire.rs` (sweep Phase 5.2) because it is the crate's WIRE surface: what
//! these builders put in a pod is what the compositor intersects against, and a missing property is
//! not a compile error but a link that silently stalls in `negotiating`. Nothing here touches the
//! stream, the buffers or the frames — every function is a pure `facts -> Vec<u8>`.
use anyhow::{Context, Result};
use pipewire as pw;
use pw::spa;
use spa::param::video::VideoFormat;
pub(super) fn serialize_pod(obj: pw::spa::pod::Object) -> Result<Vec<u8>> {
Ok(pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.context("serialize pod")?
.0
.into_inner())
}
/// Build a LINEAR/modifier DMA-BUF `EnumFormat` pod. Packed BGRx is the existing import path;
/// NV12 is gamescope's producer-side RGB→YUV path (opt-in during bring-up).
pub(super) fn build_dmabuf_format(
format: VideoFormat,
modifiers: &[u64],
preferred: Option<(u32, u32, u32)>,
) -> Result<Vec<u8>> {
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
let mut obj = pw::spa::pod::object!(
pw::spa::utils::SpaTypes::ObjectParamFormat,
pw::spa::param::ParamType::EnumFormat,
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
pw::spa::pod::property!(
FormatProperties::VideoSize,
Choice,
Range,
Rectangle,
pw::spa::utils::Rectangle {
width: dw,
height: dh
},
pw::spa::utils::Rectangle {
width: 1,
height: 1
},
pw::spa::utils::Rectangle {
width: 8192,
height: 8192
}
),
pw::spa::pod::property!(
FormatProperties::VideoFramerate,
Choice,
Range,
Fraction,
pw::spa::utils::Fraction { num: dhz, denom: 1 },
pw::spa::utils::Fraction { num: 0, denom: 1 },
pw::spa::utils::Fraction { num: 240, denom: 1 }
),
);
if format == VideoFormat::NV12 {
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorMatrix,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
pw::spa::sys::SPA_VIDEO_COLOR_MATRIX_BT709,
)),
});
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorRange,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
pw::spa::sys::SPA_VIDEO_COLOR_RANGE_16_235,
)),
});
}
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Long(
pw::spa::utils::Choice(
pw::spa::utils::ChoiceFlags::empty(),
pw::spa::utils::ChoiceEnum::Enum {
default: modifiers[0] as i64,
alternatives: modifiers.iter().map(|&m| m as i64).collect(),
},
),
)),
});
serialize_pod(obj)
}
/// Build one GNOME 50+ HDR format pod: `format` (xRGB_210LE / xBGR_210LE) as a LINEAR-only
/// dmabuf with **MANDATORY** BT.2020 primaries + SMPTE ST.2084 (PQ) transfer-function props —
/// the exact colorimetry Mutter's monitor stream advertises while the mirrored monitor is in
/// HDR mode (its HDR pods carry the same props MANDATORY, so both sides must speak them for
/// the intersection to exist; an SDR or pre-50 producer can never match this pod).
///
/// LINEAR-only because every 10-bit consumer we have reads the buffer without a de-tile pass:
/// the CPU path mmaps it, and the VAAPI passthrough imports it into a VA surface. The tiled
/// EGL de-tile blit renders into an 8-bit `GL_RGBA8` texture — it would silently crush the
/// depth — so tiled modifiers are deliberately NOT advertised (a zero-copy 10-bit de-tile is
/// the follow-up). SHM is excluded entirely: Mutter's SHM record path paints 8-bit ARGB32
/// regardless of the negotiated format.
/// `SPA_VIDEO_TRANSFER_SMPTE2084` (PQ) — spelled out rather than taken from `pw::spa::sys`
/// because libspa only grew the constant with the BT2020_10/SMPTE2084/ARIB_STD_B67 block, and
/// the distro builders (Ubuntu 24.04 noble for the .deb) ship headers predating it — bindgen
/// then emits no such constant and the host fails to compile there, even though the code never
/// runs on those systems (the HDR path needs GNOME 50+).
///
/// 14 is the enum's position in `spa/param/video/color.h` and is wire ABI, not a private
/// detail: SPA mirrors GStreamer's `GstVideoTransferFunction`, where that block was added
/// together, so the value is identical on every libspa that has the symbol at all. On one that
/// doesn't, PipeWire simply fails to intersect this format offer and the session negotiates
/// SDR — the same outcome as not offering HDR.
const SPA_VIDEO_TRANSFER_SMPTE2084: u32 = 14;
pub(super) fn build_hdr_dmabuf_format(
format: VideoFormat,
preferred: Option<(u32, u32, u32)>,
) -> Result<Vec<u8>> {
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
let mut obj = pw::spa::pod::object!(
pw::spa::utils::SpaTypes::ObjectParamFormat,
pw::spa::param::ParamType::EnumFormat,
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
pw::spa::pod::property!(
FormatProperties::VideoSize,
Choice,
Range,
Rectangle,
pw::spa::utils::Rectangle {
width: dw,
height: dh
},
pw::spa::utils::Rectangle {
width: 1,
height: 1
},
pw::spa::utils::Rectangle {
width: 8192,
height: 8192
}
),
pw::spa::pod::property!(
FormatProperties::VideoFramerate,
Choice,
Range,
Fraction,
pw::spa::utils::Fraction { num: dhz, denom: 1 },
pw::spa::utils::Fraction { num: 0, denom: 1 },
pw::spa::utils::Fraction { num: 240, denom: 1 }
),
);
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Long(0), // DRM_FORMAT_MOD_LINEAR
});
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_transferFunction,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(SPA_VIDEO_TRANSFER_SMPTE2084)),
});
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorPrimaries,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
pw::spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020,
)),
});
serialize_pod(obj)
}
/// The default (shm/CPU-path) format offer: raw video in any encoder-mappable layout, any
/// size, any framerate (0/1 = variable allowed — gamescope fixates exactly that).
pub(super) fn build_default_format_obj(preferred: Option<(u32, u32, u32)>) -> pw::spa::pod::Object {
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
pw::spa::pod::object!(
pw::spa::utils::SpaTypes::ObjectParamFormat,
pw::spa::param::ParamType::EnumFormat,
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::MediaType,
Id,
pw::spa::param::format::MediaType::Video
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::MediaSubtype,
Id,
pw::spa::param::format::MediaSubtype::Raw
),
// Offer the layouts the encoder can map to an NVENC input format. wlroots
// commonly fixates packed RGB (3 bpp); other compositors offer 4 bpp. Only
// these are requested, so negotiation fails loudly rather than handing us a
// format we'd misinterpret.
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoFormat,
Choice,
Enum,
Id,
VideoFormat::RGB,
VideoFormat::RGB,
VideoFormat::BGR,
VideoFormat::RGBx,
VideoFormat::BGRx,
VideoFormat::RGBA,
VideoFormat::BGRA,
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoSize,
Choice,
Range,
Rectangle,
pw::spa::utils::Rectangle {
width: dw,
height: dh
},
pw::spa::utils::Rectangle {
width: 1,
height: 1
},
pw::spa::utils::Rectangle {
width: 8192,
height: 8192
}
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoFramerate,
Choice,
Range,
Fraction,
pw::spa::utils::Fraction { num: dhz, denom: 1 },
pw::spa::utils::Fraction { num: 0, denom: 1 },
pw::spa::utils::Fraction { num: 240, denom: 1 }
),
)
}
/// Build a Buffers param for the CPU path accepting anything mappable: MemPtr, MemFd, and
/// DmaBuf. The DmaBuf bit matters for producers like gamescope whose format intersection
/// lands on their modifier-bearing (LINEAR) pod: they then offer *only* DmaBuf buffers, and
/// without this bit the buffer-type intersection is empty and the link silently stalls in
/// "negotiating". A LINEAR dmabuf is mmap-able by MAP_BUFFERS, so the CPU de-pad copy works.
pub(super) fn build_mappable_buffers() -> Result<Vec<u8>> {
serialize_pod(pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
id: pw::spa::param::ParamType::Buffers.as_raw(),
properties: vec![pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Int(
(1i32 << pw::spa::sys::SPA_DATA_MemPtr)
| (1i32 << pw::spa::sys::SPA_DATA_MemFd)
| (1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
),
}],
})
}
/// Build a Buffers param for a TRUE SHM path: MemPtr + MemFd only, NO DmaBuf. Forces the
/// producer to download into mappable memory (Mutter's `glReadPixels`), which orders against its
/// render — so the frame is complete and current by construction. This is the only race-free
/// capture of Mutter's virtual monitor on NVIDIA: the compositor renders straight into the buffer
/// pool, NVIDIA attaches no implicit dmabuf fence (verified: `EXPORT_SYNC_FILE` waited=false) and
/// can't produce an explicit sync_fd, so any dmabuf read (zero-copy OR mmap) races the render and
/// flashes the buffer's previous frame. Excluding DmaBuf is what makes the difference vs.
/// `build_mappable_buffers` (which still let Mutter hand dmabufs).
pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
serialize_pod(pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
id: pw::spa::param::ParamType::Buffers.as_raw(),
properties: vec![pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Int(
(1i32 << pw::spa::sys::SPA_DATA_MemPtr) | (1i32 << pw::spa::sys::SPA_DATA_MemFd),
),
}],
})
}
/// Build a Buffers param requesting dmabuf-only buffers.
pub(super) fn build_dmabuf_buffers() -> Result<Vec<u8>> {
serialize_pod(pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
id: pw::spa::param::ParamType::Buffers.as_raw(),
properties: vec![pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Int(1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
}],
})
}
/// Request the compositor attach `SPA_META_Cursor` to each buffer, so the pointer travels as
/// metadata (position + an occasional bitmap) instead of being burned into the frame. Paired
/// with the portal's `CursorMode::Metadata`; producers that don't support it simply don't
/// attach it (harmless). Size is a range up to a 256×256 bitmap — bigger than any real cursor.
pub(super) fn build_cursor_meta_param() -> Result<Vec<u8>> {
fn meta_size(w: u32, h: u32) -> i32 {
(std::mem::size_of::<spa::sys::spa_meta_cursor>()
+ std::mem::size_of::<spa::sys::spa_meta_bitmap>()
+ (w as usize * h as usize * 4)) as i32
}
serialize_pod(pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamMeta.as_raw(),
id: pw::spa::param::ParamType::Meta.as_raw(),
properties: vec![
pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_META_type,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(spa::sys::SPA_META_Cursor)),
},
pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_META_size,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
pw::spa::utils::Choice(
pw::spa::utils::ChoiceFlags::empty(),
// The max must cover the producer's offer or the Meta param silently
// fails to negotiate and NO buffer ever carries the meta region:
// Mutter offers a FIXED `SPA_POD_Int(CURSOR_META_SIZE(384, 384))`
// (meta-screen-cast-stream-src.c, GNOME 50) — a 256² max made the
// intersection empty, which cost the whole Linux cursor channel
// on-glass. 1024² is headroom, not an allocation: the negotiated
// region follows the producer's value.
pw::spa::utils::ChoiceEnum::Range {
default: meta_size(64, 64),
min: meta_size(1, 1),
max: meta_size(1024, 1024),
},
),
)),
},
],
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The `SPA_PARAM_BUFFERS_dataType` bitmask a serialized Buffers pod carries.
///
/// A deliberately literal SPA reader rather than a heuristic scan: an object property is
/// `{ key: u32, flags: u32, value: spa_pod }` and a `spa_pod` is `{ size: u32, type: u32, body }`,
/// so the `i32` sits exactly 16 bytes past the key — and the intervening `size` word is itself
/// `4`, which is why "find the first plausible-looking int" reads the wrong field.
fn buffers_data_type(pod: &[u8]) -> i32 {
let key = spa::sys::SPA_PARAM_BUFFERS_dataType.to_ne_bytes();
let at = pod
.windows(4)
.position(|w| w == key)
.expect("dataType key present in the Buffers pod");
let word = |off: usize| u32::from_ne_bytes(pod[off..off + 4].try_into().unwrap());
assert_eq!(word(at + 8), 4, "dataType's value pod should be 4 bytes");
assert_eq!(
word(at + 12),
spa::sys::SPA_TYPE_Int,
"dataType's value pod should be an Int"
);
i32::from_ne_bytes(pod[at + 16..at + 20].try_into().unwrap())
}
const MEM_PTR: i32 = 1 << spa::sys::SPA_DATA_MemPtr;
const MEM_FD: i32 = 1 << spa::sys::SPA_DATA_MemFd;
const DMABUF: i32 = 1 << spa::sys::SPA_DATA_DmaBuf;
/// The three Buffers pods differ ONLY in this bitmask, and each bit is load-bearing:
/// `build_mappable_buffers` must include DmaBuf or gamescope's modifier-bearing pod wins the
/// format intersection and the BUFFER intersection is then empty (a link stuck in
/// "negotiating"); `build_shm_only_buffers` must EXCLUDE it or Mutter hands dmabufs and the
/// race-free download path is not race-free; `build_dmabuf_buffers` must exclude the mappable
/// types or an HDR session can be handed a MemFd buffer, which Mutter paints 8-bit ARGB32
/// regardless of the negotiated 10-bit format.
#[test]
fn each_buffers_pod_requests_exactly_its_own_data_types() {
assert_eq!(
buffers_data_type(&build_mappable_buffers().unwrap()),
MEM_PTR | MEM_FD | DMABUF,
"the CPU path must accept mappable dmabufs too"
);
assert_eq!(
buffers_data_type(&build_shm_only_buffers().unwrap()),
MEM_PTR | MEM_FD,
"PUNKTFUNK_FORCE_SHM must exclude DmaBuf"
);
assert_eq!(
buffers_data_type(&build_dmabuf_buffers().unwrap()),
DMABUF,
"the zero-copy/HDR path must exclude SHM"
);
}
/// Every pod builder must produce a pod libspa will accept back — a serializer that silently
/// emitted a malformed object would fail only at negotiation, on a live compositor.
#[test]
fn every_pod_round_trips_through_pod_from_bytes() {
let mut pods: Vec<(&str, Vec<u8>)> = vec![
("mappable buffers", build_mappable_buffers().unwrap()),
("shm-only buffers", build_shm_only_buffers().unwrap()),
("dmabuf buffers", build_dmabuf_buffers().unwrap()),
("cursor meta", build_cursor_meta_param().unwrap()),
(
"default format",
serialize_pod(build_default_format_obj(None)).unwrap(),
),
(
"dmabuf BGRx",
build_dmabuf_format(VideoFormat::BGRx, &[0, 1, 2], Some((1920, 1080, 60))).unwrap(),
),
(
"dmabuf NV12",
build_dmabuf_format(VideoFormat::NV12, &[0], Some((1280, 720, 60))).unwrap(),
),
(
"hdr xRGB",
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, None).unwrap(),
),
(
"hdr xBGR",
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, Some((3840, 2160, 120))).unwrap(),
),
];
for (name, bytes) in &mut pods {
assert!(!bytes.is_empty(), "{name} serialized to nothing");
assert_eq!(bytes.len() % 8, 0, "{name} is not 8-byte aligned/padded");
assert!(
spa::pod::Pod::from_bytes(bytes).is_some(),
"{name} did not parse back as a pod"
);
}
}
/// The HDR pods carry BOTH colorimetry properties MANDATORY — Mutter's HDR pods do the same, so
/// the intersection only exists if we speak them. Dropping either would negotiate an SDR-labelled
/// 10-bit stream (or nothing at all).
#[test]
fn the_hdr_pods_carry_mandatory_pq_and_bt2020() {
for fmt in [VideoFormat::xRGB_210LE, VideoFormat::xBGR_210LE] {
let pod = build_hdr_dmabuf_format(fmt, None).unwrap();
for (name, key) in [
(
"transferFunction",
spa::sys::SPA_FORMAT_VIDEO_transferFunction,
),
("colorPrimaries", spa::sys::SPA_FORMAT_VIDEO_colorPrimaries),
("modifier", spa::sys::SPA_FORMAT_VIDEO_modifier),
] {
assert!(
pod.windows(4).any(|w| w == key.to_ne_bytes()),
"{fmt:?} pod is missing {name}"
);
}
// The PQ id and BT.2020 id must both appear as values.
assert!(
pod.windows(4)
.any(|w| w == SPA_VIDEO_TRANSFER_SMPTE2084.to_ne_bytes()),
"{fmt:?} pod does not carry the PQ transfer id"
);
assert!(
pod.windows(4)
.any(|w| w == spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020.to_ne_bytes()),
"{fmt:?} pod does not carry BT.2020 primaries"
);
}
}
/// An NV12 offer pins BT.709 limited so gamescope's producer-side RGB→YUV shader matches OUR
/// bitstream colorimetry; the packed-RGB offer must NOT carry those (it is not YUV).
#[test]
fn only_the_nv12_offer_pins_the_colour_matrix() {
let nv12 = build_dmabuf_format(VideoFormat::NV12, &[0], None).unwrap();
let bgrx = build_dmabuf_format(VideoFormat::BGRx, &[0], None).unwrap();
for (name, key) in [
("colorMatrix", spa::sys::SPA_FORMAT_VIDEO_colorMatrix),
("colorRange", spa::sys::SPA_FORMAT_VIDEO_colorRange),
] {
assert!(
nv12.windows(4).any(|w| w == key.to_ne_bytes()),
"NV12 offer is missing {name}"
);
assert!(
!bgrx.windows(4).any(|w| w == key.to_ne_bytes()),
"packed-RGB offer should not pin {name}"
);
}
}
/// Pin our hand-written PQ transfer id against the real libspa binding. We can't take the
/// constant from `pw::spa::sys` directly (older distro headers don't export it — see
/// [`super::SPA_VIDEO_TRANSFER_SMPTE2084`]), so assert the two agree wherever the symbol
/// DOES exist. Any libspa that renumbers the enum fails this instead of silently tagging
/// the HDR offer with the wrong transfer function.
///
/// Only builds where tests are compiled — the .deb/.rpm builders run plain `cargo build`,
/// so this never reintroduces the compile failure it exists to prevent.
#[test]
fn pq_transfer_id_matches_libspa() {
assert_eq!(
super::SPA_VIDEO_TRANSFER_SMPTE2084,
super::pw::spa::sys::SPA_VIDEO_TRANSFER_SMPTE2084,
"libspa renumbered spa_video_transfer_function — update the hardcoded PQ id"
);
}
}
+457 -67
View File
@@ -37,7 +37,7 @@
//! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had.
use std::sync::{
atomic::{AtomicBool, Ordering},
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
};
use std::time::Duration;
@@ -51,12 +51,29 @@ use x11rb::protocol::xproto::{
Window,
};
use x11rb::protocol::Event;
use x11rb::rust_connection::RustConnection;
use x11rb::rust_connection::{DefaultStream, RustConnection};
/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only
/// ever contended if two gamescope sessions start at once — rare, and the swap is microseconds.
use crate::GamescopeCursorTargets;
/// Serializes the `XAUTHORITY` env swap of the LEGACY connect fallback (the var is process-global).
///
/// The fallback is a last resort now — see [`connect_conn`]. It serialises this source against
/// itself and nothing else: `getenv` needs no lock to be racy, so every OTHER thread's read (libspa
/// plugin load, EGL/CUDA init — concurrent by construction, since `attach_gamescope_cursor` runs
/// while the PipeWire thread is starting) could still observe the swapped value or a torn
/// environ. That is why the primary path parses the cookie itself and never touches the
/// environment.
static XAUTH_LOCK: Mutex<()> = Mutex::new(());
/// The `MIT-MAGIC-COOKIE-1` auth-protocol name, as it appears in an `.Xauthority` entry.
const MIT_MAGIC_COOKIE_1: &[u8] = b"MIT-MAGIC-COOKIE-1";
/// Re-run the targets provider this often: adopt Xwaylands that appeared after the stream started
/// (gamescope spawns the game's on launch and advertises only the first in any child's environ) and
/// retry ones whose connection died. Two seconds is far below a human's tolerance for a missing
/// pointer and costs one cheap socket probe per known display.
const REDISCOVER: Duration = Duration::from_secs(2);
/// Position out-paces the stream fps (`POLL`); shape rides `CursorNotify` events drained each tick.
/// 4 ms ≈ 250 Hz matches the Windows GDI poller — the polled position IS the composited position
/// and must out-run a 240 fps session or the pointer stutters.
@@ -73,62 +90,64 @@ const GS_CURSOR_FEEDBACK: &str = "GAMESCOPE_CURSOR_VISIBLE_FEEDBACK";
/// `GetProperty` per display per interval is nothing next to the 250 Hz pointer poll.
const FEEDBACK_RESYNC: Duration = Duration::from_millis(250);
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the
/// X connections so it lives exactly as long as the capturer that owns it.
/// A running XFixes cursor reader. Dropping it stops the worker thread and waits — bounded — for it
/// to release the X connections, so it lives (at most) as long as the capturer that owns it.
pub(super) struct XFixesCursorSource {
stop: Arc<AtomicBool>,
/// Signalled by the worker just before it returns — lets `Drop` bound its wait (see there).
done: std::sync::mpsc::Receiver<()>,
join: Option<std::thread::JoinHandle<()>>,
}
impl XFixesCursorSource {
/// Connect to every gamescope nested Xwayland in `targets` (`(DISPLAY, XAUTHORITY)`) and start
/// publishing cursor overlays into `slot`, following the focused display's pointer. Returns
/// `None` — and logs — if NONE can be used (no X connection / no XFixes), so the caller
/// degrades to no gamescope cursor (today's behaviour) instead of failing the session.
/// Start publishing cursor overlays into `slot` from whichever of gamescope's nested Xwaylands
/// it is drawing the pointer on. `targets` is re-run every [`REDISCOVER`] to adopt Xwaylands
/// that appear later and retry dead ones; `frame_size` is the negotiated capture size, packed
/// `(w << 32) | h`, `0` until the first negotiation (see [`scale_to_frame`]).
///
/// Returns `None` only if the thread cannot be spawned. An empty or entirely-unusable target
/// list is NOT a failure: the worker idles and keeps re-running the provider, so a stream that
/// starts before the game converges instead of being cursorless for the session.
pub(super) fn spawn(
targets: Vec<(String, Option<String>)>,
targets: GamescopeCursorTargets,
slot: Arc<Mutex<Option<CursorOverlay>>>,
frame_size: Arc<AtomicU64>,
) -> Option<Self> {
// Connect on the caller's thread so failures degrade cleanly and the displays are validated
// before we commit a thread.
// First pass on the caller's thread: the common case connects here, so the log line below
// reports the real state instead of "starting…".
let mut displays = Vec::new();
for (dpy, xauth) in targets {
match connect(&dpy, xauth.as_deref()) {
Ok((conn, root, feedback)) => {
displays.push(XDisplay::new(dpy, conn, root, feedback))
}
Err(e) => tracing::warn!(
dpy = %dpy,
error = %e,
"gamescope cursor: skipping a nested Xwayland we can't use"
),
}
}
if displays.is_empty() {
tracing::warn!(
"gamescope cursor: no usable nested Xwayland — no in-video pointer this session \
(falls back to today's cursorless gamescope stream)"
);
return None;
}
rediscover(&mut displays, &targets, true);
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
let feedback = displays.iter().any(|d| d.gs_visible.is_some());
tracing::info!(
displays = ?names,
cursor_feedback = feedback,
"gamescope cursor: XFixes source live — following the Xwayland gamescope draws the \
pointer on (cursor_feedback=false this gamescope publishes no \
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)"
);
if displays.is_empty() {
tracing::warn!(
"gamescope cursor: no usable nested Xwayland yet — retrying every {}s (a game's \
Xwayland appears when it launches)",
REDISCOVER.as_secs()
);
} else {
tracing::info!(
displays = ?names,
cursor_feedback = feedback,
"gamescope cursor: XFixes source live — following the Xwayland gamescope draws the \
pointer on (cursor_feedback=false this gamescope publishes no \
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)"
);
}
let stop = Arc::new(AtomicBool::new(false));
let stop_worker = Arc::clone(&stop);
let (done_tx, done_rx) = std::sync::mpsc::sync_channel::<()>(1);
let join = std::thread::Builder::new()
.name("pf-gs-cursor".into())
.spawn(move || run(displays, slot, stop_worker))
.spawn(move || {
run(displays, slot, stop_worker, targets, frame_size);
let _ = done_tx.send(());
})
.ok()?;
Some(XFixesCursorSource {
stop,
done: done_rx,
join: Some(join),
})
}
@@ -137,35 +156,69 @@ impl XFixesCursorSource {
impl Drop for XFixesCursorSource {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
// BOUNDED join. The worker blocks in `RustConnection` replies with no read timeout, so a
// peer that stops answering while keeping its socket open (a hung Xwayland) would hang
// capturer teardown — and teardown runs on the session path. On timeout we detach: the
// thread only ever touches its own X connections and an `Arc`'d slot, so leaving it to
// finish on its own is safe (it observes `stop` and exits the moment its reply lands).
let joinable = self.done.recv_timeout(Duration::from_millis(250)).is_ok();
if let Some(j) = self.join.take() {
let _ = j.join();
if joinable {
let _ = j.join(); // returns at once: `done` already fired
} else {
tracing::warn!(
"gamescope cursor: worker did not stop within 250ms (blocked on an X reply?) — \
detaching it"
);
}
}
}
}
/// Re-run the targets provider and reconcile `displays` with it: connect to any target we do not
/// track yet, and re-connect one whose display died. Existing healthy displays are left alone, so
/// their shape cache and last position survive.
fn rediscover(displays: &mut Vec<XDisplay>, targets: &GamescopeCursorTargets, first: bool) {
for (dpy, xauth) in targets() {
let existing = displays.iter().position(|d| d.name == dpy);
if existing.is_some_and(|i| !displays[i].dead) {
continue;
}
match connect(&dpy, xauth.as_deref()) {
Ok((conn, root, root_size, feedback)) => {
let d = XDisplay::new(dpy.clone(), conn, root, root_size, feedback);
match existing {
Some(i) => {
tracing::info!(dpy = %dpy, "gamescope cursor: reconnected a nested Xwayland");
displays[i] = d;
}
None => {
if !first {
tracing::info!(dpy = %dpy, "gamescope cursor: adopted a new nested Xwayland");
}
displays.push(d);
}
}
}
// Debug, not warn: with a 2 s retry a warn would flood for every Xwayland a
// `--xwayland-count` reports but that never comes up.
Err(e) if first => tracing::warn!(
dpy = %dpy, error = %e,
"gamescope cursor: skipping a nested Xwayland we can't use (will retry)"
),
Err(e) => tracing::debug!(dpy = %dpy, error = %e, "gamescope cursor: retry failed"),
}
}
}
/// Open the X connection, negotiate XFixes, and select cursor-change + root-property events —
/// returning the connection, root window and this display's initial
/// [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`; the value is `None`
/// when gamescope publishes no such property here). `RustConnection` reads `XAUTHORITY` from the
/// env at connect time only, so set it under the lock (the host isn't a gamescope child), connect,
/// then restore.
type Connected = (RustConnection, Window, (Atom, Option<bool>));
/// returning the connection, root window, the root's pixel size (for [`scale_to_frame`]) and this
/// display's initial [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`;
/// the value is `None` when gamescope publishes no such property here).
type Connected = (RustConnection, Window, (u16, u16), (Atom, Option<bool>));
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
let (conn, screen_num) = {
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("XAUTHORITY");
if let Some(x) = xauthority {
std::env::set_var("XAUTHORITY", x);
}
let out = RustConnection::connect(Some(dpy));
match (&prev, xauthority) {
(Some(p), _) => std::env::set_var("XAUTHORITY", p),
(None, Some(_)) => std::env::remove_var("XAUTHORITY"),
(None, None) => {}
}
out.map_err(|e| format!("connect: {e}"))?
};
let (conn, screen_num) = connect_conn(dpy, xauthority)?;
// XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get.
conn.xfixes_query_version(5, 0)
@@ -173,12 +226,16 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
.and_then(|c| c.reply())
.map_err(|e| format!("XFixes unavailable: {e}"))?;
let root = conn
let screen = conn
.setup()
.roots
.get(screen_num)
.ok_or_else(|| format!("no X screen {screen_num}"))?
.root;
.ok_or_else(|| format!("no X screen {screen_num}"))?;
let root = screen.root;
// gamescope's nested root can be a DIFFERENT size from the output/PipeWire node it publishes
// (`-w/-h` vs `-W/-H` are independent knobs), and this is the space `QueryPointer` answers in.
// Free here — the setup reply is already parsed.
let root_size = (screen.width_in_pixels, screen.height_in_pixels);
// Wake the worker's event drain whenever the cursor shape changes (incl. hide/show).
conn.xfixes_select_cursor_input(root, xfixes::CursorNotifyMask::DISPLAY_CURSOR)
@@ -203,7 +260,143 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
}
let _ = conn.flush();
let feedback = read_cursor_feedback(&conn, root, feedback_atom);
Ok((conn, root, (feedback_atom, feedback)))
Ok((conn, root, root_size, (feedback_atom, feedback)))
}
/// Establish the X connection for `dpy` using `xauthority`'s cookie, WITHOUT touching this process's
/// environment.
///
/// `RustConnection::connect` reads `XAUTHORITY` from the env, so the original implementation
/// `set_var`'d it around each connect under [`XAUTH_LOCK`]. That is unsound from a live
/// multithreaded host: the lock serialises this source against itself, but `getenv` takes no lock,
/// so any concurrent reader (libspa's plugin load, EGL/CUDA init — running at exactly this moment,
/// since the PipeWire thread is starting up) could read the swapped value or race the environ
/// rewrite outright. The project already has a process-wide env-lock discipline elsewhere, but
/// sharing it would be the wrong layer AND would still not fix `getenv`.
///
/// So: parse the MIT-MAGIC-COOKIE-1 entry out of the file ourselves and hand it to
/// `connect_to_stream_with_auth_info`, which is what `RustConnection::connect` does internally with
/// the cookie IT found. The env swap survives only as a fallback for a file we cannot parse (an
/// unexpected layout, or an auth family whose entry we decline to guess at).
fn connect_conn(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, usize), String> {
let Some(path) = xauthority else {
// No per-display cookie file to inject: the ambient environment is already what this
// connect should use, so there is nothing to swap and nothing to parse.
return RustConnection::connect(Some(dpy)).map_err(|e| format!("connect: {e}"));
};
match mit_magic_cookie(path, dpy) {
Some((name, data)) => match connect_with_cookie(dpy, name, data) {
Ok(v) => return Ok(v),
Err(e) => tracing::debug!(
dpy = %dpy, xauthority = %path, error = %e,
"gamescope cursor: cookie connect failed — falling back to the XAUTHORITY env swap"
),
},
None => tracing::debug!(
dpy = %dpy, xauthority = %path,
"gamescope cursor: no MIT-MAGIC-COOKIE-1 entry for this display — falling back to the \
XAUTHORITY env swap"
),
}
connect_via_env_swap(dpy, path)
}
/// Connect to `dpy` and complete the setup handshake with an explicit cookie — the same two steps
/// `RustConnection::connect` performs, minus its env-derived auth lookup.
fn connect_with_cookie(
dpy: &str,
auth_name: Vec<u8>,
auth_data: Vec<u8>,
) -> Result<(RustConnection, usize), String> {
let parsed = x11rb::reexports::x11rb_protocol::parse_display::parse_display(Some(dpy))
.map_err(|e| format!("parse display {dpy}: {e}"))?;
let screen = usize::from(parsed.screen);
let mut stream = None;
let mut last_err = None;
for addr in parsed.connect_instruction() {
match DefaultStream::connect(&addr) {
Ok((s, _peer)) => {
stream = Some(s);
break;
}
Err(e) => last_err = Some(e),
}
}
let stream = stream.ok_or_else(|| match last_err {
Some(e) => format!("connect: {e}"),
None => "connect: no usable address".to_string(),
})?;
RustConnection::connect_to_stream_with_auth_info(stream, screen, auth_name, auth_data)
.map(|c| (c, screen))
.map_err(|e| format!("setup: {e}"))
}
/// LEGACY fallback (see [`connect_conn`]): swap `XAUTHORITY`, connect, restore. Serialised against
/// this source's own concurrent connects, but NOT against other threads' `getenv` — which is why it
/// is a fallback and not the path taken.
fn connect_via_env_swap(dpy: &str, xauthority: &str) -> Result<(RustConnection, usize), String> {
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("XAUTHORITY");
std::env::set_var("XAUTHORITY", xauthority);
let out = RustConnection::connect(Some(dpy));
match prev {
Some(p) => std::env::set_var("XAUTHORITY", p),
None => std::env::remove_var("XAUTHORITY"),
}
out.map_err(|e| format!("connect: {e}"))
}
/// The `MIT-MAGIC-COOKIE-1` `(name, data)` for `dpy` from the `.Xauthority`-format file at `path`.
///
/// Entry layout, all integers big-endian: `u16 family`, then four length-prefixed byte strings —
/// `address`, `number` (the display number in ASCII), `name`, `data`. An entry with an empty
/// `number` matches any display.
///
/// Deliberately does NOT match on family/address, unlike libxcb's own lookup. A gamescope Xwayland
/// gets its own single-entry cookie file, so there is nothing to disambiguate; and a wrong pick
/// cannot do damage — the server rejects the cookie, and [`connect_conn`] falls back to the env
/// path, which does the full match. Guessing the peer address for a `LOCAL`-family unix socket, on
/// the other hand, is exactly the sort of detail that would rot.
fn mit_magic_cookie(path: &str, dpy: &str) -> Option<(Vec<u8>, Vec<u8>)> {
let bytes = std::fs::read(path).ok()?;
let want = display_number(dpy);
// An entry whose `number` is empty matches any display — only used if no exact match is found.
let mut wildcard = None;
let mut p = 0usize;
'entries: while p + 2 <= bytes.len() {
p += 2; // family
let mut fields: [&[u8]; 4] = [&[]; 4];
for f in fields.iter_mut() {
let Some(lb) = bytes.get(p..p + 2) else {
break 'entries; // truncated — keep whatever we already found
};
let len = usize::from(u16::from_be_bytes([lb[0], lb[1]]));
p += 2;
let Some(v) = bytes.get(p..p + len) else {
break 'entries;
};
*f = v;
p += len;
}
let [_address, number, name, data] = fields;
if name != MIT_MAGIC_COOKIE_1 {
continue;
}
if want.as_deref().is_some_and(|w| number == w) {
return Some((name.to_vec(), data.to_vec()));
}
if number.is_empty() && wildcard.is_none() {
wildcard = Some((name.to_vec(), data.to_vec()));
}
}
wildcard
}
/// The display NUMBER of an X display string as ASCII bytes: `":2"`, `"host:2"` and `":2.0"` all
/// yield `b"2"`. `None` when there is no numeric display component to match on.
fn display_number(dpy: &str) -> Option<Vec<u8>> {
let num = dpy.rsplit_once(':')?.1.split('.').next()?;
(!num.is_empty() && num.bytes().all(|b| b.is_ascii_digit())).then(|| num.as_bytes().to_vec())
}
/// Read `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` off `root`. `None` = the property is absent or
@@ -227,6 +420,9 @@ struct XDisplay {
name: String,
conn: RustConnection,
root: Window,
/// The nested root's size in pixels — the space `QueryPointer` reports in, which is NOT
/// necessarily the captured frame's (see [`scale_to_frame`]).
root_size: (u16, u16),
/// Last polled pointer position — a change since the previous tick marks this display FOCUSED.
last_pos: Option<(i32, i32)>,
/// Cached cursor shape, refreshed only after this display's XFixes `CursorNotify`.
@@ -247,12 +443,14 @@ impl XDisplay {
name: String,
conn: RustConnection,
root: Window,
root_size: (u16, u16),
(feedback_atom, gs_visible): (Atom, Option<bool>),
) -> Self {
XDisplay {
name,
conn,
root,
root_size,
last_pos: None,
shape: Shape::default(),
need_shape: true,
@@ -288,11 +486,39 @@ struct Shape {
visible: bool,
}
/// Map a root-space pointer position into FRAME space.
///
/// `QueryPointer` answers in the nested root's coordinates, but `CursorOverlay::x/y`'s contract is
/// FRAME pixels — and gamescope's `-w/-h` (nested root) and `-W/-H` (output + PipeWire node) are
/// independent knobs. Measured on this box with gamescope 3.16.23 at `-W 1280 -H 720 -w 640 -h 360`
/// the two spaces differ by 2×, so publishing root coordinates verbatim drew the pointer at a
/// fraction of its real position. `frame` is `(0, 0)` until the PipeWire format negotiates, in which
/// case the position passes through unscaled — the same as before, and correct for the common case
/// where the two spaces agree.
///
/// The cursor BITMAP is not scaled: it stays at the shape's native size, so on a mismatched session
/// the pointer lands in the right place but is drawn at root scale.
fn scale_to_frame((x, y): (i32, i32), root: (u16, u16), frame: (u32, u32)) -> (i32, i32) {
let (rw, rh) = (u32::from(root.0), u32::from(root.1));
let (fw, fh) = frame;
if rw == 0 || rh == 0 || fw == 0 || fh == 0 || (rw, rh) == (fw, fh) {
return (x, y);
}
(
((i64::from(x) * i64::from(fw)) / i64::from(rw)) as i32,
((i64::from(y) * i64::from(fh)) / i64::from(rh)) as i32,
)
}
fn run(
mut displays: Vec<XDisplay>,
slot: Arc<Mutex<Option<CursorOverlay>>>,
stop: Arc<AtomicBool>,
targets: GamescopeCursorTargets,
frame_size: Arc<AtomicU64>,
) {
let mut last_discover = std::time::Instant::now();
let mut warned_scale = false;
let mut active = 0usize;
// The overlay serial must bump whenever the DRAWN cursor changes — either the active display's
// shape OR which display is active (per-display XFixes serials aren't comparable across
@@ -309,6 +535,11 @@ fn run(
if resync {
last_resync = std::time::Instant::now();
}
// Adopt Xwaylands that appeared since we started (a game's, above all) and retry dead ones.
if last_discover.elapsed() >= REDISCOVER {
last_discover = std::time::Instant::now();
rediscover(&mut displays, &targets, false);
}
// 1) Poll every display's pointer; note which moved since last tick (the fallback focus
// signal, used only when this gamescope publishes no cursor verdict).
@@ -395,8 +626,27 @@ fn run(
// a `None` here would leave the last visible overlay standing on repeat frames.
let d = &displays[active];
let drawn = d.shape.visible && !hidden_by_gamescope;
// Root space → frame space (see `scale_to_frame`). The negotiated size arrives from the
// PipeWire thread's `param_changed`, packed `(w << 32) | h`; `0` = not negotiated yet.
let packed = frame_size.load(Ordering::Relaxed);
let frame = ((packed >> 32) as u32, packed as u32);
if !warned_scale
&& frame.0 != 0
&& (u32::from(d.root_size.0), u32::from(d.root_size.1)) != frame
{
warned_scale = true;
tracing::warn!(
dpy = %d.name,
root = %format!("{}x{}", d.root_size.0, d.root_size.1),
negotiated = %format!("{}x{}", frame.0, frame.1),
"gamescope cursor: the nested root and the captured frame are different sizes \
(gamescope -w/-h vs -W/-H) scaling the pointer POSITION into frame space; the \
cursor bitmap stays at root scale"
);
}
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
(Some((px, py)), false) => {
(Some(pos), false) => {
let (px, py) = scale_to_frame(pos, d.root_size, frame);
let key = (active, d.shape.serial);
if key != last_key {
out_serial += 1;
@@ -511,7 +761,147 @@ fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
#[cfg(test)]
mod tests {
use super::pick_active;
use super::{
display_number, mit_magic_cookie, pick_active, scale_to_frame, MIT_MAGIC_COOKIE_1,
};
/// One `.Xauthority` entry in wire form: `u16 family` + four length-prefixed byte strings.
fn entry(family: u16, address: &[u8], number: &[u8], name: &[u8], data: &[u8]) -> Vec<u8> {
let mut v = family.to_be_bytes().to_vec();
for f in [address, number, name, data] {
v.extend_from_slice(&(f.len() as u16).to_be_bytes());
v.extend_from_slice(f);
}
v
}
fn write_xauth(bytes: &[u8]) -> std::path::PathBuf {
// The scratch file lives beside the test binary's temp dir; unique per call via the address
// of a local (no rand dependency, and the tests do not run concurrently on one path).
let mut p = std::env::temp_dir();
let uniq = format!(
"pf-xauth-test-{}-{:p}",
std::process::id(),
bytes as *const [u8]
);
p.push(uniq);
std::fs::write(&p, bytes).expect("write scratch xauth");
p
}
#[test]
fn display_number_handles_every_display_spelling() {
assert_eq!(display_number(":2").as_deref(), Some(&b"2"[..]));
assert_eq!(display_number(":2.0").as_deref(), Some(&b"2"[..]));
assert_eq!(display_number("host:13.1").as_deref(), Some(&b"13"[..]));
// Nothing to match on — the caller then accepts only a wildcard entry.
assert_eq!(display_number("bogus"), None);
assert_eq!(display_number(":"), None);
assert_eq!(display_number(":abc"), None);
}
/// The cookie reader is the one PARSER in this file fed a file we did not write, so it gets the
/// hostile-input treatment: exact-match, wildcard fallback, skipping other auth protocols, and
/// truncation.
#[test]
fn mit_magic_cookie_picks_the_matching_entry() {
let mut file = Vec::new();
// A different protocol on the display we want — must be skipped, not returned.
file.extend(entry(256, b"host", b"2", b"XDM-AUTHORIZATION-1", b"nope"));
// Another display's cookie.
file.extend(entry(
256,
b"host",
b"7",
MIT_MAGIC_COOKIE_1,
b"other-display",
));
// Ours.
file.extend(entry(256, b"host", b"2", MIT_MAGIC_COOKIE_1, b"the-cookie"));
let p = write_xauth(&file);
let got = mit_magic_cookie(p.to_str().unwrap(), ":2");
assert_eq!(
got,
Some((MIT_MAGIC_COOKIE_1.to_vec(), b"the-cookie".to_vec()))
);
// A display with no entry at all yields nothing (⇒ the caller falls back to the env path).
assert_eq!(mit_magic_cookie(p.to_str().unwrap(), ":9"), None);
let _ = std::fs::remove_file(p);
}
#[test]
fn mit_magic_cookie_falls_back_to_a_wildcard_entry() {
// An empty `number` matches any display — but an exact match still wins.
let mut file = entry(256, b"host", b"", MIT_MAGIC_COOKIE_1, b"wildcard");
file.extend(entry(256, b"host", b"3", MIT_MAGIC_COOKIE_1, b"exact"));
let p = write_xauth(&file);
let path = p.to_str().unwrap();
assert_eq!(
mit_magic_cookie(path, ":3").map(|(_, d)| d),
Some(b"exact".to_vec())
);
assert_eq!(
mit_magic_cookie(path, ":4").map(|(_, d)| d),
Some(b"wildcard".to_vec())
);
let _ = std::fs::remove_file(p);
}
#[test]
fn a_truncated_xauthority_keeps_what_it_already_parsed() {
let mut file = entry(256, b"host", b"", MIT_MAGIC_COOKIE_1, b"wildcard");
// A second entry cut off mid-length-prefix.
file.extend(entry(256, b"host", b"5", MIT_MAGIC_COOKIE_1, b"truncated"));
file.truncate(file.len() - 4);
let p = write_xauth(&file);
// No panic, no over-read: the wildcard found before the truncation still serves.
assert_eq!(
mit_magic_cookie(p.to_str().unwrap(), ":5").map(|(_, d)| d),
Some(b"wildcard".to_vec())
);
let _ = std::fs::remove_file(p);
}
#[test]
fn a_garbage_xauthority_is_declined_not_fatal() {
// A length prefix that claims far more than the file holds.
let p = write_xauth(&[0, 0, 0xff, 0xff, 1, 2, 3]);
assert_eq!(mit_magic_cookie(p.to_str().unwrap(), ":0"), None);
let _ = std::fs::remove_file(p);
// A missing file is simply "no cookie".
assert_eq!(mit_magic_cookie("/nonexistent/pf-xauth", ":0"), None);
}
/// L6: gamescope's `-w/-h` (nested root, the space `QueryPointer` answers in) and `-W/-H`
/// (output + PipeWire node, the space `CursorOverlay` is contracted in) are independent.
#[test]
fn pointer_positions_are_mapped_into_frame_space() {
// The measured case: root 640x360 inside a 1280x720 output — 2x.
assert_eq!(
scale_to_frame((320, 180), (640, 360), (1280, 720)),
(640, 360)
);
assert_eq!(scale_to_frame((0, 0), (640, 360), (1280, 720)), (0, 0));
// Down-scaling works the same way.
assert_eq!(
scale_to_frame((1280, 720), (1280, 720), (640, 360)),
(640, 360)
);
// Equal spaces are a pass-through (the common case — no rounding drift).
assert_eq!(scale_to_frame((7, 9), (1920, 1080), (1920, 1080)), (7, 9));
// Not negotiated yet, or a degenerate root: pass through rather than divide by zero.
assert_eq!(scale_to_frame((7, 9), (1920, 1080), (0, 0)), (7, 9));
assert_eq!(scale_to_frame((7, 9), (0, 0), (1920, 1080)), (7, 9));
}
/// A 5K frame times a 5K coordinate overflows `i32` — the scale must be computed wide.
#[test]
fn scaling_does_not_overflow_at_5k() {
assert_eq!(
scale_to_frame((2879, 1619), (2880, 1620), (5120, 2880)),
(5118, 2878)
);
}
/// Steam Gaming Mode shape: display 0 = Big Picture's Xwayland, 1 = the game's.
const BPM: usize = 0;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,648 @@
//! The P010 colour SELF-TEST and its helpers — the `hdr-p010-selftest` subcommand's whole
//! implementation, plus the f64 reference math it compares against and the f16 encoder it uploads
//! with.
//!
//! Split out of `windows/dxgi.rs` in sweep Phase 5.5: it was ~560 of that file's 1,374 lines and
//! none of it runs in a session. What remains in the parent is the production path (the win32u hook,
//! the shader sources, the three converters); this is the validation path.
//!
//! `hdr_p010_selftest_at` and `hdr_p010_convert_bars_on_luid` are re-exported by the parent, so
//! every existing `crate::capture::dxgi::…` / `pf_capture::dxgi::…` path keeps resolving.
use super::*;
/// f64 reference for the P010 colour math — the EXACT analogue of the HLSL in [`HDR_P010_COMMON`].
/// Input is one scRGB pixel (linear, Rec.709 primaries, 1.0 = 80 nits, may be >1 for HDR). Output is
/// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block.
/// Used by [`hdr_p010_selftest`].
#[cfg(target_os = "windows")]
fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
fn pq_oetf(l: f64) -> f64 {
let l = l.clamp(0.0, 1.0);
let m1 = 0.1593017578125;
let m2 = 78.84375;
let c1 = 0.8359375;
let c2 = 18.8515625;
let c3 = 18.6875;
let lp = l.powf(m1);
((c1 + c2 * lp) / (1.0 + c3 * lp)).powf(m2)
}
// scRGB -> nits -> BT.2020 linear (row-major matrix, mul(M, v)).
let (r, g, b) = (r.max(0.0) * 80.0, g.max(0.0) * 80.0, b.max(0.0) * 80.0);
let m = [
[0.627403914, 0.329283038, 0.043313048],
[0.069097292, 0.919540405, 0.011362303],
[0.016391439, 0.088013308, 0.895595253],
];
let lr = m[0][0] * r + m[0][1] * g + m[0][2] * b;
let lg = m[1][0] * r + m[1][1] * g + m[1][2] * b;
let lb = m[2][0] * r + m[2][1] * g + m[2][2] * b;
// PQ encode (normalize to 10k nits).
let pr = pq_oetf(lr / 10000.0);
let pg = pq_oetf(lg / 10000.0);
let pb = pq_oetf(lb / 10000.0);
// BT.2020 non-constant-luminance, limited 10-bit.
let (kr, kg, kb) = (0.2627, 0.6780, 0.0593);
let y = kr * pr + kg * pg + kb * pb;
let cb = (pb - y) / 1.8814;
let cr = (pr - y) / 1.4746;
let yc = (64.0 + 876.0 * y).clamp(64.0, 940.0);
let cbc = (512.0 + 896.0 * cb).clamp(64.0, 960.0);
let crc = (512.0 + 896.0 * cr).clamp(64.0, 960.0);
(yc, cbc, crc)
}
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
/// (325,448,598) (226,650,535) (64,512,512).
#[cfg(target_os = "windows")]
#[doc(hidden)]
pub fn hdr_p010_convert_bars_on_luid(
luid: [u8; 8],
w: u32,
h: u32,
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
}
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
const BARS: [(f32, f32, f32); 8] = [
(1.0, 1.0, 1.0),
(1.0, 1.0, 0.0),
(0.0, 1.0, 1.0),
(0.0, 1.0, 0.0),
(1.0, 0.0, 1.0),
(1.0, 0.0, 0.0),
(0.0, 0.0, 1.0),
(0.0, 0.0, 0.0),
];
let bar_w = (w / 8).max(1) as usize;
let mut fp16 = vec![0u16; (w * h * 4) as usize];
for y in 0..h as usize {
for x in 0..w as usize {
let (r, g, b) = BARS[(x / bar_w).min(7)];
let i = (y * w as usize + x) * 4;
fp16[i] = f32_to_f16(r);
fp16[i + 1] = f32_to_f16(g);
fp16[i + 2] = f32_to_f16(b);
fp16[i + 3] = f32_to_f16(1.0);
}
}
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
// their references.
unsafe {
let luid = windows::Win32::Foundation::LUID {
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
};
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice(
&adapter,
D3D_DRIVER_TYPE_UNKNOWN,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
.context("D3D11CreateDevice(luid) for bars convert")?;
let device = device.context("null device")?;
let context = context.context("null context")?;
let src_desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
..Default::default()
};
let init = D3D11_SUBRESOURCE_DATA {
pSysMem: fp16.as_ptr() as *const c_void,
SysMemPitch: w * 8,
SysMemSlicePitch: 0,
};
let mut src_tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
.context("CreateTexture2D(fp16 bars)")?;
let src_tex = src_tex.context("null src tex")?;
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
.context("CreateShaderResourceView(fp16 bars)")?;
let src_srv = src_srv.context("null src srv")?;
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
let p010_desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_P010,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
..Default::default()
};
let mut p010: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
.context("CreateTexture2D(P010 bars dst)")?;
let p010 = p010.context("null p010 tex")?;
let conv = HdrP010Converter::new(&device, w, h)?;
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, w, h)?;
Ok((device, p010))
}
}
/// Colour self-test for [`HdrP010Converter`] (the `hdr-p010-selftest` subcommand): create a hardware
/// D3D11 device, upload a known scRGB FP16 pattern, run the P010 shader passes, read the Y (plane 0)
/// and UV (plane 1) planes back from a staging copy, and compare against the [`p010_reference`] f64
/// math. The ONLY validation we have without green-screening a live HDR stream. PASS if max abs error
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
///
/// `w`/`h` must be even and non-zero. Run it at the FIELD capture size, not a toy one: sessions run
/// at resolutions whose height is not 16-aligned (1080 → the encoder's align16 pool seam) and a
/// driver may treat the planar RTVs differently at real sizes. `vendor` pins the adapter by PCI
/// vendor id (`0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD) — it matters on dual-GPU boxes where
/// the default adapter is not the one the session encodes on. The chosen adapter is always printed,
/// because a PASS only means anything for the GPU it actually ran on.
#[cfg(target_os = "windows")]
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
}
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
#[allow(non_snake_case)]
let (W, H) = (w, h);
const BLK: u32 = 16;
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
let named: [(&str, f32, f32, f32); 8] = [
("red1.0", 1.0, 0.0, 0.0),
("green0.5", 0.0, 0.5, 0.0),
("blue4.0", 0.0, 0.0, 4.0),
("white1.0", 1.0, 1.0, 1.0),
("black", 0.0, 0.0, 0.0),
("gray0.5", 0.5, 0.5, 0.5),
("white4.0", 4.0, 4.0, 4.0),
("amber2.0", 2.0, 1.0, 0.0),
];
let grid_cols = W / BLK; // 4
let pixel_rgb = |x: u32, y: u32| -> (f32, f32, f32, bool) {
let idx = ((y / BLK) * grid_cols + (x / BLK)) as usize;
if idx < named.len() {
let (_, r, g, b) = named[idx];
(r, g, b, true)
} else {
// Gradient (distinct per pixel; Y-only compare), within HDR scRGB range.
let r = (x as f32 / W as f32) * 3.0;
let g = (y as f32 / H as f32) * 3.0;
let b = ((x + y) as f32 / (W + H) as f32) * 3.0;
(r, g, b, false)
}
};
// Build the scRGB FP16 (R16G16B16A16_FLOAT) source as f16 bits.
let mut fp16 = vec![0u16; (W * H * 4) as usize];
let mut flat = vec![false; (W * H) as usize];
for y in 0..H {
for x in 0..W {
let (r, g, b, is_flat) = pixel_rgb(x, y);
let i = ((y * W + x) * 4) as usize;
fp16[i] = f32_to_f16(r);
fp16[i + 1] = f32_to_f16(g);
fp16[i + 2] = f32_to_f16(b);
fp16[i + 3] = f32_to_f16(1.0);
flat[(y * W + x) as usize] = is_flat;
}
}
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
// both checked non-null) and uses ONLY that device for the rest of the block: every
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
// `Map` is invoked on that device or its context, so all resources share one device and run on this
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
// proven individually at the `read_u16` closure below.
unsafe {
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
// the GPU it actually tested.
let adapter: Option<IDXGIAdapter> = match vendor {
None => None,
Some(want) => {
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
let mut found = None;
for i in 0.. {
let Ok(a) = factory.EnumAdapters(i) else {
break;
};
let desc = a.GetDesc().context("adapter desc")?;
if desc.VendorId == want {
found = Some(a);
break;
}
}
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
}
};
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice(
adapter.as_ref(),
if adapter.is_some() {
D3D_DRIVER_TYPE_UNKNOWN
} else {
D3D_DRIVER_TYPE_HARDWARE
},
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(hardware) for hdr-p010-selftest")?;
let device = device.context("null device")?;
let context = context.context("null context")?;
{
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
device.cast().context("device -> IDXGIDevice")?;
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
let name = String::from_utf16_lossy(
&desc.Description[..desc
.Description
.iter()
.position(|&c| c == 0)
.unwrap_or(desc.Description.len())],
);
println!(
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
);
}
// Source FP16 texture (initialized) + SRV.
let src_desc = D3D11_TEXTURE2D_DESC {
Width: W,
Height: H,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
..Default::default()
};
let init = D3D11_SUBRESOURCE_DATA {
pSysMem: fp16.as_ptr() as *const c_void,
SysMemPitch: W * 8, // 4 channels * 2 bytes
SysMemSlicePitch: 0,
};
let mut src_tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
.context("CreateTexture2D(fp16 src)")?;
let src_tex = src_tex.context("null src tex")?;
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
.context("CreateShaderResourceView(fp16 src)")?;
let src_srv = src_srv.context("null src srv")?;
// P010 destination texture (render-target bindable).
let p010_desc = D3D11_TEXTURE2D_DESC {
Width: W,
Height: H,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_P010,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
..Default::default()
};
let mut p010: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
.context("CreateTexture2D(P010 dst)")?;
let p010 = p010.context("null p010 tex")?;
let conv = HdrP010Converter::new(&device, W, H)?;
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, W, H)?;
// Staging copy of the whole P010 texture (both planes), MAP_READ.
let stage_desc = D3D11_TEXTURE2D_DESC {
Width: W,
Height: H,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_P010,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_STAGING,
BindFlags: 0,
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
..Default::default()
};
let mut staging: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&stage_desc, None, Some(&mut staging))
.context("CreateTexture2D(P010 staging)")?;
let staging = staging.context("null staging")?;
context.CopyResource(&staging, &p010);
let mut map = D3D11_MAPPED_SUBRESOURCE::default();
context
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut map))
.context("Map(P010 staging)")?;
let row_pitch = map.RowPitch as usize; // bytes per luma row (in 16-bit samples: /2)
let base = map.pData as *const u8;
// DIAGNOSTIC (the uncertain layout spot — verify on the box if chroma is wrong): the mapped
// P010 plane offsets. Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2
// *interleaved* (Cb,Cr) u16 pairs. P010 packs plane 1 after plane 0 at the SAME row pitch; the
// chroma plane begins at byte offset RowPitch * (luma height). For a STAGING texture that
// height is the created H (no inter-plane alignment). DepthPitch (total mapped size) lets us
// sanity-check: it should be ~ RowPitch * H * 3/2. If chroma reads garbage on the box, print
// these and adjust `chroma_base` (e.g. an aligned luma height).
tracing::info!(
row_pitch,
depth_pitch = map.DepthPitch,
expected_chroma_base = row_pitch * H as usize,
expected_total = row_pitch * H as usize * 3 / 2,
"hdr-p010-selftest: mapped P010 layout (verify chroma plane offset here if chroma is wrong)"
);
// Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2 *interleaved* (Cb,Cr)
// u16 pairs, i.e. W u16 per chroma row. P010 packs plane 1 immediately after plane 0 at the
// SAME row pitch; per spec the chroma plane begins at an allocation offset of
// RowPitch * Height (luma rows). We read it from there. (DepthPitch is the full surface size;
// not all drivers report the chroma offset, so RowPitch*Height is the portable choice.)
let read_u16 = |byte_off: usize| -> u16 {
// SAFETY: `base` is the mapped staging pointer; all offsets are within the P010 surface
// (luma H*RowPitch + chroma (H/2)*RowPitch ≤ DepthPitch). Already in the fn's unsafe scope.
let p = base.add(byte_off) as *const u16;
p.read_unaligned()
};
// Luma codes: stored u16 in the high 10 bits -> code10 = stored >> 6.
let mut y_codes = vec![0u16; (W * H) as usize];
for y in 0..H {
for x in 0..W {
let off = (y as usize) * row_pitch + (x as usize) * 2;
y_codes[(y * W + x) as usize] = read_u16(off) >> 6;
}
}
let cw = W / 2;
let ch = H / 2;
let chroma_base = row_pitch * H as usize; // plane 1 offset
let mut cb_codes = vec![0u16; (cw * ch) as usize];
let mut cr_codes = vec![0u16; (cw * ch) as usize];
for cy in 0..ch {
for cx in 0..cw {
// Interleaved (Cb, Cr) per chroma sample → 2 u16 = 4 bytes per sample.
let off = chroma_base + (cy as usize) * row_pitch + (cx as usize) * 4;
cb_codes[(cy * cw + cx) as usize] = read_u16(off) >> 6;
cr_codes[(cy * cw + cx) as usize] = read_u16(off + 2) >> 6;
}
}
context.Unmap(&staging, 0);
// Compare Y over every pixel.
let mut max_y_err = 0.0f64;
for y in 0..H {
for x in 0..W {
let (r, g, b, _) = pixel_rgb(x, y);
let (ry, _, _) = p010_reference(r as f64, g as f64, b as f64);
let got = y_codes[(y * W + x) as usize] as f64;
max_y_err = max_y_err.max((got - ry).abs());
}
}
// Compare Cb/Cr over flat blocks only (uniform 2x2 footprint → exact reference).
let mut max_u_err = 0.0f64;
let mut max_v_err = 0.0f64;
for cy in 0..ch {
for cx in 0..cw {
let (sx, sy) = (cx * 2, cy * 2);
let all_flat =
(0..2).all(|dy| (0..2).all(|dx| flat[((sy + dy) * W + (sx + dx)) as usize]));
if !all_flat {
continue;
}
let (r, g, b, _) = pixel_rgb(sx, sy);
let (_, rcb, rcr) = p010_reference(r as f64, g as f64, b as f64);
let gu = cb_codes[(cy * cw + cx) as usize] as f64;
let gv = cr_codes[(cy * cw + cx) as usize] as f64;
max_u_err = max_u_err.max((gu - rcb).abs());
max_v_err = max_v_err.max((gv - rcr).abs());
}
}
// Per-colour table.
println!("HDR P010 self-test ({W}x{H}, BT.2020 PQ, 10-bit limited range)");
println!(
" {:<10} {:>14} {:>14} {:>14}",
"color", "Y exp/got", "Cb exp/got", "Cr exp/got"
);
for (idx, (name, r, g, b)) in named.iter().enumerate() {
let bx = (idx as u32 % grid_cols) * BLK + BLK / 2;
let by = (idx as u32 / grid_cols) * BLK + BLK / 2;
let (ey, ecb, ecr) = p010_reference(*r as f64, *g as f64, *b as f64);
let gy = y_codes[(by * W + bx) as usize] as f64;
let (ccx, ccy) = (bx / 2, by / 2);
let gu = cb_codes[(ccy * cw + ccx) as usize] as f64;
let gv = cr_codes[(ccy * cw + ccx) as usize] as f64;
println!(
" {:<10} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0}",
name, ey, gy, ecb, gu, ecr, gv
);
}
println!(
" max abs error: Y={max_y_err:.2} (≤4) Cb={max_u_err:.2} (≤5) Cr={max_v_err:.2} (≤5)"
);
if max_y_err <= 4.0 && max_u_err <= 5.0 && max_v_err <= 5.0 {
println!("PASS");
Ok(())
} else {
println!("FAIL");
bail!(
"HDR P010 self-test FAILED (Y={max_y_err:.2} Cb={max_u_err:.2} Cr={max_v_err:.2})"
);
}
}
}
/// Minimal f32 → IEEE-754 half (f16) bit pattern, for uploading the FP16 scRGB self-test pattern. Not
/// on any hot path; handles normals, subnormals, and the 1.0/0.0 constants we feed. (round-to-nearest)
#[cfg(target_os = "windows")]
fn f32_to_f16(v: f32) -> u16 {
let bits = v.to_bits();
let sign = ((bits >> 16) & 0x8000) as u16;
let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
let mant = bits & 0x007f_ffff;
if exp <= 0 {
// Subnormal / zero in half precision.
if exp < -10 {
return sign; // too small → ±0
}
let mant = mant | 0x0080_0000; // implicit 1
let shift = (14 - exp) as u32;
let half_mant = (mant >> shift) as u16;
// Round to nearest.
let round = ((mant >> (shift - 1)) & 1) as u16;
sign | (half_mant + round)
} else if exp >= 0x1f {
sign | 0x7c00 // Inf/NaN → Inf (our inputs never hit this)
} else {
let half_exp = (exp as u16) << 10;
let half_mant = (mant >> 13) as u16;
let round = ((mant >> 12) & 1) as u16;
// ADD, never OR. `half_mant + round` can carry out of the 10-bit mantissa (all ones, then
// rounded up), and that carry must INCREMENT the exponent — which is exactly what an
// IEEE-754 round-to-nearest overflow means. `sign | half_exp | (…)` instead ORed it into bit
// 10, so for every ODD biased exponent (bit 10 already set) the carry vanished and the
// result came back a factor of ~2 low: `f32_to_f16(1.9998779) → 0x3C00 = 1.0`,
// `0.49996948 → 0.25`. Only values one ULP below a power of two are affected — which is
// precisely what a gradient test pattern is full of, so this made `hdr-p010-selftest` FAIL a
// correct shader. The subnormal branch above was already additive.
sign | (half_exp + half_mant + round)
}
}
#[cfg(test)]
mod f16_tests {
use super::f32_to_f16;
/// Round-trip through the reference conversion the rest of the test uses as an oracle.
fn f16_to_f32(h: u16) -> f32 {
let sign = if h & 0x8000 != 0 { -1.0f32 } else { 1.0 };
let exp = ((h >> 10) & 0x1f) as i32;
let mant = (h & 0x3ff) as f32;
match exp {
0 => sign * mant * 2f32.powi(-24), // subnormal
31 => sign * f32::INFINITY, // our encoder never emits NaN
e => sign * (1.0 + mant / 1024.0) * 2f32.powi(e - 15),
}
}
/// W7: the rounding carry out of the mantissa must INCREMENT the exponent. The composition used
/// `sign | half_exp | (half_mant + round)`, which swallowed that carry for every odd biased
/// exponent — a silent factor-of-2 error on exactly the values a gradient test pattern is full
/// of, which made `hdr-p010-selftest` fail a correct shader.
#[test]
fn a_rounding_carry_increments_the_exponent() {
// The plan's canonical case: biased exponent 127 (2^0) with a mantissa that rounds up out
// of 10 bits ⇒ 2.0 = 0x4000, NOT 1.0 = 0x3C00.
assert_eq!(f32_to_f16(f32::from_bits((127 << 23) | 0x7FF000)), 0x4000);
// The two measured regressions, by value.
assert_eq!(
f32_to_f16(1.9998779),
0x4000,
"1.9998779 must not read as 1.0"
);
assert_eq!(
f32_to_f16(0.49996948),
0x3800,
"0.49996948 must not read as 0.25"
);
// …and an EVEN biased exponent, where the bug happened to be invisible (bit 10 clear), so
// the fix must not change it.
assert_eq!(f32_to_f16(f32::from_bits((128 << 23) | 0x7FF000)), 0x4400); // → 4.0
}
#[test]
fn the_constants_the_selftest_uploads_are_exact() {
assert_eq!(f32_to_f16(0.0), 0x0000);
assert_eq!(f32_to_f16(-0.0), 0x8000);
assert_eq!(f32_to_f16(1.0), 0x3C00);
assert_eq!(f32_to_f16(-1.0), 0xBC00);
assert_eq!(f32_to_f16(0.5), 0x3800);
assert_eq!(f32_to_f16(2.0), 0x4000);
assert_eq!(f32_to_f16(4.0), 0x4400);
}
/// Every HDR scRGB value the self-test patterns use must survive the round trip to within one
/// f16 ULP — the property the P010 comparison actually depends on.
#[test]
fn hdr_scrgb_values_round_trip_within_one_ulp() {
for &v in &[
0.0f32, 0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 0.1, 0.3, 0.7, 1.9998779, 0.49996948, 2.5,
3.999, 0.001,
] {
let back = f16_to_f32(f32_to_f16(v));
// One ULP at this magnitude: f16 carries 11 significand bits.
let ulp = (v.abs() / 1024.0).max(2f32.powi(-24));
assert!(
(back - v).abs() <= ulp,
"{v} round-tripped to {back} (ulp {ulp})"
);
}
}
#[test]
fn out_of_range_magnitudes_saturate_rather_than_wrap() {
// Above f16's max finite (65504) our encoder reports Inf; below its subnormal floor, ±0.
assert_eq!(f32_to_f16(1.0e30), 0x7C00);
assert_eq!(f32_to_f16(-1.0e30), 0xFC00);
assert_eq!(f32_to_f16(1.0e-30), 0x0000);
assert_eq!(f32_to_f16(-1.0e-30), 0x8000);
}
}
#[cfg(test)]
mod hdr_selftests {
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
#[test]
#[ignore]
fn hdr_p010_selftest_intel_1080_live() {
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
//! The LAST-RESORT DWM compose kick — synthetic pointer input that dirties a specific virtual
//! display so DWM presents it.
//!
//! Split out of `idd_push.rs` in sweep Phase 5.4. It is self-contained (one function plus a
//! process-global throttle) and it is the one piece of the capture path that reaches for synthetic
//! INPUT, which is worth keeping visibly separate from the frame machinery: it is unreliable by
//! nature, user-visible in the sibling-display case, and only ever a fallback for the driver's own
//! `FrameStash` republish.
use super::*;
/// LAST-RESORT fallback: nudge DWM into composing THE TARGET virtual display. DWM presents a
/// display only when something DIRTIES it — an idle desktop never does, so a freshly-attached ring
/// (session open, or a mid-session ring recreate) can sit at E_PENDING with no first frame even
/// though everything is healthy.
///
/// The PRIMARY first-frame mechanism is the driver's `FrameStash` (frame_transport.rs): the driver
/// retains the last composed frame and republishes it into every freshly-attached ring, so with a
/// stash-capable driver the first frame lands milliseconds after the channel delivery and this kick
/// never fires. It remains for pre-stash drivers and for the empty-stash cold start (a monitor that
/// has NEVER composed — normally the activation compose covers that). Synthetic input is inherently
/// unreliable — blocked on the secure desktop, defeated by a fullscreen game's ClipCursor, and
/// user-visible in the sibling-display case — which is exactly why it was demoted to fallback.
///
/// pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into
/// the frame — a guaranteed real present onto the IDD swap-chain (empirically what
/// `punktfunk-probe --input-test` always relied on).
///
/// The cursor only dirties the display it is ON — proven on-glass in the Stage-W3 two-display
/// validation: display B's session-open kicks wiggled the cursor on display A and B never composed
/// a first frame. So the kick is per-TARGET: when the cursor already sits inside `target_id`'s
/// desktop region (always true single-display), two net-zero 1 px relative moves (the historical
/// behavior, pointer ends exactly where it started); when it sits on a SIBLING display, jump the
/// cursor to the target's center and straight back (`SetCursorPos` ×2 — each absolute move dirties
/// the cursor layer of the display it lands on, so the target composes at least one frame).
/// Best-effort — injection can be unavailable on the secure desktop, where a fresh compose just
/// happened anyway.
///
/// **COST:** the sibling-display branch SLEEPS 35 ms on the calling thread between the two
/// `SetCursorPos`es. The dwell is load-bearing (see the comment at that branch: a sub-tick
/// jump-and-return never dirties anything), but the caller is the capture/encode thread, so a kick
/// on that branch costs ~2 frames of latency at 60 Hz. Every call site is a first-frame or
/// post-recreate recovery window where no frames are flowing anyway, and the global 50 ms throttle
/// plus the callers' own 600800 ms schedules bound how often it can happen.
///
/// **HID-first**: when the host has registered [`HID_COMPOSE_KICK`] (the resident pf-mouse virtual
/// HID pointer), the kick goes through it INSTEAD of the `SendInput` paths below. A report from a
/// HID device is real input to win32k — delivered regardless of this process's session or the
/// active desktop, it wakes a powered-off display subsystem (lid-closed laptop / display idle-off /
/// modern standby) and counts as user presence — every condition under which `SendInput` is
/// silently impotent (wrong session → wrong input queue; secure desktop → blocked; display off →
/// nothing composes at all). That set is exactly the lid-closed field-report state.
pub(super) fn kick_dwm_compose(target_id: u32) {
// Process-GLOBAL throttle (Stage W3): with N parallel capturers each nudging on its own
// schedule, DWM needs only one dirty per composition window — and the nudge is synthetic INPUT
// (global, user-visible pointer state), so it must not multiply with capturer count. 50 ms
// covers every composition interval we ship (≥ 60 Hz) while staying far under the callers' own
// 600800 ms per-capturer schedules.
static LAST_KICK: Mutex<Option<Instant>> = Mutex::new(None);
{
let mut last = LAST_KICK.lock().unwrap();
let now = Instant::now();
if last.is_some_and(|t| now.duration_since(t) < Duration::from_millis(50)) {
return;
}
*last = Some(now);
}
// Where is the cursor, and where does the target display live in desktop space?
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();
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) {
let bounds = pf_win_display::win_display::desktop_bounds();
if let Some(bounds) = bounds {
if kick(rect, bounds) {
return;
}
}
}
if let (true, Some((x, y, w, h))) = (have_pos, rect) {
let inside = pos.x >= x && pos.x < x + w.max(1) && pos.y >= y && pos.y < y + h.max(1);
if !inside {
// The cursor is on a sibling display — a wiggle there dirties the WRONG display. Jump
// to the target's center, DWELL one composition interval, then restore. The dwell is
// load-bearing (proven on-glass, Stage W3): DWM computes dirty state from the CURRENT
// cursor position at the next vsync tick, so a sub-tick jump-and-return is invisible
// and the target never composes — 35 ms covers a 30 Hz tick with margin. The cursor
// visibly leaves the sibling display for those ~2 frames; kicks only fire during THIS
// display's session-open / recovery windows (throttled), so the blip is rare and brief.
// SAFETY: plain FFI; coordinates are plain ints, and the second call restores the
// observed original position.
unsafe {
let _ = SetCursorPos(x + w / 2, y + h / 2);
}
std::thread::sleep(Duration::from_millis(35));
// SAFETY: as above.
unsafe {
let _ = SetCursorPos(pos.x, pos.y);
}
return;
}
}
let mk = |dx: i32| INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx,
dy: 0,
mouseData: 0,
dwFlags: MOUSEEVENTF_MOVE,
time: 0,
dwExtraInfo: 0,
},
},
};
// SAFETY: plain FFI; the input slice is valid, fully-initialized local data for this synchronous
// call, and `cbsize` is the true element size.
unsafe {
let _ = SendInput(&[mk(1), mk(-1)], std::mem::size_of::<INPUT>() as i32);
}
}
@@ -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,102 +56,109 @@ pub(super) struct CursorBlendPass {
}
impl CursorBlendPass {
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
let mut vs = None;
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
let mut ps = None;
device.CreatePixelShader(&psb, None, Some(&mut ps))?;
let sd = D3D11_SAMPLER_DESC {
// LINEAR: the quad is drawn 1:1 in frame pixels, so this only matters at the
// half-texel edges; linear keeps them soft instead of ringing.
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
ComparisonFunc: D3D11_COMPARISON_NEVER,
MaxLOD: f32::MAX,
..Default::default()
};
let mut sampler = None;
device.CreateSamplerState(&sd, Some(&mut sampler))?;
// Straight-alpha over: dst.rgb = src.rgb*a + dst.rgb*(1-a); keep dst alpha.
let mut bd = D3D11_BLEND_DESC::default();
bd.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC {
BlendEnable: true.into(),
SrcBlend: D3D11_BLEND_SRC_ALPHA,
DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
BlendOp: D3D11_BLEND_OP_ADD,
SrcBlendAlpha: D3D11_BLEND_ONE,
DestBlendAlpha: D3D11_BLEND_ONE,
BlendOpAlpha: D3D11_BLEND_OP_ADD,
RenderTargetWriteMask: 0x0F,
};
let mut blend = None;
device.CreateBlendState(&bd, Some(&mut blend))?;
let cbd = D3D11_BUFFER_DESC {
ByteWidth: 16, // float to_linear + float3 pad
Usage: D3D11_USAGE_DYNAMIC,
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
..Default::default()
};
let mut cbuf = None;
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
Ok(Self {
vs: vs.context("cursor blend vs")?,
ps: ps.context("cursor blend ps")?,
sampler: sampler.context("cursor blend sampler")?,
blend: blend.context("cursor blend state")?,
cbuf: cbuf.context("cursor blend cbuf")?,
cbuf_scale: None,
shape: None,
})
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).
unsafe {
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
let mut vs = None;
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
let mut ps = None;
device.CreatePixelShader(&psb, None, Some(&mut ps))?;
let sd = D3D11_SAMPLER_DESC {
// LINEAR: the quad is drawn 1:1 in frame pixels, so this only matters at the
// half-texel edges; linear keeps them soft instead of ringing.
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
ComparisonFunc: D3D11_COMPARISON_NEVER,
MaxLOD: f32::MAX,
..Default::default()
};
let mut sampler = None;
device.CreateSamplerState(&sd, Some(&mut sampler))?;
// Straight-alpha over: dst.rgb = src.rgb*a + dst.rgb*(1-a); keep dst alpha.
let mut bd = D3D11_BLEND_DESC::default();
bd.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC {
BlendEnable: true.into(),
SrcBlend: D3D11_BLEND_SRC_ALPHA,
DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
BlendOp: D3D11_BLEND_OP_ADD,
SrcBlendAlpha: D3D11_BLEND_ONE,
DestBlendAlpha: D3D11_BLEND_ONE,
BlendOpAlpha: D3D11_BLEND_OP_ADD,
RenderTargetWriteMask: 0x0F,
};
let mut blend = None;
device.CreateBlendState(&bd, Some(&mut blend))?;
let cbd = D3D11_BUFFER_DESC {
ByteWidth: 16, // float to_linear + float3 pad
Usage: D3D11_USAGE_DYNAMIC,
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
..Default::default()
};
let mut cbuf = None;
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
Ok(Self {
vs: vs.context("cursor blend vs")?,
ps: ps.context("cursor blend ps")?,
sampler: sampler.context("cursor blend sampler")?,
blend: blend.context("cursor blend state")?,
cbuf: cbuf.context("cursor blend cbuf")?,
cbuf_scale: None,
shape: None,
})
}
}
/// 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<()> {
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) {
return Ok(());
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
// outlives the synchronous upload.
unsafe {
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) {
return Ok(());
}
if ov.rgba.len() < (ov.w as usize) * (ov.h as usize) * 4 || ov.w == 0 || ov.h == 0 {
bail!("malformed cursor overlay ({}x{})", ov.w, ov.h);
}
let desc = D3D11_TEXTURE2D_DESC {
Width: ov.w,
Height: ov.h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_R8G8B8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
..Default::default()
};
let init = D3D11_SUBRESOURCE_DATA {
pSysMem: ov.rgba.as_ptr().cast(),
SysMemPitch: ov.w * 4,
SysMemSlicePitch: 0,
};
let mut tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
.context("CreateTexture2D(cursor shape)")?;
let tex = tex.context("null cursor shape texture")?;
let mut srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&tex, None, Some(&mut srv))
.context("CreateShaderResourceView(cursor shape)")?;
self.shape = Some((ov.serial, srv.context("null cursor shape srv")?, ov.w, ov.h));
Ok(())
}
if ov.rgba.len() < (ov.w as usize) * (ov.h as usize) * 4 || ov.w == 0 || ov.h == 0 {
bail!("malformed cursor overlay ({}x{})", ov.w, ov.h);
}
let desc = D3D11_TEXTURE2D_DESC {
Width: ov.w,
Height: ov.h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_R8G8B8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
..Default::default()
};
let init = D3D11_SUBRESOURCE_DATA {
pSysMem: ov.rgba.as_ptr().cast(),
SysMemPitch: ov.w * 4,
SysMemSlicePitch: 0,
};
let mut tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
.context("CreateTexture2D(cursor shape)")?;
let tex = tex.context("null cursor shape texture")?;
let mut srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&tex, None, Some(&mut srv))
.context("CreateShaderResourceView(cursor shape)")?;
self.shape = Some((ov.serial, srv.context("null cursor shape srv")?, ov.w, ov.h));
Ok(())
}
/// Alpha-blend `ov` onto `dst` (frame-sized, RENDER_TARGET-capable — the blend scratch).
@@ -159,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,
@@ -167,50 +174,63 @@ impl CursorBlendPass {
ov: &pf_frame::CursorOverlay,
linear_scale: f32,
) -> Result<()> {
self.ensure_shape(device, ov)?;
let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured");
if self.cbuf_scale != Some(linear_scale) {
let cb: [f32; 4] = [linear_scale, 0.0, 0.0, 0.0];
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
if ctx
.Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
.is_ok()
{
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
ctx.Unmap(&self.cbuf, 0);
// SAFETY: all D3D11 work on the caller's live `device`/`ctx` borrows. The
// `copy_nonoverlapping` writes `cb.len()` `f32`s into the pointer the immediately preceding
// `Map` of `self.cbuf` (16 bytes = 4×`f32`, DYNAMIC/WRITE_DISCARD) returned, inside the
// `is_ok()` arm and before the paired `Unmap`. `ensure_shape` forwards this fn's `device`
// borrow, and every `*Set*`/`Draw` takes borrowed slices of live locals or clones of live COM
// interfaces.
unsafe {
self.ensure_shape(device, ov)?;
let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured");
if self.cbuf_scale != Some(linear_scale) {
let cb: [f32; 4] = [linear_scale, 0.0, 0.0, 0.0];
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
if ctx
.Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
.is_ok()
{
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
ctx.Unmap(&self.cbuf, 0);
// Cache ONLY on a successful upload. Caching unconditionally meant one transient
// `Map` failure wedged the HDR/SDR cursor scale for the rest of the session: the
// buffer still held the OLD value while this believed it held the new one, and
// no later call would retry. On failure the cache is left alone and the next
// blend tries again — a stale scale for a frame instead of forever.
self.cbuf_scale = Some(linear_scale);
}
}
self.cbuf_scale = Some(linear_scale);
}
let mut rtv: Option<ID3D11RenderTargetView> = None;
device
.CreateRenderTargetView(dst, None, Some(&mut rtv))
.context("CreateRenderTargetView(cursor blend scratch)")?;
let rtv = rtv.context("null cursor blend rtv")?;
let mut rtv: Option<ID3D11RenderTargetView> = None;
device
.CreateRenderTargetView(dst, None, Some(&mut rtv))
.context("CreateRenderTargetView(cursor blend scratch)")?;
let rtv = rtv.context("null cursor blend rtv")?;
ctx.OMSetRenderTargets(Some(&[Some(rtv)]), None);
ctx.OMSetBlendState(&self.blend, None, 0xffff_ffff);
ctx.VSSetShader(&self.vs, None);
ctx.PSSetShader(&self.ps, None);
ctx.PSSetShaderResources(0, Some(&[Some(srv.clone())]));
ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())]));
ctx.IASetInputLayout(None);
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
// Placement IS the viewport: the VS fills it, the OS clips it to the target.
let vp = D3D11_VIEWPORT {
TopLeftX: ov.x as f32,
TopLeftY: ov.y as f32,
Width: *w as f32,
Height: *h as f32,
MinDepth: 0.0,
MaxDepth: 1.0,
};
ctx.RSSetViewports(Some(&[vp]));
ctx.Draw(3, 0);
// Unbind so the scratch can be bound as a conversion INPUT without a hazard warning.
ctx.OMSetRenderTargets(None, None);
let none_srv: [Option<ID3D11ShaderResourceView>; 1] = [None];
ctx.PSSetShaderResources(0, Some(&none_srv));
Ok(())
ctx.OMSetRenderTargets(Some(&[Some(rtv)]), None);
ctx.OMSetBlendState(&self.blend, None, 0xffff_ffff);
ctx.VSSetShader(&self.vs, None);
ctx.PSSetShader(&self.ps, None);
ctx.PSSetShaderResources(0, Some(&[Some(srv.clone())]));
ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())]));
ctx.IASetInputLayout(None);
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
// Placement IS the viewport: the VS fills it, the OS clips it to the target.
let vp = D3D11_VIEWPORT {
TopLeftX: ov.x as f32,
TopLeftY: ov.y as f32,
Width: *w as f32,
Height: *h as f32,
MinDepth: 0.0,
MaxDepth: 1.0,
};
ctx.RSSetViewports(Some(&[vp]));
ctx.Draw(3, 0);
// Unbind so the scratch can be bound as a conversion INPUT without a hazard warning.
ctx.OMSetRenderTargets(None, None);
let none_srv: [Option<ID3D11ShaderResourceView>; 1] = [None];
ctx.PSSetShaderResources(0, Some(&none_srv));
Ok(())
}
}
}
@@ -85,11 +85,23 @@ impl CursorPoller {
/// stand-down) — a 2 s freeze at every UAC prompt is user-visible, ~4 `OpenInputDesktop`
/// syscalls/s are not.
const REATTACH: Duration = Duration::from_millis(250);
/// Cadence of the same-handle extent re-probe (see the [`run`] loop). Display-scale changes are
/// human/OS-timescale events and the probe reads dimensions only — no pixel copy — so 4 Hz is
/// both ample and negligible, and a ≤250 ms lag on the pointer's size is imperceptible.
const EXTENT_PROBE: Duration = Duration::from_millis(250);
/// Spawn the poller for the virtual display `target_id`. `rect` = the target's desktop rect
/// Spawn the poller for the virtual display `target_id`. `rect` SEEDS the target's desktop rect
/// (`source_desktop_rect` order: x, y, w, h) — cursor positions are desktop-global; the
/// overlay wants frame-relative, and a pointer outside the rect reports `visible: false`
/// (per-output semantics, matching the driver shm path and the Linux portal).
///
/// A SEED, not the value: the poll thread re-queries the rect on its [`Self::REATTACH`] cadence.
/// It used to be captured once here and used forever for BOTH the desktop→frame offset and the
/// `in_rect` test, while both mid-session mode-change paths (`resize_output` and
/// `poll_display_hdr` → `recreate_ring`) keep the same poller — so after an in-place resize the
/// pointer was clipped to the OLD rect and offset by a stale origin. Re-querying on the poll
/// thread is what keeps the CCD call off the capture/encode thread, which is the whole reason
/// this poller exists (see `DescriptorPoller`).
pub(super) fn spawn(target_id: u32, rect: (i32, i32, i32, i32)) -> Self {
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
let stop = Arc::new(AtomicBool::new(false));
@@ -139,7 +151,7 @@ impl Drop for CursorPoller {
/// The poll loop. Owns the thread's input-desktop binding and the shape cache.
fn run(
target_id: u32,
rect: (i32, i32, i32, i32),
mut rect: (i32, i32, i32, i32),
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
stop: &AtomicBool,
secure: &AtomicBool,
@@ -161,12 +173,32 @@ fn run(
let mut failed_handle: isize = 0; // don't re-rasterise a failing handle every tick
let mut serial: u64 = 0;
let mut logged_live = false;
let mut last_extent = Instant::now();
while !stop.load(Ordering::Relaxed) {
std::thread::sleep(CursorPoller::INTERVAL);
if last_attach.elapsed() >= CursorPoller::REATTACH {
last_attach = Instant::now();
publish_secure(secure, desktop.reattach());
// …and re-read the target's desktop rect on the same cadence: a mid-session resize (or
// an HDR recreate, or the user moving this display in the desktop arrangement) changes
// BOTH the origin the position is made relative to and the extent `in_rect` tests
// against, and this poller outlives all of them. `None` keeps the last good value — a
// transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would
// report every position invisible.
//
let fresh = pf_win_display::win_display::source_desktop_rect(target_id);
if let Some(fresh) = fresh {
if fresh != rect {
tracing::info!(
target_id,
from = ?rect,
to = ?fresh,
"cursor poller: target desktop rect changed — re-basing pointer positions"
);
rect = fresh;
}
}
}
let mut ci = CURSORINFO {
@@ -191,6 +223,42 @@ fn run(
// bitmap to have been seen. v1: animated cursors publish their first frame (the OBS
// behavior); frame cycling via DrawIconEx istep is a known follow-up.
let handle = ci.hCursor.0 as isize;
// …but the handle alone CANNOT see a re-render. Windows rebuilds the system cursors at a
// new size whenever the scale under the pointer changes — crossing to a differently-scaled
// monitor, or a monitor's own scale settling after a mode change (a fresh virtual display
// is created at the RECOMMENDED scale and gets the client's saved `PerMonitorSettings`
// override a beat later) — while the SHARED handle stays put for the session's life (the
// arrow is 0x10003 throughout). Keyed on the handle alone the cache latched whatever size
// the pointer happened to have when the poller started and never let go: a session that
// sampled inside that pre-settle window forwarded — and composited — a 96 px pointer over
// a 100 % desktop until it ended, which is exactly the "cursor is 3× too big while
// everything else is fine" report. Re-read the bitmap's EXTENT on a slow cadence
// (dimensions only, no pixel copy) and drop the cache when it moved.
if showing && handle != 0 && handle == cached_handle {
if last_extent.elapsed() >= CursorPoller::EXTENT_PROBE {
last_extent = Instant::now();
if let (Some(now), Some(s)) = (cursor_extent(ci.hCursor), shape.as_ref()) {
if now != (s.w, s.h) {
tracing::info!(
target_id,
"cursor: the pointer bitmap resized under a stable handle \
({}x{} -> {}x{}) re-rasterising (the scale under the pointer moved)",
s.w,
s.h,
now.0,
now.1
);
cached_handle = 0; // re-rasterise below, on this same tick
}
}
}
} else {
// A handle change re-rasterises on its own — hold the probe off so it can't fire on
// the very next tick against a shape that is current by construction.
last_extent = Instant::now();
}
if showing && handle != 0 && handle != cached_handle && handle != failed_handle {
match rasterize(ci.hCursor) {
Some((rgba, w, h, hot_x, hot_y)) => {
@@ -355,6 +423,57 @@ fn rasterize(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> Raste
type RasterOut = Option<(Vec<u8>, u32, u32, u32, u32)>;
/// The CURRENT bitmap extent of `hcursor` — exactly the `(w, h)` [`convert`] would derive, without
/// the pixel read. Feeds the poll loop's staleness check (a re-render keeps the handle, see there).
/// `None` on any failure, which the caller reads as "no verdict" and keeps its cached shape.
fn cursor_extent(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> Option<(u32, u32)> {
// CopyIcon first, for the reason `rasterize` does it: the owning process can destroy its
// HCURSOR between GetCursorInfo and the reads below; the copy is ours.
// SAFETY: `HICON(hcursor.0)` reinterprets the cursor handle as an icon handle (cursors ARE
// icons in user32); CopyIcon yields an owned HICON destroyed below.
let icon = unsafe { CopyIcon(HICON(hcursor.0)) }.ok()?;
let mut ii = ICONINFO::default();
// SAFETY: `ii` is a live out-param. On Ok it hands us COPIES of the mask/color bitmaps — both
// deleted below (GDI-handle leak otherwise).
let got = unsafe { GetIconInfo(icon, &mut ii) };
// Mirrors `convert`'s two families: a color cursor's extent is its color bitmap's; a
// monochrome one's mask carries the AND plane OVER the XOR plane, so its height is doubled.
let extent = got.is_ok().then_some(()).and_then(|()| {
if !ii.hbmColor.is_invalid() {
bitmap_extent(ii.hbmColor)
} else {
let (w, h) = bitmap_extent(ii.hbmMask)?;
(h >= 2 && h % 2 == 0).then_some((w, h / 2))
}
});
// SAFETY: deleting the two bitmap copies GetIconInfo returned (null-safe: DeleteObject on a
// null HGDIOBJ fails harmlessly) and the icon copy — each exactly once.
unsafe {
let _ = DeleteObject(ii.hbmColor.into());
let _ = DeleteObject(ii.hbmMask.into());
let _ = DestroyIcon(icon);
}
extent
}
/// A GDI bitmap's dimensions, under [`read_bitmap_32`]'s sanity caps so the two agree on what a
/// plausible cursor is — a bitmap `rasterize` would reject must not read here as a size CHANGE.
fn bitmap_extent(hbm: HBITMAP) -> Option<(u32, u32)> {
let mut bm = BITMAP::default();
// SAFETY: `bm` is a live out-param sized exactly as passed; GetObjectW only writes into it.
let n = unsafe {
GetObjectW(
hbm.into(),
std::mem::size_of::<BITMAP>() as i32,
Some((&mut bm as *mut BITMAP).cast()),
)
};
if n == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0 || bm.bmWidth > 512 || bm.bmHeight > 1024 {
return None;
}
Some((bm.bmWidth as u32, bm.bmHeight as u32))
}
/// Convert the ICONINFO bitmaps to straight RGBA. Two families:
/// - color (`hbmColor` set): 32bpp BGRA; if the alpha channel is entirely empty (old-style
/// cursors) the AND mask supplies it (mask bit 1 = transparent).
@@ -372,15 +491,13 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
let color = read_bitmap_32(dc, ii.hbmColor)?;
let (w, h) = (color.w as u32, color.h as u32);
let mut rgba = bgra_to_rgba(&color.bgra);
if rgba.chunks_exact(4).all(|p| p[3] == 0) {
if alpha_is_empty(&rgba) {
// Alpha-less color cursor: transparency lives in the AND mask.
let mask = read_bitmap_32(dc, ii.hbmMask)?;
if mask.w != color.w || mask.h < color.h {
return None;
}
for (px, m) in rgba.chunks_exact_mut(4).zip(mask.bgra.chunks_exact(4)) {
px[3] = if m[0] != 0 { 0 } else { 0xFF }; // mask white (AND=1) = transparent
}
apply_and_mask_alpha(&mut rgba, &mask.bgra);
}
Some((rgba, w, h))
} else {
@@ -389,42 +506,8 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
return None;
}
let (w, h) = (mask.w as usize, (mask.h / 2) as usize);
let row = w * 4;
let (and_plane, xor_plane) = mask.bgra.split_at(h * row);
let mut rgba = vec![0u8; w * h * 4];
let mut invert = vec![false; w * h];
for i in 0..w * h {
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
let px = &mut rgba[i * 4..i * 4 + 4];
match (a, x) {
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
(true, false) => {} // transparent (already zeroed)
(true, true) => {
px.copy_from_slice(&[0, 0, 0, 0xFF]);
invert[i] = true;
}
}
}
// White outline around invert regions so the (now black) shape survives dark
// backgrounds: any transparent 8-neighbor of an invert pixel turns opaque white.
for y in 0..h as i32 {
for x in 0..w as i32 {
if !invert[(y * w as i32 + x) as usize] {
continue;
}
for (dx, dy) in NEIGHBORS {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
continue;
}
let o = (ny * w as i32 + nx) as usize * 4;
if rgba[o + 3] == 0 {
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
}
}
}
}
let (and_plane, xor_plane) = mask.bgra.split_at(h * w * 4);
let rgba = mono_planes_to_rgba(and_plane, xor_plane, w, h);
Some((rgba, w as u32, h as u32))
}
})();
@@ -506,3 +589,204 @@ fn bgra_to_rgba(bgra: &[u8]) -> Vec<u8> {
}
out
}
/// Whether a 32bpp RGBA buffer's alpha channel is entirely zero — the "old-style cursor with no
/// alpha" test, whose transparency lives in the AND mask instead ([`apply_and_mask_alpha`]).
fn alpha_is_empty(rgba: &[u8]) -> bool {
rgba.chunks_exact(4).all(|p| p[3] == 0)
}
/// Take alpha from an expanded AND mask: mask WHITE (AND bit 1) means transparent, black opaque.
/// `mask_bgra` is the 32bpp expansion `GetDIBits` produces from the 1bpp mask, so any non-zero
/// channel byte is "set".
fn apply_and_mask_alpha(rgba: &mut [u8], mask_bgra: &[u8]) {
for (px, m) in rgba.chunks_exact_mut(4).zip(mask_bgra.chunks_exact(4)) {
px[3] = if m[0] != 0 { 0 } else { 0xFF };
}
}
/// The monochrome-cursor truth table, plus the white outline that makes an INVERT region legible.
///
/// A monochrome `HCURSOR` has no colour bitmap: `hbmMask` is DOUBLE height — the AND plane over the
/// XOR plane — and the pair encodes four states (the WebRTC/Chromium table):
///
/// | AND | XOR | meaning | straight-alpha result |
/// |-----|-----|-------------|------------------------------------------|
/// | 0 | 0 | black | opaque black |
/// | 0 | 1 | white | opaque white |
/// | 1 | 0 | transparent | fully transparent |
/// | 1 | 1 | INVERT dst | opaque black + a grown white outline |
///
/// INVERT is unrepresentable in straight alpha (it is a per-pixel XOR against whatever is behind
/// it), so it becomes opaque black and every TRANSPARENT 8-neighbour of an invert pixel is turned
/// opaque white. That outline is what keeps the text I-beam — which is almost entirely invert
/// pixels — legible over dark content; the earlier translucent-grey stand-in did not.
///
/// Extracted from `convert`'s GDI plumbing (sweep Phase 6.6) so the table is testable: the caller
/// needs a live `HCURSOR` and a screen DC, this needs two byte slices.
fn mono_planes_to_rgba(and_plane: &[u8], xor_plane: &[u8], w: usize, h: usize) -> Vec<u8> {
let mut rgba = vec![0u8; w * h * 4];
let mut invert = vec![false; w * h];
for i in 0..w * h {
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
let px = &mut rgba[i * 4..i * 4 + 4];
match (a, x) {
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
(true, false) => {} // transparent (already zeroed)
(true, true) => {
px.copy_from_slice(&[0, 0, 0, 0xFF]);
invert[i] = true;
}
}
}
for y in 0..h as i32 {
for x in 0..w as i32 {
if !invert[(y * w as i32 + x) as usize] {
continue;
}
for (dx, dy) in NEIGHBORS {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
continue;
}
let o = (ny * w as i32 + nx) as usize * 4;
if rgba[o + 3] == 0 {
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
}
}
}
}
rgba
}
#[cfg(test)]
mod tests {
use super::*;
/// Expand a 1-bit-per-pixel plane (as `GetDIBits` does) into the 32bpp form the converters read:
/// any non-zero channel byte means "bit set".
fn plane(bits: &[u8]) -> Vec<u8> {
bits.iter()
.flat_map(|&b| {
let v = if b != 0 { 0xFF } else { 0 };
[v, v, v, 0]
})
.collect()
}
fn px(rgba: &[u8], i: usize) -> [u8; 4] {
rgba[i * 4..i * 4 + 4].try_into().unwrap()
}
const OPAQUE_BLACK: [u8; 4] = [0, 0, 0, 0xFF];
const OPAQUE_WHITE: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF];
const TRANSPARENT: [u8; 4] = [0, 0, 0, 0];
/// All four AND/XOR states, in one 4×1 row — the table `mono_planes_to_rgba` documents.
#[test]
fn the_monochrome_truth_table_is_exact() {
// (0,0) black (0,1) white (1,0) transparent (1,1) invert
let and = plane(&[0, 0, 1, 1]);
let xor = plane(&[0, 1, 0, 1]);
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
assert_eq!(px(&out, 0), OPAQUE_BLACK, "AND=0 XOR=0 ⇒ black");
assert_eq!(px(&out, 1), OPAQUE_WHITE, "AND=0 XOR=1 ⇒ white");
// Pixel 2 is transparent by the table, but it is an 8-neighbour of the invert pixel at 3,
// so the outline claims it — that IS the documented behaviour.
assert_eq!(
px(&out, 2),
OPAQUE_WHITE,
"outline grows into adjacent transparency"
);
assert_eq!(px(&out, 3), OPAQUE_BLACK, "AND=1 XOR=1 ⇒ black + outline");
}
/// Transparency survives when there is no invert pixel next to it.
#[test]
fn transparent_pixels_stay_transparent_without_an_invert_neighbour() {
let and = plane(&[1, 1, 1, 1]);
let xor = plane(&[0, 0, 0, 0]);
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
for i in 0..4 {
assert_eq!(px(&out, i), TRANSPARENT, "pixel {i}");
}
}
/// The outline grows into all eight neighbours, and only into TRANSPARENT ones — it must not
/// repaint a black or white shape pixel.
#[test]
fn the_invert_outline_covers_eight_neighbours_and_overwrites_nothing() {
// 3×3, invert at the centre, everything else transparent.
let and = plane(&[1, 1, 1, 1, 1, 1, 1, 1, 1]);
let mut xor = plane(&[0; 9]);
for b in &mut xor[4 * 4..4 * 4 + 3] {
*b = 0xFF; // centre pixel's XOR bit
}
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
assert_eq!(px(&out, 4), OPAQUE_BLACK, "the invert pixel itself");
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
assert_eq!(px(&out, i), OPAQUE_WHITE, "neighbour {i} outlined");
}
// Now surround it with BLACK shape pixels (AND=0, XOR=0): the outline must leave them alone.
let and = plane(&[0, 0, 0, 0, 1, 0, 0, 0, 0]);
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
assert_eq!(
px(&out, i),
OPAQUE_BLACK,
"neighbour {i} must not be repainted"
);
}
}
/// The outline must clip at the bitmap edges rather than wrap to the opposite side.
#[test]
fn the_outline_clips_at_the_edges() {
// 2×2 with the invert at (0, 0): only (1,0), (0,1) and (1,1) can be outlined.
let and = plane(&[1, 1, 1, 1]);
let mut xor = plane(&[0; 4]);
for b in &mut xor[0..3] {
*b = 0xFF;
}
let out = mono_planes_to_rgba(&and, &xor, 2, 2);
assert_eq!(px(&out, 0), OPAQUE_BLACK);
for i in [1, 2, 3] {
assert_eq!(px(&out, i), OPAQUE_WHITE, "in-bounds neighbour {i}");
}
}
// ---- the alpha-less colour path ---------------------------------------------------------
#[test]
fn an_empty_alpha_channel_is_detected() {
assert!(alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 0]));
assert!(!alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 1]));
assert!(alpha_is_empty(&[]), "no pixels ⇒ vacuously empty");
}
/// Mask WHITE (AND bit 1) = transparent, black = opaque — and the colour bytes are untouched.
#[test]
fn the_and_mask_supplies_alpha_for_an_alpha_less_cursor() {
let mut rgba = vec![
10, 20, 30, 0, // pixel 0
40, 50, 60, 0, // pixel 1
];
let mask = plane(&[1, 0]); // pixel 0 masked out, pixel 1 kept
apply_and_mask_alpha(&mut rgba, &mask);
assert_eq!(px(&rgba, 0), [10, 20, 30, 0], "masked ⇒ transparent");
assert_eq!(px(&rgba, 1), [40, 50, 60, 0xFF], "unmasked ⇒ opaque");
}
/// A mask with FEWER pixels than the colour bitmap must not panic — `zip` stops at the shorter
/// side, leaving the tail at whatever alpha it had (the caller has already required
/// `mask.h >= color.h`, so this is the belt).
#[test]
fn a_short_mask_does_not_panic() {
let mut rgba = vec![1, 2, 3, 0, 4, 5, 6, 0, 7, 8, 9, 0];
apply_and_mask_alpha(&mut rgba, &plane(&[0]));
assert_eq!(px(&rgba, 0), [1, 2, 3, 0xFF]);
assert_eq!(px(&rgba, 1), [4, 5, 6, 0]);
}
}
@@ -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))
@@ -0,0 +1,863 @@
//! IDD-push CONSTRUCTION: everything that runs once, before frames flow.
//!
//! The sealed channel's whole bring-up — the render-adapter resolution and its one TEX_FAIL rebind,
//! the advanced-colour (HDR) negotiation, the shared header + ring + event creation, the channel
//! delivery, the cursor-channel/poller opt-in, and the bounded first-frame gate — plus the two types
//! that exist only for it ([`SharedObjectSa`], [`AttachTexFail`]).
//!
//! Split out of `idd_push.rs` in sweep Phase 5.4. The steady state (`try_consume`, `repeat_last`,
//! the pollers, the `Capturer` impl) deliberately stays with the parent: this file is the part you
//! read when a session will not START, and the parent is the part you read when one stops flowing.
//! A `#[path]` child sees the parent's private items through `use super::*`, so nothing had to be
//! made more visible to move here.
use super::*;
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM only** — `D:P(A;;GA;;;SY)`, protected
/// (no inherited ACEs), `bInheritHandle: false`. The sealed channel makes this the strictly-minimal
/// DACL: the objects are UNNAMED and the driver reaches them via **duplicated handles** (which carry the
/// source handle's access — `OpenSharedResourceByName`/`OpenSharedResource1` on a handle does not
/// re-check the object DACL against the opener), so the pf_vdisplay WUDFHost (LocalService) no longer
/// needs a DACL ACE. Dropping the `LS` ACE removes the last theoretical surface where a leaked handle or
/// a name-grown-by-accident could be opened by the (many-service-shared) LocalService SID. Empirically
/// confirmed unreachable regardless: a LocalService token is DACL-denied `OpenProcess` on the WUDFHost
/// (`PROCESS_DUP_HANDLE`/`VM_READ`/even `QUERY_LIMITED` → ACCESS_DENIED, tested on the RTX box
/// 2026-07-03), so it cannot dup the handles out either. History: `Global\`-named + world-openable
/// (`WD`, security-review 2026-06-28 #5) → SY+LS-scoped → nameless → now SY-only. See
/// `design/idd-push-security.md`.
///
/// RAII, because the descriptor is a `LocalAlloc` the caller must `LocalFree` and the previous
/// `(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)` tuple never did — leaking it twice per open (once
/// for the header section, once for the frame-ready event) and again on every ring recreate. Pairing
/// them in one owner also enforces the "descriptor must outlive the attributes" rule structurally:
/// `sa.lpSecurityDescriptor` points at the allocation and [`as_ptr`](Self::as_ptr) only lends a
/// borrow, so the attributes cannot escape this value's lifetime. Moving the struct is fine — the
/// pointer targets the heap allocation, not a field.
struct SharedObjectSa {
sa: SECURITY_ATTRIBUTES,
psd: PSECURITY_DESCRIPTOR,
}
impl SharedObjectSa {
fn new() -> Result<Self> {
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: `ConvertStringSecurityDescriptorToSecurityDescriptorW` reads the `w!()` literal and
// writes the descriptor it allocates into the live local `psd`; `?` rejects a failure before
// `psd` is read.
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:P(A;;GA;;;SY)"),
SDDL_REVISION_1,
&mut psd,
None,
)
.context("build SDDL for IDD-push shared objects")?;
}
Ok(Self {
sa: SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
},
psd,
})
}
/// The `SECURITY_ATTRIBUTES` to hand a create call, borrowed from this owner.
fn as_ptr(&self) -> *const SECURITY_ATTRIBUTES {
&self.sa
}
}
impl Drop for SharedObjectSa {
fn drop(&mut self) {
// SAFETY: `psd` is the descriptor `ConvertStringSecurityDescriptorToSecurityDescriptorW`
// allocated for this value and nothing else owns it; `LocalFree` releases it exactly once
// (this `Drop` runs once, and `as_ptr` only ever lends a borrow of `sa`).
unsafe {
let _ = LocalFree(Some(HLOCAL(self.psd.0)));
}
}
}
impl IddPushCapturer {
/// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched
/// 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) fn create_ring_slots(
device: &ID3D11Device,
w: u32,
h: u32,
format: DXGI_FORMAT,
) -> Result<Vec<HostSlot>> {
// SAFETY: every D3D11/DXGI call is `?`-checked on the live `device` borrow, over
// fully-initialized stack descriptors and live out-params; `&sa` stays valid for the whole loop
// because `_psd`, the security descriptor backing it, is held in scope alongside.
// `OwnedHandle::from_raw_handle` adopts the handle `CreateSharedHandle` JUST minted for this
// slot — a unique, still-open NT handle owned by this process — making the slot its sole owner.
unsafe {
let sa = SharedObjectSa::new()?;
let mut slots = Vec::new();
for _ in 0..RING_LEN {
let desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
// Match the OS-composed swap-chain surfaces so the driver's CopyResource into the slot +
// its format-guard both succeed.
Format: format,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
CPUAccessFlags: 0,
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0
| D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX.0)
as u32,
};
let mut tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, None, Some(&mut tex))
.context("CreateTexture2D(IDD-push ring slot)")?;
let tex = tex.context("null ring texture")?;
let res1: IDXGIResource1 = tex.cast()?;
let shared = res1
.CreateSharedHandle(
Some(sa.as_ptr()),
DXGI_SHARED_RESOURCE_RW,
PCWSTR::null(), // UNNAMED — reachable only through the broker's duplicate
)
.context("CreateSharedHandle(IDD-push ring slot)")?;
// Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`).
let shared = OwnedHandle::from_raw_handle(shared.0 as _);
let mutex: IDXGIKeyedMutex = tex.cast()?;
let mut srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&tex, None, Some(&mut srv))
.context("CreateShaderResourceView(IDD-push ring slot)")?;
let srv = srv.context("null slot srv")?;
slots.push(HostSlot {
tex,
mutex,
shared,
srv,
});
}
Ok(slots)
}
}
/// Open the IDD-push capturer. On success the caller's `keepalive` is attached (the capturer owns the
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
#[allow(clippy::too_many_arguments)]
pub fn open(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_444: bool,
pyrowave: bool,
keepalive: Box<dyn Send>,
sender: crate::FrameChannelSender,
cursor_sender: Option<crate::CursorChannelSender>,
cursor_forward: Option<crate::CursorForwardSender>,
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
pf_win_display::display_events::spawn_once();
match Self::open_inner(
target,
preferred,
client_10bit,
want_444,
pyrowave,
sender,
cursor_sender,
cursor_forward,
) {
Ok(mut me) => {
me._keepalive = keepalive;
Ok(me)
}
Err(e) => Err((e, keepalive)),
}
}
#[allow(clippy::too_many_arguments)]
fn open_inner(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_444: bool,
pyrowave: bool,
sender: crate::FrameChannelSender,
cursor_sender: Option<crate::CursorChannelSender>,
cursor_forward: Option<crate::CursorForwardSender>,
) -> Result<Self> {
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
// ADD, so on a healthy box they agree, and NVENC gets a device on a real GPU adapter.
// (`target.adapter_luid` is NOT that adapter: the ADD reply carries
// `IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` = the IddCx DISPLAY adapter — verified
// on-glass; it stays a last-resort fallback for a pickerless box only.) When the pick and
// the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and
// this open, or a stale kept monitor across an adapter re-init — the driver reports
// TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that.
let luid = pf_gpu::resolve_render_adapter_luid().unwrap_or(LUID {
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
HighPart: (target.adapter_luid >> 32) as i32,
});
match Self::open_on(
target.clone(),
preferred,
client_10bit,
want_444,
pyrowave,
luid,
sender.clone(),
cursor_sender.clone(),
cursor_forward.clone(),
) {
Ok(me) => Ok(me),
Err(e) => {
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
// adapter its swap-chain ACTUALLY renders on (a stale monitor across an adapter
// re-init, or a driver that ignored SET_RENDER_ADAPTER). Rebinding the ring to that
// adapter beats failing the session — the outer pipeline retries would repeat the
// exact same mismatch.
let driver_luid = e
.downcast_ref::<AttachTexFail>()
.map(|tf| tf.driver_luid)
.filter(|d| *d != 0 && *d != crate::dxgi::pack_luid(luid));
let Some(packed) = driver_luid else {
return Err(e);
};
let drv = LUID {
LowPart: (packed & 0xffff_ffff) as u32,
HighPart: (packed >> 32) as i32,
};
tracing::warn!(
ring_adapter = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
driver_adapter = format!("{:08x}:{:08x}", drv.HighPart, drv.LowPart),
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
driver's reported adapter"
);
Self::open_on(
target,
preferred,
client_10bit,
want_444,
pyrowave,
drv,
sender,
cursor_sender,
cursor_forward,
)
.context("IDD-push rebind to the driver's reported render adapter")
}
}
}
#[allow(clippy::too_many_arguments)]
fn open_on(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_444: bool,
pyrowave: bool,
luid: LUID,
sender: crate::FrameChannelSender,
cursor_sender: Option<crate::CursorChannelSender>,
cursor_forward: Option<crate::CursorForwardSender>,
) -> Result<Self> {
let (pw, ph, _hz) = preferred
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
// Size the ring to the display's ACTUAL current resolution if it differs from the negotiated mode:
// 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.
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,
negotiated = format!("{pw}x{ph}"),
actual = format!("{w}x{h}"),
"IDD push: sizing the ring to the display's actual mode (differs from negotiated)"
);
}
// The driver composes the virtual display in FP16 (R16G16B16A16_FLOAT scRGB) when the display is
// in advanced-color (HDR) mode, and 8-bit BGRA otherwise (per swap_chain_processor.rs + the
// COMMIT_MODES2 colorspace/rgb_bpc log). For a 10-bit-capable client we PROACTIVELY enable
// advanced color so HDR streams without the user toggling anything, then TRACK the display's
// actual mode (a mid-session "Use HDR" flip; the driver's format-guard drops a mismatch), polling
// the live state here and on every recreate. An SDR-only client instead forces advanced color OFF
// and is PINNED there (below + the descriptor poller), so the SDR negotiation is honored and the
// encoder never emits the in-band PQ upgrade to a client that asked for SDR.
// SAFETY: one block over the whole ring setup; every operation in it is sound:
// - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `SharedObjectSa::new`,
// `CreateFileMappingW`, `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all
// `?`-checked, so every returned interface/handle/view is non-error before use;
// `sa.as_ptr()`/`&adapter`/`&device` are live borrows that outlive each synchronous call, and
// `sa.lpSecurityDescriptor` stays valid because the owning `SharedObjectSa` is held in scope
// for the whole block (and frees the descriptor on the way out).
// - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the
// view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The
// OS view base is page-aligned, so `section.ptr::<SharedHeader>()` is suitably aligned for a
// `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay
// within those `bytes` and write THROUGH the raw pointer without forming any `&mut`.
// - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!`
// takes the field address without a reference; the field is a 4-aligned `u32` (valid for
// `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake
// that orders all preceding writes before the driver may observe `MAGIC`.
// - `broker.send` requires live `header`/`event` handles of this process: both borrow the just-
// created owned section/event for the duration of that synchronous call.
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
// into `me` leaves it valid (see the `MappedSection` doc comment).
unsafe {
// An SDR-NEGOTIATED session (either codec) must run on an SDR (BGRA) composition, so
// actively turn advanced color OFF — undoing any leftover HDR state from a prior 10-bit
// session on a reused/lingering monitor, the driver's default, or the host's global
// "Use HDR" — and settle before sizing the ring. Non-optional for two reasons:
// - PyroWave: its CSC reads 8-bit BGRA and the NVIDIA D3D11 VideoProcessor can't ingest
// the FP16 ring at all.
// - H.26x: off an HDR composition the capturer emits P010 and the encoder stamps
// Main10 + BT.2020 PQ from the pixel format alone (the in-band HDR upgrade), sending a
// 10-bit PQ stream to a client that advertised SDR-only ("HDR off = never send me
// 10-bit"). On a client whose monitor is HDR-capable but has "Use HDR" off, that PQ
// lands on an SDR desktop and blows out — the composition must honor the negotiation.
// An HDR-negotiated (10-bit) session instead enables HDR below and rides the FP16 scRGB
// ring (design/pyrowave-444-hdr.md Phase 3 for PyroWave; the H.26x P010 path otherwise).
if !client_10bit {
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
let settle = Instant::now();
while settle.elapsed() < Duration::from_millis(250) {
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
== Some(false)
{
break;
}
std::thread::sleep(Duration::from_millis(25));
}
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
== Some(true)
{
tracing::error!(
target = target.target_id,
pyrowave,
"IDD push: SDR session but advanced color (HDR) could NOT be turned off on the \
virtual display (a physical display forcing HDR?) PyroWave will likely fail \
its first frame; H.26x would emit PQ the SDR-only client never asked for"
);
} else {
tracing::info!(
target = target.target_id,
pyrowave,
settle_ms = settle.elapsed().as_millis() as u64,
"IDD push: SDR-negotiated session — advanced color forced OFF (SDR/BGRA composition)"
);
}
}
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
let enabled_hdr = client_10bit
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
if enabled_hdr {
// Let the colorspace change settle before the driver composes + we size the ring:
// poll the CCD advanced-color state instead of a fixed sleep (latency plan P0.4),
// ceiling = the old 250 ms. A read that never flips within the ceiling proceeds
// exactly like the fixed sleep did — the ring is sized FP16 from `enabled_hdr`
// either way (the set succeeded; only the driver's compose flip may lag, which the
// stash/format-guard machinery absorbs).
let hdr_settle = Instant::now();
while hdr_settle.elapsed() < Duration::from_millis(250) {
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
== Some(true)
{
break;
}
std::thread::sleep(Duration::from_millis(25));
}
tracing::debug!(
target_id = target.target_id,
settle_ms = hdr_settle.elapsed().as_millis() as u64,
"IDD push: advanced-color (HDR) enable settle"
);
}
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
// An SDR-negotiated session (either codec) forced advanced color OFF above and composes
// SDR unconditionally: `client_10bit` gates HDR so a client that advertised SDR-only is
// never handed a PQ stream, even if a physical display forces HDR on (the descriptor
// poller re-asserts OFF; PyroWave's format guard/stash absorbs any lingering FP16 compose).
// Keep the raw observation so Downgrade point D below can say whether the read reported
// OFF or failed outright — "we asked, it said no" and "we could not tell" have different
// causes and different fixes.
let observed_hdr =
pf_win_display::win_display::advanced_color_enabled(target.target_id);
let display_hdr = client_10bit && (enabled_hdr || observed_hdr.unwrap_or(false));
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
// BT.709, so the client's label overstates the stream until the descriptor poller sees
// HDR come on. Loud, because every frame of this session is affected.
if client_10bit && !display_hdr {
tracing::error!(
target = target.target_id,
want_hdr = true,
set_advanced_color_returned = enabled_hdr,
observed_hdr = ?observed_hdr,
"IDD push: 10-bit HDR was negotiated but enabling advanced color on the \
virtual display FAILED encoding 8-bit SDR while the client was told HDR \
(check the display driver / Windows HDR support on this box). \
observed_hdr=Some(false) the display reports advanced colour OFF after the \
set; None the CCD read itself failed"
);
}
let ring_fmt = if display_hdr {
DXGI_FORMAT_R16G16B16A16_FLOAT
} else {
DXGI_FORMAT_B8G8R8A8_UNORM
};
// Our device (ring + zero-copy NVENC) lives on `luid` — the selected render GPU per
// `open_inner`; the driver must render the swap-chain on the SAME adapter for the
// shared textures to open (it reports its actual render LUID into the header, which
// `open_inner` uses to rebind once if this mismatches).
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
let adapter: IDXGIAdapter1 = factory
.EnumAdapterByLuid(luid)
.context("EnumAdapterByLuid(render adapter) for IDD push")?;
let (device, context) = make_device(&adapter).context("make_device for IDD push")?;
let sa = SharedObjectSa::new()?;
let bytes = std::mem::size_of::<SharedHeader>().max(64);
// Header — UNNAMED (the sealed channel: the driver gets a duplicated handle, not a name).
let map = CreateFileMappingW(
INVALID_HANDLE_VALUE,
Some(sa.as_ptr()),
PAGE_READWRITE,
0,
bytes as u32,
PCWSTR::null(),
)
.context("CreateFileMapping(IDD-push header)")?;
// Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail.
let map = OwnedHandle::from_raw_handle(map.0 as _);
let view = MapViewOfFile(
HANDLE(map.as_raw_handle()),
FILE_MAP_ALL_ACCESS,
0,
0,
bytes,
);
if view.Value.is_null() {
bail!("MapViewOfFile failed for IDD-push header"); // `map` drops → mapping closed
}
let section = MappedSection { handle: map, view };
let generation = next_generation();
let header = section.ptr::<SharedHeader>();
std::ptr::write_bytes(header.cast::<u8>(), 0, bytes);
(*header).version = VERSION;
(*header).generation = generation;
(*header).ring_len = RING_LEN;
(*header).width = w;
(*header).height = h;
// Ring format = the display's composition format (FP16 in HDR, BGRA in SDR). The driver
// reads this into its `ring_format` and drops any surface that doesn't match.
(*header).dxgi_format = ring_fmt.0 as u32;
// The ring NAMES its monitor (proto v3, `design/idd-push-security.md` invariant #10) —
// stamped before the magic (below), never changed for the ring's life (a mid-session
// recreate reuses this mapping). The driver refuses to attach a ring naming a different
// monitor, so a stash cross-wire fails closed instead of leaking frames cross-client
// (fail-closed refusal VALIDATED on-glass 2026-07-10 via a fault-injected build: driver
// DRV_STATUS_BIND_FAIL + loud host open failure + sibling stream undisturbed).
(*header).target_id = target.target_id;
// Frame-ready event (auto-reset) — UNNAMED, like everything on this channel.
let event = CreateEventW(Some(sa.as_ptr()), false, false, PCWSTR::null())
.context("CreateEvent(IDD-push)")?;
let event = OwnedHandle::from_raw_handle(event.0 as _);
// Ring of shared keyed-mutex textures, format matched to the display's current mode.
let slots = Self::create_ring_slots(&device, w, h, ring_fmt)?;
// Publish: magic LAST (Release) — the ring must be fully initialized before the driver
// (which receives the channel strictly afterwards) can observe MAGIC.
std::sync::atomic::fence(Ordering::Release);
(*(std::ptr::addr_of!((*header).magic) as *const AtomicU32))
.store(MAGIC, Ordering::Release);
// Deliver the sealed channel: duplicate header + event + every slot texture into the
// driver's WUDFHost and hand it the values over the control device. All-or-nothing (the
// broker reaps its remote duplicates on failure), and a failure fails the open — without
// the delivery the driver can never attach.
let broker = ChannelBroker::open(target.wudf_pid, sender)?;
broker
.send(
target.target_id,
generation,
HANDLE(section.handle.as_raw_handle()),
HANDLE(event.as_raw_handle()),
&slots,
)
.context("deliver IDD-push frame channel to the driver")?;
// v5 hardware-cursor channel (M2c): create + deliver the CursorShm section. Failure
// is NON-fatal — the driver never declares the hardware cursor without this delivery,
// so the session degrades to today's composited pointer (and the forwarder simply
// never sees a live overlay).
let cursor_shared = cursor_sender.as_ref().and_then(|send_cursor| {
match cursor::CursorShared::create(target.target_id) {
Ok(cs) => {
// Deliver via the shared helper (also used for RE-delivery after a
// driver-side monitor re-arrival destroyed the worker).
deliver_cursor_channel(&broker, target.target_id, &cs, send_cursor)
.then_some(cs)
}
Err(e) => {
tracing::warn!(
"cursor section creation failed — the driver will not declare a \
hardware cursor, so this session cannot forward the pointer: {e:#}"
);
None
}
}
});
// No LIVE channel this session, but the target's sticky declare (an EARLIER session's —
// irrevocable, §8.6) keeps DWM's frames pointer-free with no client drawing either:
// the only visible pointer is the one composited here, so force composite mode on.
//
// Gated on `cursor_shared`, NOT on `cursor_sender`. §8.6's rationale is "this session has
// no cursor CHANNEL", and the delivery just above is explicitly allowed to fail
// non-fatally — which is precisely the state that needs this rescue, yet the
// `cursor_sender.is_none()` test was the one state that skipped it: the host negotiated a
// channel, failed to create or deliver it, and then declined to composite, leaving a
// cursor-excluded target with NO pointer at all.
let composite_forced = target.cursor_excluded && cursor_shared.is_none();
if composite_forced {
tracing::info!(
target_id = target.target_id,
negotiated_channel = cursor_sender.is_some(),
"target carries an irrevocable hardware-cursor declare from an earlier \
desktop-mode session and this session has no LIVE cursor channel the host \
composites the pointer into frames (forced, for the session's life). \
negotiated_channel=true one was negotiated but its creation/delivery failed"
);
}
// The GDI shape poller rides the SAME gate as the delivered channel: with the driver's
// hardware cursor keeping the frame cursor-free, the poller supplies the full-fidelity
// shape (masked/monochrome included — the IddCx query can't; see cursor_poll.rs).
// Forced-composite sessions need it too — it is their only shape/position source.
let cursor_poll = (cursor_shared.is_some() || composite_forced).then(|| {
// Safety of the CCD call: read-only QueryDisplayConfig over owned locals (same
// call CursorShared::create makes) — already inside open_on's unsafe region.
let rect = pf_win_display::win_display::source_desktop_rect(target.target_id)
.unwrap_or((0, 0, i32::MAX, i32::MAX));
cursor_poll::CursorPoller::spawn(target.target_id, rect)
});
// Heal the driver's persisted cursor-forward state: a session that died on the
// secure desktop (client drops at the lock screen — the common case) leaves the
// per-target desired state `false`, and the NEXT session's channel delivery would
// adopt UNdeclared (the exact cross-session composite trap of §8.6). A fresh
// session always starts declared; the secure-desktop guard re-disables if the
// secure desktop is (still) up, via its first `poll_secure_desktop` edge.
if let (Some(_), Some(fwd)) = (cursor_shared.as_ref(), cursor_forward.as_ref()) {
if let Err(e) = fwd(true) {
tracing::debug!("cursor-forward reset at open failed (pre-v6 driver?): {e:#}");
}
}
tracing::info!(
target_id = target.target_id,
wudf_pid = target.wudf_pid,
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
mode = format!("{w}x{h}"),
display_hdr,
client_10bit,
want_444,
ring_fp16 = display_hdr,
// Whether DXGI ever reached the win32u GPU-preference hook. By this point the
// factory + `EnumAdapterByLuid` + `make_device` above have exercised DXGI, so a
// 0 here means the hook is inert on this build — the first thing to check if a
// hybrid-GPU box keeps reporting TEX_FAIL render-adapter mismatches
// (`dxgi::install_gpu_pref_hook`).
hybrid_hook_hits = crate::dxgi::hybrid_hook_hits(),
"IDD push(host): created sealed ring + delivered the channel; waiting for the driver \
to attach + publish"
);
let mut me = Self {
device,
context,
target_id: target.target_id,
section,
header,
event,
broker,
width: w,
height: h,
slots,
generation,
client_10bit,
display_hdr,
hdr_pin_warned: false,
want_444,
pyrowave,
pyro_fence: None,
pyro_fence_handle: None,
pyro_fence_value: 0,
pyro_ring: Vec::new(),
pyro_conv: None,
pyro_last: None,
desc_poller: DescriptorPoller::spawn(
target.target_id,
DisplayDescriptor {
hdr: display_hdr,
width: w,
height: h,
},
),
desc_seq: 0,
pending_desc: None,
recovering_since: None,
last_fresh: Instant::now(),
last_liveness: Instant::now(),
last_kick: Instant::now(),
stall_watch: StallWatch::new(),
out_ring: Vec::new(),
out_idx: 0,
video_conv: None,
hdr_p010_conv: None,
last_seq: 0,
last_present: None,
status_logged: false,
cursor_shared,
cursor_poll,
cursor_sender,
cursor_forward,
secure_active: false,
composite_cursor: composite_forced,
composite_forced,
cursor_blend: None,
cursor_blend_failed: false,
cursor_shm_latched: false,
blend_scratch: None,
last_blend_key: None,
last_slot: None,
sdr_white_scale: 1.0,
// Held from BEFORE the first-frame gate (the display must not idle off while we
// wait for the first compose) until the capturer drops with the session.
_display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(),
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
// it back to the caller for the DDA fallback (audit §5.1).
_keepalive: Box::new(()),
};
// The HDR SDR-white reference for the composited cursor, queried ONCE here rather than
// from the blend (which holds the ring slot's keyed mutex — see
// `refresh_sdr_white_scale`). No-op on an SDR composition.
me.refresh_sdr_white_scale();
// Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach
// failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a
// format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA),
// instead of next_frame's 20 s black-then-bail.
me.wait_for_attach()?;
Ok(me)
}
}
/// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published
/// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 +
/// `design/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix).
///
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
/// a fullscreen game can leave the virtual display in a format/size that the driver's `publish()` guard
/// rejects, so the driver ATTACHES but silently drops every frame; without this the host sails past
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom).
/// A stash-capable driver republishes its retained desktop frame the moment it attaches (the
/// first-frame guarantee — `FrameStash`, driver frame_transport.rs), so the normal case clears this
/// gate in milliseconds even on an idle desktop; failing that, at session open the OS activates the
/// virtual display → DWM composites it → a frame arrives within ~1 s, plus the compose-kick fallback
/// below — no frame within the window = genuinely broken.
fn wait_for_attach(&self) -> Result<()> {
// Symmetric host-side binding sanity (proto v3 §3.2): OUR header must still name OUR
// monitor. The stamp is ours and nothing legitimate rewrites it, so a mismatch means a
// host-side bug (a stash/capturer cross-wire) — the exact class the driver-side check
// catches from the other end; failing here names the culprit in the same release.
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping (same access
// pattern as the `driver_status` read below); no reference into the shared region is formed.
let stamped = unsafe { (*self.header).target_id };
if stamped != self.target_id {
bail!(
"IDD-push: our ring header names target {stamped} but this capturer serves target \
{} host-side ringmonitor cross-wire (bug); failing the open",
self.target_id
);
}
let deadline = Instant::now() + Duration::from_secs(4);
// First-frame expectation: a stash-capable driver republishes its retained desktop frame
// the moment it attaches (`FrameStash`, frame_transport.rs), so on a healthy pairing the
// gate below clears in milliseconds even on a perfectly idle desktop. The compose-kick
// schedule is the FALLBACK for pre-stash drivers / an empty stash (a display that has
// never composed): DWM only presents a display something DIRTIED, so on an idle desktop
// an attach would otherwise sit at E_PENDING forever and fail this gate — the
// "idle desktop → no frames" gotcha. Give the natural post-activate compose (and the
// stash republish) a moment, then nudge; log when we do, so field logs show whether the
// stash path is working.
let mut next_kick = Instant::now() + Duration::from_millis(600);
loop {
// SAFETY: `self.header` points into the live shared-header mapping this capturer owns (sized
// `>= size_of::<SharedHeader>()`, page-aligned), so the field read is in-bounds + aligned, and
// no reference into the shared region is formed. Plain read: the driver writes this `u32`
// cross-process, but an aligned `u32` read can't tear and `driver_status` is best-effort
// diagnostics — the real handshake is the atomic `magic`/`latest` (same access as
// log_driver_status_once).
let st = unsafe { (*self.header).driver_status };
if st == DRV_STATUS_TEX_FAIL {
// The driver wrote its render LUID BEFORE attempting the texture opens
// (frame_transport.rs step 2), so it is valid here.
let (_, detail, lo, hi) = self.driver_diag();
// Typed so `open_inner` can rebind the ring to the driver's adapter once.
return Err(anyhow::Error::new(AttachTexFail {
detail,
driver_luid: ((hi as i64) << 32) | (lo as i64 & 0xffff_ffff),
}));
}
if st == DRV_STATUS_NO_DEVICE1 {
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
// through the owned, live header mapping; no reference into the shared region is formed.
let detail = unsafe { (*self.header).driver_status_detail };
bail!(
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
the driver has no ID3D11Device1 to open shared resources)"
);
}
if st == DRV_STATUS_BIND_FAIL {
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
// through the owned, live header mapping; no reference into the shared region is formed.
let claimed = unsafe { (*self.header).driver_status_detail };
bail!(
"IDD-push driver REFUSED the ring↔monitor binding (DRV_STATUS_BIND_FAIL: the \
delivered ring names target {claimed}, the monitor is {}) host \
stash/delivery cross-wire (bug); failing the open loudly (proto v3 §3.2)",
self.target_id
);
}
// Attached AND a frame has been published — the publish token's seq advances past 0.
if st == DRV_STATUS_OPENED && frame::FrameToken::unpack(self.latest()).seq != 0 {
return Ok(());
}
if Instant::now() >= next_kick {
// Reaching a kick at all means the driver did NOT republish a retained frame
// (pre-stash driver, or a never-composed display) — worth a line in the field log.
tracing::debug!(
target_id = self.target_id,
driver_status = st,
"IDD push: no first frame after attach delivery — falling back to a synthetic \
compose kick (stash-capable drivers republish instantly; old driver?)"
);
// May BLOCK this thread ~35 ms (the cursor-on-a-sibling-display branch — see
// `kick_dwm_compose`'s COST note). Fine here: we are inside the open-time
// first-frame gate, so no frames are flowing yet.
kick_dwm_compose(self.target_id);
next_kick = Instant::now() + Duration::from_millis(800);
}
if Instant::now() > deadline {
bail!(
"IDD-push: no frame published within 4s (despite compose kicks) — {}; \
falling back",
self.no_first_frame_diagnosis(st)
);
}
// Event-driven wait (latency plan P0.6): the driver signals the frame-ready event on
// every publish, so wake on it instead of a blind sleep — the 20 ms timeout keeps the
// driver_status polls above live (status writes don't signal the event). Consuming a
// signal here is fine: `next_frame` re-checks the atomic `latest` token, never the
// event, for truth.
// SAFETY: `self.event` is this capturer's owned, live auto-reset event handle;
// `WaitForSingleObject` only reads the handle and the 20 ms timeout bounds the wait.
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 20) };
}
}
/// Name a first-frame timeout from the driver's own evidence — `driver_status` plus the live
/// OPENED detail word (proto `pack_opened_detail`) — instead of guessing. The three no-frames
/// states look identical from the host side but have disjoint causes and fixes; the lid-closed
/// field report burned days for lack of exactly this line. Appends a console-session hint when
/// the host itself is in the wrong session (display writes + input kicks can't work from there).
fn no_first_frame_diagnosis(&self, st: u32) -> String {
let what = match st {
// The delivery was never consumed: no swap-chain worker ran for this monitor at all.
DRV_STATUS_NONE => "the driver never attached — the channel delivery was never \
consumed, so the OS ran no swap-chain worker for this monitor (display not \
composed at all: console display-off / modern standby, or the mode commit \
never reached the adapter)"
.to_string(),
DRV_STATUS_OPENED => {
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping
// (same best-effort diagnostic access as the `driver_status` read in the caller);
// no reference into the shared region is formed.
let detail = unsafe { (*self.header).driver_status_detail };
match unpack_opened_detail(detail) {
Some((0, _)) => "driver attached with a live swap-chain, but DWM composed \
ZERO frames an undamaged or powered-off desktop, and the compose \
kicks didn't bite (synthetic input is blocked on the secure desktop)"
.to_string(),
Some((offered, mismatched)) => format!(
"driver attached and DWM composed {offered} frame(s), but none matched \
the ring {mismatched} dropped for a size/format mismatch (the \
display's actual mode differs from what the host sized the ring to: \
a mid-open mode-set, a fullscreen game, or a stale GDI view)"
),
// A pre-detail driver never stamps the live bit — say so rather than guess.
None => "driver attached but published nothing; this pf-vdisplay build \
predates attach diagnostics, so the cause can't be named update the \
driver for a precise line here"
.to_string(),
}
}
other => format!("driver_status={other} (unexpected at this point)"),
};
match pf_win_display::console_session_mismatch() {
Some((own, console)) => format!(
"{what} [host is in session {own} but the console is session {console} — display \
writes and input kicks cannot work from a non-console session; reconnect the \
console or run via the installed service]"
),
None => what,
}
}
}
/// `wait_for_attach`'s DRV_STATUS_TEX_FAIL as a typed error: the driver could not open the ring
/// textures, and `driver_luid` (packed, from the shared header) is the adapter its swap-chain
/// ACTUALLY renders on — `open_inner` downcasts to this to rebind the ring there once.
#[derive(Debug)]
struct AttachTexFail {
detail: u32,
driver_luid: i64,
}
impl std::fmt::Display for AttachTexFail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"IDD-push driver failed to attach (driver_status={DRV_STATUS_TEX_FAIL} \
detail=0x{:08x}): it could not open the ring textures its swap-chain renders on \
adapter {:08x}:{:08x}, not the ring's (render-adapter mismatch)",
self.detail,
(self.driver_luid >> 32) as i32,
(self.driver_luid & 0xffff_ffff) as u32,
)
}
}
impl std::error::Error for AttachTexFail {}
@@ -31,6 +31,10 @@ pub(super) struct StallWatch {
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
recent: std::collections::VecDeque<Instant>,
cadence: pf_frame::metronome::Metronome,
/// Stalls seen this session, and how many had a coinciding OS display event — the discriminator
/// [`Self::report`] uses. They were capturer fields that nothing outside the report touched.
seen: u32,
with_os_events: u32,
}
impl StallWatch {
@@ -48,6 +52,8 @@ impl StallWatch {
Self {
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
cadence: pf_frame::metronome::Metronome::new(),
seen: 0,
with_os_events: 0,
}
}
@@ -79,4 +85,81 @@ impl StallWatch {
metronomic: self.cadence.note(now),
})
}
/// Log a detected stall, correlate it against OS display events, and — once the cadence turns
/// metronomic — name the class of disturbance and its cures.
///
/// Lives here rather than in `try_consume` (sweep Phase 5.4): it is ~65 lines of log prose plus
/// a running tally, all of it about stalls and none of it about consuming a frame, in a function
/// that runs per frame. `now` is the instant of the frame that ENDED the stall — the same one
/// passed to [`Self::note_fresh`] — which is what bounds the event-correlation window.
pub(super) fn report(&mut self, stall: &Stall, now: Instant) {
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
let window = stall.gap + Duration::from_millis(300);
let events = now
.checked_sub(window)
.map(|from| pf_win_display::display_events::events_between(from, now))
.unwrap_or_default();
self.seen = self.seen.saturating_add(1);
if !events.is_empty() {
self.with_os_events = self.with_os_events.saturating_add(1);
}
// debug (not warn): a single hole also happens when content legitimately pauses;
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
// at debug level, and the web-console debug ring captures these.
tracing::debug!(
gap_ms = stall.gap.as_millis() as u64,
os_display_events = %pf_win_display::display_events::summarize(&events),
"IDD-push capture stall — the desktop was composing at speed, then DWM \
delivered no frame for the gap; the present path stalled below capture"
);
if let Some(period) = stall.metronomic {
let suspects = pf_win_display::display_events::connected_inactive_physicals();
let suspects = if suspects.is_empty() {
"none".to_string()
} else {
suspects.join(", ")
};
let correlated = format!("{}/{}", self.with_os_events, self.seen);
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
// cascade is OS-visible; otherwise the disturbance never surfaces above the
// driver. Different classes, different cures — say which one this box has.
if self.with_os_events * 2 >= self.seen {
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
"capture stalls are METRONOMIC and coincide with Windows monitor \
hot-plug/re-enumeration events a connected display (or its \
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
each time. Cures, best-first: that display's OSD 'auto input \
scan/detect' OFF (and on TVs: instant-on/quick-start + CEC off), \
unplug its cable at the GPU, an HPD-holding adapter/dummy plug, or \
keep it active while streaming; the pnp_disable_monitors policy axis \
suppresses the Windows-side reaction (see connected_inactive for the \
suspects)"
);
} else {
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
"capture stalls are METRONOMIC with NO coinciding OS display event — \
the disturbance is BELOW Windows: the GPU driver servicing a \
connected-but-asleep sink (standby HPD/DDC/link probing), \
display-poller software (the SteelSeries-GG/SignalRGB class \
correlate 'slow display-descriptor poll' lines), or the DWM present \
clock (try a different refresh rate). If connected_inactive lists a \
display, its standby servicing is the prime suspect. For a LAPTOP \
PANEL (the exclusive isolate deactivated it the dark-but-connected \
head is itself the disturbance on hybrid laptops): keep it active \
with `topology: primary`, or try the `pnp_disable_monitors` axis. \
For an external display: unplug it at the GPU, disable its OSD auto \
input scan (TVs: instant-on/quick-start + CEC off), use an \
HPD-holding adapter/dummy, or keep it active while streaming"
);
}
}
}
}
+24 -14
View File
@@ -137,23 +137,29 @@ 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> {
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
return Ok(a);
/// 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 {
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
return Ok(a);
}
}
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
}
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
}
/// 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,
@@ -177,8 +183,12 @@ unsafe fn create_nv12(
..Default::default()
};
let mut tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, None, Some(&mut tex))
.context("CreateTexture2D(NV12)")?;
// 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))
.context("CreateTexture2D(NV12)")?;
}
tex.context("CreateTexture2D returned a null NV12 texture")
}
+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);
}
}
+83 -2
View File
@@ -299,6 +299,23 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
}
}
/// The kind a slot DECLARES to the host ([`InputKind::GamepadArrival`]) given the user's
/// controller-type `setting` and the pad's `physical` kind: an explicit setting emulates that pad
/// for every slot, `Auto` keeps per-pad detection (what makes a mixed session honest).
///
/// This has to be applied per pad and not just in the Hello: the host builds each virtual device
/// from that pad's arrival and only falls back to the session default for a pad that never
/// declares one, so a client that always declared the detected kind would silently undo the
/// setting the moment a controller connected. The physical kind is still what the LOCAL feedback
/// paths use (DualSense raw effects, the Deck rumble keep-alive) — those talk to the controller in
/// the user's hands, not the one the host is pretending to have.
fn declared_kind(setting: GamepadPref, physical: GamepadPref) -> GamepadPref {
match setting {
GamepadPref::Auto => physical,
explicit => explicit,
}
}
/// Best-effort "this machine is a Steam Deck". The Gaming-Mode env short-circuits; desktop
/// mode falls back to DMI (Valve board, Jupiter = LCD / Galileo = OLED — readable inside the
/// flatpak sandbox). Cached: the answer can't change while we run.
@@ -318,6 +335,7 @@ enum Ctl {
Attach(Arc<NativeClient>),
Detach,
Pin(Option<String>),
KindOverride(GamepadPref),
MenuMode(bool),
MenuRumble(MenuPulse),
}
@@ -452,6 +470,18 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::Pin(key));
}
/// Adopt the user's explicit controller-type setting for the session about to start
/// (`GamepadPref::Auto` = detect per pad, the default).
///
/// This is NOT redundant with the session default in the Hello: a current host honors a pad's
/// [`InputKind::GamepadArrival`] over the session default, so a client that declared only the
/// detected kind would silently undo the setting the moment a controller connected. Call it
/// before [`Self::attach`] — slots declare their kind at open time and the host does not
/// hot-swap a device that already exists.
pub fn set_kind_override(&self, pref: GamepadPref) {
let _ = self.ctl.send(Ctl::KindOverride(pref));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
@@ -691,6 +721,10 @@ struct Worker {
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
/// (an explicit single-player choice); Automatic forwards every real controller.
pinned: Option<String>,
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
kind_override: GamepadPref,
attached: Option<Arc<NativeClient>>,
/// Raises the UI escape signal; the escape chord fires it once per press.
escape_tx: async_channel::Sender<()>,
@@ -886,6 +920,7 @@ impl Worker {
Some(p) => p.pref,
None => GamepadPref::Xbox360,
};
let declared = declared_kind(self.kind_override, pref);
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
Ok(pad) => {
let mut slot = Slot::new(id, index, pref, pad);
@@ -895,7 +930,13 @@ impl Worker {
// re-sends it a few times against datagram loss; an older host ignores it and
// uses the session-default kind.
if let Some(c) = &self.attached {
send(c, InputKind::GamepadArrival, pref.to_u8() as u32, 0, index);
send(
c,
InputKind::GamepadArrival,
declared.to_u8() as u32,
0,
index,
);
// Declare the actuator's quirks to the shared rumble policy engine. ALWAYS
// set (defaults for a well-behaved pad): wire indices are reused within a
// connection, so a Deck slot that closes must not leave its keepalive quirk
@@ -911,7 +952,13 @@ impl Worker {
};
c.set_rumble_quirks(index as u16, quirks);
}
tracing::info!(id, index, pref = ?pref, "gamepad forwarding (slot opened)");
tracing::info!(
id,
index,
pref = ?pref,
declared = ?declared,
"gamepad forwarding (slot opened)"
);
self.slots.push(slot);
}
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
@@ -1219,6 +1266,7 @@ impl Worker {
self.pinned = key;
self.refresh_active();
}
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
Ok(Ctl::MenuMode(on)) => {
self.menu_mode = on;
if on {
@@ -1558,6 +1606,7 @@ impl Worker {
menu_open: None,
order: Vec::new(),
pinned: None,
kind_override: GamepadPref::Auto,
attached: None,
escape_tx,
disconnect_tx,
@@ -1823,6 +1872,38 @@ mod slot_tests {
assert_eq!(lowest_free_index(&but_seven), Some(7));
}
#[test]
fn an_explicit_setting_is_what_every_pad_declares() {
// The regression this pins: the setting used to reach the Hello only, and each pad's
// arrival then re-declared the DETECTED kind — which the host honors over the session
// default, so "emulate my DualSense as a DualShock 4" produced a DualSense.
assert_eq!(
declared_kind(GamepadPref::DualShock4, GamepadPref::DualSense),
GamepadPref::DualShock4
);
// Every physical pad in a mixed session follows the one explicit choice.
for physical in [
GamepadPref::DualSense,
GamepadPref::Xbox360,
GamepadPref::SwitchPro,
GamepadPref::SteamDeck,
] {
assert_eq!(
declared_kind(GamepadPref::Xbox360, physical),
GamepadPref::Xbox360
);
}
// Automatic keeps per-pad detection — otherwise a mixed session collapses to one type.
assert_eq!(
declared_kind(GamepadPref::Auto, GamepadPref::DualSense),
GamepadPref::DualSense
);
assert_eq!(
declared_kind(GamepadPref::Auto, GamepadPref::SteamDeck),
GamepadPref::SteamDeck
);
}
#[test]
fn hidout_pad_reads_every_variant() {
assert_eq!(
+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))]
+10 -1
View File
@@ -62,6 +62,10 @@ pub struct GameEntry {
pub title: String,
#[serde(default)]
pub art: Artwork,
/// The system the title runs on (`"PC"`, `"PS2"`, …) — free-form display string from the
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
#[serde(default)]
pub platform: Option<String>,
}
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
@@ -280,7 +284,7 @@ mod tests {
fn game_entry_decodes_the_wire_shape() {
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
let json = r#"[
{"id":"steam:570","store":"steam","title":"Dota 2",
{"id":"steam:570","store":"steam","title":"Dota 2","platform":"PC",
"art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
"launch":{"kind":"steam_appid","value":"570"}},
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}}
@@ -288,7 +292,12 @@ mod tests {
let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
assert_eq!(games.len(), 2);
assert_eq!(games[0].id, "steam:570");
assert_eq!(games[0].platform.as_deref(), Some("PC"));
assert!(games[1].art.portrait.is_none());
assert!(
games[1].platform.is_none(),
"pre-metadata hosts still parse"
);
}
#[test]
+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
+25 -18
View File
@@ -249,9 +249,16 @@ impl Codec {
}
}
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and
/// cursor plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap
/// `Copy`; fixed for the session (an HDR toggle re-initialises the encoder — re-query if that
/// matters).
///
/// (There is deliberately NO `supports_hdr_metadata` cap: in-band HDR SEI/OBU embedding needs no
/// host-side routing — every first-party client reads the static grade exclusively out-of-band
/// (the native 0xCE datagram / the GameStream 0x010e control message), both planes send it
/// unconditionally, and the in-band grade is a decoder-side bonus for stock clients. A cap field
/// nothing reads is a contract nobody honors; it was deleted after shipping write-only.)
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EncoderCaps {
/// The encoder can perform real reference-frame invalidation — i.e.
@@ -261,10 +268,6 @@ pub struct EncoderCaps {
/// AMF (user-LTR force-reference, when the driver accepted the LTR slots at open). The
/// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
pub supports_rfi: bool,
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
/// Windows direct-NVENC path attaches it today.
pub supports_hdr_metadata: bool,
/// The opened encoder is actually producing a full-chroma 4:4:4 (`chroma_format_idc = 3`) stream.
/// `false` on every 4:2:0 session (the default) and on a backend that declined 4:4:4. Set by the
/// NVENC backends (Linux + Windows). The chroma is committed to the wire (`Welcome::chroma_format`)
@@ -304,8 +307,11 @@ pub struct EncoderCaps {
///
/// This makes the answer queryable instead of assumed. It is deliberately a plain fact about the
/// encoder, not a policy: what to DO when a session wants blending and the backend cannot is the
/// host's call, since only the host can re-plan capture (fall back to capturer-side compositing).
/// `open_video` can only warn, which it does.
/// host's call, since only the host can re-plan capture. That call is wired now — the
/// negotiation consults the pre-open mirror ([`cursor_blend_capable`](crate::cursor_blend_capable))
/// to gate the cursor channel and to keep capture on embedded-cursor / CSC-capable shapes for
/// any backend that can't blend; `open_video`'s post-open check remains as the backstop for
/// open-time fallbacks the plan can't see.
pub blends_cursor: bool,
}
@@ -333,11 +339,10 @@ pub trait Encoder: Send {
let _ = wire_index;
self.submit(frame)
}
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
/// route by query rather than rely on the no-op/`false` defaults of
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
/// path overrides it.
/// This encoder's static [capabilities](EncoderCaps) (RFI, intra-refresh, chroma, cursor
/// blending), so the session glue can route by query rather than rely on the no-op/`false`
/// defaults of methods like [`invalidate_ref_frames`](Self::invalidate_ref_frames).
/// Default: no optional capabilities (the software / libavcodec backends).
fn caps(&self) -> EncoderCaps {
EncoderCaps::default()
}
@@ -345,10 +350,12 @@ pub trait Encoder: Send {
/// reference-frame-invalidation request). Default: no-op.
fn request_keyframe(&mut self) {}
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
/// as in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade.
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
/// every frame; only the direct-NVENC path consumes it.
/// in-band (HEVC/H.264 `mastering_display_colour_volume` + `content_light_level_info` SEI, or
/// AV1 metadata OBUs) on keyframes so a stock decoder — e.g. stock Moonlight — tone-maps from
/// the source's real grade. Default: no-op (SDR encoders / paths that don't attach it).
/// Cheap to call every frame; consumed by Windows direct-NVENC, native AMF, and native QSV.
/// Every first-party client reads the grade out-of-band (the 0xCE datagram) regardless, so
/// this is a bonus for stock decoders, never the primary channel.
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers
/// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
+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::*;
+575 -48
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;
@@ -1613,14 +1804,22 @@ impl Encoder for NvencCudaEncoder {
// `async_rt` must be absent too: in two-thread mode the frame may be recycled right after
// submit returns while the stream still holds its copy (belt-and-braces — an escalated
// session was rebuilt without the binding, so `stream_ordered` is false there anyway).
// Cursor-bearing frames additionally force the CPU-synced path: the Vulkan blend sits
// between the CUDA copy and the encode, and its cross-API ordering is fence/CPU-
// established, not stream-ordered. Frames without a cursor (games hide it; client-draws
// sessions strip it) keep the stream-ordered fast path untouched.
let ordered = self.stream_ordered
&& self.async_rt.is_none()
&& self.pending.is_empty()
&& captured.cursor.is_none();
let base_ordered =
self.stream_ordered && self.async_rt.is_none() && self.pending.is_empty();
// Cursor-bearing frames stay on the fast path when the blend itself can be stream-
// ordered: the Vulkan dispatch waits/advances a timeline semaphore CUDA also holds, so
// copy→blend→encode orders entirely on-device (`VkSlotBlend::blend_ref_ordered`). Where
// that isn't available (no timeline export, or the ring fell back to plain CUDA slots)
// a cursor forces the CPU-synced path: the blend's cross-API ordering is then fence/CPU-
// established, sitting between the copy and the encode. That slow path is why cursor
// frames USED to be gated out entirely — under gamescope the compositor re-attaches the
// live pointer to EVERY frame, and the per-frame CPU syncs (exposed to the running
// game's GPU load) capped a 120 fps session near 80 (submit p50 ~10 ms).
let cursor_ordered = base_ordered
&& captured.cursor.is_some()
&& matches!(self.ring[slot].surface, SlotSurface::Vk(_))
&& self.vk_blend.as_ref().is_some_and(|vk| vk.ordered_ready());
let ordered = base_ordered && (captured.cursor.is_none() || cursor_ordered);
let t0 = std::time::Instant::now();
// Copy the captured buffer into this slot's input surface before encoding it.
@@ -1629,29 +1828,45 @@ impl Encoder for NvencCudaEncoder {
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface via the
// SPIR-V compute pass (a dispatch over the cursor's rect — never the compositor's
// dmabuf). Cursor-bearing frames forced `ordered = false` above, so the CUDA copy has
// completed before the Vulkan dispatch and the fence-waited dispatch completes before
// the encode below — the cross-API ordering is CPU-established. Any failure degrades to
// no cursor, never a dropped frame.
// dmabuf). On the `cursor_ordered` path the enqueued copy, the dispatch, and the encode
// are ordered on-device through the timeline semaphore (no CPU sync — see the gate
// above). Otherwise `ordered` is false: the CUDA copy completed before the Vulkan
// dispatch and the fence-waited dispatch completes before the encode below — the
// cross-API ordering is CPU-established. Any failure degrades to no cursor, never a
// dropped frame (a failed ordered blend leaves the copy→encode stream ordering intact).
if let Some(ov) = &captured.cursor {
if let (Some(vk), SlotSurface::Vk(vref)) =
(self.vk_blend.as_mut(), &self.ring[slot].surface)
{
if self.cursor_serial != ov.serial {
// Quiesces any in-flight ordered blend internally before touching the
// staging buffer (bitmap changes are rare — shape flips).
vk.upload_cursor(ov.rgba.as_slice(), ov.w, ov.h);
self.cursor_serial = ov.serial;
}
// surfW = content width; the blend derives plane strides from the slot's luma
// height. Cursor pixels past the content land in cropped padding rows — harmless.
let r = vk.blend_ref(
vref,
slot_fmt_of(self.buffer_fmt),
self.width,
ov.w,
ov.h,
ov.x,
ov.y,
);
let r = if cursor_ordered {
vk.blend_ref_ordered(
vref,
slot_fmt_of(self.buffer_fmt),
self.width,
ov.w,
ov.h,
ov.x,
ov.y,
)
} else {
vk.blend_ref(
vref,
slot_fmt_of(self.buffer_fmt),
self.width,
ov.w,
ov.h,
ov.x,
ov.y,
)
};
if let Err(e) = r {
if !self.cursor_blend_warned {
self.cursor_blend_warned = true;
@@ -1686,7 +1901,9 @@ impl Encoder for NvencCudaEncoder {
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
// just filled by the device→device copy — either synchronized (blocking mode) or ordered
// before this encode by the session's IO-stream binding (`ordered` — same stream, see the
// gate above) — and is not overwritten until this slot is reused POOL submits later, by
// gate above; on the `cursor_ordered` path the blend's writes are likewise ordered before
// the encode, via the timeline-semaphore wait `blend_ref_ordered` enqueued on that same
// stream) — and is not overwritten until this slot is reused POOL submits later, by
// which time this encode was polled (POOL ≥ in-flight depth; in ordered mode the poll's
// blocking lock additionally proves the enqueued copy completed).
unsafe {
@@ -1850,7 +2067,6 @@ impl Encoder for NvencCudaEncoder {
// Composites `frame.cursor` via the SPIR-V blend over the Vulkan-allocated input slot.
blends_cursor: true,
supports_rfi: self.rfi_supported,
supports_hdr_metadata: self.hdr,
chroma_444: self.chroma_444,
intra_refresh: false,
intra_refresh_recovery: false,
@@ -2239,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).
@@ -2259,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() {
@@ -2333,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:
@@ -2674,6 +3122,85 @@ mod tests {
);
}
/// ON-HARDWARE (RTX box `.21`): cursor-bearing frames must KEEP the stream-ordered fast
/// path — the gamescope 80-fps-on-a-120-session fix. With the timeline-semaphore blend
/// available, `submit` takes `blend_ref_ordered` (the ticket advances by 2 per frame)
/// instead of the CPU-synced fence-wait blend, and AUs keep flowing — including across a
/// cursor-bitmap change (exercises the upload quiesce) and per-frame position moves.
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_cursor_blend_stream_ordered() {
const W: u32 = 1280;
const H: u32 = 720;
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
// Respect an explicit operator opt-out (or two-thread mode) rather than fail.
if !stream_ordered_requested() || async_retrieve_requested() {
println!("skipped: stream-ordered submit disabled by env");
return;
}
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
W,
H,
60,
8_000_000,
true,
8,
ChromaFormat::Yuv420,
true, // cursor_blend: bring up the Vulkan slot ring + 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 = nv12_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).
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 cursor frame");
while enc.poll().expect("poll").is_some() {
aus += 1;
}
}
assert_eq!(aus, 6, "every cursor frame must deliver an AU");
assert!(
enc.stream_ordered,
"IO-stream binding must arm on a default-env session"
);
let vk = enc
.vk_blend
.as_ref()
.expect("Vulkan slot blend must come up on an RTX box");
assert!(
vk.ordered_ready(),
"timeline semaphore must export to CUDA on this driver"
);
assert_eq!(
vk.ordered_ticket(),
12,
"all 6 cursor blends must take the ordered path (2 timeline values each)"
);
println!(
"nvenc_cuda cursor stream-ordered: 6 cursor AUs, ticket={}",
vk.ordered_ticket()
);
}
/// ON-HARDWARE (RTX box `.21`): the §7 LN3 pipelined-retrieve escalation —
/// `set_pipelined(true)` on a live sync session must rebuild it without the IO-stream
/// binding, spawn the retrieve thread on the re-open, and keep delivering AUs (the first
+28 -1
View File
@@ -21,6 +21,13 @@
//! on every AU. NOTE: until Phase 2 lands `CODEC_PYROWAVE` negotiation + a client decoder,
//! no shipping client can decode this — the backend is reachable only via an explicit
//! `PUNKTFUNK_ENCODER=pyrowave` and logs that loudly.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is `pyrowave-sys` C-API and ash/Vulkan compute calls almost line for line;
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every unsafe block in this module carries a `// SAFETY:` proof (parent module enforces it).
use super::vk_util::{
@@ -395,6 +402,9 @@ pub struct PyroWaveEncoder {
/// packet to it, so each wire shard carries whole self-delimiting packets. `None` =
/// one packet per AU (the dense MVP shape).
wire_chunk: Option<usize>,
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
/// WIRE, not just the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
wire_budget: crate::pyrowave_wire::WireBudget,
bitstream: Vec<u8>,
pending: VecDeque<EncodedFrame>,
frame_count: u64,
@@ -653,6 +663,7 @@ impl PyroWaveEncoder {
chroma444,
frame_budget: budget_for(bitrate, fps),
wire_chunk: None,
wire_budget: crate::pyrowave_wire::WireBudget::new(),
bitstream: Vec::new(),
pending: VecDeque::new(),
frame_count: 0,
@@ -1120,6 +1131,16 @@ impl PyroWaveEncoder {
Ok(self.cpu_img.unwrap().2)
}
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
/// a promise about the wire, not the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
fn rate_budget(&self) -> usize {
match self.wire_chunk {
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
None => self.frame_budget,
}
}
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
@@ -1175,6 +1196,8 @@ impl PyroWaveEncoder {
// paths propagate untouched and the recovery (`reset()`/`Drop`) `device_wait_idle()`s
// before anything touches `cmd`; a buffer that completed its one-time submit is INVALID,
// which the next `begin` may implicitly reset.
// Resolved before the closure (which borrows `self` mutably for the recording calls).
let rate_budget = self.rate_budget();
let record_and_submit = (|| -> Result<()> {
dev.begin_command_buffer(
self.cmd,
@@ -1396,7 +1419,7 @@ impl PyroWaveEncoder {
],
};
let rc = pw::pyrowave_rate_control {
maximum_bitstream_size: self.frame_budget,
maximum_bitstream_size: rate_budget,
};
pw::pyrowave_device_set_command_buffer(
self.pw_dev,
@@ -1476,6 +1499,10 @@ impl PyroWaveEncoder {
// single packet, or the datagram-aligned windowed AU (§4.4).
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
if self.wire_chunk.is_some() {
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
self.wire_budget.observe(raw, au.len());
}
self.frame_count += 1;
self.pending.push_back(EncodedFrame {
data: au,
@@ -0,0 +1,74 @@
#version 450
// The 10-bit HDR twin of `rgb2yuv.comp`: packed 2:10:10:10 RGB -> 10-bit 4:2:0 (BT.2020 NCL,
// limited range), for an HEVC Main10 session off a gamescope HDR capture.
//
// The source samples are ALREADY PQ-encoded BT.2020 R'G'B' (gamescope composites into the PQ
// container; see packaging/gamescope), so this is a pure 3x3 matrix on the code values — there is
// no transfer function to apply here, and applying one would be wrong. That is the whole
// difference from the SDR shader besides the coefficients and the store width.
//
// STORE LAYOUT. The scratch planes are `r16`/`rg16` and get `vkCmdCopyImage`d into the
// `G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture, whose plane texels put the 10-bit value in
// the HIGH bits of a 16-bit word (`X6` = 6 unused low bits). So a written normalized value `f`
// lands as `round(f * 65535)` and must equal `code10 << 6` — hence the `CODE10` factor below.
//
// What actually goes wrong if you change it: the `<< 6` PLACEMENT is the load-bearing part —
// dropping it (writing `code10 / 65535.0`) is 64x too dark, i.e. a black picture, which is at
// least obvious. Writing `code10 / 1023.0` instead is only ~0.1% high (65472 vs 65535 full
// scale) and is genuinely harmless; it is wrong, not dangerous. Verified by round-trip on RADV
// 2026-07-28: fed (160,160,800) 10-bit codes, decoded back (159,158,796).
//
// Rebuild: glslangValidator -V rgb2yuv10.comp -o rgb2yuv10.spv (vendored beside this file)
layout(local_size_x = 8, local_size_y = 8) in;
layout(binding = 0) uniform sampler2D rgb; // packed 2:10:10:10 input (sampled)
layout(binding = 1, r16) uniform writeonly image2D yImg; // full-res Y (10-bit in the high bits)
layout(binding = 2, rg16) uniform writeonly image2D uvImg; // half-res UV (interleaved)
layout(binding = 3) uniform sampler2D cursorTex; // straight-alpha RGBA cursor (top-left)
layout(push_constant) uniform Push {
ivec2 curOrigin; // top-left of the cursor in frame pixels (position - hotspot)
ivec2 curSize; // cursor w,h in pixels; x <= 0 => disabled
} pc;
// 10-bit code value -> the normalized float that stores it in the high bits of a 16-bit texel.
const float CODE10 = 64.0 / 65535.0;
// BT.2020 non-constant-luminance, limited range, expressed directly in 10-bit code values:
// Y' = 64 + 876*(0.2627R + 0.6780G + 0.0593B)
// Cb = 512 + 896*(B' - Y')/1.8814
// Cr = 512 + 896*(R' - Y')/1.4746
float lumaY(vec3 c) { return 64.0 + 230.1252*c.r + 593.9280*c.g + 51.9468*c.b; }
// Blend the cursor over `col` at frame pixel `p`, when `p` falls inside the cursor rectangle.
// The cursor bitmap is 8-bit sRGB and the frame is PQ, so this is a display-referred
// approximation — the same one the CPU path (`pw_cursor.rs::composite_cursor_rgb10`) and the
// CUDA path (`cursor_blend.comp` MODE 3/4) already make. Fine for a pointer.
vec3 withCursor(ivec2 p, vec3 col) {
if (pc.curSize.x <= 0) return col;
ivec2 cp = p - pc.curOrigin;
if (cp.x < 0 || cp.y < 0 || cp.x >= pc.curSize.x || cp.y >= pc.curSize.y) return col;
vec4 c = texelFetch(cursorTex, cp, 0);
return mix(col, c.rgb, c.a);
}
// Source may be SMALLER than the coded (16-aligned) Y plane — clamp every fetch to the source
// edge so the alignment-padding rows duplicate the last real row (see the SDR shader).
void main() {
ivec2 sz = imageSize(yImg);
ivec2 rmax = textureSize(rgb, 0) - 1;
ivec2 uvc = ivec2(gl_GlobalInvocationID.xy);
ivec2 p = uvc * 2;
if (p.x >= sz.x || p.y >= sz.y) return;
vec3 c00 = withCursor(p, texelFetch(rgb, min(p, rmax), 0).rgb);
vec3 c10 = withCursor(p + ivec2(1, 0), texelFetch(rgb, min(p + ivec2(1, 0), rmax), 0).rgb);
vec3 c01 = withCursor(p + ivec2(0, 1), texelFetch(rgb, min(p + ivec2(0, 1), rmax), 0).rgb);
vec3 c11 = withCursor(p + ivec2(1, 1), texelFetch(rgb, min(p + ivec2(1, 1), rmax), 0).rgb);
imageStore(yImg, p, vec4(lumaY(c00) * CODE10, 0, 0, 1));
imageStore(yImg, p + ivec2(1, 0), vec4(lumaY(c10) * CODE10, 0, 0, 1));
imageStore(yImg, p + ivec2(0, 1), vec4(lumaY(c01) * CODE10, 0, 0, 1));
imageStore(yImg, p + ivec2(1, 1), vec4(lumaY(c11) * CODE10, 0, 0, 1));
vec3 a = (c00 + c10 + c01 + c11) * 0.25;
float U = 512.0 - 125.1085*a.r - 322.8915*a.g + 448.0000*a.b;
float V = 512.0 + 448.0000*a.r - 411.9680*a.g - 36.0320*a.b;
imageStore(uvImg, uvc, vec4(U * CODE10, V * CODE10, 0, 1));
}
Binary file not shown.

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