fix(inject/mutter): GNOME input via Mutter's direct EIS, not the xdg portal
ci / rust (push) Successful in 56s
ci / web (push) Failing after 35s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / docs-site (push) Failing after 38s
apple / swift (push) Successful in 1m15s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:45:10 +00:00
parent 94552331ef
commit 0c4cfa40be
2 changed files with 88 additions and 10 deletions
+22 -1
View File
@@ -48,7 +48,9 @@ pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
Backend::Libei => { Backend::Libei => {
#[cfg(target_os = "linux")] #[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"))] #[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. /// 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<u16> { pub fn vk_to_evdev(vk: u8) -> Option<u16> {
match vk { match vk {
+66 -9
View File
@@ -27,10 +27,12 @@ use ashpd::desktop::{
}, },
CreateSessionOptions, PersistMode, CreateSessionOptions, PersistMode,
}; };
use ashpd::zbus;
use futures_util::StreamExt; use futures_util::StreamExt;
use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::input::{InputEvent, InputKind};
use reis::ei; use reis::ei;
use reis::event::{DeviceCapability, EiEvent}; use reis::event::{DeviceCapability, EiEvent};
use std::collections::HashMap;
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
@@ -41,8 +43,14 @@ const SCROLL_HORIZONTAL: u32 = 1;
/// Where to find the EIS server. /// Where to find the EIS server.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum EiSource { 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, 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 /// A file containing the EIS socket path/name (gamescope's relayed `LIBEI_SOCKET`); polled
/// until it appears, since the compositor may still be starting. /// until it appears, since the compositor may still be starting.
SocketPathFile(std::path::PathBuf), SocketPathFile(std::path::PathBuf),
@@ -101,7 +109,7 @@ 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 (_portal, context, mut events) = match tokio::time::timeout( let (_keepalive, context, mut events) = match tokio::time::timeout(
Duration::from_secs(30), Duration::from_secs(30),
connect(source), connect(source),
) )
@@ -157,22 +165,27 @@ async fn session_main(mut rx: UnboundedReceiver<InputEvent>, source: EiSource) {
} }
} }
/// Tie down the verbose tuple the connect step returns. The portal pair must stay alive for /// Tie down the verbose tuple the connect step returns. The keep-alive must stay alive for the
/// the whole session (dropping it closes the EIS connection); `None` for the direct-socket path. /// whole session dropping the portal/Mutter session closes the EIS connection; for the
/// direct-socket path it's `Box::new(())`.
type Connected = ( type Connected = (
Option<(RemoteDesktop, ashpd::desktop::Session<RemoteDesktop>)>, Box<dyn Send>,
ei::Context, ei::Context,
reis::tokio::EiConvertEventStream, reis::tokio::EiConvertEventStream,
); );
/// Reach an EIS server per `source` and run the EI sender handshake. /// Reach an EIS server per `source` and run the EI sender handshake.
async fn connect(source: EiSource) -> Result<Connected> { async fn connect(source: EiSource) -> Result<Connected> {
let (portal, stream) = match source { let (keepalive, stream): (Box<dyn Send>, UnixStream) = match source {
EiSource::Portal => { EiSource::Portal => {
let (rd, session, fd) = connect_portal().await?; 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}"))?; 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 // Bound the handshake. `UnixStream::connect` to a socket *file* succeeds the moment the path
@@ -188,7 +201,7 @@ async fn connect(source: EiSource) -> Result<Connected> {
anyhow!("EI handshake timed out (EIS server not responding — stale/half-ready socket?)") anyhow!("EI handshake timed out (EIS server not responding — stale/half-ready socket?)")
})? })?
.map_err(|e| anyhow!("EI handshake: {e}"))?; .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. /// 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)) 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<dyn Send>, 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 /// 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`, /// the nested app launches), then connect. A bare name is resolved against `XDG_RUNTIME_DIR`,
/// mirroring libei's own `LIBEI_SOCKET` semantics. /// mirroring libei's own `LIBEI_SOCKET` semantics.