fix(linux): serve the Wayland login screen the DRM backend was built for

The login screen support in #15420 never worked on a real greeter. fufesou found
it: the session is refused, and with the refusal commented out the client gets a
failed connection instead of a screen.

One premise under all of it. `get_values_of_seat0` is
`_get_values_of_seat0(.., ignore_gdm_wayland = true)`, so a gdm/sddm Wayland
session is skipped by construction and `get_display_server` falls back to x11.
That was correct while the portal was the only backend, since the portal cannot
serve a greeter at all. The DRM path never talks to the compositor, which is
precisely why it can serve one, so the premise stops holding there and every
x11-vs-Wayland decision in the tree answers x11 at a login screen.

The central change is the memoised `IS_X11`: when it reads x11 and seat0 is a
Wayland greeter, answer Wayland. That covers fifteen routing sites at once, and
it is under `cfg(feature = "drm")`, so a build without the backend keeps the
current answer exactly. `is_x11_for_drm` is the unmemoised form for the two
retry loops that must keep asking while a boot is still naming the session, and
the memoised accessor is scoped to per-frame callers in the per-session
`--server`, which the service only spawns once it has identified the session.

Input was the last layer and lived outside all of that. `Enigo` decides
x11-vs-Wayland once in `Default::default()`, from the same seat0 lookup, and on
"x11" routes every key and mouse event to xdo; with no X server that context is
null and libxdo drops them without an error. So the uinput devices were created,
the compositor opened them, and nothing was ever written to them. `set_is_x11`
is now called where the custom devices are installed, which is only reached once
`!is_x11()` is already established. The unit test pins both directions, since a
one-directional test passes against the bug.

With no compositor reachable, the uinput desktop rect comes from the DRM display
list instead: those are the same displays being captured, so the coordinate space
matches by construction. Telling the truth about a greeter also makes four
compositor-probing paths reachable where the probe cannot answer; all four
already treat an empty output list as "nothing to do", so they skip it and 11818
"Could not find wayland compositor" warnings in one session became 1.

Tested on an sddm Plasma Wayland greeter, MacBook T2, 2880x1800: the greeter
renders, typing from the client enters characters in the password field, a click
at an absolute coordinate opens the greeter session combo, the service pre-warm
primes in 994 us instead of timing out, and the privileged service maps no EGL
during a live capture. Not proven on gdm under Wayland.

Known limitations: non-ASCII characters cannot be typed at a greeter, because
that path goes through the clipboard and the clipboard here is X11 only; and at
a multi-monitor greeter the pointer reaches the first display only, since every
DRM output reports origin (0,0) on Wayland and there is no arrangement to derive
without the compositor.
This commit is contained in:
Mariano Abad
2026-08-07 17:35:36 -03:00
parent 429c8c6711
commit 4c7e85cfa6
9 changed files with 219 additions and 18 deletions

View File

@@ -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<AtomicUsize>);
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"
);
}

View File

@@ -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 \

View File

@@ -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<Option<String>> = std::sync::Mutex::new(None);
static ref DATABASE_XTERM_256COLOR: Option<Database> = {
@@ -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));

View File

@@ -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();

View File

@@ -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<Display>) {
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<Display>) -> Vec<DisplayInfo> {
// 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<Display>) -> Vec<DisplayInfo> {
#[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()

View File

@@ -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<DisplayInfo> {
let wl = scrap::wayland::display::get_displays();
let mut infos: Vec<DisplayInfo> = 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);

View File

@@ -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<dyn std::error::Error>> {
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")?;

View File

@@ -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.