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

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

* fix(linux): a Wayland greeter the DRM backend can serve is not headless

fufesou reported the login screen still failing on Ubuntu 24.04 with gdm3, with
the client asking for OS credentials to start an X session instead of showing the
greeter. Reproduced on a real gdm greeter here.

Same premise as the rest of the branch, one more consumer. `DesktopManager::new`
reads seat0 through `get_values_of_seat0`, which skips a gdm/sddm Wayland session
by construction, so at a greeter it finds no session at all and
`get_supported_display_seat0_username` returns None from its empty-username arm.
That makes `is_headless()` true, so the service advertises headless and
`try_start_desktop` answers `LOGIN_MSG_DESKTOP_SESSION_NOT_READY`. The corrected
`IS_X11` does not reach this one: it asks who owns seat0, not which display
server is running.

So ask again, with the greeter visible, when the DRM backend can capture and
inject into it. At query time rather than in `new()`, because the DRM probe has
not necessarily settled when the desktop manager is constructed, and the answer
would latch for the process lifetime. In a normal session the latched username is
a real user and the extra read is skipped.

* chore: drop the hbb_common bump, this branch does not need it

The bump carried rustdesk/hbb_common#580, the compositor-socket fallback. Nothing
here depends on it: the greeter paths in this branch are the ones that run when
compositor data is unavailable, which is what the commit before this one states as
a known limitation. Keeping the bump would only block the greeter fix behind a
review of a separate change, and would import that change's blocking review items
into this path.

* fix(linux): let the uinput uid gate see the greeter that owns seat0

Input at a real greeter was rejected by our own authorization. Measured on Ubuntu
24.04 with gdm3: the root service logs

  Rejected unauthorized connection on uinput ipc channel:
  postfix=_uinput_control, peer_uid=Some(120), active_uid=None

and the greeter's `--server` gets ECONNRESET out of `setup_uinput`, so no uinput
device is ever created and neither keyboard nor mouse reaches the greeter.

uid 120 is gdm, the owner of the only active seat0 session. `active_uid` is None
because the uinput authorizer deliberately bypasses the service-loop cache and
takes a fresh seat0 lookup, and the fresh read hides a Wayland greeter by
construction. The cache-based gates do not have the problem: `Desktop::refresh`
fills it through the greeter-visible read, which is also why capture and config
sync work at a greeter while input does not.

So make the fresh read agree with the cache. It keeps the property the uinput gate
wants, a lookup that cannot be stale, and it still compares the peer against the
uid of the session that owns seat0 -- which at a greeter is the greeter.

* fix: settle the DRM probe before routing login to X11, and read seat0 fresh

Two findings from the #15792 review, both verified against the code:

- drm_login_screen_seat0_username asked the cached probe, so a client
  arriving before warm_availability publishes its verdict read "no DRM"
  and, with allow-linux-headless=Y, try_start_x_session could start Xorg
  over a live Wayland greeter. Ask the probing form instead, and only
  after the cheap seat0 read says a Wayland greeter is actually there: a
  bounded definitive verdict is affordable on a login-time path.

- get_supported_display_seat0_username trusted the seat0 values cached in
  DesktopManager::new(), which go stale across a logout or a fast user
  switch: a stale non-greeter name skipped the greeter probe and was
  returned as the supported display owner. Read seat0 fresh on every
  query; every call site is connection-time, so the extra loginctl read
  is cheap.

Regression-tested on a real sddm Wayland greeter: capture streams the
greeter, the RustDesk password dialog is the only prompt, and five typed
characters appeared in the greeter password field over uinput with zero
"Rejected unauthorized connection" lines in the service log.

* fix: ask the greeter compositor for the multi-monitor layout

The display arrangement and the pointer mapping were wrong at a
multi-monitor login screen, and the mechanism is measured on a two-head
virtio VM: DRM has no origins, so every display was advertised at (0,0)
(a stacked arrangement on the client), and the uinput range was taken
from the union of the DRM modes while the compositor had arranged the
outputs side by side.

