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:
@@ -99,6 +99,10 @@ pub struct PortalCapturer {
|
||||
/// Set once the stream negotiated one of the 10-bit PQ formats (`param_changed`), i.e. frames
|
||||
/// really are PQ/BT.2020 — drives [`hdr_meta`](Capturer::hdr_meta).
|
||||
hdr_negotiated: Arc<AtomicBool>,
|
||||
/// The NEGOTIATED frame size, packed `(w << 32) | h`; `0` until `param_changed` runs. Written by
|
||||
/// the PipeWire thread, read by the gamescope XFixes cursor source, which must map root-space
|
||||
/// pointer coordinates into FRAME space (gamescope's `-w/-h` and `-W/-H` are independent knobs).
|
||||
frame_size: Arc<std::sync::atomic::AtomicU64>,
|
||||
/// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis.
|
||||
node_id: u32,
|
||||
/// Stops the PipeWire loop on teardown (sent in `Drop`). Without it a dropped or failed
|
||||
@@ -292,6 +296,8 @@ struct PwHandles {
|
||||
hdr_negotiated: Arc<AtomicBool>,
|
||||
/// See [`PortalCapturer::cursor_live`].
|
||||
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
|
||||
/// See [`PortalCapturer::frame_size`].
|
||||
frame_size: Arc<std::sync::atomic::AtomicU64>,
|
||||
quit: ::pipewire::channel::Sender<()>,
|
||||
join: thread::JoinHandle<()>,
|
||||
}
|
||||
@@ -318,6 +324,7 @@ impl PwHandles {
|
||||
hdr_offer: self.hdr_offer,
|
||||
hdr_negotiated: self.hdr_negotiated,
|
||||
cursor_live: self.cursor_live,
|
||||
frame_size: self.frame_size,
|
||||
node_id,
|
||||
quit: Some(self.quit),
|
||||
join: Some(self.join),
|
||||
@@ -375,6 +382,8 @@ fn spawn_pipewire(
|
||||
let hdr_negotiated_cb = hdr_negotiated.clone();
|
||||
let cursor_live = Arc::new(std::sync::Mutex::new(None::<pf_frame::CursorOverlay>));
|
||||
let cursor_live_cb = cursor_live.clone();
|
||||
let frame_size = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let frame_size_cb = frame_size.clone();
|
||||
// pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the
|
||||
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
|
||||
// inner `mod pipewire` shadows the crate name at this scope.
|
||||
@@ -424,6 +433,7 @@ fn spawn_pipewire(
|
||||
broken_cb,
|
||||
hdr_negotiated_cb,
|
||||
cursor_live_cb,
|
||||
frame_size_cb,
|
||||
plan,
|
||||
want_444,
|
||||
want_hdr,
|
||||
@@ -447,6 +457,7 @@ fn spawn_pipewire(
|
||||
hdr_offer: want_hdr,
|
||||
hdr_negotiated,
|
||||
cursor_live,
|
||||
frame_size,
|
||||
quit: quit_tx,
|
||||
join,
|
||||
})
|
||||
@@ -465,14 +476,18 @@ impl Capturer for PortalCapturer {
|
||||
self.cursor_live.lock().ok().and_then(|slot| slot.clone())
|
||||
}
|
||||
|
||||
fn attach_gamescope_cursor(&mut self, targets: Vec<(String, Option<String>)>) {
|
||||
fn attach_gamescope_cursor(&mut self, targets: crate::GamescopeCursorTargets) {
|
||||
// gamescope paints no `SPA_META_Cursor`, so `cursor_live` would stay empty. Spawn the
|
||||
// XFixes reader to publish gamescope's pointer into that SAME slot — `cursor()` above then
|
||||
// serves it and the encode loop composites it, exactly like the portal path. It connects
|
||||
// to every nested Xwayland and follows the focused one's pointer. A failure (no Xwayland /
|
||||
// no XFixes) logs and leaves the slot empty = today's cursorless gamescope.
|
||||
self._gs_cursor =
|
||||
xfixes_cursor::XFixesCursorSource::spawn(targets, Arc::clone(&self.cursor_live));
|
||||
// serves it and the encode loop composites it, exactly like the portal path. It connects to
|
||||
// every nested Xwayland the provider reports, RE-RUNS the provider so a game's Xwayland
|
||||
// that appears later is adopted, and follows whichever one gamescope draws the pointer on.
|
||||
// `frame_size` lets it map root-space coordinates into frame space.
|
||||
self._gs_cursor = xfixes_cursor::XFixesCursorSource::spawn(
|
||||
targets,
|
||||
Arc::clone(&self.cursor_live),
|
||||
Arc::clone(&self.frame_size),
|
||||
);
|
||||
}
|
||||
|
||||
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
||||
@@ -1193,6 +1208,8 @@ mod pipewire {
|
||||
/// LIVE overlay slot shared with [`super::PortalCapturer::cursor_live`] — refreshed after
|
||||
/// every `update_cursor_meta`, including from cursor-only buffers that never become frames.
|
||||
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
|
||||
/// See [`super::PortalCapturer::frame_size`] — published from `param_changed`.
|
||||
frame_size: Arc<std::sync::atomic::AtomicU64>,
|
||||
/// `Some((w, h))` while the producer's negotiated size is a sacrificial birth mode and a
|
||||
/// renegotiation to these dims is guaranteed (KWin virtual outputs — kwin.rs `create`):
|
||||
/// `.process` skips whole buffers until the negotiated size matches, then clears this
|
||||
@@ -2467,6 +2484,9 @@ mod pipewire {
|
||||
// LIVE cursor publisher (see `PortalCapturer::cursor_live`): refreshed from every
|
||||
// dequeued buffer's cursor meta, frames or not.
|
||||
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
|
||||
// The negotiated frame size, published for the gamescope cursor source (see
|
||||
// `super::PortalCapturer::frame_size`).
|
||||
frame_size: Arc<std::sync::atomic::AtomicU64>,
|
||||
// THE zero-copy negotiation decision, resolved once by `spawn_pipewire` (which consumes the
|
||||
// same struct for the capturer's timeout diagnosis) — never re-derived here.
|
||||
plan: NegotiationPlan,
|
||||
@@ -2654,6 +2674,7 @@ mod pipewire {
|
||||
dbg_log_n: 0,
|
||||
cursor: CursorState::default(),
|
||||
cursor_live,
|
||||
frame_size,
|
||||
expect_dims: if expect_exact_dims {
|
||||
preferred.map(|(w, h, _)| (w, h))
|
||||
} else {
|
||||
@@ -2718,6 +2739,12 @@ mod pipewire {
|
||||
imp.clear_cache();
|
||||
}
|
||||
let sz = ud.info.size();
|
||||
// Publish the negotiated size for the gamescope cursor source's root→frame
|
||||
// scaling (`xfixes_cursor::scale_to_frame`); a renegotiation updates it.
|
||||
ud.frame_size.store(
|
||||
(u64::from(sz.width) << 32) | u64::from(sz.height),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
ud.format = map_format(ud.info.format());
|
||||
ud.modifier = ud.info.modifier();
|
||||
// HDR: the 10-bit PQ formats are only ever offered with MANDATORY BT.2020/PQ
|
||||
|
||||
Reference in New Issue
Block a user