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
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:
@@ -48,7 +48,9 @@ pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
|
||||
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<u16> {
|
||||
match vk {
|
||||
|
||||
@@ -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<InputEvent>, 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<InputEvent>, 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<RemoteDesktop>)>,
|
||||
Box<dyn Send>,
|
||||
ei::Context,
|
||||
reis::tokio::EiConvertEventStream,
|
||||
);
|
||||
|
||||
/// Reach an EIS server per `source` and run the EI sender handshake.
|
||||
async fn connect(source: EiSource) -> Result<Connected> {
|
||||
let (portal, stream) = match source {
|
||||
let (keepalive, stream): (Box<dyn Send>, 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<Connected> {
|
||||
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<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
|
||||
/// the nested app launches), then connect. A bare name is resolved against `XDG_RUNTIME_DIR`,
|
||||
/// mirroring libei's own `LIBEI_SOCKET` semantics.
|
||||
|
||||
Reference in New Issue
Block a user