From 35923080fb0aa6a1343bab04078aa5ea9a96f7cb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 22 Jul 2026 10:53:14 +0200 Subject: [PATCH] fix(host/linux): scale degenerate-region absolute coords by the session's real output size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw-client-pixels fallback is exact only while the managed session runs at the client's mode. When they diverge — foreign-gamescope attach at its own resolution, supersample/under-render, transitions — raw pixels drift or land out of range. The EIS relay file now carries the session's current output size as a second "WxH" line (from current_gamescope_output_size()); the injector scales normalized client positions into it, keeping raw pixels only as the last resort. Co-Authored-By: Claude Fable 5 --- crates/pf-inject/src/inject/linux/libei.rs | 70 +++++++++++++------ .../src/vdisplay/linux/gamescope.rs | 32 ++++++--- 2 files changed, 74 insertions(+), 28 deletions(-) diff --git a/crates/pf-inject/src/inject/linux/libei.rs b/crates/pf-inject/src/inject/linux/libei.rs index 6d40ac45..1737319a 100644 --- a/crates/pf-inject/src/inject/linux/libei.rs +++ b/crates/pf-inject/src/inject/linux/libei.rs @@ -109,7 +109,7 @@ async fn session_main(mut rx: UnboundedReceiver, source: EiSource) { // Keep `_rd`/`_session` bound for the whole loop — dropping the portal session closes the // EIS connection. Bound the setup so a headless approval dialog (un-bypassed grant) can't // hang the worker forever. - let (_keepalive, context, mut events) = match tokio::time::timeout( + let (_keepalive, context, mut events, output_hint) = match tokio::time::timeout( Duration::from_secs(30), connect(source), ) @@ -130,6 +130,7 @@ async fn session_main(mut rx: UnboundedReceiver, source: EiSource) { tracing::info!("libei: EIS connected — awaiting devices"); let mut state = EiState::new(); + state.output_hint = output_hint; // Watchdog: a healthy EIS server adds + resumes an input device within a beat of the handshake. // If none has resumed by this deadline, the connection is dead-on-arrival (stale/half-ready // gamescope socket the handshake passed but no real server is behind) — exit so the next @@ -177,21 +178,29 @@ type Connected = ( Box, ei::Context, reis::tokio::EiConvertEventStream, + // The compositor's output size ("WxH" relay-file hint) — the scale target for absolute + // coordinates when the EIS advertises only a degenerate region (gamescope). `None` for + // the portal/Mutter paths, whose regions are real. + Option<(u32, u32)>, ); /// Reach an EIS server per `source` and run the EI sender handshake. async fn connect(source: EiSource) -> Result { - let (keepalive, stream): (Box, UnixStream) = match source { - EiSource::Portal => { - let (rd, session, fd) = connect_portal().await?; - (Box::new((rd, session)), UnixStream::from(fd)) - } - EiSource::MutterEis => { - let (keepalive, fd) = connect_mutter().await?; - (keepalive, UnixStream::from(fd)) - } - EiSource::SocketPathFile(file) => (Box::new(()), connect_socket_file(&file).await?), - }; + let (keepalive, stream, output_hint): (Box, UnixStream, Option<(u32, u32)>) = + match source { + EiSource::Portal => { + let (rd, session, fd) = connect_portal().await?; + (Box::new((rd, session)), UnixStream::from(fd), None) + } + EiSource::MutterEis => { + let (keepalive, fd) = connect_mutter().await?; + (keepalive, UnixStream::from(fd), None) + } + EiSource::SocketPathFile(file) => { + let (stream, hint) = connect_socket_file(&file).await?; + (Box::new(()), stream, hint) + } + }; let context = ei::Context::new(stream).map_err(|e| anyhow!("reis EI context: {e}"))?; // Bound the handshake. `UnixStream::connect` to a socket *file* succeeds the moment the path // exists, but a stale/half-ready gamescope (its socket created early in startup, or left behind @@ -206,7 +215,7 @@ async fn connect(source: EiSource) -> Result { anyhow!("EI handshake timed out (EIS server not responding — stale/half-ready socket?)") })? .map_err(|e| anyhow!("EI handshake: {e}"))?; - Ok((keepalive, context, events)) + Ok((keepalive, context, events, output_hint)) } /// Open a RemoteDesktop portal session (pointer + keyboard) and obtain the EIS socket fd. @@ -294,8 +303,10 @@ async fn connect_mutter() -> Result<(Box, std::os::fd::OwnedFd)> { /// Poll `file` for the EIS socket path (the gamescope backend relays `LIBEI_SOCKET` there once /// the nested app launches), then connect. A bare name is resolved against `XDG_RUNTIME_DIR`, -/// mirroring libei's own `LIBEI_SOCKET` semantics. -async fn connect_socket_file(file: &std::path::Path) -> Result { +/// mirroring libei's own `LIBEI_SOCKET` semantics. Line 2 of the relay file, when present, +/// carries the compositor's output size as `WxH` — returned as the absolute-coordinate scale +/// hint (gamescope's EIS region is degenerate, so the geometry can't come from the protocol). +async fn connect_socket_file(file: &std::path::Path) -> Result<(UnixStream, Option<(u32, u32)>)> { // The relay file is rewritten each session with the CURRENT gamescope's `LIBEI_SOCKET`, and the // socket may not be `listen()`ing the instant its name appears — or the file may briefly still // hold a prior, now-dead session's name (the host-lifetime injector reconnecting between @@ -319,7 +330,12 @@ async fn connect_socket_file(file: &std::path::Path) -> Result { )); } if let Ok(s) = std::fs::read_to_string(file) { - let name = s.trim(); + let mut file_lines = s.lines(); + let name = file_lines.next().unwrap_or("").trim(); + let hint = file_lines.next().and_then(|l| { + let (w, h) = l.trim().split_once('x')?; + Some((w.parse::().ok()?, h.parse::().ok()?)) + }); if !name.is_empty() { let full = if name.starts_with('/') { std::path::PathBuf::from(name) @@ -334,7 +350,7 @@ async fn connect_socket_file(file: &std::path::Path) -> Result { logged = name.to_string(); } match UnixStream::connect(&full) { - Ok(stream) => return Ok(stream), + Ok(stream) => return Ok((stream, hint)), // Refused = socket file exists but no listener yet (or a dead session); // NotFound = path not created yet. Both heal once the live gamescope's EIS is // up — retry. Anything else (e.g. permission) is a real failure. @@ -389,6 +405,10 @@ struct EiState { /// The touch id currently degraded to the absolute pointer ([`EiState::degrade_touch`]) — /// the "primary finger" while the EIS has no touchscreen device. `None` between touches. degraded_touch: Option, + /// The compositor's output size (relay-file "WxH" hint) — scale target for absolute + /// coordinates when the device's region is degenerate/absent (gamescope). Without it the + /// fallback is raw client pixels, correct only when the stream runs at the output's size. + output_hint: Option<(u32, u32)>, } /// Is this EIS region a plausible OUTPUT geometry — something to map normalized coordinates @@ -435,6 +455,7 @@ impl EiState { held_buttons: Vec::new(), held_touches: Vec::new(), degraded_touch: None, + output_hint: None, } } @@ -718,7 +739,13 @@ impl EiState { region.x as f32 + nx * region.width as f32, region.y as f32 + ny * region.height as f32, ), - None => (ev.x as f32, ev.y as f32), + // Degenerate/absent region: scale into the relay-file output hint + // (correct even when the client streams at a different resolution + // than the session runs); raw client pixels as the last resort. + None => match self.output_hint { + Some((ow, oh)) => (nx * ow as f32, ny * oh as f32), + None => (ev.x as f32, ev.y as f32), + }, }; p.motion_absolute(x, y); } @@ -789,13 +816,16 @@ impl EiState { Some(t) if w > 0.0 && h > 0.0 => { let nx = (ev.x as f32 / w).clamp(0.0, 1.0); let ny = (ev.y as f32 / h).clamp(0.0, 1.0); - // Same degenerate-region fallback as MouseMoveAbs: raw client pixels. + // Same degenerate-region fallback ladder as MouseMoveAbs. let (x, y) = match slot.regions().first().filter(|r| sane_region(r)) { Some(region) => ( region.x as f32 + nx * region.width as f32, region.y as f32 + ny * region.height as f32, ), - None => (ev.x as f32, ev.y as f32), + None => match self.output_hint { + Some((ow, oh)) => (nx * ow as f32, ny * oh as f32), + None => (ev.x as f32, ev.y as f32), + }, }; if ev.kind == InputKind::TouchDown { t.down(ev.code, x, y); diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 420409c4..f792d13f 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -1259,15 +1259,31 @@ pub fn start_restore_worker() -> std::sync::Arc<()> { /// session). Shared by the attach and host-managed-session paths. fn point_injector_at_eis() { match find_gamescope_eis_socket() { - Some(sock) => match std::fs::write(ei_socket_file(), &sock) { - Ok(()) => { - tracing::info!(socket = %sock, "gamescope: pointed injector at the session's EIS socket") + Some(sock) => { + // Relay format: line 1 = socket, optional line 2 = the session's CURRENT output + // size as "WxH". gamescope's EIS advertises only a degenerate INT32_MAX region, so + // the injector can't learn the output geometry from the protocol — the hint lets + // it scale normalized client positions correctly even when the client streams at + // a different resolution than the session runs (foreign attach, supersample). + let size = current_gamescope_output_size(); + let body = match size { + Some((w, h)) => format!("{sock}\n{w}x{h}"), + None => sock.clone(), + }; + match std::fs::write(ei_socket_file(), body) { + Ok(()) => { + tracing::info!( + socket = %sock, + output = ?size, + "gamescope: pointed injector at the session's EIS socket" + ) + } + Err(e) => tracing::warn!( + error = %e, + "gamescope: could not write the EIS relay file — input may not reach the session" + ), } - Err(e) => tracing::warn!( - error = %e, - "gamescope: could not write the EIS relay file — input may not reach the session" - ), - }, + } None => tracing::warn!( "gamescope: no connectable gamescope EIS socket found — input won't reach the session" ),