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
30 changed files with 1241 additions and 362 deletions
+9 -1
View File
@@ -294,7 +294,15 @@ jobs:
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN" Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
} }
Push-Location web 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)" } & $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) { 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" throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
Generated
+1
View File
@@ -3051,6 +3051,7 @@ dependencies = [
"sha2", "sha2",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber",
"utoipa", "utoipa",
"wayland-backend", "wayland-backend",
"wayland-client", "wayland-client",
+6 -1
View File
@@ -5831,7 +5831,8 @@
"type": "object", "type": "object",
"description": "The host's physical monitors + which one capture is pinned to.", "description": "The host's physical monitors + which one capture is pinned to.",
"required": [ "required": [
"monitors" "monitors",
"pin_supported"
], ],
"properties": { "properties": {
"compositor": { "compositor": {
@@ -5855,6 +5856,10 @@
}, },
"description": "The heads, ordered left-to-right by desktop position." "description": "The heads, ordered left-to-right by desktop position."
}, },
"pin_supported": {
"type": "boolean",
"description": "Whether this build can actually STREAM one of these monitors.\n\nEnumeration and capture are separate capabilities, and on Windows only the first exists: the\nheads below are real and worth showing (they explain the topology, and `/display/state`\ncross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a\nframe channel pushed by our OWN IddCx virtual display. There is no desktop-duplication\ncapturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so\n`vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.\n\nThe console renders the picker read-only on `false`. Reported as a capability rather than\nsniffed client-side from the OS so the answer comes from the build that would have to honor\nit — when a Windows mirror backend lands, this flips and the UI needs no change."
},
"pinned": { "pinned": {
"type": [ "type": [
"string", "string",
+2
View File
@@ -239,6 +239,7 @@ fn connect_spawn(
let target = target.clone(); let target = target.clone();
// The closure owns `target`/`fp_hex`; the call itself borrows copies. // 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 (addr, port, fp_arg) = (target.addr.clone(), target.port, fp_hex.clone());
let profile_arg = target.profile.clone();
let spawned = crate::spawn::spawn_session( let spawned = crate::spawn::spawn_session(
&addr, &addr,
port, port,
@@ -246,6 +247,7 @@ fn connect_spawn(
opts.connect_timeout.as_secs(), opts.connect_timeout.as_secs(),
fullscreen, fullscreen,
opts.launch.as_deref(), opts.launch.as_deref(),
profile_arg.as_deref(),
child, child,
move |event| { move |event| {
use crate::spawn::SpawnEvent; use crate::spawn::SpawnEvent;
+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 /// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
/// that matter. /// that matter.
const MENU_EDIT: &str = "Edit\u{2026}"; 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}"; const MENU_FORGET: &str = "Forget\u{2026}";
/// Whether the console (gamepad) UI is available in this build: the session binary ships /// 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. /// 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 { 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(); let mut items: Vec<Element> = Vec::new();
if let Some(online) = online { if let Some(online) = online {
items.push( items.push(
@@ -183,6 +203,13 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
.vertical_alignment(VerticalAlignment::Center) .vertical_alignment(VerticalAlignment::Center)
.into(), .into(),
); );
if let Some((name, kind)) = profile {
items.push(
pill(name, kind)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
}
hstack(items) hstack(items)
.spacing(6.0) .spacing(6.0)
.margin(edges(0.0, 12.0, 0.0, 0.0)) .margin(edges(0.0, 12.0, 0.0, 0.0))
@@ -250,6 +277,43 @@ fn edit_editor(
se.call(None); 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>| { let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
vstack(( vstack((
text_block(label) text_block(label)
@@ -281,6 +345,18 @@ fn edit_editor(
"auto-filled when known", "auto-filled when known",
mac_draft, 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(( vstack((
ToggleSwitch::new(clip0) ToggleSwitch::new(clip0)
.header("Share clipboard with this host") .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() { if !known.hosts.is_empty() {
body.push(section("SAVED HOSTS")); body.push(section("SAVED HOSTS"));
let mut tiles: Vec<Element> = Vec::new(); 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 { for k in &known.hosts {
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly. // Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) { 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()), fp_hex: Some(k.fp_hex.clone()),
pair_optional: false, pair_optional: false,
mac: k.mac.clone(), mac: k.mac.clone(),
profile: None,
}; };
// Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter // 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 // 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 (svc, target) = (props.svc.clone(), target.clone());
let (sf, sr) = (set_forget.clone(), set_rename.clone()); let (sf, sr) = (set_forget.clone(), set_rename.clone());
let (fp, name) = (k.fp_hex.clone(), k.name.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("") button("")
.icon(Symbol::More) .icon(Symbol::More)
.subtle() .subtle()
@@ -532,6 +617,24 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.automation_name("More options") .automation_name("More options")
.menu_flyout({ .menu_flyout({
let mut items = vec![menu_item(MENU_CONNECT)]; 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 — // The library surfaces — mouse/KB page and the gamepad console UI —
// for paired hosts only (the mgmt API needs the paired identity); // for paired hosts only (the mgmt API needs the paired identity);
// the page additionally sits behind the experimental toggle, the // 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 items
}) })
.on_item_clicked(move |item: String| match item.as_str() { .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 => { MENU_CONNECT => {
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status) 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 (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let pinned_base = target.clone();
tiles.push(host_tile( tiles.push(host_tile(
&k.fp_hex, &k.fp_hex,
&hover, &hover,
&k.name, &k.name,
&format!("{}:{}", k.addr, k.port), &format!("{}:{}", k.addr, k.port),
status_row( status_row_with(
Some(online), Some(online),
if k.paired { "Paired" } else { "Trusted" }, if k.paired { "Paired" } else { "Trusted" },
if k.paired { Pill::Good } else { Pill::Info }, 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(menu),
Some(Box::new(move || { 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)); 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()), fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
pair_optional: h.pair == "optional", pair_optional: h.pair == "optional",
mac: h.mac.clone(), mac: h.mac.clone(),
profile: None,
}; };
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let (badge, kind) = if h.pair == "required" { 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, fp_hex: None,
pair_optional: false, pair_optional: false,
mac: Vec::new(), mac: Vec::new(),
profile: None,
}, },
&ss, &ss,
&st, &st,
+11
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 /// 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. /// magic packet before connecting to an offline host. Empty when none is known.
pub(crate) mac: Vec<String>, 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 /// Stable app services handed to the page components as props. Each routed screen that uses
@@ -250,6 +255,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
// The profile a Delete… click is asking about; `Some` renders the confirmation. Root state // 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). // 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); 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 // 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 // (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. // page's "Open console UI" hint; the compare in `call` makes the steady state free.
@@ -500,6 +509,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
&set_settings_scope, &set_settings_scope,
&settings_delete, &settings_delete,
&set_settings_delete, &set_settings_delete,
settings_rev,
&set_settings_rev,
nav_progress, nav_progress,
), ),
Screen::Licenses => licenses::licenses_page(&set_screen), Screen::Licenses => licenses::licenses_page(&set_screen),
+184 -18
View File
@@ -204,6 +204,56 @@ fn commit(ctx: &Arc<AppCtx>, scope: &str, edit: impl FnOnce(&mut Settings)) {
} }
} }
/// 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. /// The layer the settings screen is editing, resolved for display: `None` = the defaults.
fn active_profile(scope: &str) -> Option<StreamProfile> { fn active_profile(scope: &str) -> Option<StreamProfile> {
(!scope.is_empty()) (!scope.is_empty())
@@ -261,6 +311,52 @@ fn setting_toggle(
/// but a caption under it reads as a caption, which is how every Windows Settings page and /// 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 /// 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. /// 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 { fn described(control: impl Into<Element>, caption: &str) -> Element {
vstack(( vstack((
control.into(), control.into(),
@@ -329,6 +425,8 @@ pub(crate) fn settings_page(
set_scope: &AsyncSetState<String>, set_scope: &AsyncSetState<String>,
delete_pending: &Option<String>, delete_pending: &Option<String>,
set_delete: &AsyncSetState<Option<String>>, set_delete: &AsyncSetState<Option<String>>,
rev: u64,
set_rev: &AsyncSetState<u64>,
progress: f64, progress: f64,
) -> Element { ) -> Element {
// The layer being edited. A scope pointing at a deleted profile degrades to the defaults, // The layer being edited. A scope pointing at a deleted profile degrades to the defaults,
@@ -339,8 +437,12 @@ pub(crate) fn settings_page(
None => "", None => "",
}; };
let profile_mode = active.is_some(); let profile_mode = active.is_some();
// Every control shows the EFFECTIVE value: the global underneath with this profile's // Which rows this profile overrides — the marker + reset each of them carries. In the
// overrides on top, so a row the profile doesn't override reads as the live global. // 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 s = {
let base = ctx.settings.lock().unwrap().clone(); let base = ctx.settings.lock().unwrap().clone();
match &active { match &active {
@@ -602,14 +704,22 @@ pub(crate) fn settings_page(
let mut out = group( let mut out = group(
Some("Resolution"), Some("Resolution"),
vec![ vec![
described( described_overridable(
(rev, set_rev),
scope,
"resolution",
over.resolution,
res_combo, res_combo,
"The host drives a real virtual output at exactly this size \u{2014} true \ "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 \ 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 \ window is on; \u{201C}Match window\u{201D} keeps the picture pixel-exact \
(1:1) through every resize.", (1:1) through every resize.",
), ),
described( described_overridable(
(rev, set_rev),
scope,
"refresh_hz",
over.refresh_hz,
hz_combo, hz_combo,
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \ "\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
connect.", connect.",
@@ -620,24 +730,40 @@ pub(crate) fn settings_page(
out.extend(group( out.extend(group(
Some("Quality"), Some("Quality"),
vec![ vec![
described( described_overridable(
(rev, set_rev),
scope,
"render_scale",
over.render_scale,
scale_combo, scale_combo,
"Above native supersamples for sharpness; below renders lighter on the \ "Above native supersamples for sharpness; below renders lighter on the \
host and the link. This device resamples the result to the window.", 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, bitrate_box,
"0 lets the host decide (its default, clamped to what it supports). A \ "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.", host card\u{2019}s context menu has a network speed test.",
), ),
described( described_overridable(
(rev, set_rev),
scope,
"codec",
over.codec,
codec_combo, codec_combo,
"A preference \u{2014} the host falls back if it can\u{2019}t encode it. \ "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 \ 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 \ bitrate (hundreds of Mb/s) for near-zero decode time, so it wants \
gigabit Ethernet.", gigabit Ethernet.",
), ),
described( described_overridable(
(rev, set_rev),
scope,
"hdr_enabled",
over.hdr_enabled,
hdr_toggle, hdr_toggle,
"HDR10, when the host has HDR content and this display supports it. \ "HDR10, when the host has HDR content and this display supports it. \
HEVC only; otherwise the stream stays SDR.", HEVC only; otherwise the stream stays SDR.",
@@ -670,7 +796,11 @@ pub(crate) fn settings_page(
)); ));
out.extend(group( out.extend(group(
Some("Host output"), Some("Host output"),
vec![described( vec![described_overridable(
(rev, set_rev),
scope,
"compositor",
over.compositor,
comp_combo, comp_combo,
"The backend the host uses for its virtual output (Linux hosts only). A \ "The backend the host uses for its virtual output (Linux hosts only). A \
specific choice falls back to auto-detection when that backend \ specific choice falls back to auto-detection when that backend \
@@ -684,7 +814,11 @@ pub(crate) fn settings_page(
"input" => { "input" => {
let mut out = group( let mut out = group(
Some("Touch & pointer"), Some("Touch & pointer"),
vec![described( vec![described_overridable(
(rev, set_rev),
scope,
"touch_mode",
over.touch_mode,
touch_combo, touch_combo,
"How a touchscreen drives the host: Trackpad moves the host cursor like a \ "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 \ laptop trackpad (tap to click), Direct pointer jumps the cursor to wherever \
@@ -695,19 +829,31 @@ pub(crate) fn settings_page(
out.extend(group( out.extend(group(
Some("Keyboard & mouse"), Some("Keyboard & mouse"),
vec![ vec![
described( described_overridable(
(rev, set_rev),
scope,
"mouse_mode",
over.mouse_mode,
mouse_combo, mouse_combo,
"Capture locks the pointer to the stream and sends relative motion — \ "Capture locks the pointer to the stream and sends relative motion — \
best for games. Desktop leaves the pointer free to enter and leave \ best for games. Desktop leaves the pointer free to enter and leave \
the stream and sends absolute positions best for remote desktop \ the stream and sends absolute positions best for remote desktop \
work. Ctrl+Alt+Shift+M switches live.", work. Ctrl+Alt+Shift+M switches live.",
), ),
described( described_overridable(
(rev, set_rev),
scope,
"inhibit_shortcuts",
over.inhibit_shortcuts,
shortcuts_toggle, shortcuts_toggle,
"Alt+Tab, the Windows key and friends reach the host while the stream \ "Alt+Tab, the Windows key and friends reach the host while the stream \
has input captured. Off, they act on this machine instead.", 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, invert_scroll_toggle,
"Reverses the wheel and trackpad scroll direction sent to the host.", "Reverses the wheel and trackpad scroll direction sent to the host.",
), ),
@@ -732,7 +878,11 @@ pub(crate) fn settings_page(
one to force single-player \u{2014} only it reaches the host.", one to force single-player \u{2014} only it reaches the host.",
) )
}), }),
Some(described( Some(described_overridable(
(rev, set_rev),
scope,
"gamepad",
over.gamepad,
pad_combo, pad_combo,
"The virtual pad created on the host. Automatic matches your controller \ "The virtual pad created on the host. Automatic matches your controller \
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \ \u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
@@ -750,12 +900,20 @@ pub(crate) fn settings_page(
group( group(
None, None,
vec![ vec![
described( described_overridable(
(rev, set_rev),
scope,
"audio_channels",
over.audio_channels,
channels_combo, channels_combo,
"The speaker layout requested from the host. It downmixes if its own \ "The speaker layout requested from the host. It downmixes if its own \
output has fewer channels.", output has fewer channels.",
), ),
described( described_overridable(
(rev, set_rev),
scope,
"mic_enabled",
over.mic_enabled,
mic_toggle, mic_toggle,
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.", "This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
), ),
@@ -775,7 +933,11 @@ pub(crate) fn settings_page(
_ => { _ => {
let mut out = group( let mut out = group(
Some("Session"), Some("Session"),
vec![described( vec![described_overridable(
(rev, set_rev),
scope,
"fullscreen_on_stream",
over.fullscreen_on_stream,
fullscreen_toggle, fullscreen_toggle,
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \ "Go fullscreen when a session starts; F11 or Alt+Enter switches back \
live.", live.",
@@ -796,7 +958,11 @@ pub(crate) fn settings_page(
); );
out.extend(group( out.extend(group(
Some("Statistics"), Some("Statistics"),
vec![described( vec![described_overridable(
(rev, set_rev),
scope,
"stats_verbosity",
over.stats_verbosity,
hud_combo, hud_combo,
"Live session stats in a corner overlay \u{2014} Compact is a one-line pill, \ "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 \ Detailed adds the latency stage breakdown. Ctrl+Alt+Shift+S cycles the \
+48 -9
View File
@@ -5,6 +5,8 @@
use super::style::*; use super::style::*;
use super::{Screen, Svc}; use super::{Screen, Svc};
use crate::probe::run_speed_probe; use crate::probe::run_speed_probe;
use crate::trust::KnownHosts;
use pf_client_core::profiles::ProfilesFile;
use windows_reactor::*; use windows_reactor::*;
/// Speed-test lifecycle. Held as ROOT state (the probe worker completes it via /// 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, recommended_kbps,
} => { } => {
let recommended_mbps = f64::from(*recommended_kbps) / 1000.0; 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 apply_btn = {
let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps); let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps);
button(format!("Use {recommended_mbps:.0} Mb/s")) let profile = profile.clone();
.accent() button(match &profile {
.icon(Symbol::Accept) Some(p) => format!(
.on_click(move || { "Set {recommended_mbps:.0} Mb/s in \u{201c}{}\u{201d}",
let mut s = ctx.settings.lock().unwrap(); p.name
s.bitrate_kbps = kbps; ),
s.save(); None => format!("Use {recommended_mbps:.0} Mb/s"),
ss.call(Screen::Hosts); })
}) .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( let results = card(
vstack(( vstack((
+6
View File
@@ -101,6 +101,7 @@ pub(crate) fn spawn_session(
connect_timeout_secs: u64, connect_timeout_secs: u64,
fullscreen: bool, fullscreen: bool,
launch: Option<&str>, launch: Option<&str>,
profile: Option<&str>,
slot: SessionChild, slot: SessionChild,
on_event: impl FnMut(SpawnEvent) + Send + 'static, on_event: impl FnMut(SpawnEvent) + Send + 'static,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -117,6 +118,11 @@ pub(crate) fn spawn_session(
if let Some(id) = launch { if let Some(id) = launch {
cmd.arg("--launch").arg(id); 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); add_window_pos(&mut cmd);
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event) spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
} }
+16 -11
View File
@@ -387,11 +387,20 @@ pub(crate) struct HdrP010Converter {
cbuf: ID3D11Buffer, cbuf: ID3D11Buffer,
} }
// The three converters' methods below are SAFE fns. They were `unsafe fn` because their bodies are
// D3D11 FFI, which is not the same thing as having a caller contract: every parameter is a borrowed
// windows-rs COM wrapper (`&ID3D11Device`, `&ID3D11DeviceContext`, `&ID3D11Texture2D`, the views) or
// a plain `u32`/`bool`, each body builds its own descriptors from those, and every created interface
// owns its reference. There is nothing a caller can pass that makes them unsound, and the proofs the
// markers carried said exactly that — "`?`-checked D3D11 methods on the live `device` borrow" — which
// is a description of the body, not an obligation. `unsafe` now marks the FFI inside them, where the
// blocks and their proofs already were. `compile_shader` KEEPS its marker: it takes `PCSTR`, a raw
// pointer the caller must guarantee is a NUL-terminated literal.
impl HdrP010Converter { impl HdrP010Converter {
/// `w`/`h` are the SOURCE dimensions this converter's chroma pass will sample, baked into the /// `w`/`h` are the SOURCE dimensions this converter's chroma pass will sample, baked into the
/// immutable constant buffer. Rebuild the converter if they change (the IDD capturer's /// immutable constant buffer. Rebuild the converter if they change (the IDD capturer's
/// `recreate_ring` already drops it). /// `recreate_ring` already drops it).
pub(crate) unsafe fn new(device: &ID3D11Device, w: u32, h: u32) -> Result<Self> { pub(crate) fn new(device: &ID3D11Device, w: u32, h: u32) -> Result<Self> {
// SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over // SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over
// fully-initialized stack descriptors and live `Option` out-params; `compile_shader` receives // fully-initialized stack descriptors and live `Option` out-params; `compile_shader` receives
// `s!()` literals (its contract). Each created COM interface owns its own reference, and no // `s!()` literals (its contract). Each created COM interface owns its own reference, and no
@@ -458,7 +467,7 @@ impl HdrP010Converter {
/// Called ONCE PER OUT-RING SLOT by the owner of the P010 textures, not per frame — see /// Called ONCE PER OUT-RING SLOT by the owner of the P010 textures, not per frame — see
/// [`Self::convert`]. Fails when the driver rejects a planar RTV, which is the one hard /// [`Self::convert`]. Fails when the driver rejects a planar RTV, which is the one hard
/// requirement of this whole path (a D3D11.3+ runtime plus driver support). /// requirement of this whole path (a D3D11.3+ runtime plus driver support).
pub(crate) unsafe fn plane_rtv( pub(crate) fn plane_rtv(
device: &ID3D11Device, device: &ID3D11Device,
dst: &ID3D11Texture2D, dst: &ID3D11Texture2D,
format: DXGI_FORMAT, format: DXGI_FORMAT,
@@ -498,7 +507,7 @@ impl HdrP010Converter {
/// ring slot's keyed-mutex hold, i.e. time the DRIVER spent blocked on that slot. Both are /// ring slot's keyed-mutex hold, i.e. time the DRIVER spent blocked on that slot. Both are
/// lifetime-of-mode facts: the views belong to the out-ring slot (built in `ensure_out_ring`), /// lifetime-of-mode facts: the views belong to the out-ring slot (built in `ensure_out_ring`),
/// the buffer to this converter, which is already rebuilt on every mode change. /// the buffer to this converter, which is already rebuilt on every mode change.
pub(crate) unsafe fn convert( pub(crate) fn convert(
&self, &self,
ctx: &ID3D11DeviceContext, ctx: &ID3D11DeviceContext,
src_srv: &ID3D11ShaderResourceView, src_srv: &ID3D11ShaderResourceView,
@@ -693,7 +702,7 @@ pub(crate) struct BgraToYuvPlanes {
} }
impl BgraToYuvPlanes { impl BgraToYuvPlanes {
pub(crate) unsafe fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> { pub(crate) fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
// SAFETY: as `HdrP010Converter::new` — `?`-checked D3D11 shader creation on the live // SAFETY: as `HdrP010Converter::new` — `?`-checked D3D11 shader creation on the live
// `device` borrow, with `s!()` literals into `compile_shader` and live out-params. // `device` borrow, with `s!()` literals into `compile_shader` and live out-params.
unsafe { unsafe {
@@ -731,7 +740,7 @@ impl BgraToYuvPlanes {
/// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque /// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque
/// passes; `w`/`h` are the full luma dims (even for 4:2:0). /// passes; `w`/`h` are the full luma dims (even for 4:2:0).
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) unsafe fn convert( pub(crate) fn convert(
&self, &self,
ctx: &ID3D11DeviceContext, ctx: &ID3D11DeviceContext,
src_srv: &ID3D11ShaderResourceView, src_srv: &ID3D11ShaderResourceView,
@@ -828,7 +837,7 @@ impl VideoConverter {
/// linear (the HDR ring, used by a PyroWave session that tone-maps the HDR desktop down to the /// linear (the HDR ring, used by a PyroWave session that tone-maps the HDR desktop down to the
/// 8-bit wavelet stream). The output is always studio-range BT.709 NV12 — the P010/BT.2020 HDR /// 8-bit wavelet stream). The output is always studio-range BT.709 NV12 — the P010/BT.2020 HDR
/// path is [`HdrP010Converter`]'s job, never this one. /// path is [`HdrP010Converter`]'s job, never this one.
pub(crate) unsafe fn new( pub(crate) fn new(
device: &ID3D11Device, device: &ID3D11Device,
context: &ID3D11DeviceContext, context: &ID3D11DeviceContext,
width: u32, width: u32,
@@ -897,11 +906,7 @@ impl VideoConverter {
/// Convert `input` (BGRA, or scRGB FP16 for a converter built with `scrgb_input`) → `output` /// Convert `input` (BGRA, or scRGB FP16 for a converter built with `scrgb_input`) → `output`
/// (NV12, BT.709 studio-range — see the type doc: never P010) on the video engine. Views are /// (NV12, BT.709 studio-range — see the type doc: never P010) on the video engine. Views are
/// created per call (cheap relative to the Blt) so the input texture can vary frame to frame. /// created per call (cheap relative to the Blt) so the input texture can vary frame to frame.
pub(crate) unsafe fn convert( pub(crate) fn convert(&self, input: &ID3D11Texture2D, output: &ID3D11Texture2D) -> Result<()> {
&self,
input: &ID3D11Texture2D,
output: &ID3D11Texture2D,
) -> Result<()> {
// SAFETY: both view creations are `?`-checked calls on `self.vdev` with fully-initialized // SAFETY: both view creations are `?`-checked calls on `self.vdev` with fully-initialized
// stack descriptors and live out-params. `stream.pInputSurface` is a `ManuallyDrop` of the // stack descriptors and live out-params. `stream.pInputSurface` is a `ManuallyDrop` of the
// input view just created: `VideoProcessorBlt` only BORROWS it (a COM in-param never transfers // input view just created: `VideoProcessorBlt` only BORROWS it (a COM in-param never transfers
+18 -22
View File
@@ -648,11 +648,7 @@ impl IddPushCapturer {
// nothing downstream could detect the mismatch, because every field it would compare against // nothing downstream could detect the mismatch, because every field it would compare against
// had already been moved. Nothing below this line fails. // had already been moved. Nothing below this line fails.
let fmt = Self::ring_format_for(new_display_hdr); let fmt = Self::ring_format_for(new_display_hdr);
// SAFETY: `create_ring_slots` is an `unsafe fn` (it makes D3D11/DXGI COM calls); we pass a live let new_slots = Self::create_ring_slots(&self.device, new_w, new_h, fmt)?;
// borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain
// `u32`/`DXGI_FORMAT` values, and `?` propagates any failure before the slots are used. Every
// returned slot's texture + keyed mutex belongs to that same `self.device`.
let new_slots = unsafe { Self::create_ring_slots(&self.device, new_w, new_h, fmt)? };
self.display_hdr = new_display_hdr; self.display_hdr = new_display_hdr;
self.width = new_w; self.width = new_w;
self.height = new_h; self.height = new_h;
@@ -969,11 +965,11 @@ impl IddPushCapturer {
/// mid-session mode swap. /// mid-session mode swap.
fn ensure_pyro_conv(&mut self) -> Result<()> { fn ensure_pyro_conv(&mut self) -> Result<()> {
if self.pyro_conv.is_none() { if self.pyro_conv.is_none() {
// SAFETY: `BgraToYuvPlanes::new` compiles D3D11 shaders on `self.device`; `?` propagates self.pyro_conv = Some(BgraToYuvPlanes::new(
// failure before it is stored. &self.device,
self.pyro_conv = Some(unsafe { self.display_hdr,
BgraToYuvPlanes::new(&self.device, self.display_hdr, self.want_444)? self.want_444,
}); )?);
} }
Ok(()) Ok(())
} }
@@ -984,22 +980,22 @@ impl IddPushCapturer {
fn ensure_converter(&mut self) -> Result<()> { fn ensure_converter(&mut self) -> Result<()> {
if self.display_hdr { if self.display_hdr {
if self.hdr_p010_conv.is_none() { if self.hdr_p010_conv.is_none() {
// SAFETY: `HdrP010Converter::new` is `unsafe` (it compiles D3D11 shaders + creates self.hdr_p010_conv = Some(HdrP010Converter::new(
// resources); we pass a live borrow of `self.device`, the device the converter's resources &self.device,
// belong to, and `?` propagates any failure before the converter is stored. self.width,
self.hdr_p010_conv = self.height,
Some(unsafe { HdrP010Converter::new(&self.device, self.width, self.height)? }); )?);
} }
} else if self.want_444 { } else if self.want_444 {
// Full-chroma passthrough — no conversion resources to build. // Full-chroma passthrough — no conversion resources to build.
} else if self.video_conv.is_none() { } else if self.video_conv.is_none() {
// SAFETY: `VideoConverter::new` is `unsafe` (it sets up the D3D11 VIDEO processor); we pass live self.video_conv = Some(VideoConverter::new(
// borrows of `self.device` + its immediate `self.context` (single-threaded, this thread) plus &self.device,
// plain `u32` dimensions, and `?` propagates any failure before it is stored. The converter's &self.context,
// resources belong to that same device/context. self.width,
self.video_conv = Some(unsafe { self.height,
VideoConverter::new(&self.device, &self.context, self.width, self.height, false)? false,
}); )?);
} }
Ok(()) Ok(())
} }
@@ -56,7 +56,7 @@ pub(super) struct CursorBlendPass {
} }
impl CursorBlendPass { impl CursorBlendPass {
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> { pub(super) fn new(device: &ID3D11Device) -> Result<Self> {
// SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over // SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over
// fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()` // fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
// literals (its contract). // literals (its contract).
@@ -116,11 +116,7 @@ impl CursorBlendPass {
} }
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise. /// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
unsafe fn ensure_shape( fn ensure_shape(&mut self, device: &ID3D11Device, ov: &pf_frame::CursorOverlay) -> Result<()> {
&mut self,
device: &ID3D11Device,
ov: &pf_frame::CursorOverlay,
) -> Result<()> {
// SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live // SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live
// `device` borrow. `init.pSysMem` points into `ov.rgba`, which the length check above proves // `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 // holds at least `ov.w * ov.h * 4` bytes for the declared `SysMemPitch = ov.w * 4`, and which
@@ -170,7 +166,7 @@ impl CursorBlendPass {
/// composition) — linearize and scale to the target's SDR white. The quad is placed purely /// 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 /// via the viewport (the fullscreen-triangle VS fills whatever viewport is set), clipped by
/// the target automatically. /// the target automatically.
pub(super) unsafe fn blend( pub(super) fn blend(
&mut self, &mut self,
device: &ID3D11Device, device: &ID3D11Device,
ctx: &ID3D11DeviceContext, ctx: &ID3D11DeviceContext,
@@ -85,7 +85,7 @@ impl IddPushCapturer {
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an /// 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 /// 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. /// the duplicate the [`ChannelBroker`] sends after the ring is published.
pub(super) unsafe fn create_ring_slots( pub(super) fn create_ring_slots(
device: &ID3D11Device, device: &ID3D11Device,
w: u32, w: u32,
h: u32, h: u32,
@@ -137,9 +137,10 @@ impl Capturer for SyntheticNv12Capturer {
/// Resolve the same render adapter the encoder will pick (`PUNKTFUNK_RENDER_ADAPTER` / preference / /// Resolve the same render adapter the encoder will pick (`PUNKTFUNK_RENDER_ADAPTER` / preference /
/// max-VRAM LUID), falling back to adapter 0. /// max-VRAM LUID), falling back to adapter 0.
/// ///
/// # Safety /// Safe: it takes no arguments, so there is nothing a caller could get wrong — it creates the
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error. /// factory itself and returns an owned adapter. The `# Safety` section it used to carry ("calls
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> { /// 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 // 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. // created here and the adapters it returns own their own COM references. No raw pointers.
unsafe { unsafe {
@@ -155,9 +156,10 @@ unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags. /// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
/// ///
/// # Safety /// Safe: its old `# Safety` asked for "a live D3D11 device", which `&ID3D11Device` — a borrowed,
/// `device` must be a live D3D11 device; the returned texture is owned by the caller. /// reference-counted COM wrapper — already guarantees; the rest ("the returned texture is owned by
unsafe fn create_nv12( /// the caller") is an ownership note, not a soundness obligation. Every flag is a plain scalar.
fn create_nv12(
device: &ID3D11Device, device: &ID3D11Device,
width: u32, width: u32,
height: u32, height: u32,
@@ -181,8 +183,8 @@ unsafe fn create_nv12(
..Default::default() ..Default::default()
}; };
let mut tex: Option<ID3D11Texture2D> = None; let mut tex: Option<ID3D11Texture2D> = None;
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow (per the contract // SAFETY: one `?`-checked `CreateTexture2D` on the `&ID3D11Device` borrow, which the borrow
// above), with a fully-initialized stack descriptor and a live `Option` out-param. // itself keeps live, with a fully-initialized stack descriptor and a live `Option` out-param.
unsafe { unsafe {
device device
.CreateTexture2D(&desc, None, Some(&mut tex)) .CreateTexture2D(&desc, None, Some(&mut tex))
+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)] #[cfg(windows)]
mod os { mod os {
//! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and //! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and
+1 -1
View File
@@ -610,7 +610,7 @@ impl D3d11vaDecoder {
/// BGRA8) under its keyed mutex and describe the hand-off. The mutex acquire also /// 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 /// back-pressures against the presenter still reading this slot (only possible if the
/// stream runs `RING_SLOTS` ahead of present). /// stream runs `RING_SLOTS` ahead of present).
unsafe fn lift(&mut self) -> Result<D3d11Frame> { fn lift(&mut self) -> Result<D3d11Frame> {
use ffmpeg::ffi; use ffmpeg::ffi;
unsafe { unsafe {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 { if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
+1 -1
View File
@@ -139,7 +139,7 @@ impl VaapiDecoder {
/// single-plane texture with the chroma dropped, painting the screen green. The fix: /// 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 → /// 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). /// `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; use ffmpeg::ffi;
unsafe { unsafe {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 { if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
+27 -13
View File
@@ -1,10 +1,4 @@
//! FFmpeg Vulkan Video decode over the presenter's own VkDevice (zero-copy VkImage). //! FFmpeg Vulkan Video decode over the presenter's own VkDevice (zero-copy VkImage).
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw libav + ash calls on the presenter's VkDevice almost line for line;
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
#![allow(clippy::unnecessary_cast)] #![allow(clippy::unnecessary_cast)]
use crate::video::{ use crate::video::{
@@ -67,25 +61,45 @@ struct VkCtxStorage {
/// [`VkCtxStorage`], which outlives the context). Replaces FFmpeg's internal default, /// [`VkCtxStorage`], which outlives the context). Replaces FFmpeg's internal default,
/// which only serializes FFmpeg against itself — the presenter submits to the same /// 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. /// 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( unsafe extern "C" fn ffvk_lock_queue(
ctx: *mut pf_ffvk::AVHWDeviceContext, ctx: *mut pf_ffvk::AVHWDeviceContext,
_queue_family: u32, _queue_family: u32,
_index: u32, _index: u32,
) { ) {
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext; // SAFETY: `ctx` is the live context FFmpeg passes to its own callback, and the two
let lock = (*dev).user_opaque as *const QueueLock; // `AVHWDeviceContext` declarations (pf_ffvk's and ffmpeg-sys's) describe the same C struct, so
(*lock).lock(); // 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`]. /// 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( unsafe extern "C" fn ffvk_unlock_queue(
ctx: *mut pf_ffvk::AVHWDeviceContext, ctx: *mut pf_ffvk::AVHWDeviceContext,
_queue_family: u32, _queue_family: u32,
_index: u32, _index: u32,
) { ) {
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext; // SAFETY: as `ffvk_lock_queue` — same live context from FFmpeg, same `user_opaque` pointer into
let lock = (*dev).user_opaque as *const QueueLock; // the `Arc<QueueLock>` that `VkCtxStorage` keeps alive for the context's whole lifetime.
(*lock).unlock(); unsafe {
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
let lock = (*dev).user_opaque as *const QueueLock;
(*lock).unlock();
}
} }
impl VulkanDecoder { impl VulkanDecoder {
@@ -306,7 +320,7 @@ impl VulkanDecoder {
/// guard — keeps the image + frames context alive through present) and ship the /// 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 /// POINTERS; the presenter reads the live sync state under the frames-context lock
/// at its own submit time. /// at its own submit time.
unsafe fn extract(&mut self) -> Result<VkVideoFrame> { fn extract(&mut self) -> Result<VkVideoFrame> {
use ffmpeg::ffi; use ffmpeg::ffi;
unsafe { unsafe {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VULKAN as i32 { if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VULKAN as i32 {
+42 -29
View File
@@ -8,12 +8,6 @@
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math). //! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on //! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs. //! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) hwcontext calls almost line for line;
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
@@ -84,30 +78,49 @@ impl CudaHw {
unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> { unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> {
// Each `?`/`bail!` below drops whatever has been built so far — `AvBuffer`'s `Drop` is the // 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. // single unref path, so the failure branches carry no cleanup of their own.
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
))
.context("av_hwdevice_ctx_alloc(CUDA) failed")?;
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
let cu = (*dev_ctx).hwctx as *mut AVCUDADeviceContext;
(*cu).cuda_ctx = cu_ctx; // share the importer's context
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
if r < 0 {
bail!("av_hwdevice_ctx_init failed ({r})");
}
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr())) // SAFETY: `av_hwdevice_ctx_alloc` returns either null — which `AvBuffer::from_raw` rejects,
.context("av_hwframe_ctx_alloc failed")?; // so the `?` returns before anything below runs — or a fresh ref whose `data` libav has
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext; // already initialized as an `AVHWDeviceContext`. For a CUDA device that context's `hwctx`
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_CUDA; // is an `AVCUDADeviceContext` (our repr(C) mirror of libav's layout), so writing
(*fc).sw_format = pixel_to_av(sw_format); // `cuda_ctx` is an in-bounds field store on a live allocation, and `cu_ctx` is a valid
(*fc).width = w as c_int; // `CUcontext` by this fn's contract. `av_hwdevice_ctx_init` then takes the same live ref;
(*fc).height = h as c_int; // it must see `cuda_ctx` already set, which is why the store precedes it.
(*fc).initial_pool_size = 0; // we supply the device pointers let device_ref = unsafe {
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr()); let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
if r < 0 { ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
bail!("av_hwframe_ctx_init failed ({r})"); ))
} .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 { Ok(CudaHw {
frames_ref, frames_ref,
device_ref, device_ref,
+81 -58
View File
@@ -19,13 +19,6 @@
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`), //! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened //! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR. //! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) calls on borrowed AVFrame/AVBuffer
// pointers almost line for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per
// call that could only restate the signature. Clearing this file means DELETING the markers that
// carry no caller contract, not wrapping the calls — until then the lint is off HERE and enforced
// everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
@@ -164,7 +157,7 @@ struct Vui {
} }
/// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010 the /// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010 the
/// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020 on the zero-copy /// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020nc on the zero-copy
/// path); the client decoder auto-detects PQ from the VUI. SDR: we hand the encoder BT.709 /// path); the client decoder auto-detects PQ from the VUI. SDR: we hand the encoder BT.709
/// *limited* NV12 (swscale CSC on the CPU path; `scale_vaapi` pinned to /// *limited* NV12 (swscale CSC on the CPU path; `scale_vaapi` pinned to
/// `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range RGB input /// `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range RGB input
@@ -211,7 +204,7 @@ fn async_depth(raw: Option<&str>) -> u32 {
/// matrix untouched.) /// matrix untouched.)
fn scale_vaapi_args(ten_bit: bool) -> &'static CStr { fn scale_vaapi_args(ten_bit: bool) -> &'static CStr {
if ten_bit { if ten_bit {
c"format=p010:out_color_matrix=bt2020:out_range=limited" c"format=p010:out_color_matrix=bt2020nc:out_range=limited"
} else { } else {
c"format=nv12:out_color_matrix=bt709:out_range=limited" c"format=nv12:out_color_matrix=bt709:out_range=limited"
} }
@@ -275,17 +268,23 @@ unsafe fn open_vaapi_encoder(
let modes: &[bool] = entrypoint_ladder(low_power_override(), cached); let modes: &[bool] = entrypoint_ladder(low_power_override(), cached);
let mut first_err = None; let mut first_err = None;
for &lp in modes { for &lp in modes {
match open_vaapi_encoder_mode( // SAFETY: `device_ref`/`frames_ref` are forwarded unchanged from this fn's own contract —
codec, // valid `AVBufferRef`s — and the callee only `av_buffer_ref`s them, so retrying another
width, // entrypoint below reuses the same still-owned buffers rather than consuming them.
height, let attempt = unsafe {
fps, open_vaapi_encoder_mode(
bitrate_bps, codec,
device_ref, width,
frames_ref, height,
ten_bit, fps,
lp, bitrate_bps,
) { device_ref,
frames_ref,
ten_bit,
lp,
)
};
match attempt {
Ok(enc) => { Ok(enc) => {
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() { if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
m.insert(key.clone(), latched_mode(lp)); m.insert(key.clone(), latched_mode(lp));
@@ -343,16 +342,24 @@ unsafe fn open_vaapi_encoder_mode(
video.set_format(if ten_bit { Pixel::P010LE } else { Pixel::NV12 }); video.set_format(if ten_bit { Pixel::P010LE } else { Pixel::NV12 });
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract. // Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
apply_low_latency_rc(&mut video, fps, bitrate_bps); apply_low_latency_rc(&mut video, fps, bitrate_bps);
let raw = video.as_mut_ptr(); // SAFETY: `as_mut_ptr` hands back the `AVCodecContext` behind the `video` encoder allocated
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI) // just above, which outlives every write here (it is moved into the return value). The
let vui = vui_for(ten_bit); // colour/gop/pix_fmt stores are in-bounds scalar field writes on that live context. The two
(*raw).colorspace = vui.colorspace; // `av_buffer_ref` calls take `device_ref`/`frames_ref`, valid `AVBufferRef`s by this fn's
(*raw).color_range = vui.range; // contract, and each returns a NEW reference that the codec context adopts and unrefs when it
(*raw).color_primaries = vui.primaries; // is freed — so this shares the caller's buffers rather than taking them over.
(*raw).color_trc = vui.trc; unsafe {
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI; let raw = video.as_mut_ptr();
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref); (*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref); let vui = vui_for(ten_bit);
(*raw).colorspace = vui.colorspace;
(*raw).color_range = vui.range;
(*raw).color_primaries = vui.primaries;
(*raw).color_trc = vui.trc;
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
}
let mut opts = Dictionary::new(); let mut opts = Dictionary::new();
if let Some(profile) = explicit_profile(codec, ten_bit) { if let Some(profile) = explicit_profile(codec, ten_bit) {
@@ -479,35 +486,52 @@ struct VaapiHw {
} }
impl VaapiHw { impl VaapiHw {
unsafe fn new(sw_format: ffi::AVPixelFormat, w: u32, h: u32, pool: c_int) -> Result<Self> { /// Safe: unlike its CUDA twin ([`super::CudaHw::new`], which is handed a `CUcontext` the caller
/// must vouch for) this takes only scalars and opens the device itself, so there is no caller
/// contract — the `unsafe` below is the libav FFI, not an obligation.
fn new(sw_format: ffi::AVPixelFormat, w: u32, h: u32, pool: c_int) -> Result<Self> {
let mut device_ref: *mut ffi::AVBufferRef = ptr::null_mut(); let mut device_ref: *mut ffi::AVBufferRef = ptr::null_mut();
let node = render_node(); let node = render_node();
let r = ffi::av_hwdevice_ctx_create( // SAFETY: `device_ref` is a live local out-param; `node` is a NUL-terminated `CString` that
&mut device_ref, // outlives the call, and the remaining arguments are the documented "no options" pair. On
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI, // success libav writes ONE owned reference into `device_ref`.
node.as_ptr(), let r = unsafe {
ptr::null_mut(), ffi::av_hwdevice_ctx_create(
0, &mut device_ref,
); ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
node.as_ptr(),
ptr::null_mut(),
0,
)
};
if r < 0 { if r < 0 {
bail!("no VAAPI device ({:?}): {}", node, ffmpeg::Error::from(r)); bail!("no VAAPI device ({:?}): {}", node, ffmpeg::Error::from(r));
} }
// `av_hwdevice_ctx_create` wrote an owned ref into `device_ref`; take ownership of it here so // `av_hwdevice_ctx_create` wrote an owned ref into `device_ref`; take ownership of it here so
// every `bail!` below drops it (and the frames ref, once built) without cleanup of its own. // every `bail!` below drops it (and the frames ref, once built) without cleanup of its own.
let device_ref = AvBuffer::from_raw(device_ref) // SAFETY: `r >= 0`, so `device_ref` is that owned reference and this is its only owner.
let device_ref = unsafe { AvBuffer::from_raw(device_ref) }
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?; .context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr())) // SAFETY: the same shape as `CudaHw::new` — `av_hwframe_ctx_alloc` is handed the live device
.context("av_hwframe_ctx_alloc(VAAPI) failed")?; // ref and returns null (rejected by `from_raw`, so the `?` leaves before the writes below)
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext; // or a ref whose `data` libav has already initialized as an `AVHWFramesContext`. Every store
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI; // is then an in-bounds scalar write on that live allocation, done before
(*fc).sw_format = sw_format; // `av_hwframe_ctx_init` reads them.
(*fc).width = w as c_int; let frames_ref = unsafe {
(*fc).height = h as c_int; let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
(*fc).initial_pool_size = pool; .context("av_hwframe_ctx_alloc(VAAPI) failed")?;
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr()); let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
if r < 0 { (*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
bail!("av_hwframe_ctx_init(VAAPI) failed ({r})"); (*fc).sw_format = sw_format;
} (*fc).width = w as c_int;
(*fc).height = h as c_int;
(*fc).initial_pool_size = pool;
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
if r < 0 {
bail!("av_hwframe_ctx_init(VAAPI) failed ({r})");
}
frames_ref
};
Ok(VaapiHw { Ok(VaapiHw {
frames_ref, frames_ref,
device_ref, device_ref,
@@ -548,12 +572,11 @@ impl CpuInner {
ffi::AVPixelFormat::AV_PIX_FMT_NV12 ffi::AVPixelFormat::AV_PIX_FMT_NV12
}; };
const POOL: c_int = 16; const POOL: c_int = 16;
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the // `VaapiHw::new` is safe now; it returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s
// only path here is `VaapiEncoder::open` → `ensure_inner` → `CpuInner::open`, and `open` ran // on drop. (libav is initialized on every path here `VaapiEncoder::open` → `ensure_inner`
// `ffmpeg::init()`. The args are valid: an NV12/P010 sw_format, the validated positive // → `CpuInner::open`, and `open` ran `ffmpeg::init()` — which the call relies on but cannot
// `width`/`height`, pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s // itself be broken by a caller, so it is a note rather than a contract.)
// on drop. let hw = VaapiHw::new(staging_av, width, height, POOL)?;
let hw = unsafe { VaapiHw::new(staging_av, width, height, POOL)? };
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both // SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
// non-null (`VaapiHw::new` guarantees it) and from the `hw` just built above, which is a live // non-null (`VaapiHw::new` guarantees it) and from the `hw` just built above, which is a live
// local that outlives this synchronous call. The fn `av_buffer_ref`s them into the encoder, so // local that outlives this synchronous call. The fn `av_buffer_ref`s them into the encoder, so
@@ -1542,7 +1565,7 @@ mod tests {
let args = scale_vaapi_args(true).to_str().unwrap(); let args = scale_vaapi_args(true).to_str().unwrap();
for needle in [ for needle in [
"format=p010", "format=p010",
"out_color_matrix=bt2020", "out_color_matrix=bt2020nc",
"out_range=limited", "out_range=limited",
] { ] {
assert!( assert!(
+189 -124
View File
@@ -37,13 +37,6 @@
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The //! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't //! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`. //! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) calls on borrowed hwcontext pointers
// almost line for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call
// that could only restate the signature. Clearing this file means DELETING the markers that carry
// no caller contract, not wrapping the calls — until then the lint is off HERE and enforced
// everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
@@ -337,29 +330,40 @@ unsafe fn open_win_encoder(
video.set_format(Pixel::from(sw_pix_fmt)); video.set_format(Pixel::from(sw_pix_fmt));
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract. // Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
apply_low_latency_rc(&mut video, fps, bitrate_bps); apply_low_latency_rc(&mut video, fps, bitrate_bps);
let raw = video.as_mut_ptr(); // SAFETY: `as_mut_ptr` hands back the `AVCodecContext` behind the `video` encoder allocated just
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI) // above, which outlives every write here (it is opened and returned below). The gop/colour/
if ten_bit { // pix_fmt stores are in-bounds scalar field writes on that live context. `device_ref` and
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from // `frames_ref` are valid `AVBufferRef`s by this fn's contract OR null — the system path passes
// the HEVC VUI; the static mastering metadata also rides the 0xCE datagram out-of-band. // null for both — and the `is_null` guards keep `av_buffer_ref` off the null case; each call
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL; // returns a NEW reference that the codec context adopts and unrefs when freed, so this shares
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG; // the caller's buffers rather than taking them over.
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020; let raw = unsafe { video.as_mut_ptr() };
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084; // SAFETY: as above — `raw` is that live `AVCodecContext` and every store below is an in-bounds
} else { // field write on it, with the two `av_buffer_ref` calls guarded against null.
// We hand the encoder BT.709 *limited* NV12 (video-processor or swscale CSC), so signal that unsafe {
// VUI — else the client decoder washes the picture out. (*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709; if ten_bit {
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG; // 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709; // the HEVC VUI; the static mastering metadata also rides the 0xCE datagram out-of-band.
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709; (*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
} (*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).pix_fmt = pix_fmt; (*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
if !device_ref.is_null() { (*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref); } else {
} // We hand the encoder BT.709 *limited* NV12 (video-processor or swscale CSC), so signal that
if !frames_ref.is_null() { // VUI — else the client decoder washes the picture out.
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref); (*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
}
(*raw).pix_fmt = pix_fmt;
if !device_ref.is_null() {
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
}
if !frames_ref.is_null() {
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
}
} }
// Low-latency tuning — the per-vendor contract lives in `vendor_opts` (pure, test-pinned). // Low-latency tuning — the per-vendor contract lives in `vendor_opts` (pure, test-pinned).
@@ -434,12 +438,19 @@ pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
} }
/// The immediate context of an `ID3D11Device` (for `CopyResource`/`CopySubresourceRegion`). /// The immediate context of an `ID3D11Device` (for `CopyResource`/`CopySubresourceRegion`).
unsafe fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext { ///
/// Safe: `&ID3D11Device` is a borrowed, reference-counted COM wrapper, so the borrow itself is the
/// "live device" guarantee, and the returned context owns its own reference.
fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
// windows-rs 0.62: the inherent method takes no args and returns the context (the OutRef form is // windows-rs 0.62: the inherent method takes no args and returns the context (the OutRef form is
// only on the `_Impl` trait, for implementing the interface). Every D3D11 device has one. // only on the `_Impl` trait, for implementing the interface). Every D3D11 device has one.
device // SAFETY: a `?`-free COM call on the live `device` borrow; it takes no pointers and every
.GetImmediateContext() // D3D11 device has an immediate context, so the `expect` is unreachable in practice.
.expect("ID3D11Device always has an immediate context") unsafe {
device
.GetImmediateContext()
.expect("ID3D11Device always has an immediate context")
}
} }
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
@@ -538,11 +549,10 @@ impl SystemInner {
} }
/// Lazily (re)build the staging texture matching `dxgi_fmt` on the captured device. /// Lazily (re)build the staging texture matching `dxgi_fmt` on the captured device.
unsafe fn ensure_staging( ///
&mut self, /// Safe: `&ID3D11Device` is the live-device guarantee and `dxgi_fmt` is a plain enum; the
device: &ID3D11Device, /// texture it creates is owned by `self`.
dxgi_fmt: DXGI_FORMAT, fn ensure_staging(&mut self, device: &ID3D11Device, dxgi_fmt: DXGI_FORMAT) -> Result<()> {
) -> Result<()> {
if self.staging.is_some() { if self.staging.is_some() {
return Ok(()); return Ok(());
} }
@@ -562,25 +572,38 @@ impl SystemInner {
MiscFlags: 0, MiscFlags: 0,
}; };
let mut t: Option<ID3D11Texture2D> = None; let mut t: Option<ID3D11Texture2D> = None;
device // SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow, over a
.CreateTexture2D(&desc, None, Some(&mut t)) // fully-initialized stack descriptor and a live `Option` out-param.
.context("CreateTexture2D(staging readback)")?; unsafe {
device
.CreateTexture2D(&desc, None, Some(&mut t))
.context("CreateTexture2D(staging readback)")?;
}
self.staging = t; self.staging = t;
self.ctx = Some(immediate_context(device)); self.ctx = Some(immediate_context(device));
Ok(()) Ok(())
} }
/// Send the reusable `sw_frame` to the encoder with the given pts / IDR flag. /// Send the reusable `sw_frame` to the encoder with the given pts / IDR flag.
unsafe fn send(&mut self, pts: i64, idr: bool) -> Result<()> { ///
(*self.sw_frame).pts = pts; /// Safe: both arguments are scalars, and `sw_frame`/`enc` are allocations `self` owns from its
(*self.sw_frame).pict_type = if idr { /// constructor until `Drop` — no caller supplies or can invalidate them.
ffi::AVPictureType::AV_PICTURE_TYPE_I fn send(&mut self, pts: i64, idr: bool) -> Result<()> {
} else { // SAFETY: `self.sw_frame` is the `AVFrame` this struct allocated and owns, so the two field
ffi::AVPictureType::AV_PICTURE_TYPE_NONE // stores are in-bounds writes on a live allocation; `avcodec_send_frame` then takes that
}; // frame and `self.enc`'s own context, both live for the call and neither retained by libav
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame); // (it references the frame's buffers itself).
if r < 0 { unsafe {
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win"); (*self.sw_frame).pts = pts;
(*self.sw_frame).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame);
if r < 0 {
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
}
} }
Ok(()) Ok(())
} }
@@ -807,7 +830,10 @@ impl SystemInner {
/// Lazily build the swscale context (src → NV12/P010, limited range, the given colorspace). A /// Lazily build the swscale context (src → NV12/P010, limited range, the given colorspace). A
/// SystemInner uses exactly one src→dst conversion for its lifetime (8-bit RGB→NV12 BT.709, or /// SystemInner uses exactly one src→dst conversion for its lifetime (8-bit RGB→NV12 BT.709, or
/// 10-bit RGB10→P010 BT.2020), so caching a single context is sound. /// 10-bit RGB10→P010 BT.2020), so caching a single context is sound.
unsafe fn ensure_sws( ///
/// Safe: every argument is a plain libav enum/int, and the context it caches belongs to `self`
/// (freed once in `Drop`).
fn ensure_sws(
&mut self, &mut self,
src_av: ffi::AVPixelFormat, src_av: ffi::AVPixelFormat,
dst_av: ffi::AVPixelFormat, dst_av: ffi::AVPixelFormat,
@@ -816,25 +842,33 @@ impl SystemInner {
if !self.sws.is_null() { if !self.sws.is_null() {
return Ok(()); return Ok(());
} }
let sws = ffi::sws_getContext( // SAFETY: `sws_getContext` takes only scalars plus the documented "no filters, no params"
self.width as c_int, // null trio, and returns an owned context or null — which is checked before use, so
self.height as c_int, // `sws_setColorspaceDetails` and the store below only ever see a live one.
src_av, // `sws_getCoefficients` returns a pointer into libav's own static tables, valid for the
self.width as c_int, // process, and the call only reads it.
self.height as c_int, let sws = unsafe {
dst_av, let sws = ffi::sws_getContext(
SWS_POINT, self.width as c_int,
ptr::null_mut(), self.height as c_int,
ptr::null_mut(), src_av,
ptr::null(), self.width as c_int,
); self.height as c_int,
if sws.is_null() { dst_av,
bail!("sws_getContext(RGB→YUV) failed"); SWS_POINT,
} ptr::null_mut(),
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI we ptr::null_mut(),
// signal). For RGB input the src coefficient table is unused; pass the dst table for both. ptr::null(),
let coeff = ffi::sws_getCoefficients(cs); );
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16); if sws.is_null() {
bail!("sws_getContext(RGB→YUV) failed");
}
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI
// we signal). For RGB input the src coefficient table is unused; pass dst for both.
let coeff = ffi::sws_getCoefficients(cs);
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
sws
};
self.sws = sws; self.sws = sws;
Ok(()) Ok(())
} }
@@ -873,7 +907,10 @@ struct D3d11Hw {
impl D3d11Hw { impl D3d11Hw {
/// Wrap the capturer's `ID3D11Device` as a D3D11VA hwdevice and build an NV12/P010 frames pool. /// Wrap the capturer's `ID3D11Device` as a D3D11VA hwdevice and build an NV12/P010 frames pool.
unsafe fn new( /// Safe: like [`super::super::linux::VaapiHw::new`] and unlike its CUDA counterpart, this is
/// handed no raw pointer — `&ID3D11Device` is a borrowed, reference-counted COM wrapper and the
/// rest are scalars — so there is no caller contract; the `unsafe` below is the libav/D3D11 FFI.
fn new(
device: &ID3D11Device, device: &ID3D11Device,
sw_format: ffi::AVPixelFormat, sw_format: ffi::AVPixelFormat,
bind_flags: u32, bind_flags: u32,
@@ -883,12 +920,19 @@ impl D3d11Hw {
) -> Result<Self> { ) -> Result<Self> {
// Owned from the moment it exists: each `bail!` below drops what was built so far, so none // Owned from the moment it exists: each `bail!` below drops what was built so far, so none
// of the failure branches carry cleanup of their own. // of the failure branches carry cleanup of their own.
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc( // SAFETY: `av_hwdevice_ctx_alloc` returns null — rejected by `AvBuffer::from_raw`, so the
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA, // `?` leaves before anything below runs — or a ref whose `data` libav has already
)) // initialized as an `AVHWDeviceContext`; for a D3D11VA device that context's `hwctx` is an
.context("av_hwdevice_ctx_alloc(D3D11VA) failed")?; // `AVD3D11VADeviceContext`, so `d11` addresses a live, correctly-typed struct.
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext; let (device_ref, d11) = unsafe {
let d11 = (*dev_ctx).hwctx as *mut AVD3D11VADeviceContext; let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA,
))
.context("av_hwdevice_ctx_alloc(D3D11VA) failed")?;
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
let d11 = (*dev_ctx).hwctx as *mut AVD3D11VADeviceContext;
(device_ref, d11)
};
// Turn on D3D11 multithread protection before libav sees the device. // Turn on D3D11 multithread protection before libav sees the device.
// //
@@ -913,7 +957,9 @@ impl D3d11Hw {
// device's internal critical section (`was` is the previous state, reported once at debug). // device's internal critical section (`was` is the previous state, reported once at debug).
match device.cast::<ID3D11Multithread>() { match device.cast::<ID3D11Multithread>() {
Ok(mt) => { Ok(mt) => {
let was = mt.SetMultithreadProtected(true); // SAFETY: a COM call on the live `ID3D11Multithread` just obtained by a checked
// `cast` of the borrowed device; it takes a BOOL and returns the previous state.
let was = unsafe { mt.SetMultithreadProtected(true) };
tracing::debug!( tracing::debug!(
previously_protected = was.as_bool(), previously_protected = was.as_bool(),
"D3D11 multithread protection enabled for the libav hwdevice" "D3D11 multithread protection enabled for the libav hwdevice"
@@ -931,26 +977,40 @@ impl D3d11Hw {
// reference (clone = AddRef, forget = don't Release ours). init() fills // reference (clone = AddRef, forget = don't Release ours). init() fills
// device_context / video_device / video_context / the default lock from a non-null device. // device_context / video_device / video_context / the default lock from a non-null device.
std::mem::forget(device.clone()); std::mem::forget(device.clone());
(*d11).device = device.as_raw(); // SAFETY: `d11` is the live `AVD3D11VADeviceContext` from above, so storing the device
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr()); // pointer is an in-bounds field write; the `forget(clone())` on the line above is what
// makes that pointer an OWNED reference, matching the Release libav does at teardown.
// `av_hwdevice_ctx_init` then reads that field, which is why the store precedes it.
let r = unsafe {
(*d11).device = device.as_raw();
ffi::av_hwdevice_ctx_init(device_ref.as_ptr())
};
if r < 0 { if r < 0 {
bail!("av_hwdevice_ctx_init(D3D11VA) failed ({r})"); bail!("av_hwdevice_ctx_init(D3D11VA) failed ({r})");
} }
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr())) // SAFETY: same shape one level up — `av_hwframe_ctx_alloc` takes the live, now-initialized
.context("av_hwframe_ctx_alloc(D3D11VA) failed")?; // device ref and returns null (rejected by `from_raw`, so the `?` leaves before the writes)
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext; // or a ref whose `data` is a live `AVHWFramesContext` whose `hwctx` is an
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_D3D11; // `AVD3D11VAFramesContext`. Every store is an in-bounds field write on those, all done
(*fc).sw_format = sw_format; // before `av_hwframe_ctx_init` reads them.
(*fc).width = w as c_int; let frames_ref = unsafe {
(*fc).height = h as c_int; let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
(*fc).initial_pool_size = pool; .context("av_hwframe_ctx_alloc(D3D11VA) failed")?;
let f11 = (*fc).hwctx as *mut AVD3D11VAFramesContext; let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
(*f11).bind_flags = bind_flags; (*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_D3D11;
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr()); (*fc).sw_format = sw_format;
if r < 0 { (*fc).width = w as c_int;
bail!("av_hwframe_ctx_init(D3D11VA) failed ({r})"); (*fc).height = h as c_int;
} (*fc).initial_pool_size = pool;
let f11 = (*fc).hwctx as *mut AVD3D11VAFramesContext;
(*f11).bind_flags = bind_flags;
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
if r < 0 {
bail!("av_hwframe_ctx_init(D3D11VA) failed ({r})");
}
frames_ref
};
Ok(D3d11Hw { Ok(D3d11Hw {
frames_ref, frames_ref,
device_ref, device_ref,
@@ -1494,20 +1554,25 @@ mod tests {
/// and "Microsoft Basic Render Driver" (vendor 0x1414) is the software rasterizer, which has no /// and "Microsoft Basic Render Driver" (vendor 0x1414) is the software rasterizer, which has no
/// video engine at all. /// video engine at all.
/// ///
/// # Safety /// Safe: `prefer_vendor` is a plain id and every DXGI object is created here and owned by the
/// Calls the DXGI enumeration FFI and `make_device`; every result is checked before use and no /// returned device; the old `# Safety` section described the body, not a caller obligation.
/// alias to the adapter outlives the call.
#[cfg(test)] #[cfg(test)]
unsafe fn test_hw_device(prefer_vendor: u32) -> Option<ID3D11Device> { fn test_hw_device(prefer_vendor: u32) -> Option<ID3D11Device> {
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1}; use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?; // SAFETY: DXGI factory/adapter enumeration over owned locals — the factory is created here,
let mut preferred = None; // each adapter it yields owns its own COM reference, and every call is `.ok()`-checked
let mut fallback = None; // before use. `GetDesc1` fills a fully-initialized stack descriptor.
let (factory, mut preferred, mut fallback): (IDXGIFactory1, _, _) =
(unsafe { CreateDXGIFactory1() }.ok()?, None, None);
for i in 0.. { for i in 0.. {
let Ok(adapter) = factory.EnumAdapters1(i) else { // SAFETY: a COM call on the live `factory` created above; it takes an index and
// yields an owned adapter, and the `Ok` binding is what proves one came back.
let Ok(adapter) = (unsafe { factory.EnumAdapters1(i) }) else {
break; // DXGI_ERROR_NOT_FOUND — end of the list break; // DXGI_ERROR_NOT_FOUND — end of the list
}; };
let Ok(desc) = adapter.GetDesc1() else { // SAFETY: a COM call on the adapter just enumerated, filling a fully-initialized
// stack descriptor it returns by value.
let Ok(desc) = (unsafe { adapter.GetDesc1() }) else {
continue; continue;
}; };
let name = String::from_utf16_lossy(&desc.Description) let name = String::from_utf16_lossy(&desc.Description)
@@ -1521,7 +1586,11 @@ mod tests {
} }
} }
let adapter = preferred.or(fallback)?; let adapter = preferred.or(fallback)?;
pf_frame::dxgi::make_device(&adapter).ok().map(|(d, _c)| d) // SAFETY: `make_device` requires a live `IDXGIAdapter1`; `adapter` is one of the adapters
// enumerated above, still owned here and borrowed only for this synchronous call.
unsafe { pf_frame::dxgi::make_device(&adapter) }
.ok()
.map(|(d, _c)| d)
} }
/// Construct/drop `D3d11Hw` repeatedly on real silicon — the D3D11VA half of the RAII change, /// Construct/drop `D3d11Hw` repeatedly on real silicon — the D3D11VA half of the RAII change,
@@ -1537,23 +1606,20 @@ mod tests {
#[test] #[test]
#[ignore = "needs a real D3D11 GPU (run on a GPU host, not the build box)"] #[ignore = "needs a real D3D11 GPU (run on a GPU host, not the build box)"]
fn d3d11hw_alloc_drop_cycles() { fn d3d11hw_alloc_drop_cycles() {
// SAFETY: see `test_hw_device`; the returned device outlives every `D3d11Hw` built below. let device = test_hw_device(0x8086).expect("a hardware D3D11 adapter");
let device = unsafe { test_hw_device(0x8086) }.expect("a hardware D3D11 adapter");
for i in 0..8 { for i in 0..8 {
// SAFETY: `D3d11Hw::new` needs libav initialised (it is, statically, by the ffmpeg-next // `D3d11Hw::new` is safe now; it still needs libav initialised, which the ffmpeg-next
// crate's first use here) and a live `ID3D11Device`, which `device` is for the whole // crate does statically on first use here. NV12 at 640x480 with an 8-surface pool are
// loop. NV12 at 640x480 with an 8-surface pool are valid pool parameters. The handle // valid pool parameters. The handle drops at the end of each iteration — that release
// drops at the end of each iteration — that release is what is under test. // is what is under test.
let hw = unsafe { let hw = D3d11Hw::new(
D3d11Hw::new( &device,
&device, ffi::AVPixelFormat::AV_PIX_FMT_NV12,
ffi::AVPixelFormat::AV_PIX_FMT_NV12, pool_bind_flags(WinVendor::Amf),
pool_bind_flags(WinVendor::Amf), 640,
640, 480,
480, 8,
8, )
)
}
.unwrap_or_else(|e| panic!("D3d11Hw::new failed on iteration {i}: {e:#}")); .unwrap_or_else(|e| panic!("D3d11Hw::new failed on iteration {i}: {e:#}"));
assert!(!hw.device_ref.as_ptr().is_null(), "device ref went null"); assert!(!hw.device_ref.as_ptr().is_null(), "device ref went null");
assert!(!hw.frames_ref.as_ptr().is_null(), "frames ref went null"); assert!(!hw.frames_ref.as_ptr().is_null(), "frames ref went null");
@@ -1587,8 +1653,7 @@ mod tests {
#[test] #[test]
#[ignore = "needs a real Intel QSV device (run on an Intel host, not the build box)"] #[ignore = "needs a real Intel QSV device (run on an Intel host, not the build box)"]
fn zerocopy_qsv_alloc_drop_cycles() { fn zerocopy_qsv_alloc_drop_cycles() {
// SAFETY: see `test_hw_device`; the device outlives every `ZeroCopyInner` built below. let device = test_hw_device(0x8086).expect("an Intel D3D11 adapter");
let device = unsafe { test_hw_device(0x8086) }.expect("an Intel D3D11 adapter");
for i in 0..8 { for i in 0..8 {
let zc = ZeroCopyInner::open( let zc = ZeroCopyInner::open(
WinVendor::Qsv, WinVendor::Qsv,
+8
View File
@@ -33,6 +33,14 @@ utoipa = { version = "5", features = ["axum_extras"] }
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
[dev-dependencies]
# The `#[ignore]`d on-glass cases drive the manager/backend through code paths whose only account of
# what they chose is `tracing`. A bare test harness installs no subscriber, so those runs were blind:
# `live_a_failed_first_isolate_is_recovered_by_adopting_the_next` could see the panel stay dark but
# not whether the adoption arm fired, or whether the dark-desk backstop ran and failed. Dev-only, so
# the shipped host's dependency closure through this crate is unchanged.
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2" libc = "0.2"
# The Mutter backend drives D-Bus RemoteDesktop + ScreenCast.RecordVirtual via ashpd on a tokio # The Mutter backend drives D-Bus RemoteDesktop + ScreenCast.RecordVirtual via ashpd on a tokio
+41 -25
View File
@@ -268,33 +268,49 @@ pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box /// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read. /// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
pub fn detect() -> Result<Compositor> { pub fn detect() -> Result<Compositor> {
if let Some(v) = pf_host_config::config().compositor.as_deref() { // Compositor detection is a Linux question — the variants ARE the Linux backends. Asked
return compositor_from_pin(v).ok_or_else(|| { // anywhere else this used to fall through to the XDG sniff below and fail with advice about
anyhow::anyhow!( // `XDG_CURRENT_DESKTOP` and `PUNKTFUNK_COMPOSITOR`, which `mgmt/display.rs` puts VERBATIM into
"unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)" // the `/display/monitors` response — so on a Windows host the console's only explanation for an
) // empty monitor picker was Linux troubleshooting (sweep §13.17). The operator pin is gated with
}); // it: naming a Wayland compositor on Windows cannot be honoured either.
#[cfg(not(target_os = "linux"))]
{
anyhow::bail!(
"compositor detection is Linux-only; on {} the host enumerates displays through the OS \
display API instead (`vdisplay::monitors::list_windows`)",
std::env::consts::OS
)
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if let Some(c) = compositor_for_kind(detect_active_session().kind) { {
return Ok(c); if let Some(v) = pf_host_config::config().compositor.as_deref() {
} return compositor_from_pin(v).ok_or_else(|| {
let desktop = std::env::var("XDG_CURRENT_DESKTOP") anyhow::anyhow!(
.unwrap_or_default() "unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
.to_ascii_uppercase(); )
if desktop.contains("KDE") { });
Ok(Compositor::Kwin) }
} else if desktop.contains("GNOME") { if let Some(c) = compositor_for_kind(detect_active_session().kind) {
Ok(Compositor::Mutter) return Ok(c);
} else if desktop.contains("HYPRLAND") { }
Ok(Compositor::Hyprland) let desktop = std::env::var("XDG_CURRENT_DESKTOP")
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") { .unwrap_or_default()
Ok(Compositor::Wlroots) .to_ascii_uppercase();
} else { if desktop.contains("KDE") {
anyhow::bail!( Ok(Compositor::Kwin)
"could not detect compositor: no live graphical session for this uid and \ } else if desktop.contains("GNOME") {
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR" Ok(Compositor::Mutter)
) } else if desktop.contains("HYPRLAND") {
Ok(Compositor::Hyprland)
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
Ok(Compositor::Wlroots)
} else {
anyhow::bail!(
"could not detect compositor: no live graphical session for this uid and \
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
)
}
} }
} }
@@ -111,6 +111,62 @@ pub fn list(compositor: Compositor) -> Result<Vec<PhysicalMonitor>> {
} }
} }
/// Every head Windows reports — the non-compositor counterpart to [`list`].
///
/// Windows has no compositor to ask, so this reads the same CCD database the rest of the Windows
/// backend drives and reports what it finds. Until this existed the mgmt API answered
/// `/display/monitors` on Windows with an empty list and a LINUX error string (`detect()` fell
/// through to an `XDG_CURRENT_DESKTOP` sniff), so the console could neither show the operator's
/// screen nor honestly say why.
///
/// INACTIVE heads are listed too, with zeroed geometry and `enabled: false` — the same contract
/// [`list`] documents, so "why can't I pick it?" still has an answer.
///
/// Two fields cannot mean here what they mean on Linux, and are reported honestly rather than
/// invented:
/// * `scale` is always `1.0`. Windows scaling is per-monitor DPI applied by each application, not
/// a compositor-global logical scale, so there is no factor that would make these coordinates
/// "logical" the way the module doc means. The geometry below is therefore PIXELS.
/// * `refresh_mhz` comes from the path's own rational rate, which keeps 59.94 distinct from 60.
#[cfg(windows)]
pub fn list_windows() -> Result<Vec<PhysicalMonitor>> {
let inv = pf_win_display::win_display::target_inventory();
if inv.is_empty() {
// Distinguish "reached it, nothing there" from a failure, exactly as [`list`] promises:
// an empty CCD database is a real state (every panel off — measured on .173 with the TV
// powered down), not an error.
return Ok(Vec::new());
}
Ok(inv
.into_iter()
.map(|t| {
// The GDI name is what an operator recognises and what capture pins on; an inactive
// path has none, so fall back to the stable target id rather than an empty string —
// `resolve` matches on this, and a blank id can never be pinned.
let connector = if t.gdi_name.is_empty() {
format!("target-{}", t.target_id)
} else {
t.gdi_name
};
PhysicalMonitor {
description: describe("", &t.friendly, &connector),
connector,
width: t.width,
height: t.height,
refresh_mhz: t.refresh_mhz,
x: t.x,
y: t.y,
scale: 1.0,
primary: t.primary,
enabled: t.active,
// Unlike the Linux backends, Windows CAN say this reliably: our IddCx monitors
// carry our own EDID manufacturer id in their device path.
managed: t.ours,
}
})
.collect())
}
/// Resolve a configured monitor name against `monitors`, exactly then case-insensitively. /// Resolve a configured monitor name against `monitors`, exactly then case-insensitively.
/// ///
/// **A miss is a hard error carrying the available names**, never a silent fall-back to some other /// **A miss is a hard error carrying the available names**, never a silent fall-back to some other
@@ -960,6 +960,32 @@ mod tests {
} }
} }
/// Run `f` on a worker thread and give up after `budget`, so a HANG fails the case instead of
/// wedging the box.
///
/// Earned the hard way: this file's 3.2 case hung inside `create`, and killing the harness
/// skipped every `Drop`, leaking an IddCx monitor. A few of those exhaust the driver's slot
/// pool, after which every later run wedges too and only a reboot clears it. A bounded wait
/// lets the harness exit NORMALLY, which is what lets the driver reap the session.
fn within<T: Send + 'static>(
budget: Duration,
what: &str,
f: impl FnOnce() -> T + Send + 'static,
) -> T {
let (tx, rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let _ = tx.send(f());
});
match rx.recv_timeout(budget) {
Ok(v) => v,
Err(_) => panic!(
"{what} did not finish within {budget:?} — failing rather than hanging, so the \
harness can exit and the driver can reap. Check for a leaked punktfunk monitor \
before the next run."
),
}
}
/// §5 3.2 on glass: when the FIRST member's isolate fails, a later member's isolate must be /// §5 3.2 on glass: when the FIRST member's isolate fails, a later member's isolate must be
/// ADOPTED as the group's restore snapshot — otherwise it deactivates the operator's panels /// ADOPTED as the group's restore snapshot — otherwise it deactivates the operator's panels
/// with nothing able to put them back. /// with nothing able to put them back.
@@ -984,6 +1010,10 @@ mod tests {
#[test] #[test]
#[ignore = "needs the pf-vdisplay driver on real hardware; run with --ignored"] #[ignore = "needs the pf-vdisplay driver on real hardware; run with --ignored"]
fn live_a_failed_first_isolate_is_recovered_by_adopting_the_next() { fn live_a_failed_first_isolate_is_recovered_by_adopting_the_next() {
// Without this the run is BLIND: the adoption arm and the dark-desk backstop announce
// themselves only through `tracing`, and a bare test harness has no subscriber. The first
// on-glass run could see the panel stay dark but not say WHICH link broke.
init_test_tracing();
assert!( assert!(
std::env::var("PUNKTFUNK_NO_ISOLATE").is_err(), std::env::var("PUNKTFUNK_NO_ISOLATE").is_err(),
"PUNKTFUNK_NO_ISOLATE forces Topology::Extend — this case needs Exclusive" "PUNKTFUNK_NO_ISOLATE forces Topology::Extend — this case needs Exclusive"
@@ -1010,10 +1040,19 @@ mod tests {
}) })
.expect("create member 1"); .expect("create member 1");
thread::sleep(Duration::from_secs(2)); thread::sleep(Duration::from_secs(2));
// ⭐ THE MEASUREMENT THAT SEPARATES THE TWO CANDIDATES. Member 1's isolate was injected to
// fail, so nothing of OURS deactivated anything here. If the operator's panel is ALREADY
// dark at this point, the arriving IddCx monitor took the desktop on its own — and every
// snapshot taken from here on records "panel off", so member 2's adopted snapshot is
// POISONED AT BIRTH and restoring it faithfully restores darkness. If the panel is still
// lit here, poisoning is excluded and the failure is downstream (adoption never fired, or
// the restore/backstop did and could not re-light it).
let physicals_after_m1 = active_physicals();
println!( println!(
"after member 1 (isolate INJECTED to fail): {:?}", "after member 1 (isolate INJECTED to fail): {:?}",
active_targets() active_targets()
); );
println!("physicals after member 1 : {physicals_after_m1:?} <- poisoned-at-birth probe");
let mut vd2 = PfVdisplayDisplay::new().expect("open pf-vdisplay (member 2)"); let mut vd2 = PfVdisplayDisplay::new().expect("open pf-vdisplay (member 2)");
vd2.set_client_identity(Some([0xB2; 32])); vd2.set_client_identity(Some([0xB2; 32]));
@@ -1049,11 +1088,54 @@ mod tests {
println!("physicals after teardown : {physicals_after:?}"); println!("physicals after teardown : {physicals_after:?}");
assert!( assert!(
!physicals_after.is_empty(), !physicals_after.is_empty(),
"the operator's physical panel was left DEACTIVATED after teardown. The first \ "the operator's physical panel was left DEACTIVATED after teardown (sweep §5 3.2). \
member's isolate failed, so the group held no restore snapshot; the second member's \ Active targets now: {:?}.\n\
isolate deactivated the physicals and its snapshot was discarded (sweep §5 3.2). \ Which candidate this run implicates read it off the poisoned-at-birth probe above:\n\
Active targets now: {:?}", * physicals after member 1 was EMPTY ({m1_empty}) -> the snapshot member 2 adopted was \
active_targets() already poisoned: the panel went dark at member 1's create (IddCx auto-activation), so \
the adopted topology records 'panel off' and restoring it faithfully restores darkness. \
Adoption is working; the SNAPSHOT SOURCE is the defect.\n\
* physicals after member 1 was NON-empty -> poisoning is excluded; the break is \
downstream. Check the trace for 'adopting this member's' (the adoption arm) and for \
'no external physical display active after the restore' (the dark-desk backstop). A \
missing adoption line means teardown's restore was never gated on; a backstop line \
followed by a non-zero force-EXTEND rc means the remedy itself failed.",
active_targets(),
m1_empty = physicals_after_m1.is_empty()
);
}
/// What `/display/monitors` will now answer on Windows — the operator's real screens.
///
/// Read-only, so it is safe against a live host. Before `monitors::list_windows` existed this
/// endpoint returned an empty list plus a LINUX error string on every Windows box (`detect()`
/// fell through to an `XDG_CURRENT_DESKTOP` sniff), so the console could show no physical
/// screen and could not honestly say why.
#[test]
#[ignore = "hardware: reads the live display topology"]
fn live_windows_monitor_enumeration_reports_the_physical_screens() {
let ms = crate::monitors::list_windows().expect("list_windows");
for m in &ms {
println!(
"connector={:<14} enabled={:<5} managed={:<5} primary={:<5} {:>5}x{:<5} @{:>3}Hz \
pos=({},{}) {:?}",
m.connector,
m.enabled,
m.managed,
m.primary,
m.width,
m.height,
m.refresh_mhz / 1000,
m.x,
m.y,
m.description
);
}
assert!(!ms.is_empty(), "no monitors enumerated at all");
// The point of the change: a real, non-managed head is visible to the console.
assert!(
ms.iter().any(|m| !m.managed),
"every enumerated head is one of OURS — the operator's physical screen is still missing"
); );
} }
@@ -1070,6 +1152,27 @@ mod tests {
.collect() .collect()
} }
/// Surface the manager/backend `tracing` output on stdout for a live case.
///
/// These on-glass cases drive decision points — the isolate ladder, the snapshot-adoption arm,
/// `restore_displays_ccd`'s dark-desk backstop — whose ONLY account of what they chose is a
/// `tracing` event. A bare `cargo test` harness installs no subscriber, so those events go
/// nowhere and a failing run cannot say which link broke; that is exactly what left §5 3.2's
/// two candidates undistinguished after the first on-glass run.
///
/// `with_test_writer` routes through the harness's capture, so the output appears under
/// `--nocapture` (and on failure) rather than racing `println!`. Idempotent and non-fatal: the
/// global default can only be set once per process, and several live cases may run in one
/// binary, so a second call is a no-op rather than a panic that would fail an unrelated test.
/// `RUST_LOG` still wins when set; the default is `debug` for our own crates, which is where
/// the ladder's reasoning lives.
fn init_test_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("pf_vdisplay=debug,pf_win_display=debug"));
let _ = fmt().with_env_filter(filter).with_test_writer().try_init();
}
/// The active targets that are EXTERNAL PHYSICAL panels — the operator's actual desk. /// The active targets that are EXTERNAL PHYSICAL panels — the operator's actual desk.
fn active_physicals() -> Vec<(u32, String)> { fn active_physicals() -> Vec<(u32, String)> {
pf_win_display::win_display::target_inventory() pf_win_display::win_display::target_inventory()
@@ -1153,8 +1256,9 @@ mod tests {
fn live_inplace_resize() { fn live_inplace_resize() {
// Live-run diagnostics: surface the manager/backend tracing (activation ladder, settle // Live-run diagnostics: surface the manager/backend tracing (activation ladder, settle
// waits, UPDATE_MODES) on stdout — a bare test harness has no subscriber, which made the // waits, UPDATE_MODES) on stdout — a bare test harness has no subscriber, which made the
// first on-glass run blind. // first on-glass run blind. `tracing-subscriber` is now a dev-dependency, so this case no
// (tracing-subscriber is not a dep of this crate — run the host binary for traced runs.) // longer has to be re-run through the host binary to be traced.
init_test_tracing();
// Context probe: can this process see the CCD active-path set at all? (`None` = the query // Context probe: can this process see the CCD active-path set at all? (`None` = the query
// itself fails in this session/window-station — the whole ladder would be blind, and a // itself fails in this session/window-station — the whole ladder would be blind, and a
// "monitor never activated" verdict would be an artifact of the test context.) // "monitor never activated" verdict would be an artifact of the test context.)
+69 -2
View File
@@ -998,6 +998,25 @@ pub struct TargetInventory {
pub friendly: String, pub friendly: String,
/// Monitor device interface path — maps to the PnP instance id (`monitor_devnode`). /// Monitor device interface path — maps to the PnP instance id (`monitor_devnode`).
pub monitor_device_path: String, pub monitor_device_path: String,
/// One of OUR virtual displays (see [`is_our_virtual_display`]) — the reliable answer to
/// "is this the operator's screen or something we made?", which the connector class cannot give.
pub ours: bool,
/// GDI device name (`\\.\DISPLAY1`) of the SOURCE driving this target; empty when inactive
/// (an inactive path has no source). This is the id a Windows operator recognises and the one
/// capture pins on.
pub gdi_name: String,
/// Desktop position + mode of the driving source, in PIXELS. All zero when inactive: the CCD
/// mode indices are only valid for active paths, and inventing geometry for a dark head would
/// be worse than reporting none.
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
/// Refresh in mHz (60000 = 60 Hz), from the path's own `refreshRate` rational. 0 when the
/// path reports no rate (inactive, or a target that does not drive one).
pub refresh_mhz: u32,
/// The desktop origin sits on this head — Windows' notion of "primary".
pub primary: bool,
} }
/// EDID manufacturer id of punktfunk's own IddCx monitors, as it appears in the PnP hardware id and /// EDID manufacturer id of punktfunk's own IddCx monitors, as it appears in the PnP hardware id and
@@ -1131,18 +1150,66 @@ pub fn target_inventory() -> Vec<TargetInventory> {
let (mut external_physical, mut tech) = output_tech_class(req.outputTechnology); let (mut external_physical, mut tech) = output_tech_class(req.outputTechnology);
// Our own IddCx monitor claims HDMI, so the connector class alone would call it one of the // Our own IddCx monitor claims HDMI, so the connector class alone would call it one of the
// operator's panels — see `is_our_virtual_display` for what that broke. // operator's panels — see `is_our_virtual_display` for what that broke.
if is_our_virtual_display(&monitor_device_path) { let ours = is_our_virtual_display(&monitor_device_path);
if ours {
external_physical = false; external_physical = false;
tech = "punktfunk-virtual"; tech = "punktfunk-virtual";
} }
let is_active = active.contains(&key);
// Geometry + the GDI name come from the SOURCE this path drives, and only an ACTIVE path
// has one — `modeInfoIdx` is the INVALID sentinel otherwise, so everything stays zeroed
// rather than indexing the mode table with 0xffffffff.
let (mut gdi_name, mut x, mut y, mut width, mut height) =
(String::new(), 0i32, 0i32, 0u32, 0u32);
if is_active {
// SAFETY: POD union read (header) — `modeInfoIdx` overlays a same-sized bitfield
// struct, both valid for every bit pattern. Used only as a bounds-checked index below.
let idx = unsafe { p.sourceInfo.Anonymous.modeInfoIdx } as usize;
if let Some(m) = modes.get(idx) {
if m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE {
// SAFETY: discriminated union read — the `infoType` test directly above is the
// discriminant the CCD contract defines for `sourceMode`.
let sm = unsafe { m.Anonymous.sourceMode };
x = sm.position.x;
y = sm.position.y;
width = sm.width;
height = sm.height;
}
}
let mut src = DISPLAYCONFIG_SOURCE_DEVICE_NAME::default();
src.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
src.header.size = size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as u32;
src.header.adapterId = p.sourceInfo.adapterId;
src.header.id = p.sourceInfo.id;
// SAFETY: `src.header` is a live local whose `size` was just set to the enclosing
// struct's own `size_of`, which is the contract telling the OS how many bytes it may
// write; the struct outlives this synchronous call.
if unsafe { DisplayConfigGetDeviceInfo(&mut src.header) } == 0 {
gdi_name = utf16z_str(&src.viewGdiDeviceName);
}
}
// A rational, not a scalar: mHz keeps 59.94 distinguishable from 60 without a float.
let refresh_mhz = match t.refreshRate.Denominator {
0 => 0,
d => (u64::from(t.refreshRate.Numerator) * 1000 / u64::from(d)) as u32,
};
out.push(TargetInventory { out.push(TargetInventory {
target_id: t.id, target_id: t.id,
active: active.contains(&key), active: is_active,
external_physical, external_physical,
internal_panel: tech == "internal-panel", internal_panel: tech == "internal-panel",
tech, tech,
friendly: utf16z_str(&req.monitorFriendlyDeviceName), friendly: utf16z_str(&req.monitorFriendlyDeviceName),
monitor_device_path, monitor_device_path,
ours,
gdi_name,
// Windows' primary is the head at the desktop origin.
primary: is_active && x == 0 && y == 0,
x,
y,
width,
height,
refresh_mhz,
}); });
} }
out out
+79 -19
View File
@@ -71,27 +71,34 @@ pub(crate) fn display_settings_state() -> DisplaySettingsState {
}) })
}) })
.collect(); .collect();
let mut enforced: Vec<String> = vec![
"keep_alive".into(),
"topology".into(),
"mode_conflict".into(),
"identity".into(),
"layout".into(),
"game_session".into(),
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
"ddc_power_off".into(),
"pnp_disable_monitors".into(),
];
// `capture_monitor` routes every session to the MIRROR backend, and that backend exists only on
// Linux — `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]`, because `pf-capture`
// has no Windows entry point that can capture an arbitrary head. This list is precisely the
// "which controls are live vs. coming soon" contract, so claiming it unconditionally is what let
// the Windows console offer a picker that saved and then did nothing
// (`design/per-monitor-portal-capture.md` §5.3).
if cfg!(target_os = "linux") {
enforced.push("capture_monitor".into());
}
DisplaySettingsState { DisplaySettingsState {
effective: settings.effective(), effective: settings.effective(),
settings, settings,
configured, configured,
presets, presets,
custom_presets: policy::load_custom_presets(), custom_presets: policy::load_custom_presets(),
enforced: vec![ enforced,
"keep_alive".into(),
"topology".into(),
"mode_conflict".into(),
"identity".into(),
"layout".into(),
"game_session".into(),
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
"ddc_power_off".into(),
"pnp_disable_monitors".into(),
// Linux-only in effect: routes every session to the mirror backend
// (design/per-monitor-portal-capture.md).
"capture_monitor".into(),
],
} }
} }
@@ -135,6 +142,24 @@ pub(crate) async fn get_display_settings() -> Json<DisplaySettingsState> {
pub(crate) async fn set_display_settings( pub(crate) async fn set_display_settings(
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>, ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
) -> Response { ) -> Response {
#[cfg_attr(target_os = "linux", allow(unused_mut))]
let mut policy = policy;
// A pin nothing can honor must not be STORED as though it were. Off Linux there is no mirror
// backend (`MonitorsResponse::pin_supported`), so drop the field rather than persisting a
// setting whose only effect would be to mislead: the console re-reads this state after the PUT
// and would otherwise render a screen as "streamed" while every session kept creating a virtual
// display. Coerced rather than rejected with a 400 on purpose — this PUT is WHOLE-OBJECT, so a
// host that already stored a pin (before this build) would have every later settings save
// rejected over a field the operator cannot even see, taking the other axes down with it. This
// way such a policy self-heals on the next write, and the response body shows the truth
// immediately.
#[cfg(not(target_os = "linux"))]
if let Some(dropped) = policy.capture_monitor.take() {
tracing::warn!(
"management API: ignoring capture_monitor={dropped:?} — streaming a chosen physical \
monitor is Linux-only (no Windows mirror backend); the pin was NOT stored"
);
}
// `keep_alive: forever` (the gaming-rig preset) is now honored: the display is Pinned (Linux // `keep_alive: forever` (the gaming-rig preset) is now honored: the display is Pinned (Linux
// registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch). // registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch).
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) { if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
@@ -222,6 +247,19 @@ pub(crate) struct MonitorsResponse {
/// The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing, /// The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,
/// so the console can show "pinned to DP-2, which this host doesn't have". /// so the console can show "pinned to DP-2, which this host doesn't have".
pinned: Option<String>, pinned: Option<String>,
/// Whether this build can actually STREAM one of these monitors.
///
/// Enumeration and capture are separate capabilities, and on Windows only the first exists: the
/// heads below are real and worth showing (they explain the topology, and `/display/state`
/// cross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a
/// frame channel pushed by our OWN IddCx virtual display. There is no desktop-duplication
/// capturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so
/// `vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.
///
/// The console renders the picker read-only on `false`. Reported as a capability rather than
/// sniffed client-side from the OS so the answer comes from the build that would have to honor
/// it — when a Windows mirror backend lands, this flips and the UI needs no change.
pin_supported: bool,
/// Why the list is empty, when enumeration failed (compositor unreachable, unsupported /// Why the list is empty, when enumeration failed (compositor unreachable, unsupported
/// platform). `None` with an empty list means "asked, and there are none". /// platform). `None` with an empty list means "asked, and there are none".
error: Option<String>, error: Option<String>,
@@ -248,12 +286,33 @@ pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
// sessions will actually mirror, not just what the console last wrote. // sessions will actually mirror, not just what the console last wrote.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let pinned = crate::vdisplay::capture_monitor(); let pinned = crate::vdisplay::capture_monitor();
// Off Linux there is no mirror backend, so there is nothing a pin could aim (see
// `MonitorsResponse::pin_supported`). Kept `None` DELIBERATELY rather than reporting the stored
// value: `pinned` is what the console highlights as "this is the screen sessions stream", and a
// highlight on a head nothing will ever capture is precisely the lie this endpoint is here to
// stop telling. `pin_supported: false` is how the unsupportedness is reported instead.
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
let pinned: Option<String> = None; let pinned: Option<String> = None;
// Enumeration shells out / round-trips D-Bus + Wayland, so keep it off the async worker. // Enumeration works on Windows; capture of an enumerated head does not.
let (compositor, listed) = tokio::task::spawn_blocking(|| match crate::vdisplay::detect() { let pin_supported = cfg!(target_os = "linux");
Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)), // Enumeration shells out / round-trips D-Bus + Wayland (and on Windows walks the CCD
Err(e) => (None, Err(e)), // database, which can serialize on the display-config lock), so keep it off the async worker.
let (compositor, listed) = tokio::task::spawn_blocking(|| {
// Windows has no compositor to detect — asking used to fail with Linux advice about
// XDG_CURRENT_DESKTOP, which landed verbatim in `error` below and was the console's only
// explanation for an empty picker. Report the display API we actually used instead.
#[cfg(windows)]
{
(
Some("windows".to_string()),
crate::vdisplay::monitors::list_windows(),
)
}
#[cfg(not(windows))]
match crate::vdisplay::detect() {
Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)),
Err(e) => (None, Err(e)),
}
}) })
.await .await
.unwrap_or_else(|e| (None, Err(anyhow::anyhow!("enumeration task failed: {e}")))); .unwrap_or_else(|e| (None, Err(anyhow::anyhow!("enumeration task failed: {e}"))));
@@ -283,6 +342,7 @@ pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
compositor, compositor,
monitors, monitors,
pinned, pinned,
pin_supported,
error, error,
}) })
} }
+1
View File
@@ -430,6 +430,7 @@
"display_monitor_none": "Dieser Host meldet keine Monitore.", "display_monitor_none": "Dieser Host meldet keine Monitore.",
"display_monitor_unavailable": "Monitore konnten auf diesem Host nicht ermittelt werden.", "display_monitor_unavailable": "Monitore konnten auf diesem Host nicht ermittelt werden.",
"display_monitor_env_locked": "Auf diesem Host über PUNKTFUNK_CAPTURE_MONITOR festgelegt — dort entfernen, um hier zu wählen.", "display_monitor_env_locked": "Auf diesem Host über PUNKTFUNK_CAPTURE_MONITOR festgelegt — dort entfernen, um hier zu wählen.",
"display_monitor_unsupported": "Das Übertragen eines dieser Bildschirme wird auf diesem Host noch nicht unterstützt — das gibt es bisher nur auf Linux-Hosts. Die Bildschirme sind hier aufgeführt, damit du siehst, was dieser Computer hat; Clients bekommen weiterhin ihren eigenen virtuellen Bildschirm.",
"display_monitor_primary": "primär", "display_monitor_primary": "primär",
"display_monitor_disabled": "aus", "display_monitor_disabled": "aus",
"display_monitor_saved": "Übertragener Bildschirm gespeichert" "display_monitor_saved": "Übertragener Bildschirm gespeichert"
+1
View File
@@ -430,6 +430,7 @@
"display_monitor_none": "This host reports no monitors.", "display_monitor_none": "This host reports no monitors.",
"display_monitor_unavailable": "Monitors could not be listed on this host.", "display_monitor_unavailable": "Monitors could not be listed on this host.",
"display_monitor_env_locked": "Pinned by PUNKTFUNK_CAPTURE_MONITOR on this host — unset it to choose here.", "display_monitor_env_locked": "Pinned by PUNKTFUNK_CAPTURE_MONITOR on this host — unset it to choose here.",
"display_monitor_unsupported": "Streaming one of these screens isn't supported on this host yet — it's available on Linux hosts only. The screens are listed so you can see what this computer has; clients keep getting their own virtual screen.",
"display_monitor_primary": "primary", "display_monitor_primary": "primary",
"display_monitor_disabled": "off", "display_monitor_disabled": "off",
"display_monitor_saved": "Streamed screen saved" "display_monitor_saved": "Streamed screen saved"
+17 -4
View File
@@ -42,9 +42,17 @@ export const MonitorCard: FC = () => {
// environment is read-only here: offering controls that silently lose to the env would be worse // environment is read-only here: offering controls that silently lose to the env would be worse
// than saying so. // than saying so.
const envLocked = !!pinned && policy?.capture_monitor !== pinned; const envLocked = !!pinned && policy?.capture_monitor !== pinned;
// The host says whether it can honor a pin at all. Windows enumerates its heads but has no
// backend that can capture one (see `MonitorsResponse.pin_supported`), and this card used to
// offer the choice anyway: the PUT persisted, nothing consumed it, and a virtual display was
// still created on connect. Defaults to TRUE when the field is absent so an older host — which
// only ever shipped this picker where it worked — is not retroactively locked out.
const pinSupported = monitors.data?.pin_supported ?? true;
// Both reasons produce the same read-only card; only the explanation above it differs.
const locked = envLocked || !pinSupported;
const choose = (connector: string | null) => { const choose = (connector: string | null) => {
if (!policy || envLocked) return; if (!policy || locked) return;
save.mutate( save.mutate(
{ data: { ...policy, capture_monitor: connector } }, { data: { ...policy, capture_monitor: connector } },
{ {
@@ -71,13 +79,13 @@ export const MonitorCard: FC = () => {
<button <button
key={key} key={key}
type="button" type="button"
disabled={busy || envLocked || !onSelect} disabled={busy || locked || !onSelect}
onClick={onSelect} onClick={onSelect}
aria-pressed={selected} aria-pressed={selected}
className={cn( className={cn(
"flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors", "flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors",
selected ? "border-primary bg-primary/5" : "hover:bg-muted/50", selected ? "border-primary bg-primary/5" : "hover:bg-muted/50",
(busy || envLocked) && "cursor-not-allowed opacity-60", (busy || locked) && "cursor-not-allowed opacity-60",
)} )}
> >
<span className="flex flex-col gap-1"> <span className="flex flex-col gap-1">
@@ -124,7 +132,12 @@ export const MonitorCard: FC = () => {
<p className="max-w-prose text-sm text-muted-foreground"> <p className="max-w-prose text-sm text-muted-foreground">
{m.display_monitor_intro()} {m.display_monitor_intro()}
</p> </p>
{envLocked && ( {!pinSupported && (
<p className="text-sm text-amber-600 dark:text-amber-500">
{m.display_monitor_unsupported()}
</p>
)}
{pinSupported && envLocked && (
<p className="text-sm text-amber-600 dark:text-amber-500"> <p className="text-sm text-amber-600 dark:text-amber-500">
{m.display_monitor_env_locked()} {m.display_monitor_env_locked()}
</p> </p>