fix(pf-capture): gamescope cursor — auth without setenv, frame-space coords, live targets (2.5)

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 10:56:05 +02:00
co-authored by Claude Opus 5
parent 759fdf767c
commit 9a7b3a46d0
4 changed files with 529 additions and 91 deletions
+15 -12
View File
@@ -3248,20 +3248,23 @@ fn build_pipeline(
let mut capturer =
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
.context("capture virtual output")?;
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer gamescope's
// nested Xwayland — it reads the pointer over X11 (XFixes shape + QueryPointer position) and
// feeds `cursor()`, which the encode loop composites. A failed discovery/connect leaves the
// stream cursorless (today's behaviour); non-gamescope plans skip this entirely.
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer a way to
// reach gamescope's nested Xwaylands — it reads the pointer over X11 (XFixes shape +
// QueryPointer position) and feeds `cursor()`, which the encode loop composites.
// Non-gamescope plans skip this entirely.
//
// A PROVIDER, not the discovered list: gamescope creates the game's Xwayland when the game
// launches and advertises only the FIRST in any child's environ, so a list captured here misses
// it — and the cursor source would then blank the pointer for the whole game session (it asks
// the connected display "are you drawing the pointer?" and gets "no"). The source re-runs this
// every couple of seconds, so a stream that starts before the game converges, and a display
// that dies is retried. Same one-way-edge shape as the Windows channel senders: the closure
// wraps the host's discovery, and pf-capture never reaches back into pf-vdisplay.
#[cfg(target_os = "linux")]
if plan.gamescope_cursor {
let targets = pf_vdisplay::gamescope_xwayland_cursor_targets();
if targets.is_empty() {
tracing::warn!(
"gamescope cursor: no nested Xwayland discovered — no in-video pointer this session"
);
} else {
capturer.attach_gamescope_cursor(targets);
}
capturer.attach_gamescope_cursor(std::sync::Arc::new(
pf_vdisplay::gamescope_xwayland_cursor_targets,
));
}
if let Some(t) = trace {
t.mark("capture_attached");