Both came from the same premise, written before the hbb_common socket
fallback existed: "a login screen has no compositor to ask".
wayland_outputs_askable() skipped the wl_output augmentation at any
greeter, and update_uinput_resolution took the DRM union directly. The
premise is false now: a greeter runs a compositor, and the socket
fallback reaches it with no environment variables, measured answering
two outputs at the VM greeter while the old gate was still routing
around it.

Drop the gate and take the compositor-first path everywhere. Where the
fallback cannot answer, the output list comes back empty and both call
sites degrade to exactly the old behavior, so a build against an older
hbb_common is unchanged.

* fix: augment a single display too, and probe the desktop rect off the executor

Two follow-ups from the automated re-review of cd80c3dee, both verified:

- augment_with_wayland_geometry skipped the compositor below two DRM
  displays, but on a multi-GPU host the one connector this service can
  open may sit at a non-zero origin of the compositor layout, and DRM
  alone reports (0,0).

- the desktop rect for uinput can now block for the socket probe
  deadline, and update_uinput_resolution runs on current-thread
  runtimes; move the query into spawn_blocking.

The third re-review finding, the warm-up allegedly skipping Wayland
greeters, is refuted: warm_availability probes while is_x11_for_drm()
is false, which includes a Wayland greeter, and the greeter log of the
VM run behind cd80c3dee shows the warm succeeding there.

* fix: baseline the layout from the blocking task, and augment a lone output's origin

The layout snapshot after the rect lookup still ran on the executor: a
failed compositor lookup is not cached, so the snapshot synchronously
repeated the whole socket probe there. The baseline is now computed
inside the same blocking task, from the snapshot the successful lookup
just cached, or omitted when only the raw DRM union was available,
which keeps the #15601 remap inactive exactly where origins are
unknown.

A single compositor output now hands its origin to a single connector:
the lone output can sit at a non-zero origin the DRM side cannot see.
Scale stays 1 on purpose, matching how a single display is advertised
at physical size, and more connectors than the one output stays
unaugmented, since the layout-order fallback would plant that origin on
a guess.

Also refresh the get_primary_index doc that still said augmentation
declines below two connectors.

* fix: read the DRM probe as a tri-state, and keep pre-auth seat0 checks cache-only

is_available() answered false both for a definitive no-DRM verdict and
for a probe that had simply not settled (another probe in flight, or a
failure still below the disable threshold), and the login-screen
decision turned that transient false into no-greeter: try_start_x_session
could put Xorg over a live greeter in exactly the window the probe
needed. The machinery now answers Available/Unavailable/Unsettled, and
only a definitive Unavailable routes the seat toward X11.

Connection setup also ran the whole lookup pre-auth: constructing
LinuxHeadlessHandle called is_headless() before authentication, holding
DESKTOP_MANAGER while loginctl ran and, at a greeter, while the DRM
probe waited out its handshake. An unauthenticated peer could occupy a
worker for seconds and serialize every other connection on the mutex.
is_headless() now answers from a snapshot refreshed off-thread, and the
fresh lookup became a free function called with the manager lock
released everywhere; the enforcing decisions, get_username and
try_start_x_session, still read seat0 fresh.

Also drops seat0_display_server, dead since the fresh-read change.

* fix: respect RUSTDESK_FORCED_DISPLAY_SERVER over the greeter correction

The greeter correction rewired IS_X11 and is_x11_for_drm() to Wayland
whenever seat0 looks like a Wayland greeter, including when the operator
explicitly forced the display server: get_display_server() kept honoring
the override while the DRM routing gates contradicted it, leaving
capture and input routing internally inconsistent. The correction now
only adjusts the auto-detected answer.

* fix: honest pre-auth snapshot, sticky negative verdict, and a complete forced-x11 gate

Four defects found by an adversarial review of the two previous
commits, all in their new lines:

- The empty-snapshot fallback derived headless from the manager's
  boot-time seat0 read, which is blank at a Wayland greeter (the
  loginctl wrapper skips greeter sessions), so the first connection of
  every server process at a greeter answered headless=true, the
  opposite of the comment on it. No snapshot now answers NOT headless,
  the snapshot is seeded at start_xdesktop, and the boot-time cache is
  gone entirely (it had no reader left).

- wait_desktop_cm_ready gated on a bool stored at construction, which
  can lag one seat0 transition behind and skipped the CM-ready wait
  right after a logout. It re-reads the snapshot at call time.

- A settled Unavailable was erased at NEGATIVE_TTL expiry (state to
  Unknown, failure counter to zero), so a permanently helper-less box
  reopened the Unsettled window every 30 seconds and the login decision
  kept adopting a greeter nothing can serve. The verdict now stays
  Unavailable while an off-thread re-probe re-verifies it: a failed or
  empty re-probe restamps the no, and only a non-empty list flips it.

- The forced-x11 gate only covered IS_X11 and is_x11_for_drm, while
  the seat0 adoption path still probed DRM and admitted greeter
  sessions whose capture and input then routed to X11. Greeter
  adoption now yields to an operator-forced X11, degrading to upstream
  behavior: the connection is refused at the login screen.

* fix: keep the login request path off the probe entirely

try_start_desktop runs while handling a LoginRequest, before password
validation, and at a Wayland greeter its seat0 lookup reached the
probing availability form: an unauthenticated peer could park a worker
for the probe deadline. The greeter adoption now reads a cached
tri-state that never blocks; when the state is Unknown it kicks the
probe off-thread and answers Unsettled, which the login decision treats
as a possibly servable greeter until it settles. Settling lives in the
startup warm-up, that kick, and the TTL re-verifiers; the blocking form
stays for the capture-side callers, where waiting is acceptable.

* fix: run the pre-auth desktop start off the executor, guard the refresh flag, trim comments

From fufesou's #15792 re-review (no blocking issues) plus a bot pass:

- try_start_desktop now runs on spawn_blocking. It executes loginctl,
  and PAM when a session must start, while handling a LoginRequest
  before password validation, so a slow logind must not tie up an async
  request worker; the blocking pool absorbs it.

- kick_seat0_refresh releases SEAT0_REFRESH_IN_FLIGHT through an RAII
  guard, so a panic in the refresh thread cannot freeze is_headless on a
  stale snapshot for the process lifetime.

- drm_can_serve_login_screen stays Available-only, and the reason is now
  in the code: it is deliberately not symmetric with the seat0 adoption
  gate. Adoption yields Xorg only on a definitive Unavailable; admission
  accepts only on a definitive Available; both wait through an unsettled
  probe. Admitting there would black-screen a client on a helper-less
  box, so a review suggestion to make them agree is declined.

- Trimmed two over-long comments to the repo's three-line rule.

* fix(linux): harden DRM login-screen startup

Keep unauthenticated headless checks cache-only, bound OS-session startup to one blocking task, and surface JoinError failures.

Wire the isolated Wayland probe consumer and update hbb_common plus libdrmtap 0.5.4.

* fix(linux): headless refresh state

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): keep headless startup state consistent

- gate concurrent desktop startup attempts
- route CM IPC after refreshing desktop state
- avoid blocking seat0 queries in the CM retry loop
- preserve newer seat0 snapshots during overlapping refreshes
- derive DRM geometry and primary display from one Wayland snapshot

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: rustdesk <info@rustdesk.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
Mariano Abad
2026-08-13 09:22:41 -03:00
committed by GitHub
parent c4fd7d692d
commit d829d1410a
13 changed files with 808 additions and 150 deletions

View File

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

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

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

View File

@@ -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<Option<String>>,
}
lazy_static::lazy_static! {
static ref DESKTOP_RUNNING: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
static ref DESKTOP_MANAGER: Arc<Mutex<Option<DesktopManager>>> = 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<Seat0Snapshot> = Mutex::new(Seat0Snapshot::default());
static ref SEAT0_NEXT_REFRESH: Mutex<Option<Instant>> = 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<AtomicBool>,
is_child_running: Arc<AtomicBool>,
@@ -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<String> {
// 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<String> {
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<String> {
// 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<String>,
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<Instant>, now: Instant) -> (Option<Instant>, 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<String> {
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!(

View File

@@ -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;
}

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

@@ -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<usize>], 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<DisplayInfo>, 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<Vec<DisplayInfo>> {
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<DisplayInfo> {
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<usize>],
) -> Vec<DisplayInfo> {
let mut infos: Vec<DisplayInfo> = 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)));

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,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<DisplayInfo>,
// 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?;