From e54377cc4efd6d98a4cdfa85458580abe85bfe9b Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Thu, 9 Jul 2026 12:24:17 -0300 Subject: [PATCH 1/4] feat(drm): opt-in DRM/KMS screen capture for Linux/Wayland adds an opt-in `drm` feature that captures below the compositor through the privileged libdrmtap helper, for unattended remote access on Wayland: no xdg-desktop-portal consent dialog, and it works at the login screen. off by default. when the feature is off the build is unchanged. everything is gated behind feature = "drm" or lives only in the separate rustdesk-unattended-wayland deb, whose package name is the informed consent. - scrap: DRM/KMS capture backend on libdrmtap-sys, homogeneous per-session backend selection, availability decided by a real grab probe with per-crtc fallback to pipewire/portal - helper: shipped only in the drm deb, installed 0750 root:rustdesk-capture with cap_sys_admin via setcap (postinst appended by build.py) - ci: builds a separate rustdesk-unattended-wayland deb on x86_64 - cursor: downscale the hardware cursor to the served display logical scale - DRM_CAPTURE_SECURITY.md: threat model and hardening notes --- .github/workflows/flutter-build.yml | 27 ++ Cargo.lock | 22 +- Cargo.toml | 4 + DRM_CAPTURE_SECURITY.md | 69 +++ build.py | 78 ++- build.rs | 31 ++ libs/scrap/Cargo.toml | 5 + libs/scrap/src/common/drm.rs | 723 ++++++++++++++++++++++++++++ libs/scrap/src/common/linux.rs | 70 +++ libs/scrap/src/common/mod.rs | 7 + libs/scrap/src/wayland/display.rs | 2 +- src/platform/linux.rs | 163 ++++++- src/server/display_service.rs | 30 ++ src/server/video_service.rs | 4 + src/server/wayland.rs | 40 +- 15 files changed, 1250 insertions(+), 25 deletions(-) create mode 100644 DRM_CAPTURE_SECURITY.md create mode 100644 libs/scrap/src/common/drm.rs diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 3649651353a..7167d7e6889 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -1676,6 +1676,26 @@ jobs: mv "$name" /workspace/"${name%%.rpm}-suse.rpm" done + # --- opt-in unattended-wayland (DRM/KMS) variant: a separate deb --- + # Ships the privileged drmtap-helper so enabling consent-free capture + # is an explicit install choice (the package name states what it does). + # Built last so the drm relink can't leak into the stock deb/rpm above. + # x86_64 only (the unattended/kiosk/server use case); the package + # Conflicts/Replaces the stock rustdesk package (install one or other). + if [[ "${{ matrix.job.arch }}" == "x86_64" ]]; then + pushd /workspace + echo -e "start packaging unattended-wayland (DRM) deb" + # drm-only build deps, installed here (not in the stock install list) + # so the default drm-off build stays identical to upstream + apt-get install -y libseccomp-dev libcap-dev libdrm-dev + cargo build --locked --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste,drm --release + python3 ./build.py --flutter --drm --skip-cargo + for name in rustdesk-unattended-wayland*??.deb; do + mv "$name" "${name%%.deb}-${{ matrix.job.arch }}.deb" + done + popd + fi + - name: Publish debian/rpm package if: env.UPLOAD_ARTIFACT == 'true' uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 @@ -1693,6 +1713,13 @@ jobs: name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb path: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb + - name: Upload unattended-wayland deb + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true' + with: + name: rustdesk-unattended-wayland-${{ env.VERSION }}-${{ matrix.job.arch }}.deb + path: rustdesk-unattended-wayland-${{ env.VERSION }}-${{ matrix.job.arch }}.deb + # only x86_64 for arch since we can not find newest arm64 docker image to build # old arch image does not make sense for arch since it is "arch" which always update to date # and failed to makepkg arm64 on x86_64 diff --git a/Cargo.lock b/Cargo.lock index 8ec2d4a5327..d3a3654d624 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2329,7 +2329,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.4", ] [[package]] @@ -2694,7 +2694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4465,6 +4465,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libdrmtap-sys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b11669c93786775711332ff36a5784b3e7be1eda96e51acb3c47cb47f720be0" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libgit2-sys" version = "0.14.2+1.5.1" @@ -4494,7 +4504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" dependencies = [ "cfg-if 1.0.0", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -7317,6 +7327,7 @@ dependencies = [ "kcp-sys", "keepawake", "lazy_static", + "libdrmtap-sys", "libpulse-binding", "libpulse-simple-binding", "libxdo-sys", @@ -7457,7 +7468,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7514,7 +7525,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7613,6 +7624,7 @@ dependencies = [ "hwcodec", "jni", "lazy_static", + "libdrmtap-sys", "log", "ndk 0.7.0", "ndk-context", diff --git a/Cargo.toml b/Cargo.toml index 05c32ab42c1..e6f89b18f07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ default = ["use_dasp"] hwcodec = ["scrap/hwcodec"] vram = ["scrap/vram"] mediacodec = ["scrap/mediacodec"] +drm = ["scrap/drm", "dep:libdrmtap-sys"] plugin_framework = [] linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"] unix-file-copy-paste = [ @@ -176,6 +177,9 @@ bytemuck = "1.23" ttf-parser = "0.25" [target.'cfg(target_os = "linux")'.dependencies] +# Direct dep so build.rs receives DEP_DRMTAP_HELPER_BIN via the `links` mechanism. +# Exact pin: the helper is a privileged TCB component, keep it on the audited version. +libdrmtap-sys = { version = "=0.4.5", optional = true } libxdo-sys = "0.11" psimple = { package = "libpulse-simple-binding", version = "2.27" } pulse = { package = "libpulse-binding", version = "2.27" } diff --git a/DRM_CAPTURE_SECURITY.md b/DRM_CAPTURE_SECURITY.md new file mode 100644 index 00000000000..47d96cdb223 --- /dev/null +++ b/DRM_CAPTURE_SECURITY.md @@ -0,0 +1,69 @@ +# DRM/KMS capture — security model & threat model + +The optional `drm` feature adds a Linux capture backend that reads the active +scanout directly from DRM/KMS, **bypassing the xdg-desktop-portal consent +dialog**. It exists for unattended / login-screen / Wayland scenarios where the +portal prompt is not acceptable. Because it bypasses consent, treat it as a +**privileged, opt-in host-mode feature**, not a normal Wayland capture backend. + +## How it works + +Capture needs DRM master / `CAP_SYS_ADMIN` to read other clients' framebuffers. +Rather than run RustDesk as root, the `drm` feature ships a small privileged +helper, **`drmtap-helper`** (from +[`libdrmtap-sys`](https://crates.io/crates/libdrmtap-sys)), which carries +`cap_sys_admin+ep` via file capabilities and talks to the unprivileged RustDesk +process over a `socketpair`, passing the scanout back as a DMA-BUF fd. + +## Threat model + +- **Consent bypass.** This mode does not show the portal "select what to share" + prompt. On a misconfigured install it could expose the login screen, the lock + screen, or another local user's graphical session. +- **The TCB is `drmtap-helper` / `libdrmtap-sys`.** The privileged behaviour + (caller authentication, IPC parsing, seccomp, DRM access, framebuffer bounds + checks) lives there and has been security-reviewed. It is hardened: + - validates it was spawned by the library (inherited socket on fd 3) **and** + checks the peer UID via `SO_PEERCRED`; + - restricts the device it opens to a realpath under `/dev/dri/`, opened + `O_RDONLY`; + - `PR_SET_NO_NEW_PRIVS`, drops all capabilities except `CAP_SYS_ADMIN`, then + installs a **default-KILL seccomp allowlist** that deliberately forbids + `open`/`openat` (the device is opened once before the filter loads), so a + compromised helper cannot open arbitrary files even with `CAP_SYS_ADMIN`; + - built with stack-protector-strong, FORTIFY, PIE and full RELRO; + - integer-overflow / DoS size guards on the framebuffer geometry. +- **`CAP_SYS_ADMIN` is broad.** The helper is deliberately tiny and confined as + above, but any unfound bug in it is a local-privilege concern — hence the + access-controlled install below. + +## Deployment + +- **Off by default.** The `drm` feature is **not** in the default feature set and + is **not** enabled in standard release packages. Build it explicitly with + `python3 build.py --flutter --drm` (Linux only). +- **Not world-executable.** The `.deb` postinst installs the helper as: + + ```bash + groupadd -r rustdesk-capture + chown root:rustdesk-capture /usr/lib/rustdesk/drmtap-helper + chmod 0750 /usr/lib/rustdesk/drmtap-helper + setcap cap_sys_admin+ep /usr/lib/rustdesk/drmtap-helper + ``` + + Only members of `rustdesk-capture` can run it; an administrator opts users in + with `usermod -aG rustdesk-capture `. Everyone else (and every host + where the group is empty) transparently falls back to the PipeWire/portal + path. There is no window where the binary is both `0755` and capability-bearing + (mode/owner are set before `setcap`). +- **Recommended for** single-user, physically-controlled, or unattended hosts. + On shared/multi-user hosts, only add trusted operators to `rustdesk-capture`; + the group is the access-control boundary. + +## Auditing + +```bash +getcap /usr/lib/rustdesk/drmtap-helper # expect: cap_sys_admin=ep +getfacl /usr/lib/rustdesk/drmtap-helper # expect: root:rustdesk-capture 0750 +setcap -r /usr/lib/rustdesk/drmtap-helper # revoke the capability +``` diff --git a/build.py b/build.py index 9579618574f..da4d95e1597 100755 --- a/build.py +++ b/build.py @@ -130,6 +130,12 @@ def make_parser(): action='store_true', help='Build with unix file copy paste feature' ) + parser.add_argument( + '--drm', + action='store_true', + help='Linux only: build the DRM/KMS capture backend (ships the privileged ' + 'drmtap-helper, installed 0750 root:rustdesk-capture). Off by default.' + ) parser.add_argument( '--skip-cargo', action='store_true', @@ -282,6 +288,8 @@ def get_features(args): features.append('flutter') if args.unix_file_copy_paste: features.append('unix-file-copy-paste') + if not windows and not osx and args.drm: + features.append('drm') if osx: if args.screencapturekit: features.append('screencapturekit') @@ -289,22 +297,29 @@ def get_features(args): return features -def generate_control_file(version): +def generate_control_file(version, extra_depends="", package_name="rustdesk"): control_file_path = "../res/DEBIAN/control" system2('/bin/rm -rf %s' % control_file_path) - content = """Package: rustdesk + # An alternative-build package (e.g. the opt-in unattended-wayland / DRM + # variant) installs the same files as the stock `rustdesk` package, so it + # must conflict with / replace it: you install one OR the other, not both. + variant_control = "" + if package_name != "rustdesk": + variant_control = "Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n" + + content = """Package: %s Section: net Priority: optional Version: %s Architecture: %s Maintainer: rustdesk Homepage: https://rustdesk.com -Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s +%sDepends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s%s Recommends: libayatana-appindicator3-1 Description: A remote control software. -""" % (version, get_deb_arch(), get_deb_extra_depends()) +""" % (package_name, version, get_deb_arch(), variant_control, get_deb_extra_depends(), extra_depends) file = open(control_file_path, "w") file.write(content) file.close() @@ -352,16 +367,51 @@ def build_flutter_deb(version, features): 'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk') system2( "echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit") - + # Install drmtap-helper for the privileged DRM/KMS capture path — but ONLY + # when this build actually enabled the `drm` feature. Gating on the feature + # (not on whether a stale ../target/release/drmtap-helper happens to exist + # from an earlier --drm build) keeps the opt-in guarantee for normal packages. + ships_helper = 'drm' in features + if ships_helper: + system2('mkdir -p tmpdeb/usr/lib/rustdesk') + system2('cp ../target/release/drmtap-helper tmpdeb/usr/lib/rustdesk/drmtap-helper') + + # A DRM build ships as a separately-named, opt-in package so installing it is + # an explicit choice; the package name itself states what it enables. It is + # an alternative build of the same app, so generate_control_file marks it + # Conflicts/Replaces/Provides rustdesk (install one or the other). + package_name = 'rustdesk-unattended-wayland' if ships_helper else 'rustdesk' system2('mkdir -p tmpdeb/DEBIAN') - generate_control_file(version) + # postinst runs setcap (from libcap2-bin) on the helper; make it a hard + # dependency so a DRM package can't install an unusable, cap-less helper. + generate_control_file(version, ", libcap2-bin" if ships_helper else "", package_name) system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') + if ships_helper: + # Append the helper group + setcap setup to the postinst ONLY for the DRM + # package, so the stock package's DEBIAN/postinst stays byte-identical to + # upstream (the drm feature must not touch shared config when it is off). + with open('tmpdeb/DEBIAN/postinst', 'a') as f: + f.write( + '\n' + 'if [ "$1" = configure ] && [ -f /usr/lib/rustdesk/drmtap-helper ]; then\n' + '\t# DRM/KMS capture helper: grant cap_sys_admin but keep it out of\n' + '\t# world-exec (a world-exec cap_sys_admin binary lets any local user\n' + '\t# read the active scanout, incl. the login/lock screen). Restrict it\n' + '\t# to a dedicated group first, then setcap; only rustdesk-capture runs it.\n' + '\tgroupadd -f -r rustdesk-capture 2>/dev/null || addgroup --system rustdesk-capture 2>/dev/null || true\n' + '\tchown root:rustdesk-capture /usr/lib/rustdesk/drmtap-helper 2>/dev/null || true\n' + '\tchmod 0750 /usr/lib/rustdesk/drmtap-helper\n' + '\tif command -v setcap >/dev/null; then\n' + '\t\tsetcap cap_sys_admin+ep /usr/lib/rustdesk/drmtap-helper || echo "rustdesk: warning: setcap on drmtap-helper failed; DRM/KMS capture will fall back to PipeWire" >&2\n' + '\tfi\n' + 'fi\n' + ) md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb;') system2('/bin/rm -rf tmpdeb/') system2('/bin/rm -rf ../res/DEBIAN/control') - os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version) + os.rename('rustdesk.deb', '../%s-%s.deb' % (package_name, version)) os.chdir("..") @@ -389,9 +439,21 @@ def build_deb_from_folder(version, binary_folder): 'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') system2( "echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit") + # Install drmtap-helper for the DRM/KMS capture path from the STAGED payload + # (binary_folder), not from ../target/release — that tree may be unrelated or + # stale for a prebuilt / cross-arch bundle. The helper is only in the payload + # when the bundle was built with the `drm` feature, so this stays opt-in. The + # `cp -r` above already placed any bundled drmtap-helper under + # usr/share/rustdesk/; move it to usr/lib/rustdesk/ where postinst applies the + # 0750 + capability. + system2('mkdir -p tmpdeb/usr/lib/rustdesk') + system2('if [ -f tmpdeb/usr/share/rustdesk/drmtap-helper ]; then mv tmpdeb/usr/share/rustdesk/drmtap-helper tmpdeb/usr/lib/rustdesk/drmtap-helper; fi') system2('mkdir -p tmpdeb/DEBIAN') - generate_control_file(version) + # Only a bundle staged with the DRM helper needs libcap2-bin (postinst runs + # setcap on it); detect it in the payload so plain bundles stay unaffected. + ships_helper = os.path.exists('tmpdeb/usr/lib/rustdesk/drmtap-helper') + generate_control_file(version, ", libcap2-bin" if ships_helper else "") system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb;') diff --git a/build.rs b/build.rs index 92fb1f4b452..79f1dad7a6c 100644 --- a/build.rs +++ b/build.rs @@ -77,9 +77,40 @@ fn install_android_deps() { println!("cargo:rustc-link-lib=OpenSLES"); } +// When the `drm` feature is enabled on Linux, copy the drmtap-helper binary +// (compiled by libdrmtap-sys/build.rs) into the cargo target dir so the +// packaging scripts (build.py) can pick it up from target// and +// install it to /usr/lib/rustdesk/, where libdrmtap searches for it at runtime. +#[cfg(feature = "drm")] +fn install_drmtap_helper() { + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "linux" { + return; + } + // DEP_DRMTAP_HELPER_BIN is emitted by libdrmtap-sys via `links = "drmtap"`. + let src = std::env::var("DEP_DRMTAP_HELPER_BIN") + .expect("DEP_DRMTAP_HELPER_BIN not set; libdrmtap-sys must emit it when drm feature is enabled"); + let out_dir = std::env::var("OUT_DIR").unwrap(); + // OUT_DIR is ...//build//out; the profile dir (where the binary + // lands) is the parent of the "build" component. Locating it by structure is + // robust to cross builds (...///...) and custom profile names. + let target_dir = std::path::Path::new(&out_dir) + .ancestors() + .find(|p| p.file_name().map_or(false, |n| n == "build")) + .and_then(|p| p.parent()) + .expect("could not locate profile dir from OUT_DIR"); + let dst = target_dir.join("drmtap-helper"); + std::fs::copy(&src, &dst).unwrap_or_else(|e| { + panic!("failed to copy drmtap-helper from {} to {}: {}", src, dst.display(), e) + }); + println!("cargo:rerun-if-changed={}", src); +} + fn main() { hbb_common::gen_version(); install_android_deps(); + #[cfg(feature = "drm")] + install_drmtap_helper(); #[cfg(all(windows, feature = "inline"))] build_manifest(); #[cfg(windows)] diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 505eca2def8..6758e9a77b9 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -11,6 +11,7 @@ edition = "2018" [features] wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"] +drm = ["dep:libdrmtap-sys"] mediacodec = ["ndk"] linux-pkg-config = ["dep:pkg-config"] hwcodec = ["dep:hwcodec"] @@ -52,6 +53,10 @@ bindgen = "0.65" pkg-config = { version = "0.3.27", optional = true } [target.'cfg(target_os = "linux")'.dependencies] +# DRM/KMS capture bindings (statically embeds the libdrmtap C sources). +# Enabled via the `drm` feature; not required for PipeWire/X11-only builds. +# Exact pin: the helper is a privileged TCB component, keep it on the audited version. +libdrmtap-sys = { version = "=0.4.5", optional = true } dbus = { version = "0.9", optional = true } tracing = { version = "0.1", optional = true } gstreamer = { version = "0.16", optional = true } diff --git a/libs/scrap/src/common/drm.rs b/libs/scrap/src/common/drm.rs new file mode 100644 index 00000000000..5b80d0c66b3 --- /dev/null +++ b/libs/scrap/src/common/drm.rs @@ -0,0 +1,723 @@ +// DRM/KMS capture backend for RustDesk — powered by libdrmtap +// +// Reads the compositor's scanout directly from the DRM/KMS subsystem without +// involving xdg-desktop-portal. libdrmtap-sys statically embeds the C sources +// (no shared library to install) and spawns a privileged helper +// (drmtap-helper, CAP_SYS_ADMIN + seccomp) for capture without running +// rustdesk as root. +// +// Multi-monitor: each Display carries its CRTC id and virtual-FB origin (x, y) +// so the Capturer opens the exact CRTC the user selected, not just the primary. +// +// Tested on: +// - Intel Meteor Lake (i915) dual 3840×2160 — EGL detiling of the compressed +// INTEL_4_TILED_MTL_RC_CCS_CC framebuffer modifier +// - virtio-gpu (QEMU/KVM) — linear framebuffer + +use crate::{Frame, TraitCapturer}; +use std::{io, time::{Duration, Instant}}; +use std::sync::atomic::{AtomicU8, Ordering}; +use super::x11::PixelBuffer; +use hbb_common::{libc, log}; + +// FFI bindings to libdrmtap — struct layouts must match drmtap.h exactly! +// Use libdrmtap-sys crate for static linking +use libdrmtap_sys::{ + drmtap_close, drmtap_config, drmtap_ctx, drmtap_cursor_info, drmtap_cursor_release, + drmtap_display, drmtap_frame_info, drmtap_get_cursor, drmtap_grab_mapped, + drmtap_frame_release, drmtap_list_displays, drmtap_open, +}; +use std::sync::Mutex; + +// Latest hardware cursor captured from the DRM cursor plane (via the privileged +// helper). RustDesk's cursor source on Wayland is XFixes, which only reflects +// the X cursor and is stale over native Wayland apps. The DRM cursor plane, in +// contrast, holds the compositor's actual current cursor and updates when the +// shape changes — so we capture it here and feed it to the cursor service. +#[derive(Clone)] +pub struct DrmCursor { + pub id: u64, // content hash; changes when the cursor shape changes + pub width: i32, + pub height: i32, + pub hotx: i32, + pub hoty: i32, + pub colors: Vec, // RGBA8888 +} + +static DRM_CURSOR: Mutex> = Mutex::new(None); + +/// Current hardware cursor captured from the DRM cursor plane, if any. +pub fn drm_cursor() -> Option { + DRM_CURSOR.lock().unwrap().clone() +} + +/// Cheap id-only accessor for the ~33ms cursor poll fast path, which only needs +/// the id to detect shape changes — avoids cloning the pixel buffer every poll. +pub fn drm_cursor_id() -> Option { + DRM_CURSOR.lock().unwrap().as_ref().map(|c| c.id) +} + +// A grab that returns one of these errnos may succeed on retry (no frame ready, +// helper busy, interrupted syscall). Anything else negative is a permanent +// failure — the helper can't run (EACCES), the CRTC is gone (ENODEV), the format +// is unsupported, etc. — and must NOT be retried as WouldBlock, or the capture +// loop spins forever on a blank frame instead of falling back to PipeWire. +fn is_transient_errno(ret: i32) -> bool { + matches!(-ret, libc::EAGAIN | libc::EBUSY | libc::EINTR | libc::ETIMEDOUT) +} + +// Errnos that mean the privileged helper itself cannot run / is not set up +// (execute denied, missing helper). These are fixed for the process lifetime — +// group membership, the file capability, and the install are all decided before +// the process starts — so a probe seeing them is safe to cache. Everything else +// that is neither success nor transient — notably ENODEV (CRTC inactive / +// display asleep under DPMS) — is a topology condition that can clear, and must +// stay retryable, or a monitor that happens to be asleep at startup would leave +// DRM disabled until the process restarts. +fn is_setup_errno(ret: i32) -> bool { + matches!(-ret, libc::EACCES | libc::EPERM | libc::ENOENT) +} + +enum ProbeResult { + Ok, + Permanent, // helper cannot run / not set up (denied, missing) — cache it + Transient, // busy, no-frame-yet, or topology (CRTC asleep) — retry later +} + +// Whether DRM/KMS capture actually works on this host, i.e. the privileged +// helper is reachable and a scanout grab succeeds. +// +// Display enumeration (drmtap_list_displays) only reads connectors/CRTCs, which +// is unprivileged and succeeds even when the helper cannot run — e.g. the user +// is not in the `rustdesk-capture` group, or `setcap` was never applied. So the +// display list alone is not proof that capture will work. This probes an actual +// grab and caches only *definitive* outcomes for the process lifetime (helper +// reachability and group membership are fixed when the process starts, and we +// must not fork a helper on every enumeration). An inconclusive/transient probe +// is not cached, so a momentary compositor blip at init can't lock DRM off for +// the whole process — it just uses PipeWire this once and re-probes next time. +// The dispatcher (common/linux.rs) gates the DRM path on this so an unusable +// helper falls back to PipeWire/portal cleanly instead of streaming blank. +pub fn capture_available() -> bool { + match PROBE.load(Ordering::Relaxed) { + 1 => return true, + 2 => return false, + _ => {} + } + match unsafe { capture_probe() } { + (ProbeResult::Ok, _) => { + PROBE.store(1, Ordering::Relaxed); + log::info!("DRM/KMS capture probe succeeded"); + true + } + (ProbeResult::Permanent, errno) => { + PROBE.store(2, Ordering::Relaxed); + // Permanent is only reached from a grab that returned a setup errno, + // so `errno` is always Some here; unwrap_or keeps it total. + log::info!( + "DRM/KMS capture probe failed (helper unavailable, errno {}); using PipeWire/portal", + errno.unwrap_or(0) + ); + false + } + (ProbeResult::Transient, Some(errno)) => { + log::info!( + "DRM/KMS capture probe got no usable frame (errno {errno}); using PipeWire/portal for now, will re-probe" + ); + false + } + (ProbeResult::Transient, None) => { + log::info!( + "DRM/KMS capture probe could not open a DRM context (no active CRTC yet or helper unavailable); using PipeWire/portal for now, will re-probe" + ); + false + } + } +} + +// Process-wide DRM availability cache. Written once by the startup probe and +// DOWNGRADED by runtime failures (see `Capturer::frame`): a setup failure sets it +// to 2 (permanently unavailable — the helper can't run at all); any other hard +// grab failure resets it to 0 so the next enumeration re-probes instead of +// trusting a stale OK. 0 = unknown, 1 = ok, 2 = permanently unavailable. +static PROBE: AtomicU8 = AtomicU8::new(0); + +// CRTCs that returned a permanent, *attributable* grab failure at runtime (e.g. a +// secondary output whose scanout format can't be captured). Keyed by connector +// name + crtc id — a bare crtc id isn't stable across a replug, so pairing it with +// the connector name avoids a stale id later matching a healthy monitor. Such a +// display is DROPPED from DRM enumeration so the rest keep DRM (whole-session +// fallback to PipeWire is useless on an unattended host — nobody clicks consent). +// Cleared whenever the enumerated topology changes (replug/wake), re-testing all. +// ENODEV (asleep/gone) never enters this set: the enumeration `active` filter +// already excludes those, and bad-marking a nap would outlive it for no benefit. +static BAD_CRTCS: Mutex> = Mutex::new(Vec::new()); +static LAST_TOPOLOGY: Mutex> = Mutex::new(Vec::new()); + +fn mark_bad_crtc(name: &str, crtc_id: u32) { + let mut bad = BAD_CRTCS.lock().unwrap(); + if !bad.iter().any(|(n, c)| n == name && *c == crtc_id) { + bad.push((name.to_string(), crtc_id)); + } +} + +fn is_bad_crtc(name: &str, crtc_id: u32) -> bool { + BAD_CRTCS + .lock() + .unwrap() + .iter() + .any(|(n, c)| n == name && *c == crtc_id) +} + +// A replug/wake/reconfigure changes the enumerated (connector, crtc) set; when it +// does, forget past per-CRTC failures and re-test everything. Canonicalize by +// sorting first: drmtap_list_displays / DRM connector order isn't guaranteed +// stable, so a bare reorder must NOT read as a topology change (that would clear +// the bad set and reintroduce a just-failed CRTC). +fn reset_bad_crtcs_on_topology_change(current: &[(String, u32)]) { + let mut sorted = current.to_vec(); + sorted.sort(); + let mut last = LAST_TOPOLOGY.lock().unwrap(); + if last.as_slice() != sorted.as_slice() { + BAD_CRTCS.lock().unwrap().clear(); + *last = sorted; + } +} + +// Build a drmtap_config for the given CRTC. Returns the owned DRM_DEVICE CString +// alongside the config that borrows it: the caller MUST keep the CString alive +// for as long as it uses the config (bind it, don't drop it). Centralised so the +// three open sites (probe, enumerate, capture) can't drift on device/debug +// handling. DRM_DEVICE is user-controlled, so an interior NUL is treated as unset +// (null device_path) rather than panicking. +fn build_drm_config(crtc_id: u32) -> (Option, drmtap_config) { + let device_cstr = std::env::var("DRM_DEVICE") + .ok() + .and_then(|s| std::ffi::CString::new(s).ok()); + let cfg = drmtap_config { + device_path: device_cstr + .as_ref() + .map(|c| c.as_ptr()) + .unwrap_or(std::ptr::null()), + crtc_id, + helper_path: std::ptr::null(), + debug: if std::env::var("DRMTAP_DEBUG").is_ok() { 1 } else { 0 }, + }; + (device_cstr, cfg) +} + +// Open a context on the auto-selected active CRTC (crtc_id 0) and attempt a +// single grab, tolerating a few transient errors before the first frame. +unsafe fn capture_probe() -> (ProbeResult, Option) { + let (_device_cstr, cfg) = build_drm_config(0); + let ctx = drmtap_open(&cfg); + if ctx.is_null() { + // open() can fail because there is no active CRTC yet (DPMS/topology) + // just as easily as for a real setup problem, so don't cache it — the + // next enumeration re-probes. + return (ProbeResult::Transient, None); + } + let mut result = ProbeResult::Transient; + let mut errno: Option = None; + for _ in 0..8 { + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = drmtap_grab_mapped(ctx, &mut frame); + if ret == 0 { + drmtap_frame_release(ctx, &mut frame); + result = ProbeResult::Ok; + break; + } + errno = Some(-ret); + if is_transient_errno(ret) { + std::thread::sleep(Duration::from_millis(10)); + continue; + } + // Non-transient: cache only a genuine helper/setup failure; leave a + // topology failure (ENODEV = CRTC asleep/gone) retryable so a display + // that is merely asleep at startup doesn't disable DRM for good. + result = if is_setup_errno(ret) { + ProbeResult::Permanent + } else { + ProbeResult::Transient + }; + break; + } + drmtap_close(ctx); + (result, errno) +} + +pub struct Display { + name: String, + // Logical origin in the compositor's coordinate space (from the matching + // Wayland output). Falls back to the physical CRTC offset when no compositor + // output matches (e.g. capturing the login screen with no compositor). + x: i32, + y: i32, + // Physical pixel size of the captured framebuffer (DRM mode). + w: usize, + h: usize, + // Logical size + scale (physical/logical) from the matching Wayland output; + // fall back to physical size and scale 1.0 when unknown. + logical_w: usize, + logical_h: usize, + scale: f64, + crtc_id: u32, + primary: bool, +} + +// Match a DRM connector to its Wayland output (by connector name) to obtain the +// compositor's LOGICAL geometry and scale factor. +// +// Rationale: video is captured by DRM in PHYSICAL pixels, but RustDesk's Wayland +// input path injects the cursor in the compositor's LOGICAL coordinate space +// (the uinput device range and the per-display `scale`/`origin` reported to the +// peer are all logical). If the DRM backend reported scale 1.0 and the physical +// origin, the client would send physical coordinates while uinput expects logical +// ones, mis-mapping the cursor under fractional/HiDPI scaling or multi-monitor +// layouts. Matching the Wayland output keeps both coordinate systems consistent +// for any client/server configuration. +// +// Falls back to the physical geometry (scale 1.0) when no compositor output +// matches — e.g. an X11 session, or the GDM/SDDM login screen with no compositor. +// Normalize a connector name so DRM and compositor naming line up. DRM exposes +// sub-typed names like "HDMI-A-1" / "DVI-I-1", while compositors (e.g. Mutter) +// often shorten them to "HDMI-1" / "DVI-1". We collapse to "-" by +// dropping any middle segments, and lowercase for a case-insensitive compare. +fn normalize_connector(name: &str) -> String { + let parts: Vec<&str> = name.split('-').filter(|s| !s.is_empty()).collect(); + match parts.as_slice() { + [] => name.to_lowercase(), + [single] => single.to_lowercase(), + // Keep the leading type and the trailing index, drop the middle (the + // sub-type letter such as the "A" in "HDMI-A-1"). + [first, .., last] => format!("{}-{}", first, last).to_lowercase(), + } +} + +fn logical_geometry_for( + name: &str, + phys_x: i32, + phys_y: i32, + phys_w: usize, + phys_h: usize, +) -> (i32, i32, usize, usize, f64) { + let displays = crate::wayland::display::get_displays(); + // Prefer an exact name match; fall back to a normalized connector match. + let want = normalize_connector(name); + let matched = displays + .displays + .iter() + .find(|d| d.name == name) + .or_else(|| { + displays + .displays + .iter() + .find(|d| normalize_connector(&d.name) == want) + }); + if let Some(d) = matched { + let (lw, lh) = d.logical_size.unwrap_or((d.width, d.height)); + let lw = (lw.max(1)) as usize; + let lh = (lh.max(1)) as usize; + let scale = if d.width > 0 { + d.width as f64 / lw as f64 + } else { + 1.0 + }; + return (d.x, d.y, lw, lh, scale); + } + // No matching compositor output — use physical geometry, no scaling. + // (e.g. an X11 session, or a login screen with no compositor running.) + (phys_x, phys_y, phys_w, phys_h, 1.0) +} + +/// Index of the display matching the compositor's primary output (by connector +/// name), or 0 if the primary is unknown or doesn't match an enumerated +/// connector. Keeps the DRM primary consistent with the Wayland/compositor +/// primary instead of just libdrmtap's enumeration order. +fn primary_display_index(displays: &[Display]) -> usize { + #[cfg(feature = "wayland")] + if let Some(name) = crate::wayland::display::get_primary_monitor() { + if let Some(idx) = displays.iter().position(|d| d.name == name) { + return idx; + } + // The compositor often shortens connector names (Mutter reports "HDMI-1" + // while DRM enumerates "HDMI-A-1"), so an exact compare misses. Fall back + // to the same normalized match used for logical geometry before giving up. + let want = normalize_connector(&name); + if let Some(idx) = displays + .iter() + .position(|d| normalize_connector(&d.name) == want) + { + return idx; + } + log::debug!( + "DRM: compositor primary '{name}' not among enumerated connectors; using first" + ); + } + let _ = displays; + 0 +} + +impl Display { + pub fn all() -> io::Result> { + // SAFETY: All FFI calls use valid pointers and check return values. + // The drmtap context is opened and closed within this function scope. + unsafe { + let (_device_cstr, cfg) = build_drm_config(0); + let ctx = drmtap_open(&cfg); + if ctx.is_null() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "drmtap_open failed", + )); + } + + let mut raw_displays = vec![std::mem::zeroed::(); 8]; + let cap = raw_displays.len() as i32; + let n = drmtap_list_displays(ctx, raw_displays.as_mut_ptr(), cap); + drmtap_close(ctx); + + if n <= 0 { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "No DRM displays found", + )); + } + + // drmtap_list_displays returns the total connected-connector count, + // which may exceed the buffer capacity (only `cap` entries are + // written). Clamp before indexing so >8 connectors can't read past + // the end of the Vec. + let count = (n as usize).min(raw_displays.len()); + + let mut idxs: Vec = + (0..count).filter(|&i| raw_displays[i].active != 0).collect(); + if idxs.is_empty() { + // nvidia-drm doesn't flag the connector active via the legacy API + // even with a live scanout; fall back to all enumerated displays + // and let the capturer auto-select the active CRTC (crtc_id 0). + idxs = (0..count).collect(); + } + let mut displays: Vec = idxs + .into_iter() + .map(|i| { + let name_bytes: Vec = raw_displays[i] + .name + .iter() + .take_while(|&&c| c != 0) + .map(|&c| c as u8) + .collect(); + let name = String::from_utf8_lossy(&name_bytes).to_string(); + let phys_w = raw_displays[i].width as usize; + let phys_h = raw_displays[i].height as usize; + let (ox, oy, logical_w, logical_h, scale) = logical_geometry_for( + &name, + raw_displays[i].x as i32, + raw_displays[i].y as i32, + phys_w, + phys_h, + ); + Display { + name, + x: ox, + y: oy, + w: phys_w, + h: phys_h, + logical_w, + logical_h, + scale, + crtc_id: raw_displays[i].crtc_id, + primary: false, + } + }) + .collect(); + + if displays.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "No active DRM displays", + )); + } + + // Drop CRTCs that a runtime grab marked permanently ungrabbable, so the + // rest keep DRM rather than the whole session dropping to PipeWire + // (useless unattended). Clear the set first when the enumerated topology + // changed, so a replug/wake re-tests everything. + let topology: Vec<(String, u32)> = + displays.iter().map(|d| (d.name.clone(), d.crtc_id)).collect(); + reset_bad_crtcs_on_topology_change(&topology); + displays.retain(|d| !is_bad_crtc(&d.name, d.crtc_id)); + if displays.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "All DRM CRTCs ungrabbable; falling back to PipeWire", + )); + } + + // Mark the primary to match the compositor's primary output (by + // connector name) rather than libdrmtap's enumeration order. Falls + // back to the first display if the compositor primary is unknown or + // its name doesn't match an enumerated connector. + let primary_idx = primary_display_index(&displays); + if let Some(d) = displays.get_mut(primary_idx) { + d.primary = true; + } + + Ok(displays) + } + } + + pub fn primary() -> io::Result { + let mut all = Self::all()?; + let idx = all.iter().position(|d| d.primary).unwrap_or(0); + Ok(all.remove(idx)) + } + + pub fn width(&self) -> usize { self.w } + pub fn height(&self) -> usize { self.h } + pub fn scale(&self) -> f64 { self.scale } + pub fn logical_width(&self) -> usize { self.logical_w } + pub fn logical_height(&self) -> usize { self.logical_h } + pub fn origin(&self) -> (i32, i32) { (self.x, self.y) } + pub fn is_online(&self) -> bool { true } + pub fn is_primary(&self) -> bool { self.primary } + pub fn name(&self) -> String { self.name.clone() } +} + +pub struct Capturer { + ctx: *mut drmtap_ctx, + w: usize, + h: usize, + buffer: Vec, + frame_count: u64, + cursor_tick: u64, + last_grab_time: Instant, + // Identity of the CRTC being captured, so a runtime grab failure can be + // attributed to a specific display (see frame()). crtc_id 0 = auto-select, + // not a stable identity, so failures on it are treated as global, not per-CRTC. + name: String, + crtc_id: u32, +} + +impl Capturer { + pub fn new(display: Display) -> io::Result { + // SAFETY: FFI call to drmtap_open with valid config struct. + // The returned pointer is checked for null before use. + unsafe { + let (_device_cstr, cfg) = build_drm_config(display.crtc_id); + let ctx = drmtap_open(&cfg); + if ctx.is_null() { + return Err(io::Error::new( + io::ErrorKind::Other, + "drmtap_open failed", + )); + } + Ok(Capturer { + ctx, + w: display.w, + h: display.h, + buffer: Vec::new(), + frame_count: 0, + cursor_tick: 0, + last_grab_time: Instant::now(), + name: display.name, + crtc_id: display.crtc_id, + }) + } + } + + pub fn width(&self) -> usize { self.w } + pub fn height(&self) -> usize { self.h } +} + +impl TraitCapturer for Capturer { + fn frame<'a>(&'a mut self, _timeout: Duration) -> io::Result> { + // SAFETY: All FFI calls use the valid self.ctx pointer (checked non-null + // in new()). Frame data pointer is validated before dereferencing. + // drmtap_frame_release is always called before returning. + unsafe { + // Rate limit: minimum 16ms between grabs (~60 FPS max) + let elapsed = self.last_grab_time.elapsed(); + let min_interval = Duration::from_millis(16); + if elapsed < min_interval { + std::thread::sleep(min_interval - elapsed); + } + + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = drmtap_grab_mapped(self.ctx, &mut frame); + if ret < 0 { + if is_transient_errno(ret) { + std::thread::sleep(Duration::from_millis(16)); + return Err(io::ErrorKind::WouldBlock.into()); + } + // Non-transient hard failure. Classify so the next enumeration + // does the right thing, then surface a hard error so the capture + // loop tears down now instead of spinning WouldBlock on a frame + // that will never arrive: + // - setup errno (helper can't run at all) -> permanently + // unavailable; PROBE=2 so DRM is not re-selected this process. + // - otherwise -> reset PROBE so the next enumeration re-probes + // (a healthy primary comes right back), and if the failure is + // attributable to a specific CRTC (not ENODEV/topology, and a + // real crtc id, not auto-select 0), mark that CRTC bad so it is + // dropped from DRM enumeration while the rest keep DRM. + if is_setup_errno(ret) { + PROBE.store(2, Ordering::Relaxed); + } else { + PROBE.store(0, Ordering::Relaxed); + if -ret != libc::ENODEV && self.crtc_id != 0 { + log::warn!( + "DRM/KMS: grab on '{}' (crtc {}) failed permanently (errno {}); dropping it from DRM, keeping DRM for the rest", + self.name, self.crtc_id, -ret + ); + mark_bad_crtc(&self.name, self.crtc_id); + } + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_grab_mapped failed: errno {}", -ret), + )); + } + + if frame.data.is_null() || frame.width == 0 || frame.height == 0 { + drmtap_frame_release(self.ctx, &mut frame); + std::thread::sleep(Duration::from_millis(16)); + return Err(io::ErrorKind::WouldBlock.into()); + } + + self.last_grab_time = Instant::now(); + + // Poll the hardware cursor plane independently of framebuffer changes. + // The cursor plane is a separate DRM plane — its shape can change even + // when the scanout framebuffer is unchanged (e.g. while the desktop is + // idle but the user is moving the mouse). + self.cursor_tick += 1; + if self.cursor_tick % 4 == 0 { + self.update_cursor(); + } + + // NB: we do NOT skip on an unchanged fb_id. A constant KMS framebuffer + // id does not imply constant pixels — a compositor can re-render into + // (or reuse) the current scanout buffer without allocating a new one, + // so gating the copy on fb_id froze the remote display after the first + // frame on those compositors. Deliver every grabbed frame and let the + // encoder collapse static content into near-empty inter-frames; the + // 16ms rate limit above (plus adaptive QoS FPS) bounds the cost. + let w = frame.width as usize; + let h = frame.height as usize; + let stride = frame.stride as usize; + let frame_size = w * 4 * h; + + if self.buffer.len() != frame_size { + self.buffer.resize(frame_size, 0); + } + + let src = frame.data as *const u8; + if stride == w * 4 { + std::ptr::copy_nonoverlapping(src, self.buffer.as_mut_ptr(), frame_size); + } else { + for y in 0..h { + std::ptr::copy_nonoverlapping( + src.add(y * stride), + self.buffer.as_mut_ptr().add(y * w * 4), + w * 4, + ); + } + } + + drmtap_frame_release(self.ctx, &mut frame); + + self.frame_count += 1; + self.w = w; + self.h = h; + Ok(Frame::PixelBuffer(PixelBuffer::new( + &self.buffer, + crate::Pixfmt::BGRA, + w, + h, + ))) + } + } + +} + +impl Capturer { + // Capture the hardware cursor from the DRM cursor plane and update DRM_CURSOR. + // The cursor plane is independent of the scanout framebuffer, so this is called + // on every cursor_tick even when the framebuffer hasn't changed. + unsafe fn update_cursor(&mut self) { + let mut c: drmtap_cursor_info = std::mem::zeroed(); + let cret = drmtap_get_cursor(self.ctx, &mut c); + if cret == 0 + && c.visible != 0 + && !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 = maxx - minx + 1; + let bh = maxy - miny + 1; + if bh > bw * 2 { + ((minx + maxx) / 2, (miny + maxy) / 2) + } else { + (minx, miny) + } + } else { + (0, 0) + }; + let mut lock = DRM_CURSOR.lock().unwrap(); + let changed = lock.as_ref().map_or(true, |old| old.id != hash); + if changed { + *lock = Some(DrmCursor { + id: hash, + width: cw, + height: ch, + hotx, + hoty, + colors, + }); + } + } + drmtap_cursor_release(self.ctx, &mut c); + } +} + +impl Drop for Capturer { + fn drop(&mut self) { + if !self.ctx.is_null() { + // SAFETY: ctx was obtained from drmtap_open and is non-null. + unsafe { drmtap_close(self.ctx); } + self.ctx = std::ptr::null_mut(); + } + } +} diff --git a/libs/scrap/src/common/linux.rs b/libs/scrap/src/common/linux.rs index ba5e8e7fff2..06299fc8ac9 100644 --- a/libs/scrap/src/common/linux.rs +++ b/libs/scrap/src/common/linux.rs @@ -8,9 +8,16 @@ use crate::{ }; use std::{io, time::Duration}; +#[cfg(all(target_os = "linux", feature = "drm"))] +use super::drm; +#[cfg(all(target_os = "linux", feature = "drm"))] +use hbb_common::log; + pub enum Capturer { X11(x11::Capturer), WAYLAND(wayland::Capturer), + #[cfg(all(target_os = "linux", feature = "drm"))] + DRM(drm::Capturer), } impl Capturer { @@ -18,6 +25,8 @@ impl Capturer { Ok(match display { Display::X11(d) => Capturer::X11(x11::Capturer::new(d)?), Display::WAYLAND(d) => Capturer::WAYLAND(wayland::Capturer::new(d)?), + #[cfg(all(target_os = "linux", feature = "drm"))] + Display::DRM(d) => Capturer::DRM(drm::Capturer::new(d)?), }) } @@ -25,6 +34,8 @@ impl Capturer { match self { Capturer::X11(d) => d.width(), Capturer::WAYLAND(d) => d.width(), + #[cfg(all(target_os = "linux", feature = "drm"))] + Capturer::DRM(d) => d.width(), } } @@ -32,6 +43,8 @@ impl Capturer { match self { Capturer::X11(d) => d.height(), Capturer::WAYLAND(d) => d.height(), + #[cfg(all(target_os = "linux", feature = "drm"))] + Capturer::DRM(d) => d.height(), } } } @@ -41,6 +54,8 @@ impl TraitCapturer for Capturer { match self { Capturer::X11(d) => d.frame(timeout), Capturer::WAYLAND(d) => d.frame(timeout), + #[cfg(all(target_os = "linux", feature = "drm"))] + Capturer::DRM(d) => d.frame(timeout), } } } @@ -48,10 +63,30 @@ impl TraitCapturer for Capturer { pub enum Display { X11(x11::Display), WAYLAND(wayland::Display), + #[cfg(all(target_os = "linux", feature = "drm"))] + DRM(drm::Display), } impl Display { pub fn primary() -> io::Result { + // On Wayland: try DRM/KMS first — no portal consent dialog, works at + // the login screen. Falls back to PipeWire/portal if DRM is unavailable + // (helper missing, no active CRTC, etc.). capture_available() probes an + // actual grab so we don't select DRM when enumeration succeeds but the + // privileged helper can't run — otherwise capture would stream blank. + #[cfg(all(target_os = "linux", feature = "drm"))] + if !super::is_x11() && drm::capture_available() { + match drm::Display::primary() { + Ok(d) => { + log::info!("DRM/KMS capture active"); + return Ok(Display::DRM(d)); + } + Err(e) => log::debug!( + "DRM/KMS capture unavailable ({e}); falling back to PipeWire/portal" + ), + } + } + Ok(if super::is_x11() { Display::X11(x11::Display::primary()?) } else { @@ -61,6 +96,23 @@ impl Display { // Currently, wayland need to call wayland::clear() before call Display::all() pub fn all() -> io::Result> { + // On Wayland: try DRM/KMS first (see primary() for rationale). + #[cfg(all(target_os = "linux", feature = "drm"))] + if !super::is_x11() && drm::capture_available() { + match drm::Display::all() { + Ok(displays) if !displays.is_empty() => { + log::info!("DRM/KMS capture active ({} display(s))", displays.len()); + return Ok(displays.into_iter().map(Display::DRM).collect()); + } + Ok(_) => log::debug!( + "DRM/KMS reported no displays; falling back to PipeWire/portal" + ), + Err(e) => log::debug!( + "DRM/KMS capture unavailable ({e}); falling back to PipeWire/portal" + ), + } + } + Ok(if super::is_x11() { x11::Display::all()? .drain(..) @@ -78,6 +130,8 @@ impl Display { match self { Display::X11(d) => d.width(), Display::WAYLAND(d) => d.width(), + #[cfg(all(target_os = "linux", feature = "drm"))] + Display::DRM(d) => d.width(), } } @@ -85,6 +139,8 @@ impl Display { match self { Display::X11(d) => d.height(), Display::WAYLAND(d) => d.height(), + #[cfg(all(target_os = "linux", feature = "drm"))] + Display::DRM(d) => d.height(), } } @@ -92,6 +148,8 @@ impl Display { match self { Display::X11(_d) => 1.0, Display::WAYLAND(d) => d.scale(), + #[cfg(all(target_os = "linux", feature = "drm"))] + Display::DRM(d) => d.scale(), } } @@ -99,6 +157,8 @@ impl Display { match self { Display::X11(d) => d.width(), Display::WAYLAND(d) => d.logical_width(), + #[cfg(all(target_os = "linux", feature = "drm"))] + Display::DRM(d) => d.logical_width(), } } @@ -106,6 +166,8 @@ impl Display { match self { Display::X11(d) => d.height(), Display::WAYLAND(d) => d.logical_height(), + #[cfg(all(target_os = "linux", feature = "drm"))] + Display::DRM(d) => d.logical_height(), } } @@ -113,6 +175,8 @@ impl Display { match self { Display::X11(d) => d.origin(), Display::WAYLAND(d) => d.origin(), + #[cfg(all(target_os = "linux", feature = "drm"))] + Display::DRM(d) => d.origin(), } } @@ -120,6 +184,8 @@ impl Display { match self { Display::X11(d) => d.is_online(), Display::WAYLAND(d) => d.is_online(), + #[cfg(all(target_os = "linux", feature = "drm"))] + Display::DRM(d) => d.is_online(), } } @@ -127,6 +193,8 @@ impl Display { match self { Display::X11(d) => d.is_primary(), Display::WAYLAND(d) => d.is_primary(), + #[cfg(all(target_os = "linux", feature = "drm"))] + Display::DRM(d) => d.is_primary(), } } @@ -134,6 +202,8 @@ impl Display { match self { Display::X11(d) => d.name(), Display::WAYLAND(d) => d.name(), + #[cfg(all(target_os = "linux", feature = "drm"))] + Display::DRM(d) => d.name(), } } } diff --git a/libs/scrap/src/common/mod.rs b/libs/scrap/src/common/mod.rs index 2d74caa0dd5..5a50cb690c9 100644 --- a/libs/scrap/src/common/mod.rs +++ b/libs/scrap/src/common/mod.rs @@ -16,7 +16,14 @@ cfg_if! { mod linux; mod wayland; mod x11; + #[cfg(all(target_os = "linux", feature = "drm"))] + mod drm; pub use self::linux::*; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub use self::drm::{ + capture_available as drm_capture_available, drm_cursor, drm_cursor_id, + DrmCursor, + }; pub use self::wayland::set_map_err; pub use self::x11::PixelBuffer; } else { diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs index a5c937491b8..fd7a01394d3 100644 --- a/libs/scrap/src/wayland/display.rs +++ b/libs/scrap/src/wayland/display.rs @@ -162,7 +162,7 @@ fn try_gdbus_primary() -> Option { None } -fn get_primary_monitor() -> Option { +pub(crate) fn get_primary_monitor() -> Option { try_xrandr_primary() .or_else(try_kscreen_primary) .or_else(try_gdbus_primary) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 9a4bb37ecb2..7b15a477972 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -361,6 +361,12 @@ pub fn get_focused_display(displays: Vec) -> Option { } pub fn get_cursor() -> ResultType> { + #[cfg(feature = "drm")] + if !is_x11() { + if let Some(id) = scrap::drm_cursor_id() { + return Ok(Some(scale_tagged_cursor_id(id))); + } + } let mut res = None; DISPLAY.with(|conn| { if let Ok(d) = conn.try_borrow_mut() { @@ -368,7 +374,7 @@ pub fn get_cursor() -> ResultType> { unsafe { let img = XFixesGetCursorImage(*d); if !img.is_null() { - res = Some((*img).cursor_serial as u64); + res = Some(scale_tagged_cursor_id((*img).cursor_serial as u64)); XFree(img as _); } } @@ -378,21 +384,158 @@ pub fn get_cursor() -> ResultType> { Ok(res) } +// Cursor downscale factor for DRM/Wayland capture. +// +// On Wayland the cursor we can read comes from XFixes (XWayland), which renders +// it at a HiDPI size (Xcursor.size, typically 2x). The video, however, is +// captured at PHYSICAL resolution and the flutter client lays the cursor image +// out in the display's LOGICAL canvas — so an unscaled XFixes cursor ends up +// ~display-scale times too big. We divide the cursor image by the display scale +// here so it matches the rest of the captured content. 0 bits == 1.0 (no +// scaling), the default for X11 / unscaled displays. +static CURSOR_DOWNSCALE_BITS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +pub fn set_cursor_downscale(scale: f64) { + let v = if scale.is_finite() && scale > 1.0 { scale } else { 1.0 }; + CURSOR_DOWNSCALE_BITS.store(v.to_bits(), std::sync::atomic::Ordering::Relaxed); +} + +fn cursor_downscale() -> f64 { + let b = CURSOR_DOWNSCALE_BITS.load(std::sync::atomic::Ordering::Relaxed); + if b == 0 { + 1.0 + } else { + f64::from_bits(b) + } +} + +// Fold the current cursor downscale into a cursor id. MouseCursorService only +// re-fetches get_cursor_data() when get_cursor()'s id changes, so if the scale +// changes (e.g. capture switches to a different-DPI monitor) while the cursor +// SHAPE is unchanged, the client would otherwise keep the old, wrong-sized image. +// Tagging the id with the scale makes a scale change look like a new cursor. +// get_cursor() and get_cursor_data() must tag identically so their ids match. +#[cfg(feature = "drm")] +fn scale_tagged_cursor_id(raw: u64) -> u64 { + let q = (cursor_downscale() * 256.0).round() as u64; // quantized; 1.0 -> 256 + raw ^ q.wrapping_mul(0x9E37_79B9_7F4A_7C15) +} + +// Without the drm feature there is no cursor downscale, so the id passes through +// unchanged: the XFixes cursor path stays behaviourally identical to upstream +// when drm is off. +#[cfg(not(feature = "drm"))] +#[inline] +fn scale_tagged_cursor_id(raw: u64) -> u64 { + raw +} + +#[cfg(all(test, feature = "drm"))] +mod cursor_downscale_tests { + use super::*; + + // One test, not two: both exercise the process-global CURSOR_DOWNSCALE_BITS, + // so keeping them in a single test keeps them sequential and race-free without + // pulling in a serial-test harness. + #[test] + fn cursor_downscale_behaviour() { + // set_cursor_downscale clamps: only > 1.0 is kept; anything else is 1.0. + set_cursor_downscale(0.5); + assert_eq!(cursor_downscale(), 1.0); + set_cursor_downscale(f64::NAN); + assert_eq!(cursor_downscale(), 1.0); + set_cursor_downscale(2.0); + assert_eq!(cursor_downscale(), 2.0); + + // A scale change must change the tagged cursor id (so MouseCursorService + // re-fetches), while the same scale must keep it stable (no re-fetch churn). + let raw = 0x0123_4567_89AB_CDEFu64; + set_cursor_downscale(1.0); + let id_1x = scale_tagged_cursor_id(raw); + set_cursor_downscale(2.0); + let id_2x = scale_tagged_cursor_id(raw); + assert_ne!(id_1x, id_2x, "scale change should change the cursor id"); + set_cursor_downscale(1.0); + assert_eq!(id_1x, scale_tagged_cursor_id(raw), "same scale, same id"); + } +} + +// Nearest-neighbor downscale of an RGBA cursor image by `factor` (> 1.0). +fn downscale_rgba( + colors: &[u8], + w: i32, + h: i32, + hotx: i32, + hoty: i32, + factor: f64, +) -> (i32, i32, Vec, i32, i32) { + let nw = ((w as f64) / factor).round().max(1.0) as i32; + let nh = ((h as f64) / factor).round().max(1.0) as i32; + let mut out = vec![0u8; (nw * nh * 4) as usize]; + for y in 0..nh { + let sy = (((y as f64) * factor) as i32).min(h - 1).max(0); + for x in 0..nw { + let sx = (((x as f64) * factor) as i32).min(w - 1).max(0); + let si = ((sy * w + sx) * 4) as usize; + let di = ((y * nw + x) * 4) as usize; + out[di..di + 4].copy_from_slice(&colors[si..si + 4]); + } + } + let nhx = (((hotx as f64) / factor).round() as i32).clamp(0, nw - 1); + let nhy = (((hoty as f64) / factor).round() as i32).clamp(0, nh - 1); + (nw, nh, out, nhx, nhy) +} + pub fn get_cursor_data(hcursor: u64) -> ResultType { + #[cfg(feature = "drm")] + if !is_x11() { + if let Some(c) = scrap::drm_cursor() { + // The DRM cursor is updated asynchronously; its id may have advanced + // past hcursor between get_cursor() and get_cursor_data(). Return the + // latest snapshot rather than bailing, which would trigger a 10-second + // MouseCursorService backoff and log spam. + let mut cd: CursorData = Default::default(); + cd.id = scale_tagged_cursor_id(c.id); + // Same logical-canvas downscale as the XFixes path: the hardware + // cursor buffer is in physical pixels, but the client lays it out in + // the display's logical canvas, so divide by the display scale. + let factor = cursor_downscale(); + if factor > 1.01 && c.width > 1 && c.height > 1 { + let (nw, nh, ncolors, nhx, nhy) = + downscale_rgba(&c.colors, c.width, c.height, c.hotx, c.hoty, factor); + cd.width = nw; + cd.height = nh; + cd.hotx = nhx; + cd.hoty = nhy; + cd.colors = ncolors.into(); + } else { + cd.width = c.width; + cd.height = c.height; + cd.hotx = c.hotx; + cd.hoty = c.hoty; + cd.colors = c.colors.into(); + } + return Ok(cd); + } + } let mut res = None; DISPLAY.with(|conn| { if let Ok(ref mut d) = conn.try_borrow_mut() { if !d.is_null() { unsafe { let img = XFixesGetCursorImage(**d); - if !img.is_null() && hcursor == (*img).cursor_serial as u64 { + // hcursor comes from get_cursor(), which tags the id with the + // scale; tag this side too so the match still holds. + if !img.is_null() + && hcursor == scale_tagged_cursor_id((*img).cursor_serial as u64) + { let mut cd: CursorData = Default::default(); cd.hotx = (*img).xhot as _; cd.hoty = (*img).yhot as _; cd.width = (*img).width as _; cd.height = (*img).height as _; // to-do: how about if it is 0 - cd.id = (*img).cursor_serial as _; + cd.id = scale_tagged_cursor_id((*img).cursor_serial as u64); let pixels = std::slice::from_raw_parts((*img).pixels, (cd.width * cd.height) as _); // cd.colors.resize(pixels.len() * 4, 0); @@ -415,7 +558,19 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType { cd_colors[pos + 3] = a as _; } } - cd.colors = cd_colors.into(); + let factor = cursor_downscale(); + if factor > 1.01 && cd.width > 1 && cd.height > 1 { + let (nw, nh, ncolors, nhx, nhy) = downscale_rgba( + &cd_colors, cd.width, cd.height, cd.hotx, cd.hoty, factor, + ); + cd.width = nw; + cd.height = nh; + cd.hotx = nhx; + cd.hoty = nhy; + cd.colors = ncolors.into(); + } else { + cd.colors = cd_colors.into(); + } res = Some(cd); } if !img.is_null() { diff --git a/src/server/display_service.rs b/src/server/display_service.rs index fe3621f26a8..6b7415e5637 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -310,6 +310,21 @@ pub(super) fn check_update_displays(all: &Vec) { let use_logical_scale = !is_x11() && crate::is_server() && scrap::wayland::display::get_displays().displays.len() > 1; + // Seed the cursor downscale from the primary display. Reset it to 1.0 when we + // are NOT using logical scaling (single monitor, or after a multi->single + // transition) so a stale scaled value can't linger and shrink the cursor. The + // served-display path (get_capturer_for_display) refines this per display for + // mixed-DPI multi-monitor. Primary is detected with get_primary_2, which is + // not guaranteed to be all[0] on the PipeWire/Wayland path. + #[cfg(all(target_os = "linux", feature = "drm"))] + { + let downscale = if use_logical_scale { + all.get(get_primary_2(all)).map(|p| p.scale()).unwrap_or(1.0) + } else { + 1.0 + }; + crate::platform::linux::set_cursor_downscale(downscale); + } let displays = all .iter() .map(|d| { @@ -392,6 +407,21 @@ pub fn get_primary_2(all: &Vec) -> usize { all.iter().position(|d| d.is_primary()).unwrap_or(0) } +// Cursor downscale should match the display actually being served, not always +// the primary; otherwise a mixed-DPI multi-monitor setup mis-scales the cursor +// when a non-primary display is captured. check_update_displays seeds a sane +// default from the primary; this overrides it from the served display when a +// capturer is created. Same multi-display/logical-scale gate as there. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) fn update_cursor_downscale_for_served(display: &Display) { + let use_logical_scale = !is_x11() + && crate::is_server() + && scrap::wayland::display::get_displays().displays.len() > 1; + if use_logical_scale { + crate::platform::linux::set_cursor_downscale(display.scale()); + } +} + #[inline] #[cfg(windows)] fn no_displays(displays: &Vec) -> bool { diff --git a/src/server/video_service.rs b/src/server/video_service.rs index 15f0ef89364..eceb66ff66c 100644 --- a/src/server/video_service.rs +++ b/src/server/video_service.rs @@ -302,6 +302,10 @@ fn create_capturer( #[cfg(not(windows))] { log::debug!("Create capturer from scrap"); + // Scale the cursor for the display actually being served (mixed + // DPI multi-monitor), not always the primary. + #[cfg(all(target_os = "linux", feature = "drm"))] + crate::server::display_service::update_cursor_downscale_for_served(&display); return Ok(Box::new( Capturer::new(display).with_context(|| "Failed to create capturer")?, )); diff --git a/src/server/wayland.rs b/src/server/wayland.rs index 1e0efc0f480..c23244255be 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -164,14 +164,24 @@ pub(super) async fn check_init() -> ResultType<()> { } 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)?; + // When DRM/KMS capture is active the displays are already enumerated + // correctly; skip PipeWire fill_displays (which needs a portal session). + #[cfg(feature = "drm")] + let is_drm = all.first().map_or(false, |d| matches!(d, Display::DRM(_))); + #[cfg(not(feature = "drm"))] + let is_drm = false; + if is_drm { + log::info!("DRM/KMS displays detected, skipping PipeWire initialisation"); + } else { + 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); } - 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); @@ -276,6 +286,22 @@ pub(super) fn get_capturer_for_display( let cap_display_info: *const CapDisplayInfo = *addr as _; unsafe { let cap_display_info = &*cap_display_info; + // Set the cursor downscale from the display actually being served. + // On Wayland get_capturer_monitor() returns through here BEFORE + // create_capturer(), so create_capturer's update never runs; this is + // the live wiring point. displays[current].scale already carries the + // logical-scale gating (1.0 unless multi-monitor logical), so a + // mixed-DPI setup uses THIS monitor's scale for the hardware cursor. + #[cfg(feature = "drm")] + if let Some(d) = cap_display_info.displays.get(cap_display_info.current) { + log::debug!( + "serving display {} '{}' scale {} -> cursor downscale", + cap_display_info.current, + d.name, + d.scale + ); + crate::platform::linux::set_cursor_downscale(d.scale); + } let rect = cap_display_info.rects[cap_display_info.current]; Ok(super::video_service::CapturerInfo { origin: rect.0, From f6205c74a380bd66d35a0b401aa56d7a9e443c20 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Thu, 9 Jul 2026 20:09:16 -0300 Subject: [PATCH 2/4] drm: address codex review (per-crtc probe, hidden cursor, --package deb) - capture_available(): a per-crtc grab failure on the probed crtc 0 (unsupported format, or ENODEV asleep) no longer sinks the whole session to PipeWire; the helper is known to run, so return available and let Display::all() enumerate and drop just the bad connector (new ProbeResult::Available) - update_cursor(): publish a distinct empty cursor when the plane reports the cursor hidden (visible == 0), so get_cursor()'s id changes and the client hides the pointer instead of keeping the last shape - build.py build_deb_from_folder(): a --package bundle carrying the helper now gets the rustdesk-unattended-wayland name and the same group/setcap postinst as build_flutter_deb (extracted into append_drm_helper_postinst) --- build.py | 50 +++++++++++-------- libs/scrap/src/common/drm.rs | 97 +++++++++++++++++++++++++++++++++--- 2 files changed, 121 insertions(+), 26 deletions(-) diff --git a/build.py b/build.py index da4d95e1597..fdc7057ba3f 100755 --- a/build.py +++ b/build.py @@ -331,6 +331,27 @@ def ffi_bindgen_function_refactor(): 'sed -i "s/ffi.NativeFunction/dev/null || addgroup --system rustdesk-capture 2>/dev/null || true\n' + '\tchown root:rustdesk-capture /usr/lib/rustdesk/drmtap-helper 2>/dev/null || true\n' + '\tchmod 0750 /usr/lib/rustdesk/drmtap-helper\n' + '\tif command -v setcap >/dev/null; then\n' + '\t\tsetcap cap_sys_admin+ep /usr/lib/rustdesk/drmtap-helper || echo "rustdesk: warning: setcap on drmtap-helper failed; DRM/KMS capture will fall back to PipeWire" >&2\n' + '\tfi\n' + 'fi\n' + ) + + def build_flutter_deb(version, features): if not skip_cargo: system2(f'cargo build --locked --features {features} --lib --release') @@ -387,25 +408,7 @@ def build_flutter_deb(version, features): generate_control_file(version, ", libcap2-bin" if ships_helper else "", package_name) system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') if ships_helper: - # Append the helper group + setcap setup to the postinst ONLY for the DRM - # package, so the stock package's DEBIAN/postinst stays byte-identical to - # upstream (the drm feature must not touch shared config when it is off). - with open('tmpdeb/DEBIAN/postinst', 'a') as f: - f.write( - '\n' - 'if [ "$1" = configure ] && [ -f /usr/lib/rustdesk/drmtap-helper ]; then\n' - '\t# DRM/KMS capture helper: grant cap_sys_admin but keep it out of\n' - '\t# world-exec (a world-exec cap_sys_admin binary lets any local user\n' - '\t# read the active scanout, incl. the login/lock screen). Restrict it\n' - '\t# to a dedicated group first, then setcap; only rustdesk-capture runs it.\n' - '\tgroupadd -f -r rustdesk-capture 2>/dev/null || addgroup --system rustdesk-capture 2>/dev/null || true\n' - '\tchown root:rustdesk-capture /usr/lib/rustdesk/drmtap-helper 2>/dev/null || true\n' - '\tchmod 0750 /usr/lib/rustdesk/drmtap-helper\n' - '\tif command -v setcap >/dev/null; then\n' - '\t\tsetcap cap_sys_admin+ep /usr/lib/rustdesk/drmtap-helper || echo "rustdesk: warning: setcap on drmtap-helper failed; DRM/KMS capture will fall back to PipeWire" >&2\n' - '\tfi\n' - 'fi\n' - ) + append_drm_helper_postinst() md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb;') @@ -453,8 +456,15 @@ def build_deb_from_folder(version, binary_folder): # Only a bundle staged with the DRM helper needs libcap2-bin (postinst runs # setcap on it); detect it in the payload so plain bundles stay unaffected. ships_helper = os.path.exists('tmpdeb/usr/lib/rustdesk/drmtap-helper') - generate_control_file(version, ", libcap2-bin" if ships_helper else "") + # A staged bundle carrying the helper is the DRM package: name it accordingly + # (Conflicts/Replaces/Provides rustdesk) and run the same helper setcap/group + # setup as build_flutter_deb, so a --package DRM bundle is not left with an + # unusable, cap-less helper under the stock package name. + package_name = 'rustdesk-unattended-wayland' if ships_helper else 'rustdesk' + generate_control_file(version, ", libcap2-bin" if ships_helper else "", package_name) system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') + if ships_helper: + append_drm_helper_postinst() md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb;') diff --git a/libs/scrap/src/common/drm.rs b/libs/scrap/src/common/drm.rs index 5b80d0c66b3..e2f23da7408 100644 --- a/libs/scrap/src/common/drm.rs +++ b/libs/scrap/src/common/drm.rs @@ -46,6 +46,13 @@ pub struct DrmCursor { static DRM_CURSOR: Mutex> = Mutex::new(None); +// Sentinel id published when the cursor plane reports the cursor as hidden +// (drmtap_get_cursor -> visible == 0). Distinct from any real cursor hash (an +// FNV-1a over the pixels), so get_cursor() reports a change and the client drops +// the last shape instead of showing it forever. Collision with a real hash is +// ~1/2^64. +const HIDDEN_CURSOR_ID: u64 = u64::MAX; + /// Current hardware cursor captured from the DRM cursor plane, if any. pub fn drm_cursor() -> Option { DRM_CURSOR.lock().unwrap().clone() @@ -80,8 +87,13 @@ fn is_setup_errno(ret: i32) -> bool { enum ProbeResult { Ok, + // Helper ran but the probed CRTC (crtc 0) could not be grabbed for a + // CRTC-specific reason (unsupported scanout format, or asleep/gone). DRM is + // still viable, so let Display::all() enumerate and drop just that connector + // instead of sinking every display to PipeWire. + Available, Permanent, // helper cannot run / not set up (denied, missing) — cache it - Transient, // busy, no-frame-yet, or topology (CRTC asleep) — retry later + Transient, // busy, no-frame-yet, or could not open a context — retry later } // Whether DRM/KMS capture actually works on this host, i.e. the privileged @@ -110,6 +122,17 @@ pub fn capture_available() -> bool { log::info!("DRM/KMS capture probe succeeded"); true } + (ProbeResult::Available, errno) => { + // The helper works; only the probed CRTC could not be grabbed. Cache + // available and let Display::all() enumerate and drop the bad CRTC, so + // a multi-monitor host keeps DRM for the displays that do work. + PROBE.store(1, Ordering::Relaxed); + log::info!( + "DRM/KMS helper works but the probed CRTC could not be grabbed (errno {}); enumerating displays", + errno.unwrap_or(0) + ); + true + } (ProbeResult::Permanent, errno) => { PROBE.store(2, Ordering::Relaxed); // Permanent is only reached from a grab that returned a setup errno, @@ -232,13 +255,15 @@ unsafe fn capture_probe() -> (ProbeResult, Option) { std::thread::sleep(Duration::from_millis(10)); continue; } - // Non-transient: cache only a genuine helper/setup failure; leave a - // topology failure (ENODEV = CRTC asleep/gone) retryable so a display - // that is merely asleep at startup doesn't disable DRM for good. + // Non-transient. A genuine helper/setup failure (denied, missing) is + // global, so cache it as Permanent. Anything else means the helper ran + // but this one CRTC could not be grabbed (unsupported format, or ENODEV = + // asleep/gone): keep DRM available and let Display::all() enumerate and + // drop just this connector, rather than gating every display on crtc 0. result = if is_setup_errno(ret) { ProbeResult::Permanent } else { - ProbeResult::Transient + ProbeResult::Available }; break; } @@ -648,7 +673,23 @@ impl Capturer { unsafe fn update_cursor(&mut self) { let mut c: drmtap_cursor_info = std::mem::zeroed(); let cret = drmtap_get_cursor(self.ctx, &mut c); - if cret == 0 + if cret == 0 && c.visible == 0 { + // Cursor hidden (e.g. a fullscreen app grabbed the pointer): publish a + // distinct empty cursor so get_cursor()'s id changes and the client + // hides the pointer, instead of keeping the last visible shape on + // screen until some new cursor shape happens to be captured. + let mut lock = DRM_CURSOR.lock().unwrap(); + if lock.as_ref().map_or(true, |old| old.id != HIDDEN_CURSOR_ID) { + *lock = Some(DrmCursor { + id: HIDDEN_CURSOR_ID, + width: 1, + height: 1, + hotx: 0, + hoty: 0, + colors: vec![0, 0, 0, 0], + }); + } + } else if cret == 0 && c.visible != 0 && !c.pixels.is_null() && c.width > 0 @@ -721,3 +762,47 @@ impl Drop for Capturer { } } } + +#[cfg(test)] +mod probe_tests { + use super::*; + + // These exercise the process-global BAD_CRTCS / LAST_TOPOLOGY, so they live in + // one test to stay sequential and race-free without a serial-test harness. + #[test] + fn errno_classification_and_per_crtc_fallback() { + // A setup errno means the helper cannot run at all -> global (Permanent), + // so DRM is disabled for the whole session. + for e in [libc::EACCES, libc::EPERM, libc::ENOENT] { + assert!(is_setup_errno(-e), "errno {e} must gate DRM globally"); + } + // Anything else is per-CRTC / topology: the probe returns Available so + // Display::all() drops just the bad connector instead of sinking DRM for + // every display (the codex round-1 fix). + for e in [libc::ENOTSUP, libc::ENODEV, libc::EINVAL, libc::EIO] { + assert!(!is_setup_errno(-e), "errno {e} must not gate DRM globally"); + } + + BAD_CRTCS.lock().unwrap().clear(); + LAST_TOPOLOGY.lock().unwrap().clear(); + let topo = [("HDMI-A-1".to_string(), 150u32), ("DP-1".to_string(), 386u32)]; + reset_bad_crtcs_on_topology_change(&topo); // establish the baseline + assert!(!is_bad_crtc("DP-1", 386)); + mark_bad_crtc("DP-1", 386); + assert!(is_bad_crtc("DP-1", 386)); + assert!(!is_bad_crtc("HDMI-A-1", 150), "other CRTCs stay usable"); + + // A bare reorder of the same connectors is NOT a topology change: a + // just-failed CRTC must not silently come back. + let reordered = [("DP-1".to_string(), 386u32), ("HDMI-A-1".to_string(), 150u32)]; + reset_bad_crtcs_on_topology_change(&reordered); + assert!(is_bad_crtc("DP-1", 386), "a reorder must keep the bad set"); + + // A real change (a connector gone) clears the set so everything re-tests. + reset_bad_crtcs_on_topology_change(&[("HDMI-A-1".to_string(), 150u32)]); + assert!(!is_bad_crtc("DP-1", 386), "a topology change must clear the bad set"); + + BAD_CRTCS.lock().unwrap().clear(); + LAST_TOPOLOGY.lock().unwrap().clear(); + } +} From 529b6c081d4bfad7087dbda2e21332184f661215 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Thu, 9 Jul 2026 22:26:35 -0300 Subject: [PATCH 3/4] build: emit the --package drm deb under its package_name build_deb_from_folder set the control Package to rustdesk-unattended-wayland for a staged drm bundle but still renamed the output to rustdesk-.deb, a filename/metadata mismatch that could also collide with a stock bundle. Match build_flutter_deb and rename by package_name. --- build.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.py b/build.py index fdc7057ba3f..ae5a3ef00c2 100755 --- a/build.py +++ b/build.py @@ -470,7 +470,7 @@ def build_deb_from_folder(version, binary_folder): system2('/bin/rm -rf tmpdeb/') system2('/bin/rm -rf ../res/DEBIAN/control') - os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version) + os.rename('rustdesk.deb', '../%s-%s.deb' % (package_name, version)) os.chdir("..") From 40a3c338402f909e28fa386c6e483499f8381b82 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Fri, 10 Jul 2026 00:04:33 -0300 Subject: [PATCH 4/4] deps: build libdrmtap-sys from the rustdesk-org fork, not crates.io Point the drm feature's libdrmtap-sys at the rustdesk-org/libdrmtap fork (pinned by rev to the 0.4.5 / HDR-guard commit df530502) instead of the crates.io crate, so the privileged drmtap-helper is compiled from source RustDesk owns and reviews, in RustDesk CI. Same code as the =0.4.5 pin. --- Cargo.lock | 3 +-- Cargo.toml | 6 ++++-- libs/scrap/Cargo.toml | 5 +++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3a3654d624..9ab93a07701 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4468,8 +4468,7 @@ dependencies = [ [[package]] name = "libdrmtap-sys" version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b11669c93786775711332ff36a5784b3e7be1eda96e51acb3c47cb47f720be0" +source = "git+https://github.com/rustdesk-org/libdrmtap?rev=df530502e69183de0b6aba36403ef811eff3a4ef#df530502e69183de0b6aba36403ef811eff3a4ef" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index e6f89b18f07..e8c9002aa31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -178,8 +178,10 @@ ttf-parser = "0.25" [target.'cfg(target_os = "linux")'.dependencies] # Direct dep so build.rs receives DEP_DRMTAP_HELPER_BIN via the `links` mechanism. -# Exact pin: the helper is a privileged TCB component, keep it on the audited version. -libdrmtap-sys = { version = "=0.4.5", optional = true } +# Built from the rustdesk-org fork of libdrmtap (not crates.io) so the privileged +# helper is compiled from source RustDesk controls, in RustDesk CI. Pinned by rev: +# it is a TCB component, so a bump must be an explicit, reviewed change. +libdrmtap-sys = { git = "https://github.com/rustdesk-org/libdrmtap", rev = "df530502e69183de0b6aba36403ef811eff3a4ef", optional = true } libxdo-sys = "0.11" psimple = { package = "libpulse-simple-binding", version = "2.27" } pulse = { package = "libpulse-binding", version = "2.27" } diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 6758e9a77b9..87535c2651d 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -55,8 +55,9 @@ pkg-config = { version = "0.3.27", optional = true } [target.'cfg(target_os = "linux")'.dependencies] # DRM/KMS capture bindings (statically embeds the libdrmtap C sources). # Enabled via the `drm` feature; not required for PipeWire/X11-only builds. -# Exact pin: the helper is a privileged TCB component, keep it on the audited version. -libdrmtap-sys = { version = "=0.4.5", optional = true } +# Built from the rustdesk-org fork of libdrmtap (not crates.io), pinned by rev: +# the helper is a privileged TCB component, so a bump must be explicit. +libdrmtap-sys = { git = "https://github.com/rustdesk-org/libdrmtap", rev = "df530502e69183de0b6aba36403ef811eff3a4ef", optional = true } dbus = { version = "0.9", optional = true } tracing = { version = "0.1", optional = true } gstreamer = { version = "0.16", optional = true }