diff --git a/Cargo.lock b/Cargo.lock index ebf21cef..0fb07091 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1459,6 +1459,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2829,6 +2839,7 @@ dependencies = [ "tokio", "tracing", "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", + "x11rb", ] [[package]] @@ -5886,6 +5897,23 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x509-parser" version = "0.16.0" diff --git a/crates/pf-capture/Cargo.toml b/crates/pf-capture/Cargo.toml index a0d04fbe..a2c7e27e 100644 --- a/crates/pf-capture/Cargo.toml +++ b/crates/pf-capture/Cargo.toml @@ -30,6 +30,12 @@ pipewire = "0.9" libc = "0.2" # ashpd 0.13 uses the tokio runtime for the one-time portal handshake (control plane). tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] } +# XFixes cursor source for gamescope (remote-desktop-sweep Phase C): gamescope paints no +# `SPA_META_Cursor`, so the pointer never reaches the PipeWire node. We read the shape/hotspot/ +# visibility from gamescope's nested Xwayland via XFixes instead and feed the existing cursor slot. +# `RustConnection` is the pure-Rust default (no libxcb link → no new C dependency on the host); the +# `xfixes` feature (auto-pulls `render` + `shape`) is what exposes GetCursorImage/SelectCursorInput. +x11rb = { version = "0.13", default-features = false, features = ["xfixes"] } [target.'cfg(target_os = "windows")'.dependencies] # The host<->driver wire contract for the sealed frame channel (control IOCTL structs + frame header). diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs index 522f0cd6..abd046ee 100644 --- a/crates/pf-capture/src/lib.rs +++ b/crates/pf-capture/src/lib.rs @@ -88,6 +88,15 @@ pub trait Capturer: Send { /// the encode loop blends its overlay instead. fn set_cursor_forward(&mut self, _on: bool) {} + /// Attach a gamescope cursor source (remote-desktop-sweep Phase C). gamescope paints no + /// `SPA_META_Cursor`, so [`cursor`](Self::cursor)'s slot stays empty — this hands the Linux + /// portal capturer gamescope's nested Xwayland `(DISPLAY, XAUTHORITY)` targets (it may run + /// several — one per `--xwayland-count`) so it reads the pointer shape/position over X11 + /// (XFixes + QueryPointer), following whichever display is focused, and publishes it into that + /// same slot. Called once, after the capturer is built, only for gamescope sessions. Default + /// no-op: every non-gamescope capturer already has a cursor source. + fn attach_gamescope_cursor(&mut self, _targets: Vec<(String, Option)>) {} + fn hdr_meta(&self) -> Option { None } diff --git a/crates/pf-capture/src/linux/mod.rs b/crates/pf-capture/src/linux/mod.rs index 35e93649..2b5a3fde 100644 --- a/crates/pf-capture/src/linux/mod.rs +++ b/crates/pf-capture/src/linux/mod.rs @@ -22,6 +22,10 @@ use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy}; use anyhow::{anyhow, Context, Result}; + +// gamescope cursor source (remote-desktop-sweep Phase C) — feeds `cursor_live` from XFixes when +// the PipeWire node carries no `SPA_META_Cursor` (gamescope's does not). +mod xfixes_cursor; use std::os::fd::OwnedFd; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, TryRecvError}; @@ -88,6 +92,12 @@ pub struct PortalCapturer { /// is, releasing the compositor-side output via the keepalive's own `Drop`. `None` for the /// portal source (its session ends with the portal thread's zbus connection). _keepalive: Option>, + /// The gamescope XFixes cursor reader (remote-desktop-sweep Phase C), when this capturer + /// serves a gamescope node. `Some` after + /// [`attach_gamescope_cursor`](Capturer::attach_gamescope_cursor); its `Drop` stops the reader + /// thread, so it lives exactly as long as the capturer. `None` on the portal path (its cursor + /// comes from `SPA_META_Cursor`). + _gs_cursor: Option, } impl PortalCapturer { @@ -218,6 +228,7 @@ impl PwHandles { quit: Some(self.quit), join: Some(self.join), _keepalive: keepalive, + _gs_cursor: None, } } } @@ -335,9 +346,21 @@ impl Capturer for PortalCapturer { fn cursor(&mut self) -> Option { // The PipeWire thread's live cursor slot (fed by every buffer's meta, frames or not) — // lets the forwarder track pointer-only motion on a static desktop. See `cursor_live`. + // On a gamescope node the meta never arrives; the XFixes source (attached below) fills + // the same slot instead. self.cursor_live.lock().ok().and_then(|slot| slot.clone()) } + fn attach_gamescope_cursor(&mut self, targets: Vec<(String, Option)>) { + // 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)); + } + fn next_frame_within(&mut self, budget: Duration) -> Result { self.frame_within(budget) } @@ -2503,11 +2526,18 @@ mod pipewire { // pointer-only movements as metadata-only "corrupted" buffers we drop for their // frame, but their cursor meta is fresh and must still move our overlay. update_cursor_meta(&mut ud.cursor, spa_buf); - // Publish the LIVE overlay (frames or not): the encode loop's forwarder polls - // it per tick, so pointer-only motion tracks on a static desktop — the - // frame-attached overlay alone stales between damage frames. - if let Ok(mut slot) = ud.cursor_live.lock() { - *slot = ud.cursor.overlay(); + // Publish the LIVE overlay (frames or not) so the encode loop's forwarder + // tracks pointer-only motion on a static desktop — the frame-attached overlay + // alone stales between damage frames. ONLY when we actually have one: a + // gamescope node carries no `SPA_META_Cursor`, so `overlay()` is always `None` + // here, and writing that would clobber — at frame rate — the `Some` the + // attached XFixes source publishes into this SAME slot, strobing the + // composited pointer on/off. Portal cursors are `None` only before the first + // bitmap (nothing to drop), and a HIDDEN pointer is still `Some(visible:false)`. + if let Some(overlay) = ud.cursor.overlay() { + if let Ok(mut slot) = ud.cursor_live.lock() { + *slot = Some(overlay); + } } // Inspect the newest buffer's header + first chunk for the diagnostic and the diff --git a/crates/pf-capture/src/linux/xfixes_cursor.rs b/crates/pf-capture/src/linux/xfixes_cursor.rs new file mode 100644 index 00000000..f00a37a5 --- /dev/null +++ b/crates/pf-capture/src/linux/xfixes_cursor.rs @@ -0,0 +1,366 @@ +//! XFixes cursor source for the gamescope capture path (remote-desktop-sweep Phase C). +//! +//! gamescope draws the pointer on a DRM hardware-cursor plane and its `paint_pipewire()` +//! deliberately excludes the cursor from the frame it feeds its built-in PipeWire node — so +//! `SPA_META_Cursor` never arrives and the ordinary [`cursor_live`](super::PortalCapturer) slot +//! stays empty (a KWin/GNOME session gets its cursor from that meta; gamescope can't embed one +//! either, its `set_hw_cursor` is inert). We instead read the pointer from gamescope's nested +//! Xwayland via X11 — the trick Sunshine uses — and publish a [`CursorOverlay`] into that same +//! slot, so the encoder blend composites the pointer into the video exactly like the portal path. +//! +//! **Multiple Xwaylands.** gamescope runs one Xwayland per `--xwayland-count` (Steam Gaming Mode +//! uses 2: one for Big Picture, one for the game). The pointer lives on whichever is FOCUSED — an +//! inactive display's pointer is frozen. So the source connects to ALL of them and each tick +//! follows the one whose pointer actually moved (gamescope routes input to the focused surface, so +//! exactly one moves at a time). It reads that display's shape too, since each Xwayland has its own +//! current cursor. This is why a single-display read froze the pointer the moment a game on the +//! OTHER Xwayland took focus. +//! +//! Two X sources per display, split by cost (Sunshine's split): +//! * **Position** — core `QueryPointer` on the root, polled fast. Cheap (a few-byte reply, no +//! bitmap), so it can out-pace the stream fps and keep the composited pointer smooth. It also +//! doubles as the focus signal (the display whose pointer moves is the active one). +//! * **Shape / hotspot / visibility** — `XFixesGetCursorImage`, refreshed only after an XFixes +//! `CursorNotify` (a real cursor change). A game hiding the pointer IS a cursor change → the +//! image comes back fully transparent → `visible: false`, which the encode loop strips before +//! any blend path draws it (so a grabbed pointer shows nothing, matching native gamescope). + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; +use std::time::Duration; + +use pf_frame::CursorOverlay; +use x11rb::connection::Connection; +use x11rb::errors::ReplyError; +use x11rb::protocol::xfixes::{self, ConnectionExt as _, GetCursorImageReply}; +use x11rb::protocol::xproto::{ConnectionExt as _, QueryPointerReply, Window}; +use x11rb::rust_connection::RustConnection; + +/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only +/// ever contended if two gamescope sessions start at once — rare, and the swap is microseconds. +static XAUTH_LOCK: Mutex<()> = Mutex::new(()); + +/// Position out-paces the stream fps (`POLL`); shape rides `CursorNotify` events drained each tick. +/// 4 ms ≈ 250 Hz matches the Windows GDI poller — the polled position IS the composited position +/// and must out-run a 240 fps session or the pointer stutters. +const POLL: Duration = Duration::from_millis(4); + +/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the +/// X connections — so it lives exactly as long as the capturer that owns it. +pub(super) struct XFixesCursorSource { + stop: Arc, + join: Option>, +} + +impl XFixesCursorSource { + /// Connect to every gamescope nested Xwayland in `targets` (`(DISPLAY, XAUTHORITY)`) and start + /// publishing cursor overlays into `slot`, following the focused display's pointer. Returns + /// `None` — and logs — if NONE can be used (no X connection / no XFixes), so the caller + /// degrades to no gamescope cursor (today's behaviour) instead of failing the session. + pub(super) fn spawn( + targets: Vec<(String, Option)>, + slot: Arc>>, + ) -> Option { + // Connect on the caller's thread so failures degrade cleanly and the displays are validated + // before we commit a thread. + let mut displays = Vec::new(); + for (dpy, xauth) in targets { + match connect(&dpy, xauth.as_deref()) { + Ok((conn, root)) => displays.push(XDisplay::new(dpy, conn, root)), + Err(e) => tracing::warn!( + dpy = %dpy, + error = %e, + "gamescope cursor: skipping a nested Xwayland we can't use" + ), + } + } + if displays.is_empty() { + tracing::warn!( + "gamescope cursor: no usable nested Xwayland — no in-video pointer this session \ + (falls back to today's cursorless gamescope stream)" + ); + return None; + } + let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect(); + tracing::info!( + displays = ?names, + "gamescope cursor: XFixes source live — following the focused Xwayland's pointer" + ); + + let stop = Arc::new(AtomicBool::new(false)); + let stop_worker = Arc::clone(&stop); + let join = std::thread::Builder::new() + .name("pf-gs-cursor".into()) + .spawn(move || run(displays, slot, stop_worker)) + .ok()?; + Some(XFixesCursorSource { + stop, + join: Some(join), + }) + } +} + +impl Drop for XFixesCursorSource { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(j) = self.join.take() { + let _ = j.join(); + } + } +} + +/// Open the X connection, negotiate XFixes, and select cursor-change events — returning the +/// connection + root window. `RustConnection` reads `XAUTHORITY` from the env at connect time only, +/// so set it under the lock (the host isn't a gamescope child), connect, then restore. +fn connect(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, Window), String> { + let (conn, screen_num) = { + let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev = std::env::var_os("XAUTHORITY"); + if let Some(x) = xauthority { + std::env::set_var("XAUTHORITY", x); + } + let out = RustConnection::connect(Some(dpy)); + match (&prev, xauthority) { + (Some(p), _) => std::env::set_var("XAUTHORITY", p), + (None, Some(_)) => std::env::remove_var("XAUTHORITY"), + (None, None) => {} + } + out.map_err(|e| format!("connect: {e}"))? + }; + + // XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get. + conn.xfixes_query_version(5, 0) + .map_err(ReplyError::from) + .and_then(|c| c.reply()) + .map_err(|e| format!("XFixes unavailable: {e}"))?; + + let root = conn + .setup() + .roots + .get(screen_num) + .ok_or_else(|| format!("no X screen {screen_num}"))? + .root; + + // Wake the worker's event drain whenever the cursor shape changes (incl. hide/show). + conn.xfixes_select_cursor_input(root, xfixes::CursorNotifyMask::DISPLAY_CURSOR) + .map_err(ReplyError::from) + .and_then(|c| c.check()) + .map_err(|e| format!("SelectCursorInput: {e}"))?; + let _ = conn.flush(); + Ok((conn, root)) +} + +/// One gamescope Xwayland the source tracks. +struct XDisplay { + name: String, + conn: RustConnection, + root: Window, + /// Last polled pointer position — a change since the previous tick marks this display FOCUSED. + last_pos: Option<(i32, i32)>, + /// Cached cursor shape, refreshed only after this display's XFixes `CursorNotify`. + shape: Shape, + /// A `CursorNotify` (or first read) is pending — fetch the shape when this display is active. + need_shape: bool, + /// The X connection died (game/Xwayland exited) — skip it. + dead: bool, +} + +impl XDisplay { + fn new(name: String, conn: RustConnection, root: Window) -> Self { + XDisplay { + name, + conn, + root, + last_pos: None, + shape: Shape::default(), + need_shape: true, + dead: false, + } + } +} + +/// Cached cursor shape for one display. +#[derive(Default)] +struct Shape { + /// Straight-alpha RGBA (`w*h*4`, bytes R,G,B,A); empty before the first image arrives. + rgba: Arc>, + w: u32, + h: u32, + hot_x: u32, + hot_y: u32, + /// XFixes' own per-display cursor serial — bumps on every shape change. + serial: u64, + /// A hidden pointer arrives as an all-transparent image; kept so a position-only tick preserves + /// the last known visibility. + visible: bool, +} + +fn run( + mut displays: Vec, + slot: Arc>>, + stop: Arc, +) { + let mut active = 0usize; + // The overlay serial must bump whenever the DRAWN cursor changes — either the active display's + // shape OR which display is active (per-display XFixes serials aren't comparable across + // displays, so switching could reuse a number and the encoder would keep the old texture). + let mut out_serial = 0u64; + let mut last_key = (usize::MAX, u64::MAX); + let mut warned_image = false; + + while !stop.load(Ordering::Relaxed) { + // 1) Poll every display's pointer; note which moved since last tick (the focus signal). + let mut active_moved = false; + let mut other_moved: Option = None; + for (i, d) in displays.iter_mut().enumerate() { + if d.dead { + continue; + } + // Drain pending events; the only ones selected are CursorNotify, so ANY event means + // "re-read this display's shape". poll_for_event never blocks. + loop { + match d.conn.poll_for_event() { + Ok(Some(_)) => d.need_shape = true, + Ok(None) => break, + Err(_) => { + d.dead = true; + break; + } + } + } + match fetch_pointer(&d.conn, d.root) { + Ok(p) if p.same_screen => { + let pos = (i32::from(p.root_x), i32::from(p.root_y)); + let moved = d.last_pos.is_some_and(|lp| lp != pos); + d.last_pos = Some(pos); + if moved { + if i == active { + active_moved = true; + } else if other_moved.is_none() { + other_moved = Some(i); + } + } + } + Ok(_) => {} // pointer on another screen — keep the last position. + Err(_) => d.dead = true, + } + } + + // 2) Switch focus: sticky to the active display while it moves (no flapping); otherwise + // follow another display that moved. If the active one died, fall to any live display. + if !active_moved { + if let Some(j) = other_moved { + active = j; + } + } + if displays.get(active).is_none_or(|d| d.dead) { + match displays.iter().position(|d| !d.dead) { + Some(k) => active = k, + None => { + std::thread::sleep(POLL); // all connections dead — idle until Drop. + continue; + } + } + } + + // 3) Fetch the active display's shape if a CursorNotify (or a focus switch) left it stale. + if displays[active].need_shape { + match fetch_cursor_image(&displays[active].conn) { + Ok(img) => { + update_shape(&mut displays[active].shape, &img); + displays[active].need_shape = false; + } + Err(e) => { + if !warned_image { + warned_image = true; + tracing::warn!(error = %e, "gamescope cursor: GetCursorImage failed — retrying"); + } + } + } + } + + // 4) Publish the ACTIVE display's pointer + shape (or clear the slot when it has no cursor + // of its own — so a focus switch never leaves the other display's stale pointer showing). + let d = &displays[active]; + let overlay = match (d.last_pos, d.shape.rgba.is_empty()) { + (Some((px, py)), false) => { + let key = (active, d.shape.serial); + if key != last_key { + out_serial += 1; + last_key = key; + } + Some(CursorOverlay { + // Top-left = pointer position − hotspot (the overlay contract). + x: px - d.shape.hot_x as i32, + y: py - d.shape.hot_y as i32, + w: d.shape.w, + h: d.shape.h, + rgba: Arc::clone(&d.shape.rgba), + serial: out_serial, + hot_x: d.shape.hot_x, + hot_y: d.shape.hot_y, + visible: d.shape.visible, + }) + } + _ => None, + }; + if let Ok(mut s) = slot.lock() { + *s = overlay; + } + + std::thread::sleep(POLL); + } +} + +/// Update `shape` from a fresh `GetCursorImage` reply. A hidden pointer (all-transparent) keeps the +/// last bitmap (instant re-show) but flips visibility; the serial still bumps so the change shows. +fn update_shape(shape: &mut Shape, img: &GetCursorImageReply) { + let visible = + img.width > 0 && img.height > 0 && img.cursor_image.iter().any(|&p| (p >> 24) & 0xff != 0); + if visible { + shape.rgba = Arc::new(argb_premul_to_straight_rgba(&img.cursor_image)); + shape.w = u32::from(img.width); + shape.h = u32::from(img.height); + shape.hot_x = u32::from(img.xhot); + shape.hot_y = u32::from(img.yhot); + } + shape.visible = visible; + shape.serial = u64::from(img.cursor_serial); +} + +/// One request+reply — x11rb splits errors (the request is `ConnectionError`, `reply()` is +/// `ReplyError` which is `From`), so `?` collapses both. +fn fetch_cursor_image(conn: &RustConnection) -> Result { + Ok(conn.xfixes_get_cursor_image()?.reply()?) +} + +fn fetch_pointer(conn: &RustConnection, root: Window) -> Result { + Ok(conn.query_pointer(root)?.reply()?) +} + +/// XFixes cursor pixels are packed `0xAARRGGBB` with **premultiplied** alpha (the Xrender / Xcursor +/// convention). The overlay + both blend paths want **straight** alpha RGBA (R,G,B,A bytes), like +/// the `SPA_META_Cursor` path — so un-premultiply here. (If on-glass shows over-bright fringes the +/// source wasn't premultiplied after all; drop the divide.) +fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec { + let mut out = Vec::with_capacity(argb.len() * 4); + for &px in argb { + let a = (px >> 24) & 0xff; + let r = (px >> 16) & 0xff; + let g = (px >> 8) & 0xff; + let b = px & 0xff; + let (r, g, b) = match a { + 0 => (0, 0, 0), + 255 => (r, g, b), + a => ( + ((r * 255 + a / 2) / a).min(255), + ((g * 255 + a / 2) / a).min(255), + ((b * 255 + a / 2) / a).min(255), + ), + }; + out.extend_from_slice(&[r as u8, g as u8, b as u8, a as u8]); + } + out +} diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index 499ba90a..e7c09ca7 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -77,8 +77,9 @@ pub use routing::{ }; #[cfg(target_os = "linux")] pub use routing::{ - cancel_pending_tv_restore, dedicated_game_exited, launch_into_gamescope_session, - launch_is_nested, steam_appid_from_launch, watch_steam_game_exit, + cancel_pending_tv_restore, dedicated_game_exited, gamescope_xwayland_cursor_targets, + launch_into_gamescope_session, launch_is_nested, steam_appid_from_launch, + watch_steam_game_exit, }; /// Compositors punktfunk knows how to drive (plan §6). diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 3f89d4c6..2a75b953 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -508,7 +508,7 @@ pub fn launch_into_session(cmd: &str) -> Result { let mut c = Command::new("sh"); c.arg("-c").arg(cmd); match discover_session_display_env() { - Some((x11, wayland)) => { + Some((x11, wayland, _xauth)) => { tracing::info!( command = %cmd, x11_display = x11.as_deref().unwrap_or("-"), @@ -533,11 +533,71 @@ pub fn launch_into_session(cmd: &str) -> Result { .context("spawn launch command into gamescope session") } -/// Find the live gamescope session's `(DISPLAY, WAYLAND_DISPLAY)` by scanning same-uid processes -/// for one whose environment carries `GAMESCOPE_WAYLAND_DISPLAY` (gamescope sets it for everything -/// it runs — Steam, the game, our own nested `sh`). The Wayland value returned is that gamescope -/// socket; `DISPLAY` is the nested Xwayland. Either can be individually absent. -fn discover_session_display_env() -> Option<(Option, Option)> { +/// EVERY nested Xwayland the running gamescope session exposes, as `(DISPLAY, XAUTHORITY)` pairs +/// for the XFixes cursor source (remote-desktop-sweep Phase C). gamescope can run several +/// (`--xwayland-count N` — Steam Gaming Mode uses 2: one for Big Picture, one for the game), and +/// the pointer lives on whichever is FOCUSED — so the source connects to all and follows the one +/// whose pointer moves. The host is not a gamescope child, so gamescope's auth cookie rides along +/// when a process exposes it. Empty when no gamescope session is running / none exposes a `DISPLAY`. +#[cfg(target_os = "linux")] +pub(crate) fn xwayland_cursor_targets() -> Vec<(String, Option)> { + // SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory. + let uid = unsafe { libc::getuid() }; + let mut out: Vec<(String, Option)> = Vec::new(); + let Ok(entries) = std::fs::read_dir("/proc") else { + return out; + }; + for e in entries.flatten() { + let name = e.file_name(); + let Some(pid_str) = name.to_str() else { + continue; + }; + if !pid_str.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + let Ok(md) = std::fs::metadata(e.path()) else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if md.uid() != uid { + continue; + } + let Ok(raw) = std::fs::read(e.path().join("environ")) else { + continue; + }; + let (mut display, mut is_gamescope, mut xauth) = (None, false, None); + for kv in raw.split(|&b| b == 0) { + let kv = String::from_utf8_lossy(kv); + if kv.starts_with("GAMESCOPE_WAYLAND_DISPLAY=") { + is_gamescope = true; + } else if let Some(v) = kv.strip_prefix("DISPLAY=") { + if !v.is_empty() { + display = Some(v.to_string()); + } + } else if let Some(v) = kv.strip_prefix("XAUTHORITY=") { + if !v.is_empty() { + xauth = Some(v.to_string()); + } + } + } + if let (true, Some(d)) = (is_gamescope, display) { + // Distinct DISPLAY only; prefer the first non-empty XAUTHORITY seen for it. + match out.iter_mut().find(|(dd, _)| *dd == d) { + Some((_, xa)) if xa.is_none() => *xa = xauth, + Some(_) => {} + None => out.push((d, xauth)), + } + } + } + out +} + +/// Find the live gamescope session's `(DISPLAY, WAYLAND_DISPLAY, XAUTHORITY)` by scanning same-uid +/// processes for one whose environment carries `GAMESCOPE_WAYLAND_DISPLAY` (gamescope sets it for +/// everything it runs — Steam, the game, our own nested `sh`). The Wayland value returned is that +/// gamescope socket; `DISPLAY` is the nested Xwayland; `XAUTHORITY` is its auth file (for X +/// clients that aren't gamescope children). Any one can be individually absent. +fn discover_session_display_env() -> Option<(Option, Option, Option)> { // SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory. let uid = unsafe { libc::getuid() }; for e in std::fs::read_dir("/proc").ok()?.flatten() { @@ -560,6 +620,7 @@ fn discover_session_display_env() -> Option<(Option, Option)> { }; let mut display = None; let mut gs_wayland = None; + let mut xauth = None; for kv in raw.split(|&b| b == 0) { let kv = String::from_utf8_lossy(kv); if let Some(v) = kv.strip_prefix("GAMESCOPE_WAYLAND_DISPLAY=") { @@ -570,11 +631,15 @@ fn discover_session_display_env() -> Option<(Option, Option)> { if !v.is_empty() { display = Some(v.to_string()); } + } else if let Some(v) = kv.strip_prefix("XAUTHORITY=") { + if !v.is_empty() { + xauth = Some(v.to_string()); + } } } // Only a process INSIDE a gamescope session (it has the marker var) is a valid source. if gs_wayland.is_some() { - return Some((display, gs_wayland)); + return Some((display, gs_wayland, xauth)); } } None diff --git a/crates/pf-vdisplay/src/vdisplay/routing.rs b/crates/pf-vdisplay/src/vdisplay/routing.rs index 65a21bc5..62f0b6e5 100644 --- a/crates/pf-vdisplay/src/vdisplay/routing.rs +++ b/crates/pf-vdisplay/src/vdisplay/routing.rs @@ -162,6 +162,15 @@ pub fn launch_into_gamescope_session(cmd: &str) -> Result { gamescope::launch_into_session(cmd) } +/// Every nested Xwayland `(DISPLAY, XAUTHORITY)` of the running gamescope session for the XFixes +/// cursor source (remote-desktop-sweep Phase C) — gamescope can run several, and the pointer is on +/// whichever is focused. Empty when no gamescope session is running / it exposes no Xwayland (the +/// host then leaves gamescope cursorless, today's behaviour). +#[cfg(target_os = "linux")] +pub fn gamescope_xwayland_cursor_targets() -> Vec<(String, Option)> { + gamescope::xwayland_cursor_targets() +} + /// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a /// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the /// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index a6ef8c18..4fe4e4cb 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1000,18 +1000,21 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option tracing::warn!(error = %format!("{e2:#}"), "capture loss: opening the newly-detected compositor failed — retrying"), @@ -2110,6 +2139,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option