Merge branch 'master' into custom-client-no-rebuild

This commit is contained in:
fufesou
2026-09-02 13:46:42 +08:00
236 changed files with 14237 additions and 9328 deletions

View File

@@ -43,7 +43,7 @@ once_cell = {version = "1.18", optional = true}
percent-encoding = {version ="2.3", optional = true}
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
fuser = {version = "0.15", default-features = false, optional = true}
fuser = {git="https://github.com/rustdesk-org/fuser", branch = "refact/tag-0.16.0-cargo-1.75.0", default-features = false, optional = true}
[target.'cfg(target_os = "macos")'.dependencies]
cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true}

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,13 @@ impl Enigo {
&mut self.custom_mouse
}
/// Override the display server guessed in `Default::default`: on "x11" every method here
/// routes to `xdo`, and a null xdo context makes all of them silent no-ops. A caller
/// installing custom devices knows better than the guess.
pub fn set_is_x11(&mut self, is_x11: bool) {
self.is_x11 = is_x11;
}
/// Clear remapped keycodes
pub fn tfc_clear_remapped(&mut self) {
if let Some(tfc) = &mut self.tfc {
@@ -390,3 +397,52 @@ fn test_key_seq() {
let mut en = Enigo::new();
en.key_sequence("^^");
}
/// Both directions: the failure is silent, so a one-directional test passes against the bug.
#[test]
fn test_custom_mouse_dispatch_follows_is_x11() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct CountingMouse(Arc<AtomicUsize>);
impl MouseControllable for CountingMouse {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_mut_any(&mut self) -> &mut dyn std::any::Any {
self
}
fn mouse_move_to(&mut self, _x: i32, _y: i32) {
self.0.fetch_add(1, Ordering::Relaxed);
}
fn mouse_move_relative(&mut self, _x: i32, _y: i32) {}
fn mouse_down(&mut self, _button: MouseButton) -> crate::ResultType {
Ok(())
}
fn mouse_up(&mut self, _button: MouseButton) {}
fn mouse_click(&mut self, _button: MouseButton) {}
fn mouse_scroll_x(&mut self, _length: i32) {}
fn mouse_scroll_y(&mut self, _length: i32) {}
}
let calls = Arc::new(AtomicUsize::new(0));
let mut en = Enigo::new();
en.set_custom_mouse(Box::new(CountingMouse(calls.clone())));
en.set_is_x11(false);
en.mouse_move_to(10, 20);
assert_eq!(
calls.load(Ordering::Relaxed),
1,
"custom mouse was not reached on the non-x11 branch"
);
// Negative control: on the x11 branch the custom device must be bypassed entirely.
en.set_is_x11(true);
en.mouse_move_to(30, 40);
assert_eq!(
calls.load(Ordering::Relaxed),
1,
"custom mouse was reached on the x11 branch"
);
}

View File

@@ -1,6 +1,6 @@
[package]
name = "rustdesk-portable-packer"
version = "1.4.9"
version = "1.5.0"
edition = "2021"
description = "RustDesk Remote Desktop"

View File

@@ -11,6 +11,16 @@ edition = "2018"
[features]
wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"]
# `drm` is a pure runtime-dlopen backend: rustdesk loads `libdrmtap.so.0` at runtime (`drmtap_dl.rs`)
# and NEVER link-time links it, so the graceful PipeWire fallback when the .so or EGL is absent is
# preserved and the drm build pulls in no libdrm/seccomp/cap/EGL link-time deps. The .so is pinned by
# `DRMTAP_SHA` in build.py, which fetches that exact commit (libdrmtap v0.5.4). We deliberately do
# NOT depend on the `libdrmtap-sys` crate: its build.rs statically compiles the whole libdrmtap C tree
# and a CAP_SYS_ADMIN helper and emits `-ldrm -lseccomp -lcap`, which would defeat the dlopen model.
# Depends on `wayland`: the three drm modules live inside the `#[cfg(feature = "wayland")]` arm of
# common/mod.rs, so `scrap/drm` on its own would compile nothing. The root crate happens to always
# enable `scrap/wayland`, which is what hid this.
drm = ["wayland", "hbb_common/wayland_probe"]
mediacodec = ["ndk"]
linux-pkg-config = ["dep:pkg-config"]
hwcodec = ["dep:hwcodec"]

View File

@@ -0,0 +1,477 @@
// Service-side DRM/KMS read engine, in the ROOT `--service`: libdrmtap reads the scanout in-process (direct mode). The DRM_DEVICE env is not consulted here.
use super::drmtap_dl::{
self, drmtap_config, drmtap_ctx, drmtap_cursor_info, drmtap_device, drmtap_display,
drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib,
};
use hbb_common::log;
use std::ffi::CString;
use std::io;
use std::os::fd::{FromRawFd, OwnedFd};
// Trust-boundary limits and formats `drm_render` (the unprivileged converter) imports: two copies that drift apart would weaken one side.
// 16384 covers 8K+ with headroom; anything larger is rejected as a bogus/hostile geometry.
pub(crate) const MAX_DIM: u32 = 16384;
// 256 MiB covers an 8K BGRA frame (7680x4320x4 ~= 127 MiB) with margin.
pub(crate) const MAX_FRAME_BYTES: usize = 256 * 1024 * 1024;
// XRGB/ARGB are little-endian B,G,R,{X,A} in memory == `Pixfmt::BGRA`; XBGR/ABGR are R,G,B,{X,A} == `Pixfmt::RGBA`.
pub(crate) const DRM_FORMAT_XRGB8888: u32 = 0x3432_5258; // 'XR24'
pub(crate) const DRM_FORMAT_ARGB8888: u32 = 0x3432_5241; // 'AR24'
pub(crate) const DRM_FORMAT_XBGR8888: u32 = 0x3432_4258; // 'XB24'
pub(crate) const DRM_FORMAT_ABGR8888: u32 = 0x3432_4241; // 'AB24'
/// Cursor id published when the plane reports the cursor hidden, so the id changes and, where the DRM cursor is authoritative, the client drops the last shape.
pub const HIDDEN_CURSOR_ID: u64 = u64::MAX;
pub struct CursorSnapshot {
pub id: u64,
pub width: u32,
pub height: u32,
pub hotx: i32,
pub hoty: i32,
pub colors: Vec<u8>,
}
/// One enumerated DRM display, physical geometry only (the server overlays the Wayland logical origin/scale where it can match one).
pub struct DisplaySnapshot {
pub name: String,
pub crtc_id: u32,
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
pub active: bool,
}
pub struct DrmDevice {
pub path: String,
/// Render node, or empty if this device has none.
pub render_node: String,
pub display_count: u32,
}
/// Copy a fixed C char array into a `String`, stopping at the first NUL WITHIN the array, so a
/// field libdrmtap failed to terminate cannot read past it.
fn cstr_field(buf: &[std::os::raw::c_char]) -> String {
// SAFETY: c_char and u8 share size/alignment; the slice is the exact length of `buf`.
let bytes: &[u8] =
unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, buf.len()) };
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
String::from_utf8_lossy(&bytes[..end]).into_owned()
}
/// Enumerate every DRM device with KMS resources. `None` = unavailable, too old, or failed (the caller then scans /dev/dri/card* itself); empty `Vec` = none found.
pub fn list_devices() -> Option<Vec<DrmDevice>> {
let lib = drmtap_dl::get()?;
let f = lib.list_devices?;
const MAX: usize = 16;
let mut raw: [drmtap_device; MAX] = unsafe { std::mem::zeroed() };
// SAFETY: `raw` is MAX valid, zeroed drmtap_device slots; the call fills up to MAX and returns the count.
let n = unsafe { f(raw.as_mut_ptr(), MAX as std::os::raw::c_int) };
if n < 0 {
log::warn!("drmtap_list_devices failed ({n}); using single-device auto-detect");
return None;
}
let n = (n as usize).min(MAX);
Some(
raw[..n]
.iter()
.map(|d| DrmDevice {
path: cstr_field(&d.path),
render_node: cstr_field(&d.render_node),
display_count: d.display_count,
})
.collect(),
)
}
/// The CANONICAL path, when `path` canonicalizes to a node directly under /dev/dri/, else `None`.
/// Callers must open the value returned: opening the original re-resolves every symlink component after the check.
pub(super) fn device_under_dev_dri(path: &str) -> Option<std::path::PathBuf> {
let p = std::fs::canonicalize(path).ok()?;
if p.parent() == Some(std::path::Path::new("/dev/dri")) {
Some(p)
} else {
None
}
}
/// An open DRM read context. Not Send/Sync deliberately (the raw ctx is used on one thread).
pub struct DrmReader {
lib: &'static DrmtapLib,
ctx: *mut drmtap_ctx,
buf: Vec<u8>,
}
impl DrmReader {
/// Open the DRM device. `device = None` auto-detects, `Some(path)` is realpath-gated to /dev/dri/. `crtc_id = 0` auto-selects the first active CRTC.
pub fn open(device: Option<&str>, crtc_id: u32) -> Option<DrmReader> {
let lib = drmtap_dl::get()?;
let device_cstr = match device {
None => None,
Some(d) => {
let Some(canonical) = device_under_dev_dri(d) else {
log::warn!("DRM device {d:?} is not under /dev/dri; refusing to open");
return None;
};
match canonical.to_str().and_then(|s| CString::new(s).ok()) {
Some(c) => Some(c),
None => return None,
}
}
};
let cfg = drmtap_config {
device_path: device_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()),
crtc_id,
helper_path: std::ptr::null(),
debug: 0,
};
// SAFETY: cfg is a valid struct; device_cstr outlives this call.
let ctx = unsafe { (lib.open)(&cfg) };
drop(device_cstr);
if ctx.is_null() {
log::info!("drmtap_open failed; DRM capture unavailable");
return None;
}
Some(DrmReader {
lib,
ctx,
buf: Vec::new(),
})
}
/// Grab one frame, tightly packed as BGRA (`w*4*h` bytes), into the internal buffer; valid until the next grab.
pub fn grab(&mut self) -> io::Result<(&[u8], usize, usize)> {
// SAFETY: ctx is valid; frame is zeroed before the call. The frame is released on every return path that OWNS one: a failing
// `drmtap_grab_mapped` leaves nothing to release, and releasing anyway would be a double free.
unsafe {
let mut frame: drmtap_frame_info = std::mem::zeroed();
let ret = (self.lib.grab_mapped)(self.ctx, &mut frame);
if ret < 0 {
let errno = -ret;
if errno == hbb_common::libc::EAGAIN
|| errno == hbb_common::libc::EBUSY
|| errno == hbb_common::libc::EINTR
{
return Err(io::ErrorKind::WouldBlock.into());
}
return Err(io::Error::new(
io::ErrorKind::Other,
format!("drmtap_grab_mapped failed: errno {errno}"),
));
}
if frame.data.is_null() || frame.width == 0 || frame.height == 0 {
(self.lib.frame_release)(self.ctx, &mut frame);
return Err(io::ErrorKind::WouldBlock.into());
}
let w = frame.width;
let h = frame.height;
let stride = frame.stride as usize;
// The row copy reads w*4 bytes from a source only stride*height bytes: reject sub-32bpp / insane geometry to avoid an OOB read.
if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 {
log::warn!(
"DRM scanout not 32-bit BGRA-compatible ({w}x{h} stride {stride} fourcc {:#010x}); falling back",
frame.format
);
(self.lib.frame_release)(self.ctx, &mut frame);
return Err(io::Error::new(
io::ErrorKind::Other,
"unsupported DRM scanout format",
));
}
// XBGR8888 passes the stride check but, labeled BGRA downstream, would ship red and blue swapped; a zero fourcc falls through to the stride invariant (kept for libdrmtap builds that do not set it).
if frame.format != 0
&& frame.format != DRM_FORMAT_XRGB8888
&& frame.format != DRM_FORMAT_ARGB8888
{
log::warn!(
"DRM scanout fourcc {:#010x} is not BGRA-compatible; falling back",
frame.format
);
(self.lib.frame_release)(self.ctx, &mut frame);
return Err(io::Error::new(
io::ErrorKind::Other,
"unsupported DRM scanout format",
));
}
let (w, h) = (w as usize, h as usize);
let frame_size = match w.checked_mul(4).and_then(|x| x.checked_mul(h)) {
Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz,
other => {
log::warn!(
"DRM scanout geometry {w}x{h} yields an out-of-range frame ({other:?} bytes); falling back"
);
(self.lib.frame_release)(self.ctx, &mut frame);
return Err(io::Error::new(
io::ErrorKind::Other,
"DRM scanout frame too large",
));
}
};
// Bound the SOURCE extent too: the row loop reads up to (h-1)*stride + w*4, and `y * stride` can overflow.
match stride.checked_mul(h) {
Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => {}
other => {
log::warn!(
"DRM scanout stride {stride} x {h} rows is out of range ({other:?} bytes); falling back"
);
(self.lib.frame_release)(self.ctx, &mut frame);
return Err(io::Error::new(
io::ErrorKind::Other,
"DRM scanout stride out of range",
));
}
}
if self.buf.len() != frame_size {
self.buf.resize(frame_size, 0);
}
let src = frame.data as *const u8;
let dst = self.buf.as_mut_ptr();
if stride == w * 4 {
std::ptr::copy_nonoverlapping(src, dst, frame_size);
} else {
for y in 0..h {
std::ptr::copy_nonoverlapping(src.add(y * stride), dst.add(y * w * 4), w * 4);
}
}
(self.lib.frame_release)(self.ctx, &mut frame);
Ok((&self.buf, w, h))
}
}
/// Render node of the GPU this reader captures from, so the converter binds to the device that EXPORTS the scanout:
/// importing across vendors can fail on an incompatible tiling modifier. `None` if the symbol is absent or the device is display-only.
pub fn render_node(&mut self) -> Option<String> {
let f = self.lib.render_node?;
// SAFETY: self.ctx is valid; the returned pointer is owned by the context and stays valid until it is closed.
let ptr = unsafe { f(self.ctx) };
if ptr.is_null() {
return None;
}
unsafe { std::ffi::CStr::from_ptr(ptr) }
.to_str()
.ok()
.map(|s| s.to_owned())
}
/// Zero-copy EXPORT grab: fills a `drmtap_dmabuf_desc` (dma-buf fd, plane layout, HDR metadata) WITHOUT mapping, detiling or copying pixels, so on this
/// path the root process never loads libEGL/libGLESv2. The exported fd is READ-ONLY (libdrmtap drops `DRM_RDWR` and `dup` shares that open file
/// description), so the `--server` that receives it can map the scanout but never write the live framebuffer. Validation here is METADATA ONLY.
pub fn grab_desc(&mut self) -> io::Result<(OwnedFd, drmtap_dmabuf_desc)> {
let grab_desc = self.lib.grab_desc;
// SAFETY: self.ctx is valid; desc/frame are zeroed before the call. Only paths that reach a populated frame release it: on `-EINVAL`
// libdrmtap returns before allocating, a failed inner grab has already cleaned up, and on `-ENOTSUP` libdrmtap releases the frame itself.
unsafe {
let mut desc: drmtap_dmabuf_desc = std::mem::zeroed();
let mut frame: drmtap_frame_info = std::mem::zeroed();
let ret = grab_desc(self.ctx, &mut desc, &mut frame);
if ret < 0 {
let errno = -ret;
if errno == hbb_common::libc::EAGAIN
|| errno == hbb_common::libc::EBUSY
|| errno == hbb_common::libc::EINTR
{
return Err(io::ErrorKind::WouldBlock.into());
}
if errno == hbb_common::libc::ENOTSUP {
// A distinct error so the caller degrades to the mapped/PipeWire path instead of tight-looping a rebuild.
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"drmtap_grab_desc: no transferable dma-buf (ENOTSUP)",
));
}
return Err(io::Error::new(
io::ErrorKind::Other,
format!("drmtap_grab_desc failed: errno {errno}"),
));
}
// `desc.dma_buf_fd` is the canonical fd (what split_capture.c sends); `frame` owns it too and `frame_release` closes the library's copy.
let raw_fd = if desc.dma_buf_fd >= 0 {
desc.dma_buf_fd
} else {
frame.dma_buf_fd
};
if raw_fd < 0 {
(self.lib.frame_release)(self.ctx, &mut frame);
return Err(io::ErrorKind::WouldBlock.into());
}
let w = desc.width;
let h = desc.height;
if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM {
(self.lib.frame_release)(self.ctx, &mut frame);
return Err(io::Error::new(
io::ErrorKind::Other,
format!("DRM scanout geometry {w}x{h} out of range"),
));
}
// No fourcc gate here: the converter handles every format libdrmtap supports, and gating here dropped convertible scanouts such as XR30.
let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes };
if planes > 4 {
(self.lib.frame_release)(self.ctx, &mut frame);
return Err(io::Error::new(
io::ErrorKind::Other,
format!("DRM scanout num_planes {} out of range (1..=4)", desc.num_planes),
));
}
for p in 0..(planes as usize) {
let extent = (desc.pitches[p] as usize)
.checked_mul(h as usize)
.and_then(|rows| rows.checked_add(desc.offsets[p] as usize));
match extent {
Some(end) if end <= MAX_FRAME_BYTES => {}
other => {
(self.lib.frame_release)(self.ctx, &mut frame);
return Err(io::Error::new(
io::ErrorKind::Other,
format!(
"DRM scanout plane {p} out of range (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})",
desc.offsets[p], desc.pitches[p]
),
));
}
}
}
// dup BEFORE releasing the frame: after release the library may recycle its handle, while an independent fd on the same open dma-buf
// keeps the buffer alive for the peer. F_DUPFD_CLOEXEC, not dup(): `dup` never copies close-on-exec and this root service forks elsewhere.
let dup_fd = hbb_common::libc::fcntl(raw_fd, hbb_common::libc::F_DUPFD_CLOEXEC, 0);
if dup_fd < 0 {
let e = io::Error::last_os_error();
(self.lib.frame_release)(self.ctx, &mut frame);
return Err(e);
}
let owned = OwnedFd::from_raw_fd(dup_fd);
(self.lib.frame_release)(self.ctx, &mut frame);
desc.num_planes = planes;
desc.dma_buf_fd = -1;
Ok((owned, desc))
}
}
/// Read the hardware cursor plane: the hidden sentinel when the plane reports the cursor invisible, the real shape when visible, and `None` when the read fails.
pub fn cursor(&mut self) -> Option<CursorSnapshot> {
// SAFETY: ctx valid; c zeroed; released on EVERY path after a successful get_cursor. Only a failed get_cursor returns without releasing, because then there is nothing to release.
unsafe {
let mut c: drmtap_cursor_info = std::mem::zeroed();
let cret = (self.lib.get_cursor)(self.ctx, &mut c);
if cret != 0 {
return None;
}
let out = if c.visible == 0 {
Some(CursorSnapshot {
id: HIDDEN_CURSOR_ID,
width: 1,
height: 1,
hotx: 0,
hoty: 0,
colors: vec![0, 0, 0, 0],
})
} else if !c.pixels.is_null()
&& c.width > 0
&& c.height > 0
&& (c.width as i64) * (c.height as i64) <= 256 * 256
{
let cw = c.width as i32;
let ch = c.height as i32;
let n = (cw * ch) as usize;
let src = std::slice::from_raw_parts(c.pixels, n);
let mut hash: u64 = 1469598103934665603;
let mut colors = Vec::with_capacity(n * 4);
let (mut minx, mut miny, mut maxx, mut maxy) = (cw, ch, -1i32, -1i32);
for (i, &p) in src.iter().enumerate() {
let a = ((p >> 24) & 0xff) as u8;
let r = ((p >> 16) & 0xff) as u8;
let g = ((p >> 8) & 0xff) as u8;
let b = (p & 0xff) as u8;
colors.push(r);
colors.push(g);
colors.push(b);
colors.push(a);
hash ^= p as u64;
hash = hash.wrapping_mul(1099511628211);
if a >= 128 {
let x = (i as i32) % cw;
let y = (i as i32) / cw;
if x < minx { minx = x; }
if x > maxx { maxx = x; }
if y < miny { miny = y; }
if y > maxy { maxy = y; }
}
}
let (hotx, hoty) = if c.hot_x != 0 || c.hot_y != 0 {
(c.hot_x, c.hot_y)
} else if maxx >= minx && maxy >= miny {
let (bw, bh) = (maxx - minx + 1, maxy - miny + 1);
if bh > bw * 2 {
((minx + maxx) / 2, (miny + maxy) / 2)
} else {
(minx, miny)
}
} else {
(0, 0)
};
// Fold geometry + hotspot into the id: identical pixels with a changed size or
// hotspot must count as a new shape, otherwise drm_capture_worker suppresses the
// update (it dedupes by id) and the client keeps rendering the stale cursor.
let mut id = hash;
for v in [cw as u32 as u64, ch as u32 as u64, hotx as u32 as u64, hoty as u32 as u64] {
id ^= v;
id = id.wrapping_mul(1099511628211);
}
Some(CursorSnapshot {
id,
width: cw as u32,
height: ch as u32,
hotx,
hoty,
colors,
})
} else {
None
};
(self.lib.cursor_release)(self.ctx, &mut c);
out
}
}
pub fn displays(&mut self) -> Vec<DisplaySnapshot> {
// SAFETY: ctx valid; raw is a zeroed, correctly-sized array; count is clamped to the buffer before indexing.
unsafe {
let mut raw = vec![std::mem::zeroed::<drmtap_display>(); 16];
let cap = raw.len() as i32;
let n = (self.lib.list_displays)(self.ctx, raw.as_mut_ptr(), cap);
if n <= 0 {
return Vec::new();
}
let count = (n as usize).min(raw.len());
(0..count)
.map(|i| {
let name_bytes: Vec<u8> = raw[i]
.name
.iter()
.take_while(|&&ch| ch != 0)
.map(|&ch| ch as u8)
.collect();
DisplaySnapshot {
name: String::from_utf8_lossy(&name_bytes).to_string(),
crtc_id: raw[i].crtc_id,
x: raw[i].x as i32,
y: raw[i].y as i32,
width: raw[i].width,
height: raw[i].height,
active: raw[i].active != 0,
}
})
.collect()
}
}
}
impl Drop for DrmReader {
fn drop(&mut self) {
if !self.ctx.is_null() {
// SAFETY: ctx came from drmtap_open and is non-null.
unsafe { (self.lib.close)(self.ctx) };
self.ctx = std::ptr::null_mut();
}
}
}

