feat: M2 — gamescope input via its EIS socket (SteamOS-like input path)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,9 @@ pub enum Backend {
|
|||||||
WlrVirtual,
|
WlrVirtual,
|
||||||
/// libei via `reis` — Wayland-native (RemoteDesktop portal). Not yet implemented.
|
/// libei via `reis` — Wayland-native (RemoteDesktop portal). Not yet implemented.
|
||||||
Libei,
|
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`).
|
/// `/dev/uinput` — universal fallback (but invisible to `WLR_LIBINPUT_NO_DEVICES=1`).
|
||||||
Uinput,
|
Uinput,
|
||||||
}
|
}
|
||||||
@@ -52,19 +55,35 @@ pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
|
|||||||
anyhow::bail!("libei input requires Linux + a RemoteDesktop portal")
|
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"),
|
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
|
/// 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.
|
/// 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 {
|
pub fn default_backend() -> Backend {
|
||||||
if let Ok(v) = std::env::var("LUMEN_INPUT_BACKEND") {
|
if let Ok(v) = std::env::var("LUMEN_INPUT_BACKEND") {
|
||||||
match v.trim().to_ascii_lowercase().as_str() {
|
match v.trim().to_ascii_lowercase().as_str() {
|
||||||
"wlr" | "wlroots" | "wlrvirtual" => return Backend::WlrVirtual,
|
"wlr" | "wlroots" | "wlrvirtual" => return Backend::WlrVirtual,
|
||||||
"libei" | "ei" | "portal" => return Backend::Libei,
|
"libei" | "ei" | "portal" => return Backend::Libei,
|
||||||
|
"gamescope" | "gamescope-ei" => return Backend::GamescopeEi,
|
||||||
"uinput" => return Backend::Uinput,
|
"uinput" => return Backend::Uinput,
|
||||||
other => tracing::warn!(
|
other => tracing::warn!(
|
||||||
value = other,
|
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 desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
|
||||||
let d = desktop.to_ascii_uppercase();
|
let d = desktop.to_ascii_uppercase();
|
||||||
if d.contains("KDE") || d.contains("GNOME") {
|
if d.contains("KDE") || d.contains("GNOME") {
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
//! libei input injection via the RemoteDesktop portal — the portable path for KWin and
|
//! libei input injection — the portable EI-sender path.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! We use `ashpd` to open a RemoteDesktop session and obtain the EIS socket fd, then `reis` to
|
//! Two ways to reach an EIS server ([`EiSource`]):
|
||||||
//! drive it as an EI *sender*: bind the seat's pointer/keyboard/scroll/button capabilities and,
|
//! * **Portal** — `org.freedesktop.portal.RemoteDesktop` via `ashpd` (KWin, GNOME/Mutter),
|
||||||
//! per device, `start_emulating` → emit → `frame`. The portal session and the EIS connection
|
//! which hands us the EIS socket fd after the session grant.
|
||||||
//! must stay alive and the event stream must be polled continuously (resume/pause/ping/modifier
|
//! * **Socket** — connect directly to a compositor's own EIS socket. gamescope runs an EIS
|
||||||
//! traffic), so the whole thing runs on a dedicated thread with its own tokio runtime; the
|
//! server and exports its path to its children as `LIBEI_SOCKET`; our gamescope backend
|
||||||
//! synchronous control thread reaches it through an unbounded channel and [`LibeiInjector::inject`]
|
//! relays that path through a file so the injector can connect (no portal involved).
|
||||||
//! merely enqueues.
|
//!
|
||||||
|
//! 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
|
//! 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
|
//! 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`).
|
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input`).
|
||||||
const SCROLL_HORIZONTAL: u32 = 1;
|
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.
|
/// Handle held by the control thread; forwards events to the libei worker thread.
|
||||||
pub struct LibeiInjector {
|
pub struct LibeiInjector {
|
||||||
tx: UnboundedSender<InputEvent>,
|
tx: UnboundedSender<InputEvent>,
|
||||||
@@ -41,15 +55,19 @@ pub struct LibeiInjector {
|
|||||||
|
|
||||||
impl LibeiInjector {
|
impl LibeiInjector {
|
||||||
pub fn open() -> Result<Self> {
|
pub fn open() -> Result<Self> {
|
||||||
|
Self::open_with(EiSource::Portal)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_with(source: EiSource) -> Result<Self> {
|
||||||
let (tx, rx) = unbounded_channel::<InputEvent>();
|
let (tx, rx) = unbounded_channel::<InputEvent>();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("lumen-libei".into())
|
.name("lumen-libei".into())
|
||||||
.spawn(move || worker(rx))
|
.spawn(move || worker(rx, source))
|
||||||
.map_err(|e| anyhow!("spawn libei worker thread: {e}"))?;
|
.map_err(|e| anyhow!("spawn libei worker thread: {e}"))?;
|
||||||
// Return immediately — the portal handshake must NOT run on the caller's (control)
|
// Return immediately — the portal/socket handshake must NOT run on the caller's
|
||||||
// thread, or a slow/denied portal would freeze the ENet control stream and drop the
|
// (control) thread, or a slow/denied setup would freeze the ENet control stream and
|
||||||
// client. The worker establishes the session asynchronously and logs its status;
|
// drop the client. The worker establishes the session asynchronously and logs its
|
||||||
// events enqueue until devices resume (a few startup events may be dropped).
|
// status; events enqueue until devices resume (a few startup events may be dropped).
|
||||||
Ok(Self { tx })
|
Ok(Self { tx })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +81,7 @@ impl InputInjector for LibeiInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Worker thread entry: build a tokio runtime and run the session to completion.
|
/// Worker thread entry: build a tokio runtime and run the session to completion.
|
||||||
fn worker(rx: UnboundedReceiver<InputEvent>) {
|
fn worker(rx: UnboundedReceiver<InputEvent>, source: EiSource) {
|
||||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||||
.worker_threads(1)
|
.worker_threads(1)
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -75,17 +93,17 @@ fn worker(rx: UnboundedReceiver<InputEvent>) {
|
|||||||
return;
|
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.
|
/// Open the portal/socket + EIS (bounded), then pump events until disconnect or shutdown.
|
||||||
async fn session_main(mut rx: UnboundedReceiver<InputEvent>) {
|
async fn session_main(mut rx: UnboundedReceiver<InputEvent>, source: EiSource) {
|
||||||
// Keep `_rd`/`_session` bound for the whole loop — dropping the portal session closes the
|
// 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
|
// EIS connection. Bound the setup so a headless approval dialog (un-bypassed grant) can't
|
||||||
// hang the worker forever.
|
// 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),
|
Duration::from_secs(30),
|
||||||
connect(),
|
connect(source),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -96,7 +114,7 @@ async fn session_main(mut rx: UnboundedReceiver<InputEvent>) {
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::error!(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -119,17 +137,37 @@ async fn session_main(mut rx: UnboundedReceiver<InputEvent>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 = (
|
type Connected = (
|
||||||
RemoteDesktop,
|
Option<(RemoteDesktop, ashpd::desktop::Session<RemoteDesktop>)>,
|
||||||
ashpd::desktop::Session<RemoteDesktop>,
|
|
||||||
ei::Context,
|
ei::Context,
|
||||||
reis::tokio::EiConvertEventStream,
|
reis::tokio::EiConvertEventStream,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Open a RemoteDesktop portal session (pointer + keyboard), connect to EIS, and run the EI
|
/// Reach an EIS server per `source` and run the EI sender handshake.
|
||||||
/// sender handshake. Returns the live portal + EI objects.
|
async fn connect(source: EiSource) -> Result<Connected> {
|
||||||
async fn connect() -> Result<Connected> {
|
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<RemoteDesktop>,
|
||||||
|
std::os::fd::OwnedFd,
|
||||||
|
)> {
|
||||||
let rd = RemoteDesktop::new()
|
let rd = RemoteDesktop::new()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("open RemoteDesktop portal (is xdg-desktop-portal-kde/gnome running and XDG_CURRENT_DESKTOP set?): {e}"))?;
|
.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<Connected> {
|
|||||||
.connect_to_eis(&session, ConnectToEISOptions::default())
|
.connect_to_eis(&session, ConnectToEISOptions::default())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("connect_to_eis (RemoteDesktop portal version < 2?): {e}"))?;
|
.map_err(|e| anyhow!("connect_to_eis (RemoteDesktop portal version < 2?): {e}"))?;
|
||||||
let context =
|
Ok((rd, session, fd))
|
||||||
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, 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<UnixStream> {
|
||||||
|
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.
|
/// One EI device and its emulation state.
|
||||||
|
|||||||
@@ -109,6 +109,13 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
mod gamescope;
|
mod gamescope;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -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 -- <app>`. The app comes from
|
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
|
||||||
/// `LUMEN_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real
|
/// `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<Child> {
|
fn spawn(w: u32, h: u32, hz: u32) -> Result<Child> {
|
||||||
let app = std::env::var("LUMEN_GAMESCOPE_APP").unwrap_or_else(|_| "sleep infinity".to_string());
|
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");
|
let mut cmd = Command::new("gamescope");
|
||||||
cmd.args(["--backend", "headless"])
|
cmd.args(["--backend", "headless"])
|
||||||
.args(["-W", &w.to_string()])
|
.args(["-W", &w.to_string()])
|
||||||
.args(["-H", &h.to_string()])
|
.args(["-H", &h.to_string()])
|
||||||
.args(["-r", &hz.to_string()])
|
.args(["-r", &hz.to_string()])
|
||||||
.args(["--xwayland-count", "1", "--"])
|
.args(["--xwayland-count", "1", "--"])
|
||||||
|
.args([
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
&format!("printf %s \"$LIBEI_SOCKET\" > {EI_SOCKET_FILE}; exec \"$@\""),
|
||||||
|
"sh",
|
||||||
|
])
|
||||||
.args(app.split_whitespace())
|
.args(app.split_whitespace())
|
||||||
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
||||||
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
|
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
|
||||||
|
|||||||
Reference in New Issue
Block a user