From 7f3897e0d30c2739239c13c1ab383d4ea5db4abd Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 9 Jun 2026 22:34:27 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20M2=20=E2=80=94=20gamescope=20input=20vi?= =?UTF-8?q?a=20its=20EIS=20socket=20(SteamOS-like=20input=20path)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gamescope runs its own EIS server and exports the socket to its children as LIBEI_SOCKET — no portal involved. The gamescope backend now launches the nested app through a tiny shell wrapper that relays that value to /tmp/lumen-gamescope-ei; the libei injector gains an EiSource enum (Portal | SocketPathFile) and connects a UnixStream directly to gamescope's socket (polling until the app has started), then runs the identical reis sender flow. Backend::GamescopeEi is auto-selected when LUMEN_COMPOSITOR=gamescope (LUMEN_INPUT_BACKEND=gamescope overrides). Validated end-to-end: input-test against a headless gamescope running xev — 129 MotionNotify/KeyPress/ButtonPress events delivered into the nested X app ("Gamescope Virtual Input" device bound, sender handshake + emulation working). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/lumen-host/src/inject.rs | 26 ++++- crates/lumen-host/src/inject/libei.rs | 123 ++++++++++++++------ crates/lumen-host/src/vdisplay.rs | 7 ++ crates/lumen-host/src/vdisplay/gamescope.rs | 16 ++- 4 files changed, 134 insertions(+), 38 deletions(-) diff --git a/crates/lumen-host/src/inject.rs b/crates/lumen-host/src/inject.rs index 2bd0675..f297857 100644 --- a/crates/lumen-host/src/inject.rs +++ b/crates/lumen-host/src/inject.rs @@ -26,6 +26,9 @@ pub enum Backend { WlrVirtual, /// libei via `reis` — Wayland-native (RemoteDesktop portal). Not yet implemented. Libei, + /// libei directly against gamescope's own EIS socket (no portal): input lands in the + /// nested game — the SteamOS-like session. + GamescopeEi, /// `/dev/uinput` — universal fallback (but invisible to `WLR_LIBINPUT_NO_DEVICES=1`). Uinput, } @@ -52,19 +55,35 @@ pub fn open(backend: Backend) -> Result> { anyhow::bail!("libei input requires Linux + a RemoteDesktop portal") } } + Backend::GamescopeEi => { + #[cfg(target_os = "linux")] + { + Ok(Box::new(libei::LibeiInjector::open_with( + libei::EiSource::SocketPathFile( + crate::vdisplay::gamescope_ei_socket_file().into(), + ), + )?)) + } + #[cfg(not(target_os = "linux"))] + { + anyhow::bail!("gamescope EIS input requires Linux") + } + } other => anyhow::bail!("injection backend {other:?} not implemented"), } } -/// Pick the injection backend for the current session. wlroots/Sway only implements the +/// Pick the injection backend for the current session. gamescope hosts its own EIS server (no +/// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the /// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input /// protocols. KWin and GNOME implement RemoteDesktop but not the wlr protocols, so use libei. -/// `LUMEN_INPUT_BACKEND=wlr|libei` overrides the auto-detection. +/// `LUMEN_INPUT_BACKEND=wlr|libei|gamescope|uinput` overrides the auto-detection. pub fn default_backend() -> Backend { if let Ok(v) = std::env::var("LUMEN_INPUT_BACKEND") { match v.trim().to_ascii_lowercase().as_str() { "wlr" | "wlroots" | "wlrvirtual" => return Backend::WlrVirtual, "libei" | "ei" | "portal" => return Backend::Libei, + "gamescope" | "gamescope-ei" => return Backend::GamescopeEi, "uinput" => return Backend::Uinput, other => tracing::warn!( value = other, @@ -72,6 +91,9 @@ pub fn default_backend() -> Backend { ), } } + if std::env::var("LUMEN_COMPOSITOR").is_ok_and(|v| v.trim().eq_ignore_ascii_case("gamescope")) { + return Backend::GamescopeEi; + } let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); let d = desktop.to_ascii_uppercase(); if d.contains("KDE") || d.contains("GNOME") { diff --git a/crates/lumen-host/src/inject/libei.rs b/crates/lumen-host/src/inject/libei.rs index 0aaaa00..4b0b69d 100644 --- a/crates/lumen-host/src/inject/libei.rs +++ b/crates/lumen-host/src/inject/libei.rs @@ -1,14 +1,18 @@ -//! libei input injection via the RemoteDesktop portal — the portable path for KWin and -//! GNOME/Mutter, which (unlike wlroots/Sway) implement `org.freedesktop.portal.RemoteDesktop` -//! and route emulated input through libei/EIS rather than the wlr virtual-input protocols. +//! libei input injection — the portable EI-sender path. //! -//! We use `ashpd` to open a RemoteDesktop session and obtain the EIS socket fd, then `reis` to -//! drive it as an EI *sender*: bind the seat's pointer/keyboard/scroll/button capabilities and, -//! per device, `start_emulating` → emit → `frame`. The portal session and the EIS connection -//! must stay alive and the event stream must be polled continuously (resume/pause/ping/modifier -//! traffic), so the whole thing runs on a dedicated thread with its own tokio runtime; the -//! synchronous control thread reaches it through an unbounded channel and [`LibeiInjector::inject`] -//! merely enqueues. +//! Two ways to reach an EIS server ([`EiSource`]): +//! * **Portal** — `org.freedesktop.portal.RemoteDesktop` via `ashpd` (KWin, GNOME/Mutter), +//! which hands us the EIS socket fd after the session grant. +//! * **Socket** — connect directly to a compositor's own EIS socket. gamescope runs an EIS +//! server and exports its path to its children as `LIBEI_SOCKET`; our gamescope backend +//! relays that path through a file so the injector can connect (no portal involved). +//! +//! Either way, `reis` drives the connection as an EI *sender*: bind the seat's +//! pointer/keyboard/scroll/button capabilities and, per device, `start_emulating` → emit → +//! `frame`. The session and the EIS connection must stay alive and the event stream must be +//! polled continuously (resume/pause/ping/modifier traffic), so the whole thing runs on a +//! dedicated thread with its own tokio runtime; the synchronous control thread reaches it +//! through an unbounded channel and [`LibeiInjector::inject`] merely enqueues. //! //! Keyboard codes are Linux evdev (the same space our VK→evdev table produces) and the //! compositor supplies the keymap, so — unlike the wlr path — there is no keymap to upload and @@ -34,6 +38,16 @@ use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; /// `code` value marking a horizontal scroll event (mirrors `gamestream::input`). const SCROLL_HORIZONTAL: u32 = 1; +/// Where to find the EIS server. +#[derive(Clone, Debug)] +pub enum EiSource { + /// `org.freedesktop.portal.RemoteDesktop` (KWin, GNOME/Mutter). + Portal, + /// A file containing the EIS socket path/name (gamescope's relayed `LIBEI_SOCKET`); polled + /// until it appears, since the compositor may still be starting. + SocketPathFile(std::path::PathBuf), +} + /// Handle held by the control thread; forwards events to the libei worker thread. pub struct LibeiInjector { tx: UnboundedSender, @@ -41,15 +55,19 @@ pub struct LibeiInjector { impl LibeiInjector { pub fn open() -> Result { + Self::open_with(EiSource::Portal) + } + + pub fn open_with(source: EiSource) -> Result { let (tx, rx) = unbounded_channel::(); std::thread::Builder::new() .name("lumen-libei".into()) - .spawn(move || worker(rx)) + .spawn(move || worker(rx, source)) .map_err(|e| anyhow!("spawn libei worker thread: {e}"))?; - // Return immediately — the portal handshake must NOT run on the caller's (control) - // thread, or a slow/denied portal would freeze the ENet control stream and drop the - // client. The worker establishes the session asynchronously and logs its status; - // events enqueue until devices resume (a few startup events may be dropped). + // Return immediately — the portal/socket handshake must NOT run on the caller's + // (control) thread, or a slow/denied setup would freeze the ENet control stream and + // drop the client. The worker establishes the session asynchronously and logs its + // status; events enqueue until devices resume (a few startup events may be dropped). Ok(Self { tx }) } } @@ -63,7 +81,7 @@ impl InputInjector for LibeiInjector { } /// Worker thread entry: build a tokio runtime and run the session to completion. -fn worker(rx: UnboundedReceiver) { +fn worker(rx: UnboundedReceiver, source: EiSource) { let rt = match tokio::runtime::Builder::new_multi_thread() .worker_threads(1) .enable_all() @@ -75,17 +93,17 @@ fn worker(rx: UnboundedReceiver) { return; } }; - rt.block_on(session_main(rx)); + rt.block_on(session_main(rx, source)); } -/// Open the portal + EIS (bounded), then pump events until disconnect or shutdown. -async fn session_main(mut rx: UnboundedReceiver) { +/// Open the portal/socket + EIS (bounded), then pump events until disconnect or shutdown. +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 (_rd, _session, context, mut events) = match tokio::time::timeout( + let (_portal, context, mut events) = match tokio::time::timeout( Duration::from_secs(30), - connect(), + connect(source), ) .await { @@ -96,7 +114,7 @@ async fn session_main(mut rx: UnboundedReceiver) { } Err(_) => { tracing::error!( - "libei: portal setup timed out (headless approval needed, or kde-authorized grant not seeded / app-id mismatch)" + "libei: EIS setup timed out (headless approval needed / kde-authorized grant not seeded / gamescope socket never appeared)" ); return; } @@ -119,17 +137,37 @@ async fn session_main(mut rx: UnboundedReceiver) { } } -/// Tie down the verbose tuple the connect step returns. +/// Tie down the verbose tuple the connect step returns. The portal pair must stay alive for +/// the whole session (dropping it closes the EIS connection); `None` for the direct-socket path. type Connected = ( - RemoteDesktop, - ashpd::desktop::Session, + Option<(RemoteDesktop, ashpd::desktop::Session)>, ei::Context, reis::tokio::EiConvertEventStream, ); -/// Open a RemoteDesktop portal session (pointer + keyboard), connect to EIS, and run the EI -/// sender handshake. Returns the live portal + EI objects. -async fn connect() -> Result { +/// Reach an EIS server per `source` and run the EI sender handshake. +async fn connect(source: EiSource) -> Result { + let (portal, stream) = match source { + EiSource::Portal => { + let (rd, session, fd) = connect_portal().await?; + (Some((rd, session)), UnixStream::from(fd)) + } + EiSource::SocketPathFile(file) => (None, connect_socket_file(&file).await?), + }; + let context = ei::Context::new(stream).map_err(|e| anyhow!("reis EI context: {e}"))?; + let (_conn, events) = context + .handshake_tokio("lumen-host", ei::handshake::ContextType::Sender) + .await + .map_err(|e| anyhow!("EI handshake: {e}"))?; + Ok((portal, context, events)) +} + +/// Open a RemoteDesktop portal session (pointer + keyboard) and obtain the EIS socket fd. +async fn connect_portal() -> Result<( + RemoteDesktop, + ashpd::desktop::Session, + std::os::fd::OwnedFd, +)> { let rd = RemoteDesktop::new() .await .map_err(|e| anyhow!("open RemoteDesktop portal (is xdg-desktop-portal-kde/gnome running and XDG_CURRENT_DESKTOP set?): {e}"))?; @@ -160,14 +198,29 @@ async fn connect() -> Result { .connect_to_eis(&session, ConnectToEISOptions::default()) .await .map_err(|e| anyhow!("connect_to_eis (RemoteDesktop portal version < 2?): {e}"))?; - let context = - ei::Context::new(UnixStream::from(fd)).map_err(|e| anyhow!("reis EI context: {e}"))?; - let (_conn, events) = context - .handshake_tokio("lumen-host", ei::handshake::ContextType::Sender) - .await - .map_err(|e| anyhow!("EI handshake: {e}"))?; + Ok((rd, session, fd)) +} - Ok((rd, session, context, events)) +/// 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 { + let path = loop { + match std::fs::read_to_string(file) { + Ok(s) if !s.trim().is_empty() => break s.trim().to_string(), + _ => tokio::time::sleep(Duration::from_millis(300)).await, + } + }; + let full = if path.starts_with('/') { + std::path::PathBuf::from(&path) + } else { + let runtime = std::env::var("XDG_RUNTIME_DIR").map_err(|_| { + anyhow!("XDG_RUNTIME_DIR unset (needed to resolve EIS socket '{path}')") + })?; + std::path::Path::new(&runtime).join(&path) + }; + tracing::info!(socket = %full.display(), "libei: connecting to EIS socket"); + UnixStream::connect(&full).map_err(|e| anyhow!("connect EIS socket {}: {e}", full.display())) } /// One EI device and its emulation state. diff --git a/crates/lumen-host/src/vdisplay.rs b/crates/lumen-host/src/vdisplay.rs index 5342a53..0171dbb 100644 --- a/crates/lumen-host/src/vdisplay.rs +++ b/crates/lumen-host/src/vdisplay.rs @@ -109,6 +109,13 @@ pub fn open(compositor: Compositor) -> Result> { } } +/// Path of the file where the gamescope backend relays the nested session's `LIBEI_SOCKET` +/// (gamescope's EIS server) for the input injector. +#[cfg(target_os = "linux")] +pub fn gamescope_ei_socket_file() -> &'static str { + gamescope::EI_SOCKET_FILE +} + #[cfg(target_os = "linux")] mod gamescope; #[cfg(target_os = "linux")] diff --git a/crates/lumen-host/src/vdisplay/gamescope.rs b/crates/lumen-host/src/vdisplay/gamescope.rs index ac7c236..85b0f2a 100644 --- a/crates/lumen-host/src/vdisplay/gamescope.rs +++ b/crates/lumen-host/src/vdisplay/gamescope.rs @@ -71,17 +71,31 @@ impl VirtualDisplay for GamescopeDisplay { } } +/// File where the wrapper below writes gamescope's `LIBEI_SOCKET` (its EIS server socket), +/// read by the libei injector to drive input into the nested app. See [`crate::inject`]. +pub const EI_SOCKET_FILE: &str = "/tmp/lumen-gamescope-ei"; + /// Spawn `gamescope --backend headless -W w -H h -r hz -- `. The app comes from /// `LUMEN_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real -/// game/GL app for actual content). stdout/stderr go to `/tmp/lumen-gamescope.log`. +/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session). +/// stdout/stderr go to `/tmp/lumen-gamescope.log`. The app is launched through a tiny shell +/// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`EI_SOCKET_FILE`] +/// so the input injector can connect to gamescope's EIS server from outside. fn spawn(w: u32, h: u32, hz: u32) -> Result { let app = std::env::var("LUMEN_GAMESCOPE_APP").unwrap_or_else(|_| "sleep infinity".to_string()); + let _ = std::fs::remove_file(EI_SOCKET_FILE); // stale socket path from a previous session let mut cmd = Command::new("gamescope"); cmd.args(["--backend", "headless"]) .args(["-W", &w.to_string()]) .args(["-H", &h.to_string()]) .args(["-r", &hz.to_string()]) .args(["--xwayland-count", "1", "--"]) + .args([ + "sh", + "-c", + &format!("printf %s \"$LIBEI_SOCKET\" > {EI_SOCKET_FILE}; exec \"$@\""), + "sh", + ]) .args(app.split_whitespace()) // Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box). .env("__GLX_VENDOR_LIBRARY_NAME", "nvidia");