View File

@@ -0,0 +1,184 @@
// Unprivileged half of the split DRM/KMS capture path: the root `--service` exports a scanout
// dma-buf fd + descriptor, this side imports it and EGL-detiles. libEGL/libGLESv2 are dlopen'd
// in the UNPRIVILEGED process on this path; the root service loads them only if it falls back to
// its own CPU-mapped grab (`drmtap_grab_mapped`).
use super::drmtap_dl::{self, drmtap_ctx, drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib};
use super::Pixfmt;
use hbb_common::log;
use std::ffi::CString;
use std::io;
use std::os::fd::RawFd;
use super::drm_reader::{
DRM_FORMAT_ABGR8888, DRM_FORMAT_ARGB8888, DRM_FORMAT_XBGR8888, DRM_FORMAT_XRGB8888,
MAX_DIM, MAX_FRAME_BYTES,
};
/// Unprivileged DRM render-node convert context. !Send/!Sync via the raw ctx pointer: the context
/// and libdrmtap's thread-local EGL state must be created, used (`convert`) and closed on ONE thread.
pub struct RenderConverter {
lib: &'static DrmtapLib,
ctx: *mut drmtap_ctx,
}
impl RenderConverter {
/// `node` is the render node of the GPU that exports the scanout; `None`/invalid path falls back to libdrmtap auto-selection.
pub fn open_render(node: Option<&str>) -> Option<RenderConverter> {
let lib = drmtap_dl::get()?;
let open_render = lib.open_render;
let node_cstr = match node.filter(|n| !n.is_empty()) {
None => None,
// Open the CANONICAL path the gate resolved: opening the IPC string would re-walk its symlinks after the check.
Some(n) => match super::drm_reader::device_under_dev_dri(n) {
None => {
log::warn!("drm: render node {n:?} is not under /dev/dri; auto-selecting");
None
}
Some(canonical) => canonical.to_str().and_then(|s| CString::new(s).ok()),
},
};
// SAFETY: resolved C entry point; `node_cstr` outlives the call, NULL requests auto-selection.
let ctx = unsafe {
open_render(node_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()))
};
if ctx.is_null() {
log::info!(
"drmtap_open_render({}) failed; no usable DRM render node",
node_cstr.as_ref().map_or("NULL".to_owned(), |c| format!("{c:?}"))
);
return None;
}
match node_cstr {
Some(c) => log::info!(
"drm: opened unprivileged convert context on the exporting GPU ({c:?})"
),
None => log::info!(
"drm: opened unprivileged render-node convert context (auto-selected)"
),
}
Some(RenderConverter { lib, ctx })
}
/// Returns context-owned linear pixels valid ONLY until the next `convert()`; row stride is `len / height`.
pub fn convert(
&mut self,
desc: &mut drmtap_dmabuf_desc,
received_fd: RawFd,
) -> io::Result<(&[u8], u32, u32, Pixfmt)> {
{
let (w, h) = (desc.width, desc.height);
if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("drm: refusing a dma-buf descriptor with geometry {w}x{h}"),
));
}
// Reject, do not clamp, and write the normalized count back so the C reads the count bounded here.
let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes };
if planes > 4 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"drm: refusing a dma-buf descriptor with num_planes {} (1..=4)",
desc.num_planes
),
));
}
desc.num_planes = planes;
let planes = planes as usize;
for p in 0..planes {
let extent = (desc.pitches[p] as usize)
.checked_mul(h as usize)
.and_then(|rows| rows.checked_add(desc.offsets[p] as usize));
match extent {
Some(end) if end <= MAX_FRAME_BYTES => {}
other => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"drm: refusing dma-buf plane {p} (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})",
desc.offsets[p], desc.pitches[p]
),
));
}
}
}
}
let convert_dmabuf = self.lib.convert_dmabuf;
// LOAD-BEARING: the fd the exporter serialized was process-local; -1 means reuse the cached import for `fb_id`.
desc.dma_buf_fd = received_fd;
// SAFETY: self.ctx is a valid render context; `desc` is fully initialized; `frame` is zeroed
// before the call. libdrmtap OWNS `frame.data`: no release/free from this side (drmtap.h).
unsafe {
let mut frame: drmtap_frame_info = std::mem::zeroed();
let ret = convert_dmabuf(self.ctx, &*desc as *const drmtap_dmabuf_desc, &mut frame);
if ret < 0 {
let errno = -ret;
if errno == hbb_common::libc::EAGAIN
|| errno == hbb_common::libc::EBUSY
|| errno == hbb_common::libc::EINTR
{
return Err(io::ErrorKind::WouldBlock.into());
}
return Err(io::Error::new(
io::ErrorKind::Other,
format!("drmtap_convert_dmabuf failed: errno {errno}"),
));
}
if frame.data.is_null() || frame.width == 0 || frame.height == 0 || frame.stride == 0 {
return Err(io::Error::new(
io::ErrorKind::Other,
"drmtap_convert_dmabuf produced an empty frame",
));
}
let w = frame.width;
let h = frame.height;
let stride = frame.stride as usize;
// A stride below 32bpp under-sizes the row and, read as BGRA downstream, discloses adjacent memory.
if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 {
return Err(io::Error::new(
io::ErrorKind::Other,
format!(
"drmtap_convert_dmabuf bad geometry {w}x{h} stride {stride} fourcc {:#010x}",
frame.format
),
));
}
let len = match stride.checked_mul(h as usize) {
Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz,
other => {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("drmtap_convert_dmabuf frame size out of range ({other:?} bytes)"),
));
}
};
let pixfmt = match frame.format {
DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => Pixfmt::BGRA,
DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => Pixfmt::RGBA,
// Unset by an older convert -> libdrmtap's normalized BGRA.
0 => Pixfmt::BGRA,
other => {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("drmtap_convert_dmabuf produced an unsupported output fourcc {other:#010x}"),
));
}
};
let data = std::slice::from_raw_parts(frame.data as *const u8, len);
Ok((data, w, h, pixfmt))
}
}
}
impl Drop for RenderConverter {
fn drop(&mut self) {
if !self.ctx.is_null() {
// SAFETY: ctx came from drmtap_open_render and is non-null; the !Send ctx pointer keeps
// this drop on the thread that created and used it (thread-local EGL + cached imports).
unsafe { (self.lib.close)(self.ctx) };
self.ctx = std::ptr::null_mut();
}
}
}

