// 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, 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, 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 { // 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 = 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 = lib.get(b"drmtap_grab_desc").ok().map(|s| *s); let open_r: Option = lib.get(b"drmtap_open_render").ok().map(|s| *s); let conv: Option = 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 = 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> = 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)); } }