diff --git a/build.py b/build.py index b32e95672..6b770f993 100755 --- a/build.py +++ b/build.py @@ -390,9 +390,9 @@ def ffi_bindgen_function_refactor(): # The commit is fetched directly by sha, so no branch or tag name takes part in the build: see # build_libdrmtap_so(). This is the SINGLE source of truth for the pin, deliberately not duplicated in # any workflow, so a bump is one edit here (plus the informational version comment in -# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.2. +# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.4. LIBDRMTAP_REPO_PINNED = 'https://github.com/rustdesk-org/libdrmtap' -LIBDRMTAP_SHA_PINNED = '653de8c774bc245eaf960611ca7a136f7a544d21' +LIBDRMTAP_SHA_PINNED = '5da68a3a368db569716d0d0f11cefacbb11b2290' LIBDRMTAP_REPO = os.environ.get('DRMTAP_REPO', LIBDRMTAP_REPO_PINNED) LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED) # Every way of getting a different .so than the pin needs the same explicit opt-in. Otherwise the 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..f124c0a5d 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 69cea8dafee147848ae88702029f4bf7df7224c3 +Subproject commit f124c0a5d49a4a13381902124b65364ff28fa541 diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index da056b46d..bab2b4e9f 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -14,13 +14,13 @@ wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", " # `drm` is a pure runtime-dlopen backend: rustdesk loads `libdrmtap.so.0` at runtime (`drmtap_dl.rs`) # and NEVER link-time links it, so the graceful PipeWire fallback when the .so or EGL is absent is # preserved and the drm build pulls in no libdrm/seccomp/cap/EGL link-time deps. The .so is pinned by -# `DRMTAP_SHA` in build.py, which fetches that exact commit (libdrmtap v0.5.2). We deliberately do +# `DRMTAP_SHA` in build.py, which fetches that exact commit (libdrmtap v0.5.4). We deliberately do # NOT depend on the `libdrmtap-sys` crate: its build.rs statically compiles the whole libdrmtap C tree # and a CAP_SYS_ADMIN helper and emits `-ldrm -lseccomp -lcap`, which would defeat the dlopen model. # Depends on `wayland`: the three drm modules live inside the `#[cfg(feature = "wayland")]` arm of # common/mod.rs, so `scrap/drm` on its own would compile nothing. The root crate happens to always # enable `scrap/wayland`, which is what hid this. -drm = ["wayland"] +drm = ["wayland", "hbb_common/wayland_probe"] mediacodec = ["ndk"] linux-pkg-config = ["dep:pkg-config"] hwcodec = ["dep:hwcodec"] diff --git a/src/common.rs b/src/common.rs index 592ab2a45..09fa1b4ca 100644 --- a/src/common.rs +++ b/src/common.rs @@ -122,6 +122,8 @@ impl Drop for SimpleCallOnReturn { } pub fn global_init() -> bool { + #[cfg(all(target_os = "linux", feature = "drm"))] + crate::platform::linux::dispatch_wayland_display_probe(); #[cfg(target_os = "linux")] { if !crate::platform::linux::is_x11() { 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..f67952e9b 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,6 +1,15 @@ use super::{gtk_sudo, CursorData, ResultType}; use desktop::Desktop; pub use hbb_common::platform::linux::*; + +#[cfg(feature = "drm")] +pub fn dispatch_wayland_display_probe() { + use std::ffi::OsStr; + + if std::env::args_os().nth(1).as_deref() == Some(OsStr::new(WAYLAND_DISPLAY_PROBE_ARG)) { + wayland_display_probe_child_main(); + } +} use hbb_common::{ allow_err, anyhow::anyhow, @@ -43,8 +52,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 && !display_server_forced() && 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 +246,34 @@ pub fn is_login_screen_wayland() -> bool { is_gdm_user(&values[1]) && get_display_server_of_session(&values[0]) == DISPLAY_SERVER_WAYLAND } +/// An explicit `RUSTDESK_FORCED_DISPLAY_SERVER` is an operator override, and the root service +/// forwards it to the per-user server on purpose: the greeter correction may only fix an +/// AUTO-detected answer, never argue with the operator — a half-applied override would leave +/// `get_display_server()` and the DRM routing gates disagreeing with each other. +#[cfg(feature = "drm")] +pub(crate) fn display_server_forced() -> bool { + std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER").is_ok() +} + +/// X11 as far as the DRM path is concerned: a Wayland greeter is not, unless the operator +/// forced the display server. +/// +/// 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() && (display_server_forced() || !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)); @@ -1062,6 +1128,11 @@ pub fn get_active_userid() -> String { #[inline] /// Returns the active uid from a fresh seat0 lookup, bypassing the service-loop cache. pub fn get_active_userid_fresh() -> String { + // A Wayland greeter owns seat0 while it is up and the DRM backend serves it, so a uid gate that + // cannot see it rejects the greeter's own `--server`. `Desktop::refresh` reads it the same way. + #[cfg(feature = "drm")] + return get_values_of_seat0_with_gdm_wayland(&[1])[0].clone(); + #[cfg(not(feature = "drm"))] get_values_of_seat0(&[1])[0].clone() } diff --git a/src/platform/linux_desktop_manager.rs b/src/platform/linux_desktop_manager.rs index 4cfde61a2..573dfa018 100644 --- a/src/platform/linux_desktop_manager.rs +++ b/src/platform/linux_desktop_manager.rs @@ -17,22 +17,34 @@ use std::{ path::Path, process::{Child, Command}, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, mpsc::{sync_channel, SyncSender}, Arc, Mutex, }, time::{Duration, Instant}, }; +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct Seat0Snapshot { + sequence: usize, + username: Option>, +} + lazy_static::lazy_static! { static ref DESKTOP_RUNNING: Arc = Arc::new(AtomicBool::new(false)); static ref DESKTOP_MANAGER: Arc>> = Arc::new(Mutex::new(None)); + /// Last settled "who owns seat0" answer, for the PRE-AUTH path only; see `is_headless`. + static ref SEAT0_SNAPSHOT: Mutex = Mutex::new(Seat0Snapshot::default()); + static ref SEAT0_NEXT_REFRESH: Mutex> = Mutex::new(None); } +static SEAT0_REFRESH_IN_FLIGHT: AtomicBool = AtomicBool::new(false); +const FIRST_SEAT0_QUERY_SEQUENCE: usize = 1; +static SEAT0_QUERY_SEQUENCE: AtomicUsize = AtomicUsize::new(FIRST_SEAT0_QUERY_SEQUENCE); +const SEAT0_REFRESH_INTERVAL: Duration = Duration::from_secs(1); + #[derive(Debug)] struct DesktopManager { - seat0_username: String, - seat0_display_server: String, child_username: String, child_exit: Arc, is_child_running: Arc, @@ -53,6 +65,9 @@ pub fn start_xdesktop() { std::thread::spawn(|| { DesktopManager::recover_orphaned_session(); *DESKTOP_MANAGER.lock().unwrap() = Some(DesktopManager::new()); + // Seed the pre-auth snapshot now, off the connection path: without this the first + // connection of every server process would read no snapshot at all. + kick_seat0_refresh(); let interval = time::Duration::from_millis(super::SERVICE_INTERVAL); DESKTOP_RUNNING.store(true, Ordering::SeqCst); @@ -154,6 +169,7 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String { .to_owned() } else { let username = get_username(); + log::debug!("try_start_desktop, username: {}, _username: {}", &username, &_username); if username == _username { // No need to verify password here. return "".to_owned(); @@ -195,9 +211,12 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String { } fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), XSessionStartError> { + // Seat0 is read BEFORE the manager lock: the lookup runs loginctl, and at a greeter the DRM + // probe, and holding DESKTOP_MANAGER across those waits serializes every other caller. + let seat0_username = refresh_seat0_snapshot(); let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap(); if let Some(desktop_manager) = &mut (*desktop_manager) { - if let Some(seat0_username) = desktop_manager.get_supported_display_seat0_username() { + if let Some(seat0_username) = seat0_username { return Ok((seat0_username, true)); } @@ -219,27 +238,188 @@ fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), } #[inline] +/// The PRE-AUTH form: connection setup asks this before the peer has authenticated, so it must +/// not run loginctl or wait on the DRM probe (an unauthenticated client would occupy a worker, +/// and every connection would serialize behind the same lookup). It answers from the last +/// settled snapshot and refreshes it off-thread; the decisions that ENFORCE — `get_username`, +/// `try_start_x_session` — stay fresh. pub fn is_headless() -> bool { - DESKTOP_MANAGER - .lock() - .unwrap() - .as_ref() - .map_or(false, |manager| { - manager.get_supported_display_seat0_username().is_none() + if DESKTOP_MANAGER.lock().unwrap().is_none() { + return false; + } + let cached = SEAT0_SNAPSHOT.lock().unwrap().username.clone(); + kick_seat0_refresh(); + // No snapshot yet answers NOT headless: guessing in the headless direction would show the + // OS-login flow over a live Wayland greeter, which reads as an empty seat0 too. A false + // only delays the headless flow until the first refresh lands, and the snapshot is seeded + // from `start_xdesktop`, so the empty window is server start, not every connection. + cached.map_or(false, |answer| answer.is_none()) +} + +/// A free function on purpose: it runs loginctl (and at a greeter the DRM probe), so no caller +/// may reach it while holding `DESKTOP_MANAGER` — that mutex held across subprocess or IPC waits +/// serializes every connection behind one slow lookup. +fn supported_display_seat0_username() -> Option { + // Read seat0 fresh on every query: the values cached in `DesktopManager::new()` go stale + // across a logout or fast-user-switch, which would skip the greeter probe below and hand + // back the previous session owner. Queried here and not in `new()` also because the read + // there hides greeters. + let seat0_values = get_values_of_seat0(&[0, 2]); + let seat0_username = seat0_values[1].clone(); + #[cfg(feature = "drm")] + if seat0_username.is_empty() || is_gdm_user(&seat0_username) { + if let Some(username) = drm_login_screen_seat0_username() { + return Some(username); + } + } + if seat0_username.is_empty() { + None + } else if is_gdm_user(&seat0_username) + && get_display_server_of_session(&seat0_values[0]) == DISPLAY_SERVER_WAYLAND + { + None + } else { + Some(seat0_username) + } +} + +fn select_newer_seat0_snapshot(current: Seat0Snapshot, candidate: Seat0Snapshot) -> Seat0Snapshot { + if candidate.sequence > current.sequence { + candidate + } else { + current + } +} + +fn refresh_seat0_snapshot() -> Option { + let sequence = SEAT0_QUERY_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let fresh = supported_display_seat0_username(); + let candidate = Seat0Snapshot { + sequence, + username: Some(fresh.clone()), + }; + let mut snapshot = SEAT0_SNAPSHOT.lock().unwrap(); + let current = std::mem::take(&mut *snapshot); + *snapshot = select_newer_seat0_snapshot(current, candidate); + fresh +} + +/// Clears the single-flight flag on every exit, including a panic in the refresh thread; without +/// it a panic would freeze `is_headless` on a stale snapshot for the process lifetime. +struct Seat0RefreshGuard; +impl Drop for Seat0RefreshGuard { + fn drop(&mut self) { + SEAT0_REFRESH_IN_FLIGHT.store(false, Ordering::Release); + } +} + +/// Refresh the snapshot off-thread with a process-wide rate limit and single-flight. +fn kick_seat0_refresh() { + let now = Instant::now(); + { + let mut next_refresh = SEAT0_NEXT_REFRESH.lock().unwrap(); + let (next, should_refresh) = schedule_seat0_refresh(*next_refresh, now); + *next_refresh = next; + if !should_refresh { + return; + } + } + if SEAT0_REFRESH_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return; + } + let guard = Seat0RefreshGuard; + if let Err(err) = std::thread::Builder::new() + .name("seat0-snapshot".into()) + .spawn(move || { + let _guard = guard; + let _ = refresh_seat0_snapshot(); }) + { + log::warn!("Could not spawn the seat0 snapshot refresh thread: {err}"); + } +} + +/// The Wayland greeter on seat0, if the DRM backend can capture and inject into it. +#[cfg(feature = "drm")] +fn drm_login_screen_seat0_username() -> Option { + // An operator-forced X11 wins over greeter adoption: adopting would rebuild exactly the + // inconsistency the forced gate exists to prevent — a session admitted for DRM serving + // while capture and input route down the X11 path. + if crate::platform::linux::display_server_forced() && crate::platform::linux::is_x11() { + return None; + } + let values = get_values_of_seat0_with_gdm_wayland(&[0, 2]); + if !is_gdm_user(&values[1]) + || get_display_server_of_session(&values[0]) != DISPLAY_SERVER_WAYLAND + { + return None; + } + // The cached tri-state, never the probing form: this runs on the unauthenticated login path, + // so it must not wait out a probe deadline. Only a definitive unavailable hands the seat to + // X11; an unsettled result keeps the maybe-live greeter (settling happens off-thread). + if crate::server::drm_capturer::availability_cached() + == crate::server::drm_capturer::Availability::Unavailable + { + return None; + } + Some(values[1].clone()) +} + +fn cached_username_from_state( + seat0_username: Option, + managed_session: Option<(&str, bool)>, +) -> String { + if let Some(username) = seat0_username { + return username; + } + match managed_session { + Some((username, true)) => username.to_owned(), + _ => String::new(), + } +} + +fn schedule_seat0_refresh(next_refresh: Option, now: Instant) -> (Option, bool) { + if next_refresh.is_some_and(|deadline| now < deadline) { + return (next_refresh, false); + } + (Some(now + SEAT0_REFRESH_INTERVAL), true) +} + +/// Returns the last settled username without running external commands. +pub fn get_cached_username() -> String { + let seat0_username = SEAT0_SNAPSHOT.lock().unwrap().username.clone().flatten(); + let username = { + let manager = DESKTOP_MANAGER.lock().unwrap(); + let Some(manager) = manager.as_ref() else { + return String::new(); + }; + cached_username_from_state( + seat0_username, + Some((&manager.child_username, manager.is_running())), + ) + }; + if username.is_empty() { + kick_seat0_refresh(); + } + username } pub fn get_username() -> String { + if DESKTOP_MANAGER.lock().unwrap().is_none() { + return "".to_owned(); + } + // Computed with the manager lock RELEASED: the lookup runs loginctl, and at a greeter the + // DRM probe, and holding DESKTOP_MANAGER across those waits serializes every caller behind + // one slow probe. + if let Some(seat0_username) = refresh_seat0_snapshot() { + return seat0_username; + } match &*DESKTOP_MANAGER.lock().unwrap() { Some(manager) => { - if let Some(seat0_username) = manager.get_supported_display_seat0_username() { - seat0_username + if manager.is_running() && !manager.child_username.is_empty() { + manager.child_username.clone() } else { - if manager.is_running() && !manager.child_username.is_empty() { - manager.child_username.clone() - } else { - "".to_owned() - } + "".to_owned() } } None => "".to_owned(), @@ -258,33 +438,13 @@ impl DesktopManager { } pub fn new() -> Self { - let mut seat0_username = "".to_owned(); - let mut seat0_display_server = "".to_owned(); - let seat0_values = get_values_of_seat0(&[0, 2]); - if !seat0_values[0].is_empty() { - seat0_username = seat0_values[1].clone(); - seat0_display_server = get_display_server_of_session(&seat0_values[0]); - } Self { - seat0_username, - seat0_display_server, child_username: "".to_owned(), child_exit: Arc::new(AtomicBool::new(true)), is_child_running: Arc::new(AtomicBool::new(false)), } } - fn get_supported_display_seat0_username(&self) -> Option { - if is_gdm_user(&self.seat0_username) && self.seat0_display_server == DISPLAY_SERVER_WAYLAND - { - None - } else if self.seat0_username.is_empty() { - None - } else { - Some(self.seat0_username.clone()) - } - } - #[inline] fn get_xauth() -> String { let xauth = get_env_var("XAUTHORITY"); @@ -1100,6 +1260,38 @@ fn pam_get_service_name() -> String { mod tests { use super::*; + #[test] + fn cached_username_prefers_seat0_and_running_managed_session() { + assert_eq!( + cached_username_from_state(Some("seat0".to_owned()), Some(("managed", true))), + "seat0" + ); + assert_eq!( + cached_username_from_state(None, Some(("managed", true))), + "managed" + ); + assert_eq!( + cached_username_from_state(None, Some(("managed", false))), + "" + ); + assert_eq!(cached_username_from_state(None, None), ""); + } + + #[test] + fn seat0_refresh_schedule_limits_process_wide_rate() { + let started = Instant::now(); + let (next_refresh, should_refresh) = schedule_seat0_refresh(None, started); + assert!(should_refresh); + + let (unchanged, should_refresh) = schedule_seat0_refresh(next_refresh, started); + assert!(!should_refresh); + assert_eq!(unchanged, next_refresh); + + let (_, should_refresh) = + schedule_seat0_refresh(next_refresh, started + SEAT0_REFRESH_INTERVAL); + assert!(should_refresh); + } + #[test] fn session_scope_truncates_at_first_scope() { assert_eq!( diff --git a/src/server/connection.rs b/src/server/connection.rs index b461a3eff..bf05d56bd 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -122,9 +122,21 @@ fn should_check_linux_headless_os_auth_before_desktop_start( is_headless_allowed: bool, username: &str, ) -> bool { - is_headless_allowed - && !username.trim().is_empty() - && linux_desktop_manager::get_username().is_empty() + is_headless_allowed && !username.trim().is_empty() +} + +#[cfg(target_os = "linux")] +fn linux_desktop_start_credentials( + is_headless_allowed: bool, + os_login: Option<&OSLogin>, +) -> Option<(String, String)> { + if !is_headless_allowed { + return None; + } + if let Some(os_login) = os_login.filter(|os_login| !os_login.username.trim().is_empty()) { + return Some((os_login.username.clone(), os_login.password.clone())); + } + Some((String::new(), String::new())) } #[cfg(target_os = "linux")] @@ -464,6 +476,24 @@ 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. Available-only ON PURPOSE, and +/// deliberately NOT symmetric with the seat0 adoption gate: that one only starts Xorg on a +/// definitive Unavailable (never over a maybe-live greeter), while admission only accepts on a +/// definitive Available (never a greeter nothing can yet capture). Both err toward refuse-and-retry +/// during an unsettled probe; admitting there would black-screen a client on a helper-less box. +#[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, @@ -1967,7 +1997,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(); @@ -2883,6 +2914,7 @@ impl Connection { #[cfg(not(any(target_os = "android", target_os = "ios")))] if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { + #[cfg(not(target_os = "linux"))] self.try_start_cm_ipc(); } @@ -2900,9 +2932,18 @@ impl Connection { #[cfg(not(target_os = "linux"))] let err_msg = "".to_owned(); #[cfg(target_os = "linux")] - let err_msg = self + let err_msg = match self .linux_headless_handle - .try_start_desktop(lr.os_login.as_ref()); + .try_start_desktop(lr.os_login.as_ref()) + .await + { + LinuxDesktopStartOutcome::Finished(err_msg) => err_msg, + LinuxDesktopStartOutcome::Busy => { + self.send_login_error(crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY) + .await; + return true; + } + }; // If err is LOGIN_MSG_DESKTOP_SESSION_NOT_READY, just keep this msg and go on checking password. if !err_msg.is_empty() && err_msg != crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY @@ -2923,6 +2964,12 @@ impl Connection { return true; } + #[cfg(target_os = "linux")] + if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { + // In headless mode, the desktop check above settles the snapshot used by CM routing. + self.try_start_cm_ipc(); + } + // https://github.com/rustdesk/rustdesk-server-pro/discussions/646 // `is_logon` is used to check login with `OPTION_ALLOW_LOGON_SCREEN_PASSWORD` == "Y". // `is_logon_ui()` is a fallback for logon UI detection on Windows. @@ -6221,20 +6268,20 @@ async fn start_ipc( // Cm run as user, wait until desktop session is ready. #[cfg(target_os = "linux")] if headless_cm { - let mut username = linux_desktop_manager::get_username(); + let mut username = linux_desktop_manager::get_cached_username(); loop { if !username.is_empty() { break; } // `_rx_desktop_ready` is used as a wake-up signal from desktop/session state changes // (for example wait_desktop_cm_ready paths). It is not itself a proof of CM readiness. - // TODO: - // When `_rx_desktop_ready` is closed, `recv()` returns - // `None` immediately and this loop may spin if `username` remains empty. - // Keep behavior unchanged for now; if field reports appear, handle `Ok(None)` by - // breaking/returning to avoid hot-looping. - let _res = timeout(1_000, _rx_desktop_ready.recv()).await; - username = linux_desktop_manager::get_username(); + let wait_result = timeout(1_000, _rx_desktop_ready.recv()).await; + if matches!(wait_result, Ok(None)) { + return Err(anyhow!( + "Desktop-ready channel closed before a Linux session became available" + )); + } + username = linux_desktop_manager::get_cached_username(); } let uid = { let username_for_cmd = username.clone(); @@ -6652,10 +6699,30 @@ impl Drop for Connection { } } +// Login requests are unauthenticated here, so only one may reach loginctl/PAM at a time. +#[cfg(target_os = "linux")] +static LINUX_DESKTOP_START_IN_FLIGHT: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[cfg(target_os = "linux")] +struct LinuxDesktopStartGuard; + +#[cfg(target_os = "linux")] +impl Drop for LinuxDesktopStartGuard { + fn drop(&mut self) { + LINUX_DESKTOP_START_IN_FLIGHT.store(false, Ordering::Release); + } +} + +#[cfg(target_os = "linux")] +enum LinuxDesktopStartOutcome { + Finished(String), + Busy, +} + #[cfg(target_os = "linux")] struct LinuxHeadlessHandle { pub is_headless_allowed: bool, - pub is_headless: bool, pub wait_ipc_timeout: u64, pub rx_cm_stream_ready: mpsc::Receiver<()>, pub tx_desktop_ready: mpsc::Sender<()>, @@ -6665,31 +6732,45 @@ struct LinuxHeadlessHandle { impl LinuxHeadlessHandle { pub fn new(rx_cm_stream_ready: mpsc::Receiver<()>, tx_desktop_ready: mpsc::Sender<()>) -> Self { let is_headless_allowed = crate::is_server() && crate::platform::is_headless_allowed(); - let is_headless = is_headless_allowed && linux_desktop_manager::is_headless(); Self { is_headless_allowed, - is_headless, wait_ipc_timeout: 10_000, rx_cm_stream_ready, tx_desktop_ready, } } - pub fn try_start_desktop(&mut self, os_login: Option<&OSLogin>) -> String { - if self.is_headless_allowed { - match os_login { - Some(os_login) => { - linux_desktop_manager::try_start_desktop(&os_login.username, &os_login.password) - } - None => linux_desktop_manager::try_start_desktop("", ""), - } - } else { - "".to_string() + pub async fn try_start_desktop( + &mut self, + os_login: Option<&OSLogin>, + ) -> LinuxDesktopStartOutcome { + let Some((username, password)) = + linux_desktop_start_credentials(self.is_headless_allowed, os_login) + else { + return LinuxDesktopStartOutcome::Finished(String::new()); + }; + if LINUX_DESKTOP_START_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return LinuxDesktopStartOutcome::Busy; } + let guard = LinuxDesktopStartGuard; + let err_msg = match tokio::task::spawn_blocking(move || { + let _guard = guard; + linux_desktop_manager::try_start_desktop(&username, &password) + }) + .await + { + Ok(err_msg) => err_msg, + Err(err) => { + log::error!("Linux desktop start task failed: {err}"); + crate::client::LOGIN_MSG_DESKTOP_XSESSION_FAILED.to_owned() + } + }; + LinuxDesktopStartOutcome::Finished(err_msg) } pub async fn wait_desktop_cm_ready(&mut self) { - if self.is_headless { + // A value captured at construction can lag behind a seat0 transition. + if self.is_headless_allowed && linux_desktop_manager::is_headless() { self.tx_desktop_ready.send(()).await.ok(); let _res = timeout(self.wait_ipc_timeout, self.rx_cm_stream_ready.recv()).await; } 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..0c6beb493 100644 --- a/src/server/drm_capturer.rs +++ b/src/server/drm_capturer.rs @@ -823,40 +823,110 @@ impl Drop for UinputRefreshGuard { /// Never probes, never blocks: the form the ROUTING gates must use. Seconds of IPC inside /// `wayland::clear()`, `is_inited()` or the display enumeration trips "deadline has elapsed". -pub(super) fn is_available_cached() -> bool { +pub(crate) fn is_available_cached() -> bool { matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) } -/// MAY BLOCK for seconds: never a routing gate. -pub(super) fn is_available() -> bool { - let verdict = { - let mut st = DRM_STATE.lock().unwrap(); - if let ProbeState::Unavailable(since) = &*st { - if since.elapsed() >= NEGATIVE_TTL { - publish_probe_state(&mut st, ProbeState::Unknown); - DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); +/// The three honest answers the availability machinery can give. `Unsettled` — another probe in +/// flight, or a failure still below the disable threshold — is not a verdict, and the +/// login-screen headless decision must not read it as one. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Availability { + Available, + Unavailable, + Unsettled, +} + +/// MAY BLOCK for seconds: never a routing gate, and never on the login request path — that path +/// reads `availability_cached`. This blocking form serves the capture-side callers through +/// `is_available`, where waiting out a settle is acceptable. +fn availability() -> Availability { + let (verdict, stale_no) = { + let st = DRM_STATE.lock().unwrap(); + // A settled "no" STAYS the answer while an off-thread re-probe re-verifies it; going + // Unknown at expiry would reopen an Unsettled window every TTL on a helper-less box, and + // the login decision reads Unsettled as a possible greeter. + let stale_no = + matches!(&*st, ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL); + let verdict = match &*st { + ProbeState::Available(since, _) => { + Some((Availability::Available, since.elapsed() >= POSITIVE_TTL)) } - } - match &*st { - ProbeState::Available(since, _) => Some((true, since.elapsed() >= POSITIVE_TTL)), - ProbeState::Unavailable(_) => Some((false, false)), + ProbeState::Unavailable(_) => Some((Availability::Unavailable, false)), ProbeState::Unknown => None, // fall through and probe with the lock released - } + }; + (verdict, stale_no) }; - if let Some((available, stale)) = verdict { + if let Some((answer, stale)) = verdict { if stale { refresh_available_async(); } - return available; + if stale_no { + refresh_unavailable_async(); + } + return answer; } if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { - return matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)); + // Someone else is mid-probe: their result is not in yet, and "not yet" is not "no". + return match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(..) => Availability::Available, + ProbeState::Unavailable(_) => Availability::Unavailable, + ProbeState::Unknown => Availability::Unsettled, + }; } let _in_flight = ProbeInFlightGuard; + probe_and_publish() +} + +/// The non-blocking tri-state, for decisions on the LOGIN REQUEST path that must never wait: an +/// unauthenticated peer reaches that path, so a probe there would let it park a worker for the +/// probe deadline. Unknown kicks the probe off-thread and answers Unsettled, which the login +/// decision treats as a possibly servable greeter (no Xorg) until the state settles. +pub(crate) fn availability_cached() -> Availability { + let (verdict, stale_no) = { + let st = DRM_STATE.lock().unwrap(); + let stale_no = + matches!(&*st, ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL); + let verdict = match &*st { + ProbeState::Available(since, _) => { + Some((Availability::Available, since.elapsed() >= POSITIVE_TTL)) + } + ProbeState::Unavailable(_) => Some((Availability::Unavailable, false)), + ProbeState::Unknown => None, + }; + (verdict, stale_no) + }; + if let Some((answer, stale)) = verdict { + if stale { + refresh_available_async(); + } + if stale_no { + refresh_unavailable_async(); + } + return answer; + } + if !DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + let in_flight = ProbeInFlightGuard; + let spawned = std::thread::Builder::new() + .name("drm-avail-probe".into()) + .spawn(move || { + let _in_flight = in_flight; + probe_and_publish(); + }); + // On error the guard moved into the dropped closure and released the flag already. + if let Err(err) = spawned { + log::warn!("drm: could not spawn the availability probe thread: {err}"); + } + } + Availability::Unsettled +} + +/// Probe synchronously and publish the outcome. The caller must hold DRM_PROBE_IN_FLIGHT. +fn probe_and_publish() -> Availability { let t = Instant::now(); let result = query_displays(); let mut st = DRM_STATE.lock().unwrap(); - let available = match result { + let answer = match result { Ok(list) if !list.is_empty() => { log::debug!( "drm: availability probe -> available ({} displays) in {:?}", @@ -865,28 +935,84 @@ pub(super) fn is_available() -> bool { ); DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); - true + Availability::Available } Ok(_) => { log::info!("drm: availability probe -> no displays in {:?}", t.elapsed()); publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); - false + Availability::Unavailable } Err(err) => { let n = DRM_PROBE_FAILURES.fetch_add(1, Ordering::Relaxed) + 1; if n >= DRM_PROBE_MAX_FAILURES { log::info!("drm: availability probe failed {n}x ({err}); disabling DRM"); publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + Availability::Unavailable } else { log::info!( "drm: availability probe failed ({err}), attempt {n}/{DRM_PROBE_MAX_FAILURES}; will retry" ); + // Deliberately still Unknown in DRM_STATE: this is a retry window, not a verdict. + Availability::Unsettled } - false } }; drop(st); - available + answer +} + +/// The boolean form for capture-path callers, where an unsettled probe and a definitive "no" +/// route the same way (into the non-DRM fallback). +pub(crate) fn is_available() -> bool { + availability() == Availability::Available +} + +/// The negative mirror of `refresh_available_async`: re-verify a stale Unavailable without ever +/// answering Unknown in the meantime. A failed or empty re-probe re-confirms the "no" with a +/// fresh timestamp; only a non-empty display list flips the verdict. +fn refresh_unavailable_async() { + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return; + } + let in_flight = ProbeInFlightGuard; + let sampled_gen = { + let st = DRM_STATE.lock().unwrap(); + match &*st { + ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL => {} + _ => return, + } + DRM_STATE_GEN.load(Ordering::Acquire) + }; + let spawned = std::thread::Builder::new() + .name("drm-unavail-refresh".into()) + .spawn(move || { + let _in_flight = in_flight; + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen { + return; + } + match result { + Ok(list) if !list.is_empty() => { + log::info!( + "drm: availability re-probe -> available ({} displays)", + list.len() + ); + DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + drop(st); + scrap::wayland::display::clear_wayland_displays_cache(); + } + _ => { + // Restamp: a failed or empty re-probe is a fresh confirmation of "no". + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } + } + }); + // Nothing to release on error: the guard moved into the closure and drops with it either way. + if let Err(err) = spawned { + log::warn!("drm: could not spawn the unavailability re-probe thread: {err}"); + } } fn refresh_available_async() { @@ -963,9 +1089,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; } @@ -1059,64 +1186,99 @@ pub(super) fn display_count_and_any_demoted() -> Option<(usize, bool)> { Some((len, any_demoted)) } -/// Releases DRM_STATE before taking the health map: never hold it while taking a per-display map. +// A multi-display portal stream cannot replace one demoted connector. Keep its index but mark it +// offline; a single connector remains usable through the whole-desktop fallback. +fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) { + if list.len() <= 1 { + return; + } + let health = DRM_DISPLAY_HEALTH.lock().unwrap(); + for (display, info) in list.iter().zip(infos.iter_mut()) { + if health + .get(&connector_key(display)) + .is_some_and(|health| health.demoted()) + { + info.online = false; + } + } +} + +fn primary_index_from_assignment(assignment: &[Option], primary: usize) -> usize { + assignment + .iter() + .position(|assigned| *assigned == Some(primary)) + .unwrap_or(0) +} + +/// Releases DRM_STATE before taking the Wayland and health locks. +pub(super) fn get_display_infos_and_primary() -> Option<(Vec, usize)> { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.clone(), + _ => return None, + }; + let wl = scrap::wayland::display::get_displays(); + let assignment = assign_wayland_outputs(&list, &wl.displays); + let mut infos = augment_with_wayland_geometry_from(&list, &wl, &assignment); + mark_demoted_displays(&list, &mut infos); + // Primary and geometry must use the same connector assignment snapshot. + let primary = primary_index_from_assignment(&assignment, wl.primary); + Some((infos, primary)) +} + pub(super) fn get_display_infos() -> Option> { let list = match &*DRM_STATE.lock().unwrap() { ProbeState::Available(_, list) => list.clone(), _ => return None, }; - let multi = list.len() > 1; let mut infos = augment_with_wayland_geometry(&list); - // The portal exposes one whole-desktop stream, so a demoted display on a multi-monitor host - // has nothing geometry-consistent to fall back to: OFFLINE but KEEPING its list position, so - // the index space stays aligned with get_capturer_info(). A single-display host stays online. - if multi { - let health = DRM_DISPLAY_HEALTH.lock().unwrap(); - for (idx, info) in infos.iter_mut().enumerate() { - let key = match list.get(idx) { - Some(d) => connector_key(d), - None => continue, - }; - if health.get(&key).is_some_and(|h| h.demoted()) { - info.online = false; - } - } - } + mark_demoted_displays(&list, &mut infos); Some(infos) } -/// Index of the compositor's PRIMARY output; 0 when unknown. Asking `assign_wayland_outputs` makes -/// the advertised primary and geometry agree, but not below two connectors or two outputs, where -/// `augment_with_wayland_geometry` declines to run the assignment. -pub(super) fn get_primary_index() -> usize { - let list = match &*DRM_STATE.lock().unwrap() { - ProbeState::Available(_, list) => list.clone(), - _ => return 0, - }; - let wl = scrap::wayland::display::get_displays(); - if wl.displays.is_empty() { - return 0; - } - assign_wayland_outputs(&list, &wl.displays) - .iter() - .position(|assigned| *assigned == Some(wl.primary)) - .unwrap_or(0) -} - /// DRM reports every monitor at physical size and origin (0,0), stacking a multi-monitor client. +/// +/// Asked at login screens too, on purpose: a greeter runs a compositor, and the socket fallback in +/// hbb_common lets the enumerator reach it with no environment variables. Where that fallback +/// cannot answer, the list comes back empty and everything stays unaugmented, which is what the +/// old is-login-screen gate produced unconditionally. fn augment_with_wayland_geometry(drm: &[DrmDisplayInfo]) -> Vec { let wl = scrap::wayland::display::get_displays(); + let assignment = assign_wayland_outputs(drm, &wl.displays); + augment_with_wayland_geometry_from(drm, &wl, &assignment) +} + +fn augment_with_wayland_geometry_from( + drm: &[DrmDisplayInfo], + wl: &scrap::wayland::display::Displays, + matched: &[Option], +) -> Vec { let mut infos: Vec = drm.iter().map(display_info_from_drm).collect(); - if drm.len() < 2 || wl.displays.len() < 2 { + // A single display is still augmented: on a multi-GPU host the one connector this service can + // open may sit at a non-zero origin in the compositor layout, and DRM alone reports (0,0). + if drm.is_empty() { + return infos; + } + if wl.displays.is_empty() { + return infos; + } + // One connector against one output is the origin-only case: the lone output can still sit at + // a non-zero origin this side cannot see, but it keeps the scale-1 convention — a single + // display is advertised at physical size (see `logical_rects_of`), so its logical size must + // not be adopted. More connectors than the one output is an inconsistent snapshot, and the + // layout-order fallback in `assign_wayland_outputs` would plant that origin on a guess. + let origin_only = wl.displays.len() == 1; + if origin_only && drm.len() > 1 { return infos; } - let matched = assign_wayland_outputs(drm, &wl.displays); for (i, info) in infos.iter_mut().enumerate() { let Some(w) = matched[i].map(|j| &wl.displays[j]) else { continue; }; info.x = w.x; info.y = w.y; + if origin_only { + continue; + } if let Some((lw, lh)) = w.logical_size { if lw > 0 && lh > 0 { info.scale = drm[i].width as f64 / lw as f64; @@ -1487,6 +1649,26 @@ mod drm_capturer_tests { } } + #[test] + fn one_connector_assignment_drives_geometry_and_primary() { + let drm = [ + drm_display("HDMI-A-1", 1920, 1080), + drm_display("DP-1", 2560, 1440), + ]; + let wl = scrap::wayland::display::Displays { + primary: 0, + displays: vec![ + wl_display("DP-1", 1920, 0, 2560, 1440), + wl_display("HDMI-1", 0, 0, 1920, 1080), + ], + }; + + let assignment = assign_wayland_outputs(&drm, &wl.displays); + let infos = augment_with_wayland_geometry_from(&drm, &wl, &assignment); + assert_eq!((infos[0].x, infos[1].x), (0, 1920)); + assert_eq!(primary_index_from_assignment(&assignment, wl.primary), 1); + } + #[test] fn frame_buffers_circulate_instead_of_being_reallocated() { let mut c = capturer_with(Some((64, 32))); 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..023e9e559 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,17 +153,41 @@ 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; + // Compositor first at a login screen too: a greeter runs one, and the hbb_common socket + // fallback reaches it with no environment variables. The DRM union is the fallback, and it is + // a real loss to land there on a multi-monitor host: DRM has no origins, so its union rect + // mis-maps the pointer whenever the compositor arranged the outputs side by side. + // + // Off the executor: the compositor query can block for the socket probe deadline, and this + // runs on current-thread runtimes (session init and the hotplug worker). The layout baseline + // is computed in the SAME task: a failed lookup is not cached, so asking for the rects + // afterwards would rerun the whole socket probe synchronously. + let (rect, layout) = match hbb_common::tokio::task::spawn_blocking(|| { + scrap::wayland::display::clear_wayland_displays_cache(); + match scrap::wayland::display::get_desktop_rect_for_uinput() { + // The lookup above just cached the displays, so the rects come from that snapshot. + Some(rect) => Some((rect, scrap::wayland::display::get_display_rects_for_uinput())), + // Raw DRM union: there is no compositor layout to baseline. Empty keeps the #15601 + // remap inactive, which is right when the origins are unknown anyway. + None => drm_desktop_rect_for_uinput().map(|rect| (rect, Vec::new())), + } + }) + .await + { + Ok(Some(pair)) => pair, + Ok(None) => { + log::warn!("Failed to get desktop rect for uinput"); + return; + } + Err(err) => { + log::warn!("The desktop rect probe task failed: {err}"); + 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. let snapshot_layout = || { - super::display_service::set_wayland_layout_baseline( - scrap::wayland::display::get_display_rects_for_uinput(), - ); + super::display_service::set_wayland_layout_baseline(layout.clone()); }; // Reprogram the device only when the range actually changes. A display stuck in a rebuild loop // calls this about once a second, and reapplying an identical range is an IPC roundtrip plus a @@ -331,10 +374,13 @@ pub(super) async fn get_displays_and_primary() -> ResultType<(Vec, // client had already been given. Properly async, so the executor is never blocked; on any // failure the cache serves as before. super::drm_capturer::refresh_displays_for_login().await; - if let Some(displays) = super::drm_capturer::get_display_infos() { - // DRM connector order is not the compositor's primary; resolve the real primary from - // the compositor layout (matched by normalized connector name), not a hardcoded index 0. - return Ok((displays, super::drm_capturer::get_primary_index())); + let snapshot = hbb_common::tokio::task::spawn_blocking( + super::drm_capturer::get_display_infos_and_primary, + ) + .await + .map_err(|err| anyhow::anyhow!("Wayland display probe task failed: {err}"))?; + if let Some(snapshot) = snapshot { + return Ok(snapshot); } } check_init().await?;