fix(host/linux): scale degenerate-region absolute coords by the session's real output size
The raw-client-pixels fallback is exact only while the managed session runs at the client's mode. When they diverge — foreign-gamescope attach at its own resolution, supersample/under-render, transitions — raw pixels drift or land out of range. The EIS relay file now carries the session's current output size as a second "WxH" line (from current_gamescope_output_size()); the injector scales normalized client positions into it, keeping raw pixels only as the last resort. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -109,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 (_keepalive, context, mut events) = match tokio::time::timeout(
|
let (_keepalive, context, mut events, output_hint) = match tokio::time::timeout(
|
||||||
Duration::from_secs(30),
|
Duration::from_secs(30),
|
||||||
connect(source),
|
connect(source),
|
||||||
)
|
)
|
||||||
@@ -130,6 +130,7 @@ async fn session_main(mut rx: UnboundedReceiver<InputEvent>, source: EiSource) {
|
|||||||
tracing::info!("libei: EIS connected — awaiting devices");
|
tracing::info!("libei: EIS connected — awaiting devices");
|
||||||
|
|
||||||
let mut state = EiState::new();
|
let mut state = EiState::new();
|
||||||
|
state.output_hint = output_hint;
|
||||||
// Watchdog: a healthy EIS server adds + resumes an input device within a beat of the handshake.
|
// Watchdog: a healthy EIS server adds + resumes an input device within a beat of the handshake.
|
||||||
// If none has resumed by this deadline, the connection is dead-on-arrival (stale/half-ready
|
// If none has resumed by this deadline, the connection is dead-on-arrival (stale/half-ready
|
||||||
// gamescope socket the handshake passed but no real server is behind) — exit so the next
|
// gamescope socket the handshake passed but no real server is behind) — exit so the next
|
||||||
@@ -177,21 +178,29 @@ type Connected = (
|
|||||||
Box<dyn Send>,
|
Box<dyn Send>,
|
||||||
ei::Context,
|
ei::Context,
|
||||||
reis::tokio::EiConvertEventStream,
|
reis::tokio::EiConvertEventStream,
|
||||||
|
// The compositor's output size ("WxH" relay-file hint) — the scale target for absolute
|
||||||
|
// coordinates when the EIS advertises only a degenerate region (gamescope). `None` for
|
||||||
|
// the portal/Mutter paths, whose regions are real.
|
||||||
|
Option<(u32, u32)>,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 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 (keepalive, stream): (Box<dyn Send>, UnixStream) = match source {
|
let (keepalive, stream, output_hint): (Box<dyn Send>, UnixStream, Option<(u32, u32)>) =
|
||||||
EiSource::Portal => {
|
match source {
|
||||||
let (rd, session, fd) = connect_portal().await?;
|
EiSource::Portal => {
|
||||||
(Box::new((rd, session)), UnixStream::from(fd))
|
let (rd, session, fd) = connect_portal().await?;
|
||||||
}
|
(Box::new((rd, session)), UnixStream::from(fd), None)
|
||||||
EiSource::MutterEis => {
|
}
|
||||||
let (keepalive, fd) = connect_mutter().await?;
|
EiSource::MutterEis => {
|
||||||
(keepalive, UnixStream::from(fd))
|
let (keepalive, fd) = connect_mutter().await?;
|
||||||
}
|
(keepalive, UnixStream::from(fd), None)
|
||||||
EiSource::SocketPathFile(file) => (Box::new(()), connect_socket_file(&file).await?),
|
}
|
||||||
};
|
EiSource::SocketPathFile(file) => {
|
||||||
|
let (stream, hint) = connect_socket_file(&file).await?;
|
||||||
|
(Box::new(()), stream, hint)
|
||||||
|
}
|
||||||
|
};
|
||||||
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
|
||||||
// exists, but a stale/half-ready gamescope (its socket created early in startup, or left behind
|
// exists, but a stale/half-ready gamescope (its socket created early in startup, or left behind
|
||||||
@@ -206,7 +215,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((keepalive, context, events))
|
Ok((keepalive, context, events, output_hint))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -294,8 +303,10 @@ async fn connect_mutter() -> Result<(Box<dyn Send>, std::os::fd::OwnedFd)> {
|
|||||||
|
|
||||||
/// 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. Line 2 of the relay file, when present,
|
||||||
async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
/// carries the compositor's output size as `WxH` — returned as the absolute-coordinate scale
|
||||||
|
/// hint (gamescope's EIS region is degenerate, so the geometry can't come from the protocol).
|
||||||
|
async fn connect_socket_file(file: &std::path::Path) -> Result<(UnixStream, Option<(u32, u32)>)> {
|
||||||
// The relay file is rewritten each session with the CURRENT gamescope's `LIBEI_SOCKET`, and the
|
// The relay file is rewritten each session with the CURRENT gamescope's `LIBEI_SOCKET`, and the
|
||||||
// socket may not be `listen()`ing the instant its name appears — or the file may briefly still
|
// socket may not be `listen()`ing the instant its name appears — or the file may briefly still
|
||||||
// hold a prior, now-dead session's name (the host-lifetime injector reconnecting between
|
// hold a prior, now-dead session's name (the host-lifetime injector reconnecting between
|
||||||
@@ -319,7 +330,12 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Ok(s) = std::fs::read_to_string(file) {
|
if let Ok(s) = std::fs::read_to_string(file) {
|
||||||
let name = s.trim();
|
let mut file_lines = s.lines();
|
||||||
|
let name = file_lines.next().unwrap_or("").trim();
|
||||||
|
let hint = file_lines.next().and_then(|l| {
|
||||||
|
let (w, h) = l.trim().split_once('x')?;
|
||||||
|
Some((w.parse::<u32>().ok()?, h.parse::<u32>().ok()?))
|
||||||
|
});
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
let full = if name.starts_with('/') {
|
let full = if name.starts_with('/') {
|
||||||
std::path::PathBuf::from(name)
|
std::path::PathBuf::from(name)
|
||||||
@@ -334,7 +350,7 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
|||||||
logged = name.to_string();
|
logged = name.to_string();
|
||||||
}
|
}
|
||||||
match UnixStream::connect(&full) {
|
match UnixStream::connect(&full) {
|
||||||
Ok(stream) => return Ok(stream),
|
Ok(stream) => return Ok((stream, hint)),
|
||||||
// Refused = socket file exists but no listener yet (or a dead session);
|
// Refused = socket file exists but no listener yet (or a dead session);
|
||||||
// NotFound = path not created yet. Both heal once the live gamescope's EIS is
|
// NotFound = path not created yet. Both heal once the live gamescope's EIS is
|
||||||
// up — retry. Anything else (e.g. permission) is a real failure.
|
// up — retry. Anything else (e.g. permission) is a real failure.
|
||||||
@@ -389,6 +405,10 @@ struct EiState {
|
|||||||
/// The touch id currently degraded to the absolute pointer ([`EiState::degrade_touch`]) —
|
/// The touch id currently degraded to the absolute pointer ([`EiState::degrade_touch`]) —
|
||||||
/// the "primary finger" while the EIS has no touchscreen device. `None` between touches.
|
/// the "primary finger" while the EIS has no touchscreen device. `None` between touches.
|
||||||
degraded_touch: Option<u32>,
|
degraded_touch: Option<u32>,
|
||||||
|
/// The compositor's output size (relay-file "WxH" hint) — scale target for absolute
|
||||||
|
/// coordinates when the device's region is degenerate/absent (gamescope). Without it the
|
||||||
|
/// fallback is raw client pixels, correct only when the stream runs at the output's size.
|
||||||
|
output_hint: Option<(u32, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is this EIS region a plausible OUTPUT geometry — something to map normalized coordinates
|
/// Is this EIS region a plausible OUTPUT geometry — something to map normalized coordinates
|
||||||
@@ -435,6 +455,7 @@ impl EiState {
|
|||||||
held_buttons: Vec::new(),
|
held_buttons: Vec::new(),
|
||||||
held_touches: Vec::new(),
|
held_touches: Vec::new(),
|
||||||
degraded_touch: None,
|
degraded_touch: None,
|
||||||
|
output_hint: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,7 +739,13 @@ impl EiState {
|
|||||||
region.x as f32 + nx * region.width as f32,
|
region.x as f32 + nx * region.width as f32,
|
||||||
region.y as f32 + ny * region.height as f32,
|
region.y as f32 + ny * region.height as f32,
|
||||||
),
|
),
|
||||||
None => (ev.x as f32, ev.y as f32),
|
// Degenerate/absent region: scale into the relay-file output hint
|
||||||
|
// (correct even when the client streams at a different resolution
|
||||||
|
// than the session runs); raw client pixels as the last resort.
|
||||||
|
None => match self.output_hint {
|
||||||
|
Some((ow, oh)) => (nx * ow as f32, ny * oh as f32),
|
||||||
|
None => (ev.x as f32, ev.y as f32),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
p.motion_absolute(x, y);
|
p.motion_absolute(x, y);
|
||||||
}
|
}
|
||||||
@@ -789,13 +816,16 @@ impl EiState {
|
|||||||
Some(t) if w > 0.0 && h > 0.0 => {
|
Some(t) if w > 0.0 && h > 0.0 => {
|
||||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||||
// Same degenerate-region fallback as MouseMoveAbs: raw client pixels.
|
// Same degenerate-region fallback ladder as MouseMoveAbs.
|
||||||
let (x, y) = match slot.regions().first().filter(|r| sane_region(r)) {
|
let (x, y) = match slot.regions().first().filter(|r| sane_region(r)) {
|
||||||
Some(region) => (
|
Some(region) => (
|
||||||
region.x as f32 + nx * region.width as f32,
|
region.x as f32 + nx * region.width as f32,
|
||||||
region.y as f32 + ny * region.height as f32,
|
region.y as f32 + ny * region.height as f32,
|
||||||
),
|
),
|
||||||
None => (ev.x as f32, ev.y as f32),
|
None => match self.output_hint {
|
||||||
|
Some((ow, oh)) => (nx * ow as f32, ny * oh as f32),
|
||||||
|
None => (ev.x as f32, ev.y as f32),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if ev.kind == InputKind::TouchDown {
|
if ev.kind == InputKind::TouchDown {
|
||||||
t.down(ev.code, x, y);
|
t.down(ev.code, x, y);
|
||||||
|
|||||||
@@ -1259,15 +1259,31 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
|||||||
/// session). Shared by the attach and host-managed-session paths.
|
/// session). Shared by the attach and host-managed-session paths.
|
||||||
fn point_injector_at_eis() {
|
fn point_injector_at_eis() {
|
||||||
match find_gamescope_eis_socket() {
|
match find_gamescope_eis_socket() {
|
||||||
Some(sock) => match std::fs::write(ei_socket_file(), &sock) {
|
Some(sock) => {
|
||||||
Ok(()) => {
|
// Relay format: line 1 = socket, optional line 2 = the session's CURRENT output
|
||||||
tracing::info!(socket = %sock, "gamescope: pointed injector at the session's EIS socket")
|
// size as "WxH". gamescope's EIS advertises only a degenerate INT32_MAX region, so
|
||||||
|
// the injector can't learn the output geometry from the protocol — the hint lets
|
||||||
|
// it scale normalized client positions correctly even when the client streams at
|
||||||
|
// a different resolution than the session runs (foreign attach, supersample).
|
||||||
|
let size = current_gamescope_output_size();
|
||||||
|
let body = match size {
|
||||||
|
Some((w, h)) => format!("{sock}\n{w}x{h}"),
|
||||||
|
None => sock.clone(),
|
||||||
|
};
|
||||||
|
match std::fs::write(ei_socket_file(), body) {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
socket = %sock,
|
||||||
|
output = ?size,
|
||||||
|
"gamescope: pointed injector at the session's EIS socket"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
"gamescope: could not write the EIS relay file — input may not reach the session"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!(
|
}
|
||||||
error = %e,
|
|
||||||
"gamescope: could not write the EIS relay file — input may not reach the session"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
None => tracing::warn!(
|
None => tracing::warn!(
|
||||||
"gamescope: no connectable gamescope EIS socket found — input won't reach the session"
|
"gamescope: no connectable gamescope EIS socket found — input won't reach the session"
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user