From 0c4cfa40bed81f6de80dc5f9daf9ffae608e0337 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 12 Jun 2026 12:45:10 +0000 Subject: [PATCH] fix(inject/mutter): GNOME input via Mutter's direct EIS, not the xdg portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a headless GNOME host the xdg-desktop-portal RemoteDesktop Start() blocks on an interactive "Allow remote control?" approval nobody can click, so libei input timed out ("EIS setup timed out") and neither mouse nor keyboard worked — even though video worked (it uses Mutter's direct RemoteDesktop API). Add EiSource::MutterEis: obtain the EIS fd from org.gnome.Mutter.RemoteDesktop.Session.ConnectToEIS (CreateSession → Start → ConnectToEIS), no portal and no approval. Selected for GNOME/Mutter; KWin keeps the RemoteDesktop portal, gamescope keeps its own EIS socket. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/inject.rs | 23 ++++++- crates/punktfunk-host/src/inject/libei.rs | 75 ++++++++++++++++++++--- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index b88c6e8..e537a29 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -48,7 +48,9 @@ pub fn open(backend: Backend) -> Result> { Backend::Libei => { #[cfg(target_os = "linux")] { - Ok(Box::new(libei::LibeiInjector::open()?)) + Ok(Box::new( + libei::LibeiInjector::open_with(libei_ei_source())?, + )) } #[cfg(not(target_os = "linux"))] { @@ -105,6 +107,25 @@ pub fn default_backend() -> Backend { } } +/// How the libei backend reaches its EIS server. KWin goes through the `RemoteDesktop` *portal* +/// (with a pre-seeded grant), but GNOME's portal `Start()` needs an interactive approval a +/// headless host can't answer — so GNOME goes straight to Mutter's *direct* RemoteDesktop EIS +/// (`org.gnome.Mutter.RemoteDesktop`), the same direct API the Mutter video backend uses. +#[cfg(target_os = "linux")] +fn libei_ei_source() -> libei::EiSource { + let gnome = std::env::var("PUNKTFUNK_COMPOSITOR") + .is_ok_and(|v| v.trim().eq_ignore_ascii_case("mutter")) + || std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .to_ascii_uppercase() + .contains("GNOME"); + if gnome { + libei::EiSource::MutterEis + } else { + libei::EiSource::Portal + } +} + /// Map a Windows Virtual-Key code (as sent by Moonlight/GameStream) to a Linux evdev key code. pub fn vk_to_evdev(vk: u8) -> Option { match vk { diff --git a/crates/punktfunk-host/src/inject/libei.rs b/crates/punktfunk-host/src/inject/libei.rs index 7ad7033..aedd59e 100644 --- a/crates/punktfunk-host/src/inject/libei.rs +++ b/crates/punktfunk-host/src/inject/libei.rs @@ -27,10 +27,12 @@ use ashpd::desktop::{ }, CreateSessionOptions, PersistMode, }; +use ashpd::zbus; use futures_util::StreamExt; use punktfunk_core::input::{InputEvent, InputKind}; use reis::ei; use reis::event::{DeviceCapability, EiEvent}; +use std::collections::HashMap; use std::os::unix::net::UnixStream; use std::time::{Duration, Instant}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; @@ -41,8 +43,14 @@ const SCROLL_HORIZONTAL: u32 = 1; /// Where to find the EIS server. #[derive(Clone, Debug)] pub enum EiSource { - /// `org.freedesktop.portal.RemoteDesktop` (KWin, GNOME/Mutter). + /// `org.freedesktop.portal.RemoteDesktop` via `ashpd` (KWin — a pre-seeded grant avoids the + /// approval dialog). Portal, + /// Mutter's *direct* `org.gnome.Mutter.RemoteDesktop` EIS (GNOME). Unlike the xdg portal, this + /// needs no interactive "Allow remote control?" approval — which a headless host can't answer, + /// so the portal's `Start()` would just time out. Mirrors how the Mutter *video* backend uses + /// the same direct API. + MutterEis, /// 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), @@ -101,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 (_portal, context, mut events) = match tokio::time::timeout( + let (_keepalive, context, mut events) = match tokio::time::timeout( Duration::from_secs(30), connect(source), ) @@ -157,22 +165,27 @@ async fn session_main(mut rx: UnboundedReceiver, source: EiSource) { } } -/// 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. +/// Tie down the verbose tuple the connect step returns. The keep-alive must stay alive for the +/// whole session — dropping the portal/Mutter session closes the EIS connection; for the +/// direct-socket path it's `Box::new(())`. type Connected = ( - Option<(RemoteDesktop, ashpd::desktop::Session)>, + Box, ei::Context, reis::tokio::EiConvertEventStream, ); /// Reach an EIS server per `source` and run the EI sender handshake. async fn connect(source: EiSource) -> Result { - let (portal, stream) = match source { + let (keepalive, stream): (Box, UnixStream) = match source { EiSource::Portal => { let (rd, session, fd) = connect_portal().await?; - (Some((rd, session)), UnixStream::from(fd)) + (Box::new((rd, session)), UnixStream::from(fd)) } - EiSource::SocketPathFile(file) => (None, connect_socket_file(&file).await?), + EiSource::MutterEis => { + let (keepalive, fd) = connect_mutter().await?; + (keepalive, UnixStream::from(fd)) + } + EiSource::SocketPathFile(file) => (Box::new(()), connect_socket_file(&file).await?), }; 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 @@ -188,7 +201,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((portal, context, events)) + Ok((keepalive, context, events)) } /// Open a RemoteDesktop portal session (pointer + keyboard) and obtain the EIS socket fd. @@ -230,6 +243,50 @@ async fn connect_portal() -> Result<( Ok((rd, session, fd)) } +/// GNOME path: get the EIS socket fd from Mutter's *direct* `org.gnome.Mutter.RemoteDesktop` API +/// (`CreateSession` → `Start` → `ConnectToEIS`). No xdg portal is involved, so there is no +/// interactive "Allow remote control?" approval to satisfy — exactly why [`connect_portal`] times +/// out on a headless GNOME host. (Same direct API the Mutter *video* backend uses.) The returned +/// keep-alive owns the D-Bus connection + session; dropping it tears the Mutter session down and +/// closes the EIS connection (Mutter sessions die with their D-Bus connection). +async fn connect_mutter() -> Result<(Box, std::os::fd::OwnedFd)> { + use zbus::zvariant::{OwnedObjectPath, Value}; + let conn = zbus::Connection::session() + .await + .map_err(|e| anyhow!("connect session D-Bus (Mutter RemoteDesktop): {e}"))?; + let rd = zbus::Proxy::new( + &conn, + "org.gnome.Mutter.RemoteDesktop", + "/org/gnome/Mutter/RemoteDesktop", + "org.gnome.Mutter.RemoteDesktop", + ) + .await + .map_err(|e| anyhow!("Mutter RemoteDesktop proxy (is gnome-shell running?): {e}"))?; + let session_path: OwnedObjectPath = rd + .call("CreateSession", &()) + .await + .map_err(|e| anyhow!("Mutter RemoteDesktop.CreateSession: {e}"))?; + let session = zbus::Proxy::new( + &conn, + "org.gnome.Mutter.RemoteDesktop", + session_path, + "org.gnome.Mutter.RemoteDesktop.Session", + ) + .await + .map_err(|e| anyhow!("Mutter RemoteDesktop.Session proxy: {e}"))?; + session + .call_method("Start", &()) + .await + .map_err(|e| anyhow!("Mutter RemoteDesktop.Session.Start: {e}"))?; + let options: HashMap<&str, Value> = HashMap::new(); + let fd: zbus::zvariant::OwnedFd = session + .call("ConnectToEIS", &(options,)) + .await + .map_err(|e| anyhow!("Mutter RemoteDesktop.Session.ConnectToEIS: {e}"))?; + tracing::info!("libei: connected to Mutter's direct RemoteDesktop EIS (no portal approval)"); + Ok((Box::new((conn, session)), std::os::fd::OwnedFd::from(fd))) +} + /// 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.