diff --git a/libs/enigo/src/linux/nix_impl.rs b/libs/enigo/src/linux/nix_impl.rs index c16be3469..4e379407f 100644 --- a/libs/enigo/src/linux/nix_impl.rs +++ b/libs/enigo/src/linux/nix_impl.rs @@ -42,6 +42,13 @@ impl Enigo { &mut self.custom_mouse } + /// Override the display server guessed in `Default::default`: on "x11" every method here + /// routes to `xdo`, and a null xdo context makes all of them silent no-ops. A caller + /// installing custom devices knows better than the guess. + pub fn set_is_x11(&mut self, is_x11: bool) { + self.is_x11 = is_x11; + } + /// Clear remapped keycodes pub fn tfc_clear_remapped(&mut self) { if let Some(tfc) = &mut self.tfc { @@ -390,3 +397,52 @@ fn test_key_seq() { let mut en = Enigo::new(); en.key_sequence("^^"); } + +/// Both directions: the failure is silent, so a one-directional test passes against the bug. +#[test] +fn test_custom_mouse_dispatch_follows_is_x11() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + struct CountingMouse(Arc); + impl MouseControllable for CountingMouse { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn as_mut_any(&mut self) -> &mut dyn std::any::Any { + self + } + fn mouse_move_to(&mut self, _x: i32, _y: i32) { + self.0.fetch_add(1, Ordering::Relaxed); + } + fn mouse_move_relative(&mut self, _x: i32, _y: i32) {} + fn mouse_down(&mut self, _button: MouseButton) -> crate::ResultType { + Ok(()) + } + fn mouse_up(&mut self, _button: MouseButton) {} + fn mouse_click(&mut self, _button: MouseButton) {} + fn mouse_scroll_x(&mut self, _length: i32) {} + fn mouse_scroll_y(&mut self, _length: i32) {} + } + + let calls = Arc::new(AtomicUsize::new(0)); + let mut en = Enigo::new(); + en.set_custom_mouse(Box::new(CountingMouse(calls.clone()))); + + en.set_is_x11(false); + en.mouse_move_to(10, 20); + assert_eq!( + calls.load(Ordering::Relaxed), + 1, + "custom mouse was not reached on the non-x11 branch" + ); + + // Negative control: on the x11 branch the custom device must be bypassed entirely. + en.set_is_x11(true); + en.mouse_move_to(30, 40); + assert_eq!( + calls.load(Ordering::Relaxed), + 1, + "custom mouse was reached on the x11 branch" + ); +} diff --git a/libs/hbb_common b/libs/hbb_common index 69cea8daf..f436f53f0 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 69cea8dafee147848ae88702029f4bf7df7224c3 +Subproject commit f436f53f0d3a71431596bd620fbd20b349ab8711 diff --git a/src/ipc/drm.rs b/src/ipc/drm.rs index c2c399e6f..15e500e60 100644 --- a/src/ipc/drm.rs +++ b/src/ipc/drm.rs @@ -641,11 +641,12 @@ fn drm_udev_listener() { fn drm_prewarm() { // Re-ask, bounded: `get_display_server()` falls back to "x11" when it cannot tell (measured: - // "x11" 0.8 s into a boot on a Wayland host). `scrap::is_x11()` is the UNMEMOISED path. + // "x11" 0.8 s into a boot on a Wayland host). `is_x11_for_drm()` is that path minus the + // greeter blind spot, which a login screen never leaves. const PREWARM_SESSION_RECHECK: std::time::Duration = std::time::Duration::from_secs(2); const PREWARM_SESSION_BUDGET: std::time::Duration = std::time::Duration::from_secs(30); let waited = std::time::Instant::now(); - while scrap::is_x11() { + while crate::platform::linux::is_x11_for_drm() { if waited.elapsed() >= PREWARM_SESSION_BUDGET { log::info!( "drm: session still reads as X11 after {:?}; skipping the pre-warm \ diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 68a005ff7..200f19db5 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -43,8 +43,37 @@ const TERM_XTERM_256COLOR: &str = "xterm-256color"; const TERM_SCREEN_256COLOR: &str = "screen-256color"; const TERM_XTERM: &str = "xterm"; +#[cfg(feature = "drm")] lazy_static::lazy_static! { - pub static ref IS_X11: bool = hbb_common::platform::linux::is_x11_or_headless(); + /// Only for per-frame callers; see `is_login_screen_wayland_cached`. + /// Own block because `#[cfg]` on one item inside a shared one breaks the macro. + static ref IS_LOGIN_SCREEN_WAYLAND: bool = is_login_screen_wayland(); +} + +lazy_static::lazy_static! { + /// `is_x11_or_headless()` answers x11 at a Wayland greeter, which the portal could not + /// serve but the DRM path can. Unmemoised lookup on purpose: this may run mid-boot, and + /// a "no" cached that early would be wrong for the rest of the process. + pub static ref IS_X11: bool = { + let x11 = hbb_common::platform::linux::is_x11_or_headless(); + #[cfg(feature = "drm")] + { + if x11 && is_login_screen_wayland() { + log::info!( + "drm: seat0 is a Wayland login screen that reads as x11 upstream; \ + treating it as Wayland so the DRM path is not disabled at the one \ + screen it exists for" + ); + false + } else { + x11 + } + } + #[cfg(not(feature = "drm"))] + { + x11 + } + }; // Cache for TERM value - once TERM_XTERM_256COLOR is found, reuse it directly static ref CACHED_TERM: std::sync::Mutex> = std::sync::Mutex::new(None); static ref DATABASE_XTERM_256COLOR: Option = { @@ -208,6 +237,24 @@ pub fn is_login_screen_wayland() -> bool { is_gdm_user(&values[1]) && get_display_server_of_session(&values[0]) == DISPLAY_SERVER_WAYLAND } +/// X11 as far as the DRM path is concerned: a Wayland greeter is not. +/// +/// Both halves unmemoised, for the retry loops that must keep asking until seat0 can be named. +#[cfg(feature = "drm")] +pub fn is_x11_for_drm() -> bool { + scrap::is_x11() && !is_login_screen_wayland() +} + +/// Memoised `is_login_screen_wayland`, for per-frame callers that must not run `loginctl`. +/// +/// Only from the per-session `--server`: it is spawned after the session is identified, so the +/// answer is settled. Anything that can run mid-boot must use the uncached form. +#[cfg(feature = "drm")] +#[inline] +pub fn is_login_screen_wayland_cached() -> bool { + *IS_LOGIN_SCREEN_WAYLAND +} + #[inline] fn sleep_millis(millis: u64) { std::thread::sleep(Duration::from_millis(millis)); diff --git a/src/server/connection.rs b/src/server/connection.rs index 25d9b6792..37a123801 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -457,6 +457,21 @@ const SEND_TIMEOUT_VIDEO: u64 = 12_000; const SEND_TIMEOUT_OTHER: u64 = SEND_TIMEOUT_VIDEO * 10; const SESSION_TIMEOUT: Duration = Duration::from_secs(30); +/// Whether the DRM backend can serve a Wayland login screen here. +/// +/// The cached probe, not the blocking one: this is a routing gate. Not yet settled answers +/// false, which refuses as today and clears on a retry. +#[cfg(all(target_os = "linux", feature = "drm"))] +fn drm_can_serve_login_screen() -> bool { + super::drm_capturer::is_available_cached() +} + +/// Without the feature nothing can capture a Wayland greeter, so the refusal stands. +#[cfg(all(target_os = "linux", not(feature = "drm")))] +fn drm_can_serve_login_screen() -> bool { + false +} + impl Connection { pub async fn start( addr: SocketAddr, @@ -1959,7 +1974,8 @@ impl Connection { #[cfg(target_os = "linux")] if self.is_remote() { let mut msg = "".to_string(); - if crate::platform::linux::is_login_screen_wayland() { + // Refuse only while nothing can capture a Wayland greeter: the DRM path can. + if crate::platform::linux::is_login_screen_wayland() && !drm_can_serve_login_screen() { msg = crate::client::LOGIN_SCREEN_WAYLAND.to_owned() } else { let dtype = crate::platform::linux::get_display_server(); diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 3647d7ee6..7572caf10 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -100,6 +100,11 @@ fn refresh_wayland_uinput_rect_if_changed() { if is_x11() || !crate::input_service::wayland_use_uinput() { return; } + // Nothing to poll at a login screen; the DRM path owns the rect there. + #[cfg(feature = "drm")] + if crate::platform::linux::is_login_screen_wayland_cached() { + return; + } { let mut lock = WAYLAND_UINPUT_RECT.lock().unwrap(); if let Some(last_check) = lock.last_check { @@ -484,6 +489,22 @@ pub(super) fn check_update_displays(all: &Vec) { let _ = update_sync_displays(all); } +/// Whether there is a compositor on this seat worth asking. `get_displays()` does not cache +/// its failure, so where there is none it re-probes every call for an answer that cannot +/// change any caller's outcome. Last in the `&&` chain, so it never runs first on a poll. +#[inline] +#[cfg(target_os = "linux")] +fn wayland_has_compositor() -> bool { + #[cfg(feature = "drm")] + { + !crate::platform::linux::is_login_screen_wayland_cached() + } + #[cfg(not(feature = "drm"))] + { + true + } +} + // Return the converted input snapshot while updating the shared display cache. pub(super) fn update_sync_displays(all: &Vec) -> Vec { // For compatibility: if only one display, scale remains 1.0 and we use the physical size for `uinput`. @@ -491,6 +512,7 @@ pub(super) fn update_sync_displays(all: &Vec) -> Vec { #[cfg(target_os = "linux")] let use_logical_scale = !is_x11() && crate::is_server() + && wayland_has_compositor() && scrap::wayland::display::get_displays().displays.len() > 1; let displays = all .iter() diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs index d447715df..8b56c58d8 100644 --- a/src/server/drm_capturer.rs +++ b/src/server/drm_capturer.rs @@ -963,9 +963,10 @@ fn refresh_available_async() { pub(super) fn warm_availability() { // The gate is INSIDE the loop because `get_display_server()` answers "x11" whenever loginctl - // cannot yet name the seat0 session. `scrap::is_x11()` is the UNMEMOISED form. + // cannot yet name the seat0 session. `is_x11_for_drm()` is that form minus the greeter + // blind spot, where plain `is_x11()` is permanently true. for _ in 0..10 { - if scrap::is_x11() { + if crate::platform::linux::is_x11_for_drm() { std::thread::sleep(Duration::from_millis(300)); continue; } @@ -1093,6 +1094,9 @@ pub(super) fn get_primary_index() -> usize { ProbeState::Available(_, list) => list.clone(), _ => return 0, }; + if !wayland_outputs_askable() { + return 0; + } let wl = scrap::wayland::display::get_displays(); if wl.displays.is_empty() { return 0; @@ -1103,11 +1107,22 @@ pub(super) fn get_primary_index() -> usize { .unwrap_or(0) } +/// Whether the compositor can be asked for its outputs here. A login screen has none, and +/// `get_displays()` does not cache that failure, so it reopens a connection every call. Both +/// callers treat an empty list as "no augmentation", so skipping cannot change either answer. +fn wayland_outputs_askable() -> bool { + !crate::platform::linux::is_login_screen_wayland_cached() +} + /// DRM reports every monitor at physical size and origin (0,0), stacking a multi-monitor client. fn augment_with_wayland_geometry(drm: &[DrmDisplayInfo]) -> Vec { - let wl = scrap::wayland::display::get_displays(); let mut infos: Vec = drm.iter().map(display_info_from_drm).collect(); - if drm.len() < 2 || wl.displays.len() < 2 { + // Below two displays there is nothing to augment, compositor or not. + if drm.len() < 2 || !wayland_outputs_askable() { + return infos; + } + let wl = scrap::wayland::display::get_displays(); + if wl.displays.len() < 2 { return infos; } let matched = assign_wayland_outputs(drm, &wl.displays); diff --git a/src/server/input_service.rs b/src/server/input_service.rs index aa6893f39..f8f943276 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -663,17 +663,22 @@ pub async fn setup_uinput(minx: i32, maxx: i32, miny: i32, maxy: i32) -> ResultT let mouse = super::uinput::client::UInputMouse::new().await?; log::info!("UInput mouse created"); - ENIGO - .lock() - .unwrap() - .set_custom_keyboard(Box::new(keyboard)); - ENIGO.lock().unwrap().set_custom_mouse(Box::new(mouse)); + let mut en = ENIGO.lock().unwrap(); + // enigo guessed x11 once at construction, which is what a Wayland greeter reads as, and + // then routes the devices installed below to a null xdo that drops everything silently. + // Reaching here means `wayland_use_uinput()` was true, so this states a fact. + en.set_is_x11(false); + // One lock for both, so there is no window where the keyboard is custom and the mouse is not. + en.set_custom_keyboard(Box::new(keyboard)); + en.set_custom_mouse(Box::new(mouse)); Ok(()) } #[cfg(target_os = "linux")] pub async fn setup_rdp_input() -> ResultType<(), Box> { let mut en = ENIGO.lock()?; + // Same as `setup_uinput`: the caller is gated on `wayland_use_rdp_input()`. + en.set_is_x11(false); let rdp_info_lock = RDP_SESSION_INFO.lock()?; let rdp_info = rdp_info_lock.as_ref().ok_or("RDP session is None")?; diff --git a/src/server/wayland.rs b/src/server/wayland.rs index ffdf12c98..e51ca89c5 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -107,6 +107,25 @@ struct CapDisplayInfo { capturer: CapturerPtr, } +/// Uinput desktop rect from the DRM display list, for a login screen where no compositor can be +/// asked. `(minx, maxx, miny, maxy)`, in scanout pixels: no compositor here applied a scale, so +/// unlike `desktop_rect_of` there is no logical size to handle. +#[cfg(feature = "drm")] +fn drm_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { + let displays = super::drm_capturer::get_display_infos()?; + if displays.is_empty() { + return None; + } + let minx = displays.iter().map(|d| d.x).min()?; + let miny = displays.iter().map(|d| d.y).min()?; + let maxx = displays.iter().map(|d| d.x + d.width).max()?; + let maxy = displays.iter().map(|d| d.y + d.height).max()?; + if maxx <= minx || maxy <= miny { + return None; + } + Some((minx, maxx, miny, maxy)) +} + /// Set the uinput absolute-pointer range to the whole logical desktop so the compositor maps /// injected coordinates 1:1 instead of stretching a single-monitor range across all outputs. The /// PipeWire path does this inline in `check_init`; the DRM path bypasses check_init so it must do it @@ -134,10 +153,30 @@ pub(super) async fn update_uinput_resolution() { if !crate::input_service::wayland_use_uinput() { return; } - scrap::wayland::display::clear_wayland_displays_cache(); - let Some(rect) = scrap::wayland::display::get_desktop_rect_for_uinput() else { - log::warn!("Failed to get desktop rect for uinput"); - return; + // A greeter's `--server` gets no compositor variables, so the enumerator cannot answer and + // the device would keep its default range. The DRM displays are the ones being captured, so + // their coordinate space matches by construction; ask them first rather than after a failure. + let rect = if crate::platform::linux::is_login_screen_wayland_cached() { + let Some(rect) = drm_desktop_rect_for_uinput() else { + log::warn!("Failed to get desktop rect for uinput"); + return; + }; + log::info!( + "uinput desktop rect taken from the DRM display list (no compositor here): {rect:?}" + ); + rect + } else { + scrap::wayland::display::clear_wayland_displays_cache(); + // Also for a compositor that answers late: the DRM list beats no answer. + match scrap::wayland::display::get_desktop_rect_for_uinput() + .or_else(drm_desktop_rect_for_uinput) + { + Some(rect) => rect, + None => { + log::warn!("Failed to get desktop rect for uinput"); + return; + } + } }; // Re-snapshot the baseline on every call: this runs at session init and after every hotplug, and // the baseline is what the client's coordinates are measured against.