View File

@@ -0,0 +1,421 @@
// Runtime loader for libdrmtap.so (the DRM/KMS capture engine), dlopen'd so the binary carries no hard libdrm/libEGL/libGLESv2 dependency.
use hbb_common::{libloading::Library, log};
use std::os::raw::{c_char, c_int, c_void};
use std::sync::OnceLock;
// C ABI structs: must match libdrmtap include/drmtap.h.
#[repr(C)]
pub struct drmtap_ctx {
_private: [u8; 0],
}
#[repr(C)]
pub struct drmtap_config {
pub device_path: *const c_char, // NULL = auto-detect /dev/dri/card*
pub crtc_id: u32, // 0 = auto-select first active CRTC
pub helper_path: *const c_char, // only consulted if the direct DRM export is denied (no CAP_SYS_ADMIN)
pub debug: c_int,
}
impl Default for drmtap_config {
fn default() -> Self {
Self {
device_path: std::ptr::null(),
crtc_id: 0,
helper_path: std::ptr::null(),
debug: 0,
}
}
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct drmtap_display {
pub crtc_id: u32,
pub connector_id: u32,
pub name: [c_char; 32],
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
pub refresh_hz: u32,
pub active: c_int,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct drmtap_device {
pub path: [c_char; 64],
pub render_node: [c_char; 64],
pub driver: [c_char; 32],
pub display_count: u32,
}
#[repr(C)]
pub struct drmtap_frame_info {
pub data: *mut c_void,
pub dma_buf_fd: c_int,
pub width: u32,
pub height: u32,
pub stride: u32,
pub format: u32,
pub modifier: u64,
pub fb_id: u32,
pub _priv: *mut c_void,
}
// Descriptor of an externally-supplied scanout DMA-BUF: the privileged exporter fills it via
// `drmtap_grab_desc`; the converter overwrites `dma_buf_fd` with the fd it got via SCM_RIGHTS.
// Mirrors `drmtap_dmabuf_desc` EXACTLY (field order + widths); a mismatch mis-reads CCS/HDR scanouts.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct drmtap_dmabuf_desc {
pub dma_buf_fd: c_int, // scanout DMA-BUF; -1 for an already-imported fb_id
pub width: u32,
pub height: u32,
pub format: u32, // DRM fourcc of the scanout
pub modifier: u64, // DRM format modifier (tiling/compression)
pub fb_id: u32, // import-once cache key; 0 disables caching
pub num_planes: u32, // used entries in offsets/pitches (1..4); 0 => 1
pub offsets: [u32; 4], // per-plane byte offsets (CCS main+aux+clear-color)
pub pitches: [u32; 4], // per-plane strides; pitches[0] = main stride
pub hdr_eotf: u32, // DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3)
pub hdr_max_nits: u32, // mastering/content peak luminance cd/m2; 0=unknown
}
impl Default for drmtap_dmabuf_desc {
fn default() -> Self {
Self {
dma_buf_fd: -1,
width: 0,
height: 0,
format: 0,
modifier: 0,
fb_id: 0,
num_planes: 0,
offsets: [0; 4],
pitches: [0; 4],
hdr_eotf: 0,
hdr_max_nits: 0,
}
}
}
#[repr(C)]
pub struct drmtap_cursor_info {
pub x: i32,
pub y: i32,
pub hot_x: i32,
pub hot_y: i32,
pub width: u32,
pub height: u32,
pub pixels: *mut u32,
pub visible: c_int,
pub _priv: *mut c_void,
}
// Resolved symbol typedefs.
type FnVersion = unsafe extern "C" fn() -> c_int;
type FnOpen = unsafe extern "C" fn(*const drmtap_config) -> *mut drmtap_ctx;
type FnClose = unsafe extern "C" fn(*mut drmtap_ctx);
type FnListDisplays = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_display, c_int) -> c_int;
type FnListDevices = unsafe extern "C" fn(*mut drmtap_device, c_int) -> c_int;
type FnGrabMapped = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info) -> c_int;
type FnFrameRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info);
type FnGetCursor = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info) -> c_int;
type FnCursorRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info);
// Split-capture entry points (libdrmtap >= 0.4.10), required: `grab_desc` runs on the privileged
// export side, `open_render`/`convert_dmabuf` on the unprivileged converter side.
type FnGrabDesc =
unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int;
type FnOpenRender = unsafe extern "C" fn(*const c_char) -> *mut drmtap_ctx;
// libdrmtap >= 0.4.15; returns a ctx-owned string, or NULL if it has none.
type FnRenderNode = unsafe extern "C" fn(*mut drmtap_ctx) -> *const c_char;
type FnConvertDmabuf =
unsafe extern "C" fn(*mut drmtap_ctx, *const drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int;
/// The dlopen'd libdrmtap; the `Library` is kept alive for the process lifetime, so the raw fn pointers stay valid.
pub struct DrmtapLib {
_lib: Library,
pub open: FnOpen,
pub close: FnClose,
pub list_displays: FnListDisplays,
pub list_devices: Option<FnListDevices>,
pub grab_mapped: FnGrabMapped,
pub frame_release: FnFrameRelease,
pub get_cursor: FnGetCursor,
pub cursor_release: FnCursorRelease,
pub grab_desc: FnGrabDesc,
pub open_render: FnOpenRender,
pub convert_dmabuf: FnConvertDmabuf,
pub render_node: Option<FnRenderNode>,
pub version: (c_int, c_int, c_int),
}
// SAFETY: the resolved fn pointers are plain C entry points with no interior mutability;
// libdrmtap contexts are used single-threaded by the caller. The Library handle is never moved out.
unsafe impl Send for DrmtapLib {}
unsafe impl Sync for DrmtapLib {}
const DRMTAP_ABI_MAJOR: c_int = 0;
// Lowest (minor, patch) accepted. 0.5.0 is the floor because it fixes the padded-framebuffer read
// (a scanout whose pitch exceeds width*bpp was decoded at the wrong stride); the whole split API
// has been present since 0.4.10.
const DRMTAP_MIN_MINOR_PATCH: (c_int, c_int) = (5, 0);
// The MINOR series this build's mirrored structs were verified against: libdrmtap's header freezes
// only `drmtap_device` and `drmtap_dmabuf_desc`, so an unverified minor could be read at wrong offsets.
const DRMTAP_ABI_MINOR: c_int = 5;
/// Whether a library reporting `major.minor.patch` may be loaded (major and minor exact, patch at or above the floor).
fn abi_accepted(major: c_int, minor: c_int, patch: c_int) -> bool {
major == DRMTAP_ABI_MAJOR
&& minor == DRMTAP_ABI_MINOR
&& (minor, patch) >= DRMTAP_MIN_MINOR_PATCH
}
impl DrmtapLib {
fn load() -> Option<Self> {
// Absolute path FIRST: the deb bundles the .so privately under /usr/lib/rustdesk and does NOT register that dir with ld.so.
const INSTALLED: &str = "/usr/lib/rustdesk/libdrmtap.so.0";
// Bare sonames exist so an unpackaged development build can load a locally built .so from
// the normal ld.so search path. They are NOT offered when running as root: this is the one
// place where which file happens to be on the load path decides what gets mapped into the
// CAP_SYS_ADMIN process, and the packaged service always finds the absolute path first
// anyway. A root process that reaches the fallback has no bundled library, which is the
// PipeWire-fallback case, not a reason to search.
const DEV_ONLY: [&str; 2] = ["libdrmtap.so.0", "libdrmtap.so"];
let is_root = unsafe { hbb_common::libc::geteuid() } == 0;
let candidates: Vec<&str> = if is_root {
vec![INSTALLED]
} else {
std::iter::once(INSTALLED).chain(DEV_ONLY).collect()
};
unsafe {
let mut errs = Vec::new();
let found = candidates.iter().find_map(|n| match Library::new(*n) {
Ok(l) => Some((l, *n)),
Err(e) => {
errs.push(format!("{n}: {e}"));
None
}
});
let Some((lib, name)) = found else {
// The dlerror names the real cause (a missing soname, a glibc too old for the
// bundled build); the caller only reports that DRM capture is off.
log::warn!("libdrmtap dlopen failed: {}", errs.join("; "));
return None;
};
// Canonicalize the absolute candidate only: `dlopen` does not search the CWD for a bare
// soname, while `canonicalize` resolves a relative name against it.
let real = std::path::Path::new(name)
.is_absolute()
.then(|| std::fs::canonicalize(name).ok())
.flatten();
let version: FnVersion = *lib.get(b"drmtap_version").ok()?;
let v = version();
let (major, minor, patch) = ((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff);
if !abi_accepted(major, minor, patch) {
let why = if major != DRMTAP_ABI_MAJOR {
"the struct layouts this build mirrors track the ABI major, so reading a \
frame descriptor through a mismatched one would mis-decode it"
} else if minor != DRMTAP_ABI_MINOR {
"this build mirrors the struct layouts of one minor and only that one; \
under 0.x semver the minor is the breaking axis, so an unverified minor \
could be read at the wrong offsets. Widening it is a deliberate act, done \
with the layouts re-checked field by field"
} else {
"it predates the split-capture API, so its only capture path converts \
in-process, which in the root service means loading the GL stack there"
};
let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH;
log::warn!(
"libdrmtap {name} reports v{major}.{minor}.{patch}, which this build cannot \
use (needs ABI major {DRMTAP_ABI_MAJOR}, minor {DRMTAP_ABI_MINOR}, at least \
v{DRMTAP_ABI_MAJOR}.{min_minor}.{min_patch}): {why}. Refusing to load; \
falling back to PipeWire/portal."
);
return None;
}
let open: FnOpen = *lib.get(b"drmtap_open").ok()?;
let close: FnClose = *lib.get(b"drmtap_close").ok()?;
let list_displays: FnListDisplays = *lib.get(b"drmtap_list_displays").ok()?;
let list_devices: Option<FnListDevices> =
lib.get(b"drmtap_list_devices").ok().map(|s| *s);
let grab_mapped: FnGrabMapped = *lib.get(b"drmtap_grab_mapped").ok()?;
let frame_release: FnFrameRelease = *lib.get(b"drmtap_frame_release").ok()?;
let get_cursor: FnGetCursor = *lib.get(b"drmtap_get_cursor").ok()?;
let cursor_release: FnCursorRelease = *lib.get(b"drmtap_cursor_release").ok()?;
let grab: Option<FnGrabDesc> = lib.get(b"drmtap_grab_desc").ok().map(|s| *s);
let open_r: Option<FnOpenRender> = lib.get(b"drmtap_open_render").ok().map(|s| *s);
let conv: Option<FnConvertDmabuf> =
lib.get(b"drmtap_convert_dmabuf").ok().map(|s| *s);
let (grab_desc, open_render, convert_dmabuf) = match (grab, open_r, conv) {
(Some(g), Some(o), Some(c)) => (g, o, c),
(grab, open_r, conv) => {
let mut missing = Vec::new();
if grab.is_none() {
missing.push("drmtap_grab_desc");
}
if open_r.is_none() {
missing.push("drmtap_open_render");
}
if conv.is_none() {
missing.push("drmtap_convert_dmabuf");
}
log::warn!(
"libdrmtap {name} reports v{major}.{minor}.{patch} but does not export \
{}: it is a stale or pre-release build, not the version it claims. \
Refusing to load; falling back to PipeWire/portal.",
missing.join(", ")
);
return None;
}
};
let render_node: Option<FnRenderNode> =
lib.get(b"drmtap_render_node").ok().map(|s| *s);
// Log the load only now that every required symbol resolved: this fn still returns None on a missing one.
let loaded_from = real
.as_ref()
.map_or_else(|| name.to_owned(), |p| p.display().to_string());
if loaded_from == name {
log::info!("libdrmtap loaded: {name} (v{major}.{minor}.{patch})");
} else {
log::info!("libdrmtap loaded: {name} -> {loaded_from} (v{major}.{minor}.{patch})");
}
let (no_node, no_devices) = (render_node.is_none(), list_devices.is_none());
if (minor, patch) >= (4, 15) && (no_node || no_devices) {
let missing = if no_node && no_devices {
"drmtap_render_node and drmtap_list_devices"
} else if no_node {
"drmtap_render_node"
} else {
"drmtap_list_devices"
};
let effect = if no_node && no_devices {
"Multi-GPU display enumeration and exporting-GPU selection stay disabled."
} else if no_node {
"Exporting-GPU selection stays disabled."
} else {
"Multi-GPU display enumeration stays disabled."
};
log::warn!(
"libdrmtap at {loaded_from} reports v{major}.{minor}.{patch} but is missing \
{missing}: it is a stale or pre-release build. Check what the soname symlink \
points at and remove any leftover libdrmtap.so.0* beside it. {effect}"
);
}
Some(DrmtapLib {
_lib: lib,
open,
close,
list_displays,
list_devices,
grab_mapped,
frame_release,
get_cursor,
cursor_release,
grab_desc,
open_render,
convert_dmabuf,
render_node,
version: (major, minor, patch),
})
}
}
}
static DRMTAP_LIB: OnceLock<Option<DrmtapLib>> = OnceLock::new();
/// The loaded libdrmtap, or None if the .so (or a runtime dep) is absent or its version/exports fall outside the ABI gate. Loaded once; a failure is remembered.
pub fn get() -> Option<&'static DrmtapLib> {
DRMTAP_LIB
.get_or_init(|| {
let lib = DrmtapLib::load();
if lib.is_none() {
log::info!("libdrmtap not available or not usable; DRM capture disabled");
}
lib
})
.as_ref()
}
#[cfg(test)]
mod tests {
use super::{abi_accepted, DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, DRMTAP_MIN_MINOR_PATCH};
#[test]
fn abi_gate_rejects_a_library_from_before_the_split() {
// These are refused because their MINOR differs from the verified one, which is the only
// reason the gate needs. Naming the pre-split releases keeps the intent readable, but do
// not read this as the floor doing the work: see the test below.
for (minor, patch) in [(3, 3), (4, 0), (4, 8), (4, 9)] {
assert!(
!abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
"v0.{minor}.{patch} is not the verified minor and must be refused"
);
}
}
#[test]
fn the_patch_floor_is_currently_vacuous_and_that_is_deliberate() {
// With MIN_MINOR_PATCH.0 == DRMTAP_ABI_MINOR the floor can never reject anything: the
// minor equality already forces `(minor, patch) >= (minor, 0)`. It is kept because it is
// the mechanism that WOULD do the work the next time a floor lands mid-minor, as (4, 10)
// did for the split API. This test exists so nobody reads the pre-split test above as
// evidence that the floor is live -- if that ever matters, this assert is the tripwire.
let (floor_minor, floor_patch) = DRMTAP_MIN_MINOR_PATCH;
assert_eq!(
floor_minor, DRMTAP_ABI_MINOR,
"the floor is inside the verified minor; a floor in a DIFFERENT minor is unreachable"
);
if floor_patch == 0 {
assert!(
abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, 0),
"patch 0 of the verified minor must be accepted while the floor is 0"
);
} else {
assert!(!abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, floor_patch - 1));
}
}
#[test]
fn abi_gate_accepts_the_floor_and_later_patches_of_the_same_minor() {
let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH;
assert!(abi_accepted(DRMTAP_ABI_MAJOR, min_minor, min_patch));
for (minor, patch) in [(DRMTAP_ABI_MINOR, min_patch + 15), (DRMTAP_ABI_MINOR, 200)] {
assert!(
abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
"v0.{minor}.{patch} is a patch of the verified minor and must be accepted"
);
}
}
#[test]
fn abi_gate_rejects_an_unknown_newer_minor() {
// Relative to DRMTAP_ABI_MINOR, so the next bump cannot leave this test asserting that the
// NEW verified minor must be refused -- which is what a hardcoded list did before.
let verified = DRMTAP_ABI_MINOR;
for (minor, patch) in [
(verified - 1, 99),
(verified + 1, 0),
(verified + 1, 99),
(verified + 4, 9),
] {
assert!(
!abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
"v0.{minor}.{patch} is an unverified minor and must be refused"
);
}
}
#[test]
fn abi_gate_rejects_another_major_in_both_directions() {
assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 0, 0));
assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 99, 99));
}
}

View File

@@ -16,6 +16,12 @@ cfg_if! {
mod linux;
mod wayland;
mod x11;
#[cfg(all(target_os = "linux", feature = "drm"))]
pub mod drmtap_dl;
#[cfg(all(target_os = "linux", feature = "drm"))]
pub mod drm_reader;
#[cfg(all(target_os = "linux", feature = "drm"))]
pub mod drm_render;
pub use self::linux::*;
pub use self::wayland::set_map_err;
pub use self::x11::PixelBuffer;

View File

@@ -19,6 +19,18 @@ static MISSING_LOGICAL_SIZE_WARNED: std::sync::atomic::AtomicBool =
const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000);
// drm builds only: an unnamed-endpoint failure there forks the probe child, and the pollers
// turn every few hundred milliseconds. Every other failure is one cheap in-process error.
#[cfg(any(test, feature = "drm"))]
const FAILED_LOOKUP_BACKOFF: Duration = Duration::from_secs(5);
#[cfg(any(test, feature = "drm"))]
static LAST_FAILED_LOOKUP: Mutex<Option<Instant>> = Mutex::new(None);
#[cfg(feature = "drm")]
static LOOKUP_FAILURE_WARNED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub struct Displays {
pub primary: usize,
pub displays: Vec<WaylandDisplayInfo>,
@@ -171,11 +183,76 @@ fn get_primary_monitor() -> Option<String> {
.or_else(try_gdbus_primary)
}
// Pure, so the backoff policy is testable without a compositor.
#[cfg(any(test, feature = "drm"))]
fn lookup_allowed(failed_at: Option<Instant>, now: Instant) -> bool {
failed_at.map_or(true, |at| {
now.saturating_duration_since(at) >= FAILED_LOOKUP_BACKOFF
})
}
#[cfg(feature = "drm")]
fn backed_off() -> bool {
let failed_at = *LAST_FAILED_LOOKUP.lock().unwrap();
!lookup_allowed(failed_at, Instant::now())
}
// Mirrors the probe module's gate, latch included: connecting consumes WAYLAND_SOCKET, so a
// once-named endpoint must stay named for the life of the process.
#[cfg(feature = "drm")]
fn endpoint_named() -> bool {
use std::sync::atomic::{AtomicBool, Ordering};
static WAS_NAMED: AtomicBool = AtomicBool::new(false);
let named = ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"]
.iter()
.any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()));
if named {
WAS_NAMED.store(true, Ordering::Release);
}
WAS_NAMED.load(Ordering::Acquire)
}
// Enumerates and keeps the failure stamp current. Suppresses nothing itself: one-shot callers
// (session init, pipewire) must always get a fresh read, or a transient failure latches.
fn enumerate_displays() -> hbb_common::ResultType<Vec<WaylandDisplayInfo>> {
// Read before connecting, which consumes WAYLAND_SOCKET.
#[cfg(feature = "drm")]
let named = endpoint_named();
let probed = get_wayland_displays();
// Only the failure that would fork stamps; a named endpoint fails cheaply in-process.
#[cfg(feature = "drm")]
{
*LAST_FAILED_LOOKUP.lock().unwrap() = (probed.is_err() && !named).then(Instant::now);
if let Err(err) = &probed {
if !LOOKUP_FAILURE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
warn!("Failed to get wayland displays: {}", err);
}
} else {
LOOKUP_FAILURE_WARNED.store(false, std::sync::atomic::Ordering::Relaxed);
}
}
probed
}
// True when a lookup now could neither hit the cache nor probe. Pollers skip their turn on
// it and keep their last published state; one-shot callers must not consult it.
#[cfg(feature = "drm")]
pub fn wayland_lookup_suppressed() -> bool {
DISPLAYS.lock().unwrap().is_none() && backed_off()
}
// Whether any failure stamp exists, expired or not: pollers use it to tell a first failure
// from one that has already persisted across a backoff.
#[cfg(feature = "drm")]
pub fn wayland_failure_stamped() -> bool {
LAST_FAILED_LOOKUP.lock().unwrap().is_some()
}
pub fn get_displays() -> Arc<Displays> {
let mut lock = DISPLAYS.lock().unwrap();
match lock.as_ref() {
Some(displays) => displays.clone(),
None => match get_wayland_displays() {
None => match enumerate_displays() {
Ok(displays) => {
let mut primary_index = None;
if let Some(name) = get_primary_monitor() {
@@ -201,8 +278,9 @@ pub fn get_displays() -> Arc<Displays> {
*lock = Some(displays.clone());
displays
}
Err(err) => {
warn!("Failed to get wayland displays: {}", err);
Err(_err) => {
#[cfg(not(feature = "drm"))]
warn!("Failed to get wayland displays: {}", _err);
Arc::new(Displays {
primary: 0,
displays: Vec::new(),
@@ -215,6 +293,8 @@ pub fn get_displays() -> Arc<Displays> {
#[inline]
pub fn clear_wayland_displays_cache() {
let _ = DISPLAYS.lock().unwrap().take();
// The failure stamp survives on purpose: it describes the seat, not the cache, and the
// capturer rebuild loop clears about once a second.
}
// Return (min_x, max_x, min_y, max_y)
@@ -223,17 +303,21 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
desktop_rect_of(&wayland_displays.displays)
}
// The desktop rect and per-display logical rects, always read live from the
// compositor in a single roundtrip. Skips the displays cache and the primary-monitor
// detection (which may spawn external commands), so it is cheap enough to poll for
// layout changes. https://github.com/rustdesk/rustdesk/issues/15601
// The desktop rect and per-display logical rects, read live from the compositor in a single
// roundtrip (drm builds may skip a turn during the failure backoff). Skips the displays cache
// and the primary-monitor detection, cheap enough to poll. rustdesk/rustdesk#15601
pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec<DisplayRect>)> {
match get_wayland_displays() {
#[cfg(feature = "drm")]
if backed_off() {
return None;
}
match enumerate_displays() {
Ok(displays) => {
desktop_rect_of(&displays).map(|rect| (rect, logical_rects_of(&displays)))
}
Err(err) => {
warn!("Failed to get wayland displays: {}", err);
Err(_err) => {
#[cfg(not(feature = "drm"))]
warn!("Failed to get wayland displays: {}", _err);
None
}
}
@@ -386,6 +470,40 @@ fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_e
mod tests {
use super::*;
#[test]
fn test_lookup_backoff_boundaries() {
// Future `now`s sidestep Instant subtraction, which can panic near boot.
let failed_at = Instant::now();
assert!(lookup_allowed(None, failed_at));
assert!(!lookup_allowed(
Some(failed_at),
failed_at + FAILED_LOOKUP_BACKOFF / 2
));
assert!(lookup_allowed(
Some(failed_at),
failed_at + FAILED_LOOKUP_BACKOFF
));
}
#[test]
fn test_lookup_stamp_from_the_future_only_waits() {
// saturating_duration_since answers zero rather than underflowing.
let now = Instant::now();
assert!(!lookup_allowed(Some(now + FAILED_LOOKUP_BACKOFF), now));
}
#[test]
fn test_clear_keeps_the_failure_stamp() {
// The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears,
// and dropping the stamp with it would defeat the backoff. Sole test touching these
// statics; serialize before adding another.
*LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now());
clear_wayland_displays_cache();
let stamp = *LAST_FAILED_LOOKUP.lock().unwrap();
assert!(stamp.is_some());
*LAST_FAILED_LOOKUP.lock().unwrap() = None;
}
fn display(
x: i32,
y: i32,