Files
rustdesk/src/server/wayland.rs
Mariano Abad d829d1410a 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>
2026-08-13 20:22:41 +08:00

590 lines
28 KiB
Rust

use super::*;
use hbb_common::{allow_err, anyhow, platform::linux::DISTRO};
use scrap::{
is_cursor_embedded, set_map_err,
wayland::pipewire::{fill_displays, try_fix_logical_size},
Capturer, Display, Frame, TraitCapturer,
};
use std::collections::HashMap;
use std::io;
use crate::{
client::{
SCRAP_OTHER_VERSION_OR_X11_REQUIRED, SCRAP_UBUNTU_HIGHER_REQUIRED,
SCRAP_X11_REQUIRED, SCRAP_XDP_PORTAL_UNAVAILABLE,
},
platform::linux::is_x11,
};
lazy_static::lazy_static! {
static ref CAP_DISPLAY_INFO: RwLock<HashMap<usize, u64>> = RwLock::new(HashMap::new());
static ref PIPEWIRE_INITIALIZED: RwLock<bool> = RwLock::new(false);
static ref LOG_SCRAP_COUNT: Mutex<u32> = Mutex::new(0);
static ref ACTIVE_DISPLAY_COUNT: RwLock<usize> = RwLock::new(0);
}
pub fn init() {
set_map_err(map_err_scrap);
}
pub(super) fn increment_active_display_count() -> usize {
let mut count = ACTIVE_DISPLAY_COUNT.write().unwrap();
*count += 1;
*count
}
pub(super) fn decrement_active_display_count() -> usize {
let mut count = ACTIVE_DISPLAY_COUNT.write().unwrap();
if *count > 0 {
*count -= 1;
}
*count
}
fn map_err_scrap(err: String) -> io::Error {
// to-do: Handle error better, do not restart server
if err.starts_with("Did not receive a reply") {
log::error!("Fatal pipewire error, {}", &err);
std::process::exit(-1);
}
if DISTRO.name.to_uppercase() == "Ubuntu".to_uppercase() {
if DISTRO.version_id < "21".to_owned() {
io::Error::new(io::ErrorKind::Other, SCRAP_UBUNTU_HIGHER_REQUIRED)
} else {
try_log(&err);
io::Error::new(io::ErrorKind::Other, err)
}
} else {
try_log(&err);
let err_lower = err.to_ascii_lowercase();
if err_lower.contains("org.freedesktop.portal")
|| err_lower.contains("dbus")
|| err_lower.contains("d-bus")
{
// The portal D-Bus interface is unreachable. This typically means
// xdg-desktop-portal has crashed... for more info, see: Issue #12897
io::Error::new(io::ErrorKind::Other, SCRAP_XDP_PORTAL_UNAVAILABLE)
} else if err_lower.contains("pipewire") {
io::Error::new(io::ErrorKind::Other, SCRAP_OTHER_VERSION_OR_X11_REQUIRED)
} else {
io::Error::new(io::ErrorKind::Other, SCRAP_X11_REQUIRED)
}
}
}
fn try_log(err: &String) {
let mut lock_count = LOG_SCRAP_COUNT.lock().unwrap();
if *lock_count >= 1000000 {
return;
}
if *lock_count % 10000 == 0 {
log::error!("Failed scrap {}", err);
}
*lock_count += 1;
}
struct CapturerPtr(*mut Capturer);
impl Clone for CapturerPtr {
fn clone(&self) -> Self {
Self(self.0)
}
}
impl TraitCapturer for CapturerPtr {
fn frame<'a>(&'a mut self, timeout: std::time::Duration) -> std::io::Result<Frame<'a>> {
unsafe { (*self.0).frame(timeout) }
}
}
struct CapDisplayInfo {
rects: Vec<((i32, i32), usize, usize)>,
displays: Vec<DisplayInfo>,
num: usize,
primary: usize,
current: usize,
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
/// too, otherwise on a multi-monitor host the injected pointer lands on the wrong output — and the
/// hardware cursor, which lives on whichever CRTC the pointer is over, never appears on the captured
/// CRTC (the "cursor not visible" symptom). Reads the layout from the Wayland outputs, so it is
/// independent of the capture backend.
///
/// This is the DRM path's single copy of what `check_init` does inline for PipeWire, and it does the
/// same three things, for the same reasons:
///
/// - drops the cached Wayland layout first, because it can predate compositor changes made while no
/// session was active (rustdesk#15601), and on the hotplug path it is stale by definition;
/// - bounds the IPC wait, because `uinput::client::set_resolution` reads its reply with no timeout of
/// its own, so a hung uinput socket would otherwise block every video-service start on this branch
/// and wedge the hotplug worker inside `rt.block_on`, leaving `UINPUT_REFRESH_BUSY` latched true so
/// that every later hotplug refresh is silently skipped for the process lifetime;
/// - records the applied rect and snapshots the per-display layout baseline, which is what arms the
/// #15601 drift remap. Without it the remap never activates on the DRM path at all.
///
/// It stays a separate copy rather than being folded into `check_init` because `check_init` ships in
/// every Linux build and this feature must not change the drm-off one by so much as a line.
#[cfg(feature = "drm")]
pub(super) async fn update_uinput_resolution() {
if !crate::input_service::wayland_use_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(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
// uinput device reconfiguration under a user who may be at the console.
if super::display_service::wayland_uinput_rect() == Some(rect) {
snapshot_layout();
return;
}
let (minx, maxx, miny, maxy) = rect;
log::info!("update mouse resolution: ({minx}, {maxx}), ({miny}, {maxy})");
match timeout(
3_000,
input_service::update_mouse_resolution(minx, maxx, miny, maxy),
)
.await
{
// Record the rect only after a successful apply, so a transient failure is retried on the
// next call instead of being remembered as applied.
Ok(Ok(())) => {
super::display_service::set_wayland_uinput_rect(rect);
snapshot_layout();
}
Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err),
Err(err) => log::error!("Failed to update mouse resolution: {}", err),
}
}
#[tokio::main(flavor = "current_thread")]
pub(super) async fn ensure_inited() -> ResultType<()> {
// DRM/KMS capture (opt-in): the root service owns the reader and the capturer self-inits over
// IPC, so there is no PipeWire recorder to initialize here. But we still must set the uinput
// desktop rect (check_init does this on the PipeWire path, and the DRM path skips check_init).
#[cfg(feature = "drm")]
if super::drm_capturer::is_available_cached() {
update_uinput_resolution().await;
return Ok(());
}
check_init().await
}
pub(super) fn is_inited() -> Option<Message> {
if is_x11() {
None
} else {
#[cfg(feature = "drm")]
if super::drm_capturer::is_available_cached() {
return None;
}
if CAP_DISPLAY_INFO.read().unwrap().is_empty() {
let mut msg_out = Message::new();
let res = MessageBox {
msgtype: "nook-nocancel-hasclose".to_owned(),
title: "Wayland".to_owned(),
text: "Please Select the screen to be shared(Operate on the peer side).".to_owned(),
link: "".to_owned(),
..Default::default()
};
msg_out.set_message_box(res);
Some(msg_out)
} else {
None
}
}
}
pub(super) async fn check_init() -> ResultType<()> {
if !is_x11() {
if CAP_DISPLAY_INFO.read().unwrap().is_empty() {
if crate::input_service::wayland_use_uinput() {
// The cached layout may predate compositor changes made while no session
// was active, https://github.com/rustdesk/rustdesk/issues/15601
scrap::wayland::display::clear_wayland_displays_cache();
if let Some((minx, maxx, miny, maxy)) =
scrap::wayland::display::get_desktop_rect_for_uinput()
{
log::info!(
"update mouse resolution: ({}, {}), ({}, {})",
minx,
maxx,
miny,
maxy
);
// Bound the IPC wait like the periodic refresh does, so a hung
// response can't stall session init.
match timeout(
3_000,
input_service::update_mouse_resolution(minx, maxx, miny, maxy),
)
.await
{
Ok(Ok(())) => {
super::display_service::set_wayland_uinput_rect((
minx, maxx, miny, maxy,
));
// Snapshot the per-display layout the client's coordinates
// will be based on, so the mouse path can correct them if
// the compositor moves a monitor mid-session.
super::display_service::set_wayland_layout_baseline(
scrap::wayland::display::get_display_rects_for_uinput(),
);
}
Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err),
Err(err) => log::error!("Failed to update mouse resolution: {}", err),
}
} else {
log::warn!("Failed to get desktop rect for uinput");
}
}
let mut lock = CAP_DISPLAY_INFO.write().unwrap();
if lock.is_empty() {
// Check if PipeWire is already initialized to prevent duplicate recorder creation
if *PIPEWIRE_INITIALIZED.read().unwrap() {
log::warn!("wayland_diag: Preventing duplicate PipeWire initialization");
return Ok(());
}
let mut all = Display::all()?;
log::debug!("Initializing displays with fill_displays()");
{
let temp_mouse_move_handle = input_service::TemporaryMouseMoveHandle::new();
let move_mouse_to = |x, y| temp_mouse_move_handle.move_mouse_to(x, y);
fill_displays(move_mouse_to, crate::get_cursor_pos, &mut all)?;
}
log::debug!("Attempting to fix logical size with try_fix_logical_size()");
try_fix_logical_size(&mut all);
*PIPEWIRE_INITIALIZED.write().unwrap() = true;
let num = all.len();
let primary = super::display_service::get_primary_2(&all);
let mut displays = super::display_service::update_sync_displays(&all);
for display in displays.iter_mut() {
display.cursor_embedded = is_cursor_embedded();
}
let mut rects: Vec<((i32, i32), usize, usize)> = Vec::new();
for d in &all {
rects.push((d.origin(), d.width(), d.height()));
}
log::debug!(
"#displays={}, primary={}, rects: {:?}, cpus={}/{}",
num,
primary,
rects,
num_cpus::get_physical(),
num_cpus::get()
);
// Create individual CapDisplayInfo for each display with its own capturer
for (idx, display) in all.into_iter().enumerate() {
let capturer =
Box::into_raw(Box::new(Capturer::new(display).with_context(|| {
format!("Failed to create capturer for display {}", idx)
})?));
let capturer = CapturerPtr(capturer);
let cap_display_info = Box::into_raw(Box::new(CapDisplayInfo {
rects: rects.clone(),
displays: displays.clone(),
num,
primary,
current: idx,
capturer,
}));
lock.insert(idx, cap_display_info as u64);
}
}
}
}
Ok(())
}
pub(super) async fn get_displays_and_primary() -> ResultType<(Vec<DisplayInfo>, usize)> {
#[cfg(feature = "drm")]
if super::drm_capturer::is_available_cached() {
// This function runs once per login (update_get_sync_displays_on_login is its only
// caller), and login is the moment the client is PROMISED a display list -- so refresh
// that list over a live `_drm` handshake first. The service wakes sleeping displays and
// answers with the settled truth, which is what makes an unattended box with an idled,
// DISABLED panel connectable at all: the cached list would either omit the panel (probed
// while asleep) or advertise a display with no scanout behind it (probed while awake), and
// either way the wake then firing inside the capture handshake would change the list the
// 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;
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?;
// Keep one read guard so clear/reinitialization cannot split these across cache snapshots.
let cap_map = CAP_DISPLAY_INFO.read().unwrap();
if let Some(addr) = cap_map.values().next() {
let cap_display_info: *const CapDisplayInfo = *addr as _;
unsafe {
let cap_display_info = &*cap_display_info;
Ok((cap_display_info.displays.clone(), cap_display_info.primary))
}
} else {
bail!("Failed to get capturer display info");
}
}
pub fn clear() {
if is_x11() {
return;
}
// The DRM path augments its geometry from the compositor's Wayland outputs (logical origin +
// scale), which scrap caches process-wide. The PipeWire path clears that cache on session close,
// but the DRM path opens no PipeWire session, so without this it would keep matching DRM outputs
// against STALE geometry after a monitor hotplug/rotation/scale change. Invalidate it on teardown
// so the next session re-reads fresh geometry (lazily, on the next enumeration) and self-heals.
#[cfg(feature = "drm")]
if super::drm_capturer::is_available_cached() {
scrap::wayland::display::clear_wayland_displays_cache();
}
// NOTE: intentionally do NOT reset the DRM probe cache here. `clear()` runs on every capturer
// teardown (which happens on each video-service restart), and re-probing `_drm` from the async
// enumeration path blocks the executor long enough to trip "deadline has elapsed" and spiral
// into a restart loop. DRM availability is fixed at service start, so the cache stays valid.
let mut write_lock = CAP_DISPLAY_INFO.write().unwrap();
for (_, addr) in write_lock.iter() {
let cap_display_info: *mut CapDisplayInfo = *addr as _;
unsafe {
let _box_capturer = Box::from_raw((*cap_display_info).capturer.0);
let _box_cap_display_info = Box::from_raw(cap_display_info);
}
}
write_lock.clear();
// Reset PipeWire initialization flag to allow recreation on next init
*PIPEWIRE_INITIALIZED.write().unwrap() = false;
}
/// Initialize the PipeWire/portal capture path from the plain (sync) video thread, so a DRM display
/// that cannot be captured can fall through to PipeWire for THAT display. `ensure_inited` short-circuits
/// to the DRM branch whenever DRM is globally available, so it never runs `check_init`; this helper
/// drives the same async portal ScreenCast init directly (mirroring `ensure_inited`'s pattern). Needed
/// because `is_available()` is a GLOBAL verdict — it stays true for the still-working DRM outputs — so
/// without a per-display fallback a single failed/demoted DRM display would restart-loop the video
/// service instead of degrading to PipeWire only for itself.
#[cfg(feature = "drm")]
#[tokio::main(flavor = "current_thread")]
async fn ensure_pipewire_inited() -> ResultType<()> {
check_init().await
}
pub(super) fn get_capturer_for_display(
display_idx: usize,
) -> ResultType<super::video_service::CapturerInfo> {
if is_x11() {
bail!("Do not call this function if not wayland");
}
// DRM/KMS capture path: build the capturer straight from the service `_drm` stream, bypassing
// the PipeWire CAP_DISPLAY_INFO machinery entirely. `is_available()` is a GLOBAL verdict, so a
// per-display DRM failure (an ungrabbable/demoted CRTC, or — after the phase-2 split — a
// render-node-absent seat or a convert failure on the unprivileged side) must NOT propagate out
// and restart-loop this per-display video service. Instead fall THROUGH to PipeWire for just this
// display; the other DRM outputs keep streaming over DRM.
// The ONE gate that keeps the probing form on purpose: this runs on the plain video thread,
// not an async executor, and it is the capture-build path, so a definitive verdict is worth
// seconds here. It is also what makes a cold cache recoverable at all -- warm_availability
// gives up after its attempts, so if EVERY gate were cache-only a --server that started
// before the root service would never see DRM again for the rest of its life.
#[cfg(feature = "drm")]
if super::drm_capturer::is_available() {
match super::drm_capturer::get_capturer_info(display_idx) {
Ok(info) => return Ok(info),
Err(e) => {
log::warn!(
"drm capturer for display {} unavailable ({:#}); falling back to PipeWire",
display_idx,
e
);
ensure_pipewire_inited()?;
}
}
}
// Resolved BEFORE the read guard below, deliberately. `get_display_infos` runs
// `augment_with_wayland_geometry`, which is a compositor output roundtrip, and `clear()` takes
// the WRITE guard on every capturer teardown -- which is exactly what is happening when a DRM
// display is demoted or flapping, i.e. precisely when this path runs. Holding the read guard
// across that roundtrip would stall every concurrent teardown for its duration, and the value
// does not depend on anything inside the guard.
#[cfg(feature = "drm")]
let drm_advertised = if super::drm_capturer::is_available_cached() {
match super::drm_capturer::get_display_infos() {
Some(list) => Some((list.get(display_idx).cloned(), list.len() == 1)),
None => Some((None, false)),
}
} else {
None
};
let cap_map = CAP_DISPLAY_INFO.read().unwrap();
// Serve ONLY the exact PipeWire entry for this index. Do NOT fall back to another index's
// `CapDisplayInfo`: `CapturerPtr` is a bare `*mut Capturer` cloned by raw-pointer copy, so aliasing
// one entry to two `display_idx` values would let two video-service threads call `frame()` on the
// same `Recorder` with no lock (data race / UB), and it would also mis-map input against the wrong
// rect. DRM and PipeWire do not share an index space (the portal often exposes one whole-desktop
// stream at index 0), so a demoted non-primary DRM index has no PipeWire entry here; that case is
// handled at the source by dropping the demoted display from the advertised list (see
// drm_capturer demotion) so the client re-enumerates against a consistent list, rather than being
// papered over with a shared/mismatched capturer.
if let Some(addr) = cap_map.get(&display_idx) {
let cap_display_info: *const CapDisplayInfo = *addr as _;
unsafe {
let cap_display_info = &*cap_display_info;
let rect = cap_display_info.rects[cap_display_info.current];
// Reaching here with DRM active means get_capturer_info bailed (a demoted display) and
// we fell through to PipeWire. Serve this stream ONLY if its rect matches the
// geometry we advertised for this index. The portal typically exposes one whole-desktop
// stream, so on a multi-monitor host that rect is the FULL desktop while the advertised DRM
// geometry is a single connector -> serving it would stretch the frame and offset all
// input. Bail instead; get_display_infos advertised the display offline, so the client
// re-enumerates against a consistent list. A single-display host matches (whole-desktop ==
// that display) and is served normally. On a pure-PipeWire host is_available() is false and
// this guard is skipped, preserving upstream behavior exactly.
#[cfg(feature = "drm")]
if let Some((advertised, single_display)) = drm_advertised {
if let Some(advertised) = advertised {
// BOTH SIDES ARE PHYSICAL, so compare them raw. Traced rather than assumed,
// because it was twice "corrected" to a scale conversion that broke it:
// `rect` is built above from `Display::width()/height()`, and the WAYLAND
// variant of those returns `physical_width()/physical_height()`
// (scrap `common/wayland.rs`), i.e. `PipeWireCapturable.physical_size`.
// `try_fix_logical_size` only repairs the capturable's SEPARATE
// `logical_size` field and never touches `physical_size`, so the rect is not
// logical. The advertised DRM geometry is physical too
// (`augment_with_wayland_geometry` sets x/y/scale and deliberately leaves
// width/height as the DRM mode). Dividing one side by the scale therefore
// compares logical against physical and rejects the valid stream on exactly
// the scaled outputs it was meant to rescue.
//
// The size check is what tells one connector apart from the whole-desktop
// rect the portal usually exposes. It is skipped only when BOTH sides say
// there is a single display -- the DRM list has one entry and the PipeWire
// map has one -- because only then is "the whole-desktop stream IS this
// display" true by construction. (The portal can report a different physical
// size for a Full Workspace selection than the connector's mode, which is why
// that case needs the carve-out at all.) The DRM count alone is not enough:
// a monitor on a card the service cannot open is missing from the DRM list
// while the compositor still drives it.
let single_display = single_display && cap_display_info.num == 1;
let consistent = advertised.x == rect.0 .0
&& advertised.y == rect.0 .1
&& (single_display
|| (advertised.width as usize == rect.1
&& advertised.height as usize == rect.2));
if !consistent {
bail!(
"drm display {} demoted with no geometry-consistent PipeWire stream (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline",
display_idx,
advertised.width,
advertised.height,
advertised.x,
advertised.y,
rect.1,
rect.2,
rect.0 .0,
rect.0 .1
);
}
}
}
Ok(super::video_service::CapturerInfo {
origin: rect.0,
width: rect.1,
height: rect.2,
ndisplay: cap_display_info.num,
current: cap_display_info.current,
privacy_mode_id: 0,
_capturer_privacy_mode_id: 0,
capturer: Box::new(cap_display_info.capturer.clone()),
})
}
} else {
bail!(
"Failed to get capturer display info for display {}",
display_idx
);
}
}
pub fn common_get_error() -> String {
if DISTRO.name.to_uppercase() == "Ubuntu".to_uppercase() {
if DISTRO.version_id < "21".to_owned() {
return "".to_owned();
}
} else {
// to-do: check other distros
}
return "".to_owned();
}