diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 2491789e6..01db303a5 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -1676,6 +1676,61 @@ jobs: mv "$name" /workspace/"${name%%.rpm}-suse.rpm" done + # --- opt-in unattended-wayland (DRM/KMS) variant: a separate deb --- + # Bundles libdrmtap.so (dlopen-ed in-process by the root service) 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. + if [[ "${{ matrix.job.arch }}" == "x86_64" ]]; then + pushd /workspace + echo -e "start packaging unattended-wayland (DRM) deb" + # drm-only build deps (meson builds libdrmtap.so from the cloned source), + # installed here — not in the stock install list — so the default + # drm-off build stays identical to upstream. libdrmtap's meson.build + # needs meson >= 0.57 (fs.read) + `meson compile` (>= 0.54); the distro + # apt meson is far older on the 18.04 build container, so install it via + # pip (pinned < 0.62 for the container's python 3.6). The EGL/GLES dev + # packages must be the mesa-specific names (libegl1-mesa-dev / + # libgles2-mesa-dev): the newer libegl-dev / libgles-dev metapackages + # do not exist on the ubuntu18.04 build container. + apt-get install -y ninja-build libdrm-dev libegl1-mesa-dev libgles2-mesa-dev python3-pip + python3 -m pip install --upgrade pip + python3 -m pip install 'meson>=0.57,<0.62' + # libdrmtap is sourced by cloning the rustdesk-org fork at a pinned + # ref (it is no longer a git submodule). DRMTAP_REPO / DRMTAP_REF are + # exported so build.py reuses the exact same source. We clone + build + # the .so here and hand it to build.py via DRMTAP_PREBUILT_DIR, because + # a later build step in this container disturbs the working tree. + # rustdesk-org/libdrmtap main tracks the current release (0.4.8+). + export DRMTAP_REPO="https://github.com/rustdesk-org/libdrmtap" + export DRMTAP_REF="main" + git config --global --add safe.directory '*' || true + rm -rf third_party/libdrmtap + git clone --depth 1 --branch "$DRMTAP_REF" "$DRMTAP_REPO" third_party/libdrmtap + test -f third_party/libdrmtap/meson.build || { echo "FATAL: libdrmtap source missing"; exit 1; } + # Build libdrmtap.so now, while the cloned source is definitely + # present, and stash the real object OUTSIDE the source tree. A later + # build step in this container disturbs that working tree (it ends up + # empty by the time build.py runs), so build.py picks up this prebuilt + # .so via DRMTAP_PREBUILT_DIR instead of rebuilding from source. + meson setup third_party/libdrmtap/build-pkg third_party/libdrmtap --buildtype=release + meson compile -C third_party/libdrmtap/build-pkg drmtap + mkdir -p "$PWD/prebuilt-libdrmtap" + find third_party/libdrmtap/build-pkg -maxdepth 1 -name 'libdrmtap.so.0.*' -type f \ + -exec cp -a {} "$PWD/prebuilt-libdrmtap/" \; + export DRMTAP_PREBUILT_DIR="$PWD/prebuilt-libdrmtap" + [ -n "$(find "$DRMTAP_PREBUILT_DIR" -name 'libdrmtap.so.0.*' -type f)" ] \ + || { echo "FATAL: prebuilt libdrmtap.so missing"; exit 1; } + ls -l "$DRMTAP_PREBUILT_DIR" + 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 +1748,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/.gitignore b/.gitignore index d2e09a906..f51a5b8cd 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,6 @@ examples/**/target/ vcpkg_installed flutter/lib/generated_plugin_registrant.dart libsciter.dylib -flutter/web/ \ No newline at end of file +flutter/web/ +# libdrmtap is cloned at build time by build.py (not a submodule) +/third_party/libdrmtap/ diff --git a/Cargo.toml b/Cargo.toml index d909ec3a2..05cd9db1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ default = ["use_dasp"] hwcodec = ["scrap/hwcodec"] vram = ["scrap/vram"] mediacodec = ["scrap/mediacodec"] +drm = ["scrap/drm"] plugin_framework = [] linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"] unix-file-copy-paste = [ diff --git a/DRM_CAPTURE_SECURITY.md b/DRM_CAPTURE_SECURITY.md new file mode 100644 index 000000000..b711de74c --- /dev/null +++ b/DRM_CAPTURE_SECURITY.md @@ -0,0 +1,94 @@ +# 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 + +Reading the active scanout needs `CAP_SYS_ADMIN` (to map other clients' +framebuffers). RustDesk's root `--service` already runs with `CAP_SYS_ADMIN`, so +the `drm` feature does the read **in-process in that root service**: it +`dlopen`s `libdrmtap.so` and calls it in direct mode — no privileged child, no +`setcap` helper. Captured frames are copied to packed BGRA and streamed to the +unprivileged user `--server` over a dedicated service-scoped IPC channel +(`_drm`), which feeds them to the encoder. This mirrors the Windows +`portable_service` split (a privileged process captures, an unprivileged one +presents) but reuses RustDesk's own hardened IPC. + +- `libdrmtap.so` is loaded through a small `dlopen` loader (`drmtap_dl`); if the + library or one of its runtime deps is missing the load fails cleanly and the + caller falls back to the PipeWire/portal path. +- The reader restricts the device it opens to a realpath under `/dev/dri/` + (`drm_reader.rs`); RustDesk always runs libdrmtap in direct in-process mode + (`helper_path` is `NULL`), so no privileged child process is ever spawned and + none is built, shipped, or installed. There is no `drmtap-helper` binary, no + `setcap`, no capability-bearing file, and no capture group in this deployment. +- The `_drm` socket lives beside the hardened `_service` socket + (`/tmp/-service/ipc_drm`). It is `0666` so the unprivileged `--server` + can connect, but every accepted peer is authorized in `handle_drm_conn` + (`authorize_service_scoped_ipc_connection`: peer must be root or the active + session uid, with a `/proc//exe` identity match). Connectable is not + authorized. + +## 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 scanout parse runs in the root service.** Moving the read in-process + removes the old `setcap` helper and its world-exec / DMA-BUF-fd-passing attack + surface, but it also means the pixel-format conversion / detile of an + untrusted framebuffer runs inside the `CAP_SYS_ADMIN` service, without a + seccomp cage around it. Mitigations: the device is realpath-gated to + `/dev/dri/`; the frame copy has format / stride / geometry and + integer-overflow guards (`drm_reader.rs`); non-32bpp scanouts are rejected + before the copy. +- **`_drm` is a screen-content channel.** It is authorized per connection (see + above); without that authz any local process could read the screen. There is + no fd passing and no shared memory — frames cross as plain bytes over the + authorized socket. + +## Deployment + +- **Off by default.** The `drm` feature is **not** in the default feature set and + is **not** enabled in standard release packages; the drm-off build is + byte-identical to upstream. Build it explicitly with + `python3 build.py --flutter --drm` (Linux only). +- **Separate opt-in package.** A `--drm` build ships as a distinctly named + `rustdesk-unattended-wayland` package (Conflicts/Replaces `rustdesk`), so + enabling consent-free capture is an explicit install choice. +- **Bundled library, no capabilities.** The package installs `libdrmtap.so.0` + under `/usr/lib/rustdesk/` and registers that directory with the dynamic + linker so the in-process `dlopen("libdrmtap.so.0")` resolves: + + ```bash + # /etc/ld.so.conf.d/rustdesk-unattended-wayland.conf contains /usr/lib/rustdesk + ldconfig + ``` + + There is no `setcap`, no `rustdesk-capture` group, and no privileged binary: + the capture runs inside the root `--service`, which already holds the + capability it needs. Hosts without `/dev/dri` access (or where the library + fails to load) transparently fall back to the PipeWire/portal path. +- **Minimum OS: Ubuntu 18.04 (or equivalent, libdrm ≥ 2.4.95).** `libdrmtap` needs the DRM + `GetFB2` framebuffer API (libdrm 2.4.95); Ubuntu 18.04 ships 2.4.101, so 18.04 is the floor. The + `rustdesk-unattended-wayland` deb is built and packaged on an ubuntu18.04 container in CI (a + build-time compatibility check only — DRM capture itself is not installed or exercised there), so + it is built against the 18.04 toolchain and libraries and is compatible with 18.04 and newer. + Capture also requires an active KMS scanout (a Wayland/KMS session with a display + on); on hosts where the compositor drives the display outside DRM/KMS (e.g. the proprietary NVIDIA + X11 stack) there is no capturable CRTC and the path falls back to PipeWire/portal. +- **Recommended for** single-user, physically-controlled, or unattended hosts. + +## Auditing + +```bash +# the bundled capture library — no capabilities are set on it +ls -l /usr/lib/rustdesk/libdrmtap.so.0 +cat /etc/ld.so.conf.d/rustdesk-unattended-wayland.conf # expect: /usr/lib/rustdesk +# confirm no privileged helper is present (there should be none) +getcap -r /usr/lib/rustdesk 2>/dev/null # expect: no output +``` diff --git a/build.py b/build.py index 957961857..e9e27db2d 100755 --- a/build.py +++ b/build.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import os +import glob import pathlib import platform import zipfile @@ -130,6 +131,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 (bundles libdrmtap.so, ' + 'dlopen-ed in-process by the root service). Off by default.' + ) parser.add_argument( '--skip-cargo', action='store_true', @@ -282,6 +289,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 +298,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() @@ -316,6 +332,98 @@ def ffi_bindgen_function_refactor(): 'sed -i "s/ffi.NativeFunction/dev/null || ldconfig 2>/dev/null || true\n' + 'fi\n' + ) + + +def finalize_deb(version, ships_so, so_basename=None): + # Shared deb finalization for build_flutter_deb / build_deb_from_folder. Any DRM .so is assumed + # already staged at tmpdeb/usr/lib/rustdesk/. For a DRM build this adds the soname symlink + the + # ld.so.conf.d drop-in, names the package rustdesk-unattended-wayland with libdrmtap's runtime + # deps (libdrm / EGL / GLESv2), and appends the ldconfig postinst; otherwise it builds the stock + # rustdesk package. Then it writes the control, checksums, builds, and renames the .deb. + if ships_so: + system2(f'ln -sf {so_basename} tmpdeb/usr/lib/rustdesk/libdrmtap.so.0') + system2('mkdir -p tmpdeb/etc/ld.so.conf.d') + with open('tmpdeb/etc/ld.so.conf.d/rustdesk-unattended-wayland.conf', 'w') as f: + f.write('/usr/lib/rustdesk\n') + package_name = 'rustdesk-unattended-wayland' if ships_so else 'rustdesk' + drm_depends = ", libdrm2, libegl1, libgles2" if ships_so else "" + system2('mkdir -p tmpdeb/DEBIAN') + generate_control_file(version, drm_depends, package_name) + system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') + if ships_so: + append_drm_ldconfig_postinst() + 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', f'../{package_name}-{version}.deb') + + def build_flutter_deb(version, features): if not skip_cargo: system2(f'cargo build --locked --features {features} --lib --release') @@ -352,16 +460,21 @@ 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") - - system2('mkdir -p tmpdeb/DEBIAN') - generate_control_file(version) - system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') - 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) + # Bundle libdrmtap.so for the DRM/KMS capture path — but ONLY when this build + # actually enabled the `drm` feature, so normal packages stay opt-out. The root + # service dlopen-s it in-process (no setcap helper); it lives in a private dir + # that postinst registers with ldconfig so dlopen("libdrmtap.so.0") resolves. + # Bundle libdrmtap.so for a DRM build (opt-in), then finalize the deb. A DRM build ships as a + # separately-named rustdesk-unattended-wayland package (finalize_deb marks it + # Conflicts/Replaces/Provides rustdesk), so installing it is an explicit choice. + ships_so = 'drm' in features + so_basename = None + if ships_so: + so_path = build_libdrmtap_so() + so_basename = os.path.basename(so_path) + system2('mkdir -p tmpdeb/usr/lib/rustdesk') + system2(f'cp {so_path} tmpdeb/usr/lib/rustdesk/') + finalize_deb(version, ships_so, so_basename) os.chdir("..") @@ -389,16 +502,19 @@ 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") - - system2('mkdir -p tmpdeb/DEBIAN') - generate_control_file(version) - system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') - 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) + # A staged bundle (binary_folder) carries its own libdrmtap.so.0* for a --drm build, so we do + # not rebuild it here; the `cp -r` above placed it under usr/share/rustdesk/. Move it to the + # private lib dir, then finalize the deb the same way build_flutter_deb does. + bundled_glob = glob.glob('tmpdeb/usr/share/rustdesk/libdrmtap.so.0.*') + ships_so = any(os.path.isfile(p) and not os.path.islink(p) for p in bundled_glob) + so_basename = None + if ships_so: + so = _single_real_so(bundled_glob, 'the staged --drm bundle') + so_basename = os.path.basename(so) + system2('mkdir -p tmpdeb/usr/lib/rustdesk') + system2(f'mv {so} tmpdeb/usr/lib/rustdesk/') + system2('rm -f tmpdeb/usr/share/rustdesk/libdrmtap.so tmpdeb/usr/share/rustdesk/libdrmtap.so.0') + finalize_deb(version, ships_so, so_basename) os.chdir("..") diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 0af7dfe0f..68a7ae4f3 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 = [] mediacodec = ["ndk"] linux-pkg-config = ["dep:pkg-config"] hwcodec = ["dep:hwcodec"] diff --git a/libs/scrap/src/common/drm_reader.rs b/libs/scrap/src/common/drm_reader.rs new file mode 100644 index 000000000..3d5d02548 --- /dev/null +++ b/libs/scrap/src/common/drm_reader.rs @@ -0,0 +1,351 @@ +// Service-side DRM/KMS read engine. Runs in the ROOT `--service`, which already +// holds CAP_SYS_ADMIN, so libdrmtap reads the scanout in-process (direct mode, +// no helper fork, no setcap). Loaded via the dlopen loader (drmtap_dl) so the +// main binary has no hard libdrm/EGL dependency. +// +// SECURITY (direct-mode mitigation): the scanout parse now runs in the root +// service with no seccomp cage, so we do NOT honor an untrusted device path. +// The caller passes either None (libdrmtap auto-detects /dev/dri/card* by a +// hardcoded pattern) or an explicit path that we realpath-gate to /dev/dri/ +// before opening. The DRM_DEVICE env is intentionally NOT consulted here. + +use super::drmtap_dl::{ + self, drmtap_config, drmtap_ctx, drmtap_cursor_info, drmtap_display, drmtap_frame_info, + DrmtapLib, +}; +use hbb_common::log; +use std::ffi::CString; +use std::io; + +// Largest scanout we will copy; also bounds w*4*h against overflow. 16384 covers +// 8K+ with headroom; anything larger is rejected as a bogus/hostile geometry. +const MAX_DIM: u32 = 16384; + +/// Sentinel cursor id published when the plane reports the cursor hidden, so the +/// id changes and the client drops the last shape. Distinct from any real hash. +pub const HIDDEN_CURSOR_ID: u64 = u64::MAX; + +/// A hardware-cursor snapshot to ship to the server (RGBA colors). +pub struct CursorSnapshot { + pub id: u64, + pub width: u32, + pub height: u32, + pub hotx: i32, + pub hoty: i32, + pub colors: Vec, +} + +/// One enumerated DRM display (physical geometry only; the server augments with +/// the Wayland logical geometry/scale, which needs the user session). +pub struct DisplaySnapshot { + pub name: String, + pub crtc_id: u32, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub active: bool, +} + +/// Returns true only if `path` canonicalizes to a node directly under /dev/dri/. +/// This is the realpath gate the libdrmtap helper applied but the in-process +/// (direct) path does not, so the service must apply it itself. +fn device_under_dev_dri(path: &str) -> bool { + match std::fs::canonicalize(path) { + Ok(p) => p.parent().map_or(false, |d| d == std::path::Path::new("/dev/dri")), + Err(_) => false, + } +} + +/// An open DRM read context. Not Send/Sync deliberately (the raw ctx is used on +/// one thread, like the old Capturer). +pub struct DrmReader { + lib: &'static DrmtapLib, + ctx: *mut drmtap_ctx, + // grow-once packed-BGRA scratch buffer (preallocated model): resized up to the + // frame size and never shrunk. + buf: Vec, +} + +impl DrmReader { + /// Open the DRM device. `device = None` auto-detects (safe); `Some(path)` is + /// realpath-gated to /dev/dri/. `crtc_id = 0` auto-selects the first active + /// CRTC (primary); a non-zero value targets that specific CRTC/display (from + /// `displays()`). Returns None if libdrmtap is unavailable (dlopen failed), + /// the device is not allowed, or the open failed — the caller then falls back + /// to PipeWire/portal. + pub fn open(device: Option<&str>, crtc_id: u32) -> Option { + let lib = drmtap_dl::get()?; + let device_cstr = match device { + None => None, + Some(d) => { + if !device_under_dev_dri(d) { + log::warn!("DRM device {d:?} is not under /dev/dri; refusing to open"); + return None; + } + match CString::new(d) { + Ok(c) => Some(c), + Err(_) => return None, // interior NUL + } + } + }; + let cfg = drmtap_config { + device_path: device_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()), + crtc_id, + helper_path: std::ptr::null(), + debug: 0, + }; + // SAFETY: cfg is a valid struct; device_cstr outlives this call. + let ctx = unsafe { (lib.open)(&cfg) }; + drop(device_cstr); + if ctx.is_null() { + log::info!("drmtap_open failed; DRM capture unavailable"); + return None; + } + Some(DrmReader { + lib, + ctx, + buf: Vec::new(), + }) + } + + /// Grab one frame and copy it, tightly packed as BGRA (`w*4*h` bytes), into + /// the internal buffer. Returns (width, height). The returned slice is valid + /// until the next grab. A non-32bpp scanout, an oversized/degenerate + /// geometry, or a stride < w*4 is rejected with a hard error so the caller + /// falls back to PipeWire (see the codex format finding). Errno failures map + /// to WouldBlock (retry) or a hard error (tear down) as in the old path. + pub fn grab(&mut self) -> io::Result<(&[u8], usize, usize)> { + // SAFETY: self.ctx is a valid context; frame is zeroed before the call + // and released on every path. + unsafe { + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = (self.lib.grab_mapped)(self.ctx, &mut frame); + if ret < 0 { + let errno = -ret; + // Transient contention (compositor mid page-flip, device momentarily + // busy, interrupted syscall) -> retry rather than tear the stream down. + if errno == hbb_common::libc::EAGAIN + || errno == hbb_common::libc::EBUSY + || errno == hbb_common::libc::EINTR + { + return Err(io::ErrorKind::WouldBlock.into()); + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_grab_mapped failed: errno {errno}"), + )); + } + if frame.data.is_null() || frame.width == 0 || frame.height == 0 { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::ErrorKind::WouldBlock.into()); + } + let w = frame.width; + let h = frame.height; + let stride = frame.stride as usize; + // 4-bytes-per-pixel-per-row invariant: the row copy reads w*4 bytes + // from a source that is only stride*height bytes. Reject sub-32bpp / + // insane geometry to avoid an OOB read (heap disclosure to the peer). + if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 { + log::warn!( + "DRM scanout not 32-bit BGRA-compatible ({w}x{h} stride {stride} fourcc {:#010x}); falling back", + frame.format + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "unsupported DRM scanout format", + )); + } + // Byte-order guard: libdrmtap normalizes the scanout to a BGRA-compatible 32-bit layout + // (XRGB/ARGB8888 = little-endian B,G,R,{X,A} in memory). A different 32-bit order such as + // XBGR8888 passes the stride check above but, labeled BGRA downstream, would ship with red + // and blue swapped — so reject any fourcc we cannot present as BGRA. A zero/unknown fourcc + // falls through to the stride invariant (kept for libdrmtap builds that do not set it). + const DRM_FORMAT_XRGB8888: u32 = 0x3432_5258; // 'XR24' + const DRM_FORMAT_ARGB8888: u32 = 0x3432_5241; // 'AR24' + if frame.format != 0 + && frame.format != DRM_FORMAT_XRGB8888 + && frame.format != DRM_FORMAT_ARGB8888 + { + log::warn!( + "DRM scanout fourcc {:#010x} is not BGRA-compatible; falling back", + frame.format + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "unsupported DRM scanout format", + )); + } + let (w, h) = (w as usize, h as usize); + // Bound the reusable buffer: a malformed or hostile scanout geometry (e.g. 16384x16384) + // would otherwise resize to gigabytes and, with several concurrent readers, OOM the root + // --service. 256 MiB covers an 8K BGRA scanout (7680x4320x4 ~= 127 MiB) with margin; + // anything larger (or an overflow) is rejected as unsupported. checked_mul guards the + // multiply on 32-bit usize too. + const MAX_FRAME_BYTES: usize = 256 * 1024 * 1024; + let frame_size = match w.checked_mul(4).and_then(|x| x.checked_mul(h)) { + Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz, + other => { + log::warn!( + "DRM scanout geometry {w}x{h} yields an out-of-range frame ({other:?} bytes); falling back" + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "DRM scanout frame too large", + )); + } + }; + if self.buf.len() != frame_size { + self.buf.resize(frame_size, 0); + } + let src = frame.data as *const u8; + let dst = self.buf.as_mut_ptr(); + if stride == w * 4 { + std::ptr::copy_nonoverlapping(src, dst, frame_size); + } else { + for y in 0..h { + std::ptr::copy_nonoverlapping(src.add(y * stride), dst.add(y * w * 4), w * 4); + } + } + (self.lib.frame_release)(self.ctx, &mut frame); + Ok((&self.buf, w, h)) + } + } + + /// Read the hardware cursor plane. Returns a hidden sentinel when the plane + /// reports the cursor invisible, the real shape when visible, or None on a + /// read error / unsupported cursor. Ported from the old drm.rs update_cursor. + pub fn cursor(&mut self) -> Option { + // SAFETY: ctx valid; c zeroed before the call; released only on success. + unsafe { + let mut c: drmtap_cursor_info = std::mem::zeroed(); + let cret = (self.lib.get_cursor)(self.ctx, &mut c); + if cret != 0 { + return None; + } + let out = if c.visible == 0 { + Some(CursorSnapshot { + id: HIDDEN_CURSOR_ID, + width: 1, + height: 1, + hotx: 0, + hoty: 0, + colors: vec![0, 0, 0, 0], + }) + } else if !c.pixels.is_null() + && c.width > 0 + && c.height > 0 + && (c.width as i64) * (c.height as i64) <= 256 * 256 + { + let cw = c.width as i32; + let ch = c.height as i32; + let n = (cw * ch) as usize; + let src = std::slice::from_raw_parts(c.pixels, n); + let mut hash: u64 = 1469598103934665603; + let mut colors = Vec::with_capacity(n * 4); + let (mut minx, mut miny, mut maxx, mut maxy) = (cw, ch, -1i32, -1i32); + for (i, &p) in src.iter().enumerate() { + let a = ((p >> 24) & 0xff) as u8; + let r = ((p >> 16) & 0xff) as u8; + let g = ((p >> 8) & 0xff) as u8; + let b = (p & 0xff) as u8; + colors.push(r); + colors.push(g); + colors.push(b); + colors.push(a); + hash ^= p as u64; + hash = hash.wrapping_mul(1099511628211); + if a >= 128 { + let x = (i as i32) % cw; + let y = (i as i32) / cw; + if x < minx { minx = x; } + if x > maxx { maxx = x; } + if y < miny { miny = y; } + if y > maxy { maxy = y; } + } + } + let (hotx, hoty) = if c.hot_x != 0 || c.hot_y != 0 { + (c.hot_x, c.hot_y) + } else if maxx >= minx && maxy >= miny { + let (bw, bh) = (maxx - minx + 1, maxy - miny + 1); + if bh > bw * 2 { + ((minx + maxx) / 2, (miny + maxy) / 2) + } else { + (minx, miny) + } + } else { + (0, 0) + }; + // Fold geometry + hotspot into the id: a cursor with identical pixels but a changed + // size or hotspot must count as a new shape, otherwise drm_capture_worker suppresses + // the update (it dedupes by id) and the client keeps rendering the stale cursor. + let mut id = hash; + for v in [cw as u32 as u64, ch as u32 as u64, hotx as u32 as u64, hoty as u32 as u64] { + id ^= v; + id = id.wrapping_mul(1099511628211); + } + Some(CursorSnapshot { + id, + width: cw as u32, + height: ch as u32, + hotx, + hoty, + colors, + }) + } else { + None + }; + (self.lib.cursor_release)(self.ctx, &mut c); + out + } + } + + /// Enumerate the connected DRM displays (physical geometry). The buffer holds + /// up to 16 connectors (the old path truncated at 8); the raw list is shipped + /// to the server, which does primary selection + Wayland logical geometry. + pub fn displays(&mut self) -> Vec { + // SAFETY: ctx valid; raw is a zeroed, correctly-sized array; count is + // clamped to the buffer before indexing. + unsafe { + let mut raw = vec![std::mem::zeroed::(); 16]; + let cap = raw.len() as i32; + let n = (self.lib.list_displays)(self.ctx, raw.as_mut_ptr(), cap); + if n <= 0 { + return Vec::new(); + } + let count = (n as usize).min(raw.len()); + (0..count) + .map(|i| { + let name_bytes: Vec = raw[i] + .name + .iter() + .take_while(|&&ch| ch != 0) + .map(|&ch| ch as u8) + .collect(); + DisplaySnapshot { + name: String::from_utf8_lossy(&name_bytes).to_string(), + crtc_id: raw[i].crtc_id, + x: raw[i].x as i32, + y: raw[i].y as i32, + width: raw[i].width, + height: raw[i].height, + active: raw[i].active != 0, + } + }) + .collect() + } + } +} + +impl Drop for DrmReader { + fn drop(&mut self) { + if !self.ctx.is_null() { + // SAFETY: ctx came from drmtap_open and is non-null. + unsafe { (self.lib.close)(self.ctx) }; + self.ctx = std::ptr::null_mut(); + } + } +} diff --git a/libs/scrap/src/common/drmtap_dl.rs b/libs/scrap/src/common/drmtap_dl.rs new file mode 100644 index 000000000..8d052ee98 --- /dev/null +++ b/libs/scrap/src/common/drmtap_dl.rs @@ -0,0 +1,183 @@ +// Runtime loader for libdrmtap.so (the DRM/KMS capture engine), loaded via +// dlopen instead of static-linked. This keeps the main rustdesk binary free of +// hard libdrm/libEGL/libGLESv2 dependencies: the .so is only opened when the +// drm capture path is actually used, and if it (or one of its deps) is missing +// the load fails cleanly and the caller falls back to PipeWire/portal. The .so +// is shipped only in the opt-in unattended-wayland package. +// +// The privileged read runs in-process in whatever process opens it. When that +// process already holds CAP_SYS_ADMIN (the root --service) libdrmtap reads the +// scanout directly, without forking the setcap helper (see do_grab() in the C). +// +// Mirrors the graceful-load pattern of libs/libxdo-sys-stub. + +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 / libdrmtap-sys) ---- + +#[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 a helper is needed (never, when root) + 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)] +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, +} + +#[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 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); + +/// The dlopen'd libdrmtap with its resolved entry points. The `Library` is kept +/// alive for the process lifetime (this lives in a `OnceLock`), so the raw fn +/// pointers stay valid. +pub struct DrmtapLib { + _lib: Library, + pub open: FnOpen, + pub close: FnClose, + pub list_displays: FnListDisplays, + pub grab_mapped: FnGrabMapped, + pub frame_release: FnFrameRelease, + pub get_cursor: FnGetCursor, + pub cursor_release: FnCursorRelease, +} + +// 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. Matches how libxdo-sys-stub treats XdoLib. +unsafe impl Send for DrmtapLib {} +unsafe impl Sync for DrmtapLib {} + +// The #[repr(C)] struct layouts above track libdrmtap's ABI *major* version, +// which in turn tracks the `.so.0` soname. drmtap_version() packs the semver as +// (major << 16) | (minor << 8) | patch. A major mismatch means the structs may +// be laid out differently, so we refuse the library rather than read through a +// mismatched layout. Minor/patch bumps are additive and remain compatible. +const DRMTAP_ABI_MAJOR: c_int = 0; + +impl DrmtapLib { + fn load() -> Option { + // soname first (what a packaged .so installs), then the dev symlink. + const LIB_NAMES: [&str; 2] = ["libdrmtap.so.0", "libdrmtap.so"]; + unsafe { + let (lib, name) = LIB_NAMES + .iter() + .find_map(|n| Library::new(n).ok().map(|l| (l, *n)))?; + // every symbol is required; a missing one means an incompatible .so, + // so bail to None and let the caller fall back to PipeWire. + let version: FnVersion = *lib.get(b"drmtap_version").ok()?; + // Call it once at load time: this smoke-checks that the .so responds + // through the resolved entry point *and* lets us reject a rebuilt + // library whose ABI (struct layout) no longer matches the #[repr(C)] + // definitions above. Resolving symbols alone would not catch that. + let v = version(); + let (major, minor, patch) = ((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff); + if major != DRMTAP_ABI_MAJOR { + log::warn!( + "libdrmtap {name} reports ABI major {major} (v{major}.{minor}.{patch}), \ + expected {DRMTAP_ABI_MAJOR}; refusing to load to avoid struct-layout \ + mismatch (falling back to PipeWire/portal)" + ); + return None; + } + log::info!("libdrmtap loaded: {name} (v{major}.{minor}.{patch})"); + 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 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()?; + Some(DrmtapLib { + _lib: lib, + open, + close, + list_displays, + grab_mapped, + frame_release, + get_cursor, + cursor_release, + }) + } + } +} + +static DRMTAP_LIB: OnceLock> = OnceLock::new(); + +/// Returns the loaded libdrmtap, or None if the .so (or one of its runtime deps) +/// is not present. Loaded once; a failure is remembered (no repeated dlopen). +pub fn get() -> Option<&'static DrmtapLib> { + DRMTAP_LIB + .get_or_init(|| { + let lib = DrmtapLib::load(); + if lib.is_none() { + log::info!("libdrmtap not available (dlopen failed); DRM capture disabled"); + } + lib + }) + .as_ref() +} diff --git a/libs/scrap/src/common/mod.rs b/libs/scrap/src/common/mod.rs index 2d74caa0d..edfe52cee 100644 --- a/libs/scrap/src/common/mod.rs +++ b/libs/scrap/src/common/mod.rs @@ -16,6 +16,10 @@ cfg_if! { mod linux; mod wayland; mod x11; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drmtap_dl; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drm_reader; pub use self::linux::*; pub use self::wayland::set_map_err; pub use self::x11::PixelBuffer; diff --git a/src/ipc.rs b/src/ipc.rs index 4498ceb5f..35f3b8955 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -481,6 +481,47 @@ pub enum Data { ControlPermissionsRemoteModify(Option), #[cfg(target_os = "windows")] FileTransferEnabledState(Option), + // --- DRM/KMS capture (opt-in `drm` feature) over the `_drm` service-scoped channel --- + // All of the following are `cfg(all(linux, drm))`, so the drm-off IPC wire is byte-identical + // to upstream. Protocol on `_drm`: on connect the root service sends `DrmDisplayList`, the + // client replies `DrmStart{display}`, then the service streams `DrmFrame` + send_raw(BGRA) and + // `DrmCursor` + send_raw(RGBA). A frame/cursor header is ALWAYS immediately followed by exactly + // one `send_raw()` payload (the same header-then-raw pairing as `FileBlockFromCM`). This keeps + // the header extensible: a future zero-copy `DrmFrameDmabuf { fd, stride, modifier, .. }` slots + // in as a sibling variant without changing the transport. + /// Client -> service: begin streaming the chosen display. + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmStart { display: i32 }, + /// Service -> client: the enumerated DRM displays (sent once, before frames). + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmDisplayList(Vec), + /// Service -> client: a frame header; the packed BGRA pixels follow via `send_raw()`. + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmFrame { width: u32, height: u32 }, + /// Service -> client: a hardware-cursor header; the RGBA pixels follow via `send_raw()`. + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmCursor { + id: u64, + width: u32, + height: u32, + hotx: i32, + hoty: i32, + }, +} + +/// One enumerated DRM display shipped over `_drm` (physical geometry). The serializable IPC +/// form of `scrap::drm_reader::DisplaySnapshot`; the server augments it with the Wayland +/// logical geometry/scale, which needs the user session. +#[cfg(all(target_os = "linux", feature = "drm"))] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DrmDisplayInfo { + pub name: String, + pub crtc_id: u32, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub active: bool, } #[tokio::main(flavor = "current_thread")] @@ -1448,6 +1489,443 @@ pub async fn start_pa() { } } +/// Filesystem path of the `_drm` capture socket. It lives beside the hardened `_service` socket in +/// the shared `/tmp/-service` directory (cross-uid, traversable) so the root `--service` and +/// the user `--server` share one uid-independent path. Derived from the real `_service` path so we +/// inherit hbb_common's directory convention WITHOUT teaching hbb_common about a drm-specific +/// postfix (keeps the isolation clean: no shared-lib change). Both ends call this. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) fn drm_ipc_path() -> String { + let service_path = Config::ipc_path("_service"); + let dir = std::path::Path::new(&service_path) + .parent() + .unwrap_or_else(|| std::path::Path::new("/tmp")); + dir.join("ipc_drm").to_string_lossy().into_owned() +} + +/// Connect (from the user `--server`) to the root service's `_drm` capture channel. Uses the +/// derived `drm_ipc_path()` rather than `Config::ipc_path` since `_drm` is not a hbb_common +/// service postfix (Option 2 isolation — no shared-lib change). +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) async fn connect_drm(ms_timeout: u64) -> ResultType> { + connect_with_path(ms_timeout, &drm_ipc_path()).await +} + +/// Bind the `_drm` listener. Unlike `new_listener`, this does not route through hbb_common's +/// service-postfix machinery — it places the socket in the shared service dir directly, so the +/// drm-off build needs no hbb_common change. The socket is 0666 (world-connectable) so the +/// unprivileged `--server` can reach it; every accepted peer is still authorized in +/// `handle_drm_conn` (root or the active session uid + exe identity), so connectable != authorized. +#[cfg(all(target_os = "linux", feature = "drm"))] +async fn new_drm_listener() -> ResultType { + let path = drm_ipc_path(); + // Ensure the shared service dir exists at its hardened (0711) mode. Passing the `_service` + // postfix reuses hbb_common's expected mode for that directory; it only creates/chmods the + // directory (no pid/socket side effects) and is idempotent with the real `_service` listener. + let _ = ensure_secure_ipc_parent_dir(&path, "_service")?; + // Clear any stale socket from a previous run before binding. + std::fs::remove_file(&path).ok(); + let mut endpoint = Endpoint::new(path.clone()); + endpoint.set_security_attributes(SecurityAttributes::allow_everyone_create()?); + let incoming = endpoint.incoming()?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).map_err(|err| { + std::fs::remove_file(&path).ok(); + err + })?; + log::info!("Started drm ipc server at path: {}", &path); + Ok(incoming) +} + +/// Message from a per-connection DRM worker thread (which owns the `!Send` `DrmReader`) to its +/// async socket task. The worker does the blocking device I/O; the task only forwards to the wire. +#[cfg(all(target_os = "linux", feature = "drm"))] +enum DrmProducerMsg { + /// Enumerated displays, sent once before any frame so the task can answer the handshake. + Displays(Vec), + /// A captured frame header + its packed BGRA pixels. + Frame { + width: u32, + height: u32, + data: Bytes, + }, + /// A changed hardware-cursor shape + its packed RGBA pixels. + Cursor { + id: u64, + width: u32, + height: u32, + hotx: i32, + hoty: i32, + colors: Vec, + }, +} + +/// Sets the shared stop flag when the async task ends (any path), so the blocking worker thread +/// terminates promptly even while it is between channel sends (e.g. spinning on WouldBlock). +#[cfg(all(target_os = "linux", feature = "drm"))] +struct DrmStopGuard(std::sync::Arc); +#[cfg(all(target_os = "linux", feature = "drm"))] +impl Drop for DrmStopGuard { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::Relaxed); + } +} + +/// Cached DRM display enumeration. The pre-warm populates it and each capture open refreshes it, so +/// a consumer's handshake can send the display list without first paying a DRM enumeration open. +#[cfg(all(target_os = "linux", feature = "drm"))] +static DRM_DISPLAY_CACHE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Snapshot a reader's enumerated displays as the IPC `DrmDisplayInfo` form. `displays()` lists all +/// device outputs regardless of the reader's target CRTC, so a capture reader can refresh the cache. +#[cfg(all(target_os = "linux", feature = "drm"))] +fn drm_displays_from_reader(reader: &mut scrap::drm_reader::DrmReader) -> Vec { + reader + .displays() + .into_iter() + // Only offer outputs actually bound to a CRTC (i.e. scanning out). A + // CONNECTED-but-unbound connector (e.g. a virtual/dummy HDMI plug the + // compositor is not driving) enumerates with `crtc_id == 0`. Such an + // entry has no scanout to capture, yet was still shipped to the client as + // a selectable monitor; picking it made libdrmtap's `open(crtc=0)` + // AUTO-SELECT the first active CRTC (the primary) and stream ITS frames at + // the wrong geometry (e.g. a 3840x2160 frame into a 1280x1024 encoder -> + // `src rect > dst rect`), which failed every frame and drove a ~1/sec + // capturer restart loop (the flap that leaked EGL contexts to OOM). Drop + // these here so they are never offered; the client keeps its real monitors. + .filter(|d| d.active && d.crtc_id != 0) + .map(|d| DrmDisplayInfo { + name: d.name, + crtc_id: d.crtc_id, + x: d.x, + y: d.y, + width: d.width, + height: d.height, + active: d.active, + }) + .collect() +} + +/// Best-effort warm-up at listener start: loads libdrmtap, initializes EGL, enumerates displays into +/// the cache, and maps the first framebuffer once. Moves that one-time cost (which otherwise lands +/// on the first consumer and can push the first frame past the client's initial-frame timeout) off +/// the critical path. Runs on its own thread since `DrmReader` is `!Send` and `open`/`grab` block. +#[cfg(all(target_os = "linux", feature = "drm"))] +fn drm_prewarm() { + let t = std::time::Instant::now(); + match scrap::drm_reader::DrmReader::open(None, 0) { + Some(mut r) => { + let displays = drm_displays_from_reader(&mut r); + let n = displays.len(); + let _ = r.grab(); // force the first framebuffer map / import + *DRM_DISPLAY_CACHE.lock().unwrap() = displays; + log::info!("drm: pre-warm ok ({n} displays) in {:?}", t.elapsed()); + } + None => log::info!("drm: pre-warm skipped (reader unavailable)"), + } +} + +/// DRM/KMS capture producer. Runs in the ROOT `--service` (which holds CAP_SYS_ADMIN, so libdrmtap +/// reads the scanout in-process — no helper, no setcap). One dedicated `current_thread` runtime +/// owns the `_drm` listener and `tokio::spawn`s a task per accepted consumer, so a multi-monitor +/// client (which opens one `_drm` connection per captured display) is served CONCURRENTLY instead +/// of serially. The `!Send` `DrmReader` never runs on this runtime: each connection offloads its +/// blocking `grab()` loop to a private std worker thread (see `handle_drm_conn`), which keeps the +/// connection future `Send` (thus spawnable) and lets the tasks multiplex on the one listener +/// thread while the workers capture in parallel. +#[cfg(all(target_os = "linux", feature = "drm"))] +#[tokio::main(flavor = "current_thread")] +pub async fn start_drm() { + match new_drm_listener().await { + Ok(mut incoming) => { + // Warm libdrmtap/EGL + enumeration off-thread so the first consumer does not pay that + // one-time cost on its critical path. + std::thread::spawn(drm_prewarm); + loop { + match incoming.next().await { + Some(Ok(stream)) => { + tokio::spawn(async move { + if let Err(err) = handle_drm_conn(Connection::new(stream)).await { + log::info!("drm ipc connection ended: {}", err); + } + }); + } + Some(Err(err)) => log::error!("Couldn't get drm client: {:?}", err), + // Stream exhausted: without this the `if let Some` form would re-poll the dead + // stream forever and busy-spin the root service. Stop the producer instead. + None => { + log::error!("drm ipc listener stream ended; stopping drm producer"); + break; + } + } + } + } + Err(err) => { + log::error!("Failed to start drm ipc server: {}", err); + } + } +} + +/// Handle one `_drm` consumer. `DrmReader` is `!Send` and `grab()` is a blocking C call, so it +/// cannot live on the shared listener runtime; this task spawns a private std worker thread that +/// owns the reader (`drm_capture_worker`) and streams `DrmProducerMsg`s back over a bounded channel +/// (capacity 2 = backpressure: a slow consumer throttles capture instead of growing memory). The +/// task itself stays fully async — hence `Send`, hence `tokio::spawn`able — and only forwards +/// messages to the wire. On any error / disconnect it returns; the `DrmStopGuard` plus dropping the +/// channels tears the worker down, and the client falls back to PipeWire/portal. +#[cfg(all(target_os = "linux", feature = "drm"))] +async fn handle_drm_conn(mut stream: Connection) -> ResultType<()> { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + + // The `_drm` socket is world-connectable (0666) so the unprivileged `--server` can reach it, + // so we MUST authorize the peer here — this is a dedicated listener that does not go through + // the generic `start()` accept loop where service-scoped channels are checked. Same policy as + // `_service`: peer must be root or the active session uid, with a `/proc/pid/exe` identity + // match. Without this any local process could connect and receive the screen contents. + if !authorize_service_scoped_ipc_connection(&stream, "_drm") { + log::warn!("drm: rejected unauthorized connection to _drm"); + return Ok(()); + } + + // Admission bound: each accepted _drm consumer spawns a worker thread that opens a DRM context. + // The peer is authorized (root/active-session), but we still cap concurrency so a buggy or + // compromised --server cannot exhaust root-service threads/memory by opening an unbounded number + // of streams. One connection per served display is plenty; MAX_DRM_CONNS covers multi-monitor + // plus a little slack for a reconnect overlapping an old worker still tearing down. + const MAX_DRM_CONNS: usize = 8; + static DRM_CONN_COUNT: AtomicUsize = AtomicUsize::new(0); + struct DrmConnGuard; + impl Drop for DrmConnGuard { + fn drop(&mut self) { + DRM_CONN_COUNT.fetch_sub(1, Ordering::SeqCst); + } + } + if DRM_CONN_COUNT.fetch_add(1, Ordering::SeqCst) >= MAX_DRM_CONNS { + DRM_CONN_COUNT.fetch_sub(1, Ordering::SeqCst); + log::warn!("drm: too many concurrent _drm connections (>= {MAX_DRM_CONNS}); rejecting"); + return Ok(()); + } + let _conn_guard = DrmConnGuard; + + // worker -> task: display list, frames, cursor (bounded = backpressure). + let (frame_tx, mut frame_rx) = tokio::sync::mpsc::channel::(2); + // task -> worker: the chosen CRTC, sent once after the client's DrmStart. + let (crtc_tx, crtc_rx) = std::sync::mpsc::channel::(); + let stop = Arc::new(AtomicBool::new(false)); + let _stop_guard = DrmStopGuard(stop.clone()); + let worker_stop = stop.clone(); + std::thread::spawn(move || drm_capture_worker(frame_tx, crtc_rx, worker_stop)); + + // Handshake: the worker sends the display list (from the pre-warmed cache, or a throwaway + // enumeration open if the cache is empty). A closed channel (no Displays) means the reader was + // unavailable, so let the client fall back. + let displays = match frame_rx.recv().await { + Some(DrmProducerMsg::Displays(d)) => d, + _ => { + log::info!("drm: reader unavailable; closing _drm connection (client falls back)"); + return Ok(()); + } + }; + stream.send(&Data::DrmDisplayList(displays.clone())).await?; + + // Wait for the client to choose a display before streaming. + let display_idx = loop { + match stream.next_timeout(10_000).await? { + Some(Data::DrmStart { display }) => break display, + Some(_) => continue, + None => return Ok(()), + } + }; + // Resolve the chosen display's CRTC. `displays` here is already filtered to + // CRTC-bound outputs (see drm_displays_from_reader), so a valid selection + // always yields a non-zero crtc_id. Reject a 0 (out-of-range index, or an + // unbound display that somehow slipped through) rather than passing it to + // `open(crtc=0)`, whose "auto-select the first/primary CRTC" sentinel would + // silently stream the WRONG monitor at a mismatched geometry and flap the + // capturer. Closing lets the consumer fall back (PipeWire) for that display. + let target_crtc = usize::try_from(display_idx) + .ok() + .and_then(|i| displays.get(i)) + .map(|d| d.crtc_id) + .unwrap_or(0); + if target_crtc == 0 { + log::warn!( + "drm: client selected display {display_idx} with no bound CRTC; closing _drm (client falls back)" + ); + return Ok(()); + } + // Hand the CRTC to the worker; an error means it already gave up (reader vanished). + if crtc_tx.send(target_crtc).is_err() { + return Ok(()); + } + + // Forward frames + cursor updates until the worker ends or the client disconnects (a wire send + // error on a dropped client propagates out and tears the worker down via the guard). + while let Some(msg) = frame_rx.recv().await { + match msg { + DrmProducerMsg::Frame { + width, + height, + data, + } => { + stream.send(&Data::DrmFrame { width, height }).await?; + stream.send_raw(data).await?; + } + DrmProducerMsg::Cursor { + id, + width, + height, + hotx, + hoty, + colors, + } => { + stream + .send(&Data::DrmCursor { + id, + width, + height, + hotx, + hoty, + }) + .await?; + stream.send_raw(Bytes::from(colors)).await?; + } + DrmProducerMsg::Displays(_) => {} + } + } + Ok(()) +} + +/// The blocking half of a `_drm` connection: owns the `!Send` `DrmReader`(s) on its own thread and +/// streams messages to the async task. Ends (thread exits, reader closes) when the device is +/// unavailable, errors/stalls, or the task drops the channels / sets the stop flag. +#[cfg(all(target_os = "linux", feature = "drm"))] +fn drm_capture_worker( + frame_tx: tokio::sync::mpsc::Sender, + crtc_rx: std::sync::mpsc::Receiver, + stop: std::sync::Arc, +) { + use std::sync::atomic::Ordering; + use std::time::Duration; + // ~30 fps producer ceiling; the consumer's encoder/QoS sets the effective rate and the bounded + // channel throttles us further if it is slower. Also avoids a busy-spin when `grab()` returns + // the same scanout repeatedly. + const FRAME_INTERVAL: Duration = Duration::from_millis(33); + // Bound continuous no-frame (WouldBlock) time so a wedged device ends the stream (~5s) instead + // of freezing forever; the client then falls back. + const MAX_STALLED: u32 = 150; + + let t_conn = std::time::Instant::now(); + + // Send the display list. Prefer the pre-warmed cache (skips a per-connection enumeration open); + // fall back to a throwaway enumeration reader if the pre-warm has not populated it yet. + let displays = { + let cached = DRM_DISPLAY_CACHE.lock().unwrap().clone(); + if !cached.is_empty() { + cached + } else { + let mut enum_reader = match scrap::drm_reader::DrmReader::open(None, 0) { + Some(r) => r, + None => return, + }; + drm_displays_from_reader(&mut enum_reader) + } + }; + if frame_tx + .blocking_send(DrmProducerMsg::Displays(displays)) + .is_err() + { + return; + } + + // Wait for the task to relay the client's chosen CRTC (Err => the task gave up / disconnected). + let target_crtc = match crtc_rx.recv() { + Ok(c) => c, + Err(_) => return, + }; + let t_open = std::time::Instant::now(); + let mut reader = match scrap::drm_reader::DrmReader::open(None, target_crtc) { + Some(r) => r, + None => { + log::warn!("drm: failed to open crtc {target_crtc}; closing _drm connection"); + // The cached display list handed out a CRTC that no longer opens (a hotplug/modeset + // likely invalidated it). Drop the cache so the next connection re-enumerates from the + // live device instead of serving the same stale, unopenable CRTC on every reconnect. + DRM_DISPLAY_CACHE.lock().unwrap().clear(); + return; + } + }; + // Refresh the cache from the live device so the next consumer's handshake uses fresh geometry. + *DRM_DISPLAY_CACHE.lock().unwrap() = drm_displays_from_reader(&mut reader); + log::debug!( + "drm: capture reader for crtc {target_crtc} opened in {:?}", + t_open.elapsed() + ); + + let mut last_cursor_id: u64 = 0; + let mut stalled: u32 = 0; + let mut logged_first = false; + while !stop.load(Ordering::Relaxed) { + match reader.grab() { + Ok((buf, w, h)) => { + stalled = 0; + if !logged_first { + logged_first = true; + log::debug!( + "drm: first frame {w}x{h} for crtc {target_crtc} in {:?}", + t_conn.elapsed() + ); + } + if frame_tx + .blocking_send(DrmProducerMsg::Frame { + width: w as u32, + height: h as u32, + data: Bytes::copy_from_slice(buf), + }) + .is_err() + { + break; + } + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + stalled += 1; + if stalled > MAX_STALLED { + log::info!("drm: capture stalled (no frame); closing _drm connection"); + break; + } + std::thread::sleep(FRAME_INTERVAL); + continue; + } + Err(err) => { + log::warn!("drm: capture error: {err}; closing _drm connection"); + break; + } + } + + // Ship the cursor shape only when it changes (id is a content hash or the hidden sentinel). + if let Some(c) = reader.cursor() { + if c.id != last_cursor_id { + last_cursor_id = c.id; + if frame_tx + .blocking_send(DrmProducerMsg::Cursor { + id: c.id, + width: c.width, + height: c.height, + hotx: c.hotx, + hoty: c.hoty, + colors: c.colors, + }) + .is_err() + { + break; + } + } + } + + std::thread::sleep(FRAME_INTERVAL); + } +} + pub struct ConnectionTmpl { inner: Framed, } diff --git a/src/platform/linux.rs b/src/platform/linux.rs index ab6b1879b..4615489db 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -361,6 +361,13 @@ pub fn get_focused_display(displays: Vec) -> Option { } pub fn get_cursor() -> ResultType> { + // DRM/KMS capture: the hardware cursor arrives over the `_drm` stream, not from XFixes. + #[cfg(feature = "drm")] + if !is_x11() { + if let Some(id) = crate::server::drm_capturer::drm_cursor_id() { + return Ok(Some(id)); + } + } let mut res = None; DISPLAY.with(|conn| { if let Ok(d) = conn.try_borrow_mut() { @@ -379,6 +386,22 @@ pub fn get_cursor() -> ResultType> { } pub fn get_cursor_data(hcursor: u64) -> ResultType { + // DRM/KMS capture: return the latest hardware-cursor snapshot from the `_drm` stream. Its id may + // have advanced past `hcursor` between get_cursor() and here, so return the latest rather than + // bailing (which would trigger a MouseCursorService backoff). + #[cfg(feature = "drm")] + if !is_x11() { + if let Some(c) = crate::server::drm_capturer::drm_cursor() { + let mut cd: CursorData = Default::default(); + cd.id = c.id; + 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() { @@ -810,6 +833,15 @@ pub fn start_os_service() { allow_err!(crate::ipc::start(crate::POSTFIX_SERVICE)); }); + // DRM/KMS capture producer (opt-in `drm` feature): a dedicated thread + runtime that streams + // scanout frames to the user `--server` over the `_drm` service-scoped channel. Runs here + // because this process is the root service that already holds CAP_SYS_ADMIN for the in-process + // (direct-mode) libdrmtap read. + #[cfg(feature = "drm")] + std::thread::spawn(|| { + crate::ipc::start_drm(); + }); + let running = Arc::new(AtomicBool::new(true)); let r = running.clone(); let (mut display, mut xauth): (String, String) = ("".to_owned(), "".to_owned()); diff --git a/src/server.rs b/src/server.rs index f02a15a7f..465217abd 100644 --- a/src/server.rs +++ b/src/server.rs @@ -44,6 +44,8 @@ mod clipboard_service; pub use clipboard_service::is_clipboard_service_ok; #[cfg(target_os = "linux")] pub(crate) mod wayland; +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) mod drm_capturer; #[cfg(target_os = "linux")] pub mod uinput; #[cfg(target_os = "linux")] @@ -599,6 +601,10 @@ pub async fn start_server(is_server: bool, no_server: bool) { std::process::exit(-1); } }); + // Warm the DRM availability cache before any client connects, so the first connection does + // not race a cold `_drm` probe and ship an empty display list ("No displays" + retry). + #[cfg(all(target_os = "linux", feature = "drm"))] + std::thread::spawn(drm_capturer::warm_availability); input_service::fix_key_down_timeout_loop(); #[cfg(target_os = "linux")] if input_service::wayland_use_uinput() { diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 8531076a9..33bdad096 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -328,6 +328,16 @@ fn check_get_displays_changed_msg() -> Option { #[cfg(target_os = "linux")] { if !is_x11() { + // On the DRM/KMS capture path the PipeWire enumeration (which is what feeds + // `SYNC_DISPLAYS` via `check_update_displays`) is bypassed, so populate the sync list + // from the DRM display list here. Without this the display service broadcasts an empty + // list that overwrites the login peer-info displays and the client shows "No displays". + #[cfg(feature = "drm")] + if super::drm_capturer::is_available() { + if let Some(displays) = super::drm_capturer::get_display_infos() { + SYNC_DISPLAYS.lock().unwrap().check_changed(&displays); + } + } return get_displays_msg(); } } @@ -535,6 +545,7 @@ pub fn get_primary_2(all: &Vec) -> usize { all.iter().position(|d| d.is_primary()).unwrap_or(0) } + #[inline] #[cfg(windows)] fn no_displays(displays: &Vec) -> bool { diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs new file mode 100644 index 000000000..04d80e1c7 --- /dev/null +++ b/src/server/drm_capturer.rs @@ -0,0 +1,680 @@ +// Server-side (`--server`, unprivileged) consumer of the root `--service`'s DRM/KMS capture stream. +// +// The architecture pivot moved the scanout read into the root service; this process no longer +// links or dlopens libdrmtap. It connects to the service's `_drm` channel, learns the display +// geometry from the service, and pulls packed-BGRA frames. This mirrors the Windows +// `portable_service` CapturerPortable split (a privileged process captures, this process presents), +// but over rustdesk's own IPC instead of shared memory. +// +// `TraitCapturer::frame()` is synchronous (the encoder loop calls it) while the IPC receive is +// async, so a dedicated background thread runs the receive loop and keeps only the newest frame +// (latest-wins, so a slow encoder never backs the socket up). `frame()` returns that frame as a +// borrowed `PixelBuffer`, `WouldBlock` when nothing new arrived within the timeout, and a hard +// `Err` once the stream ends (the caller then rebuilds the capturer or falls back to PipeWire). + +use crate::ipc::{connect_drm, Data, DrmDisplayInfo}; +use hbb_common::{anyhow::anyhow, log, message_proto::DisplayInfo, tokio, ResultType}; +use scrap::{Frame, Pixfmt, PixelBuffer, TraitCapturer}; +use std::collections::BTreeMap; +use std::io; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +// Upper bound on how long `new()` waits for the service to answer with the display list before +// giving up and letting the caller fall back. +const HANDSHAKE_TIMEOUT_MS: u64 = 3000; + +struct FrameSlot { + // (width, height, packed-BGRA) of the newest frame not yet consumed by `frame()`; latest-wins. + latest: Option<(usize, usize, Vec)>, + // Set once the stream ends so `frame()` returns a hard error (triggers a capturer rebuild). + ended: Option, +} + +struct Shared { + slot: Mutex, + cv: Condvar, +} + +pub struct IpcDrmCapturer { + shared: Arc, + stop: Arc, + // The buffer `frame()` hands out a borrow of; kept across calls (grow-once) and only replaced + // when a new frame is taken from the slot. + // The requested display index this capturer streams, for per-display failure tracking. + display: i32, + cur: Vec, + cur_w: usize, + cur_h: usize, + // Whether this capturer ever delivered a frame. Used to distinguish a stream that fails to + // produce ANY frame (a permanent grab failure — unsupported scanout on that CRTC) from a normal + // teardown, so DRM can fall back to PipeWire for that display instead of rebuilding it forever. + got_frame: bool, +} + +// Consecutive DRM capture sessions, keyed BY requested display index, that ended without ever +// producing a frame. A display whose scanout can never be grabbed (e.g. an unsupported format on its +// CRTC) enumerates fine but never streams, so the video service would keep rebuilding it onto DRM. +// Tracking this per display — not globally — stops a working monitor from masking a permanently +// failing one: after DRM_GRAB_MAX_FAILURES consecutive zero-frame sessions for a given display, +// get_capturer_info() refuses it so the video service falls back to PipeWire for THAT display; any +// session that produces a frame clears that display's entry. +static DRM_DISPLAY_FAILURES: Mutex> = Mutex::new(BTreeMap::new()); +const DRM_GRAB_MAX_FAILURES: u32 = 4; +// A demotion is recoverable: after this cooldown the display retries DRM. The map is keyed by display +// index (stable within a session); the cooldown also releases a demotion that a hotplug/modeset may +// have pinned to an index a different monitor later occupies, so a stale verdict cannot stick forever. +const DEMOTE_COOLDOWN: Duration = Duration::from_secs(30); + +// Rapid-rebuild guard (defense-in-depth against a capturer flap). The zero-frame streak above does +// not catch a display that keeps delivering a first frame and then failing downstream (e.g. a +// frame the encoder rejects), because got_frame clears the streak each session — so such a display +// would rebuild ~once per second forever. Track per-display rebuild cadence: after +// RAPID_REBUILD_MAX rebuilds all within RAPID_REBUILD_WINDOW of each other, demote it to PipeWire +// via the same failure gate. A capturer that streams longer than the window resets the count, so a +// healthy display is never demoted. +static DRM_DISPLAY_REBUILDS: Mutex> = Mutex::new(BTreeMap::new()); +const RAPID_REBUILD_WINDOW: Duration = Duration::from_secs(3); +const RAPID_REBUILD_MAX: u32 = 6; + +impl IpcDrmCapturer { + /// Connect to the service `_drm` channel, complete the handshake (receive the display list, then + /// request `display`), and start streaming on a background thread. Returns the capturer plus the + /// enumerated displays so the caller can populate `display_service`. `Err` if the service has no + /// DRM capture available or the handshake fails — the caller then falls back to PipeWire/portal. + pub fn new(display: i32) -> ResultType<(IpcDrmCapturer, Vec)> { + let shared = Arc::new(Shared { + slot: Mutex::new(FrameSlot { + latest: None, + ended: None, + }), + cv: Condvar::new(), + }); + let stop = Arc::new(AtomicBool::new(false)); + let (tx, rx) = std::sync::mpsc::channel::>>(); + { + let shared = shared.clone(); + let stop = stop.clone(); + std::thread::spawn(move || recv_thread(display, shared, stop, tx)); + } + let displays = match rx.recv_timeout(Duration::from_millis(HANDSHAKE_TIMEOUT_MS + 500)) { + Ok(res) => res?, + Err(_) => { + // The recv thread still has its own connect/handshake budget. If we just returned, + // a handshake that completes after our timeout would leave that thread streaming + // with no owning capturer (our Drop never runs — the capturer was never built), so + // signal it to stop before giving up. + stop.store(true, Ordering::SeqCst); + return Err(anyhow!("drm capture handshake timed out")); + } + }; + Ok(( + IpcDrmCapturer { + shared, + stop, + display, + cur: Vec::new(), + cur_w: 0, + cur_h: 0, + got_frame: false, + }, + displays, + )) + } +} + +impl Drop for IpcDrmCapturer { + fn drop(&mut self) { + // Signal the receive thread to exit; it also exits on its own when the connection drops. + self.stop.store(true, Ordering::SeqCst); + } +} + +impl TraitCapturer for IpcDrmCapturer { + fn frame<'a>(&'a mut self, timeout: Duration) -> io::Result> { + let deadline = Instant::now() + timeout; + { + let mut slot = self.shared.slot.lock().unwrap(); + loop { + if slot.latest.is_some() || slot.ended.is_some() { + break; + } + let now = Instant::now(); + if now >= deadline { + return Err(io::ErrorKind::WouldBlock.into()); + } + let (guard, _timed_out) = + self.shared.cv.wait_timeout(slot, deadline - now).unwrap(); + slot = guard; + } + // Deliver a pending frame before surfacing an end, so the last frame is not dropped. + if let Some((w, h, buf)) = slot.latest.take() { + drop(slot); + self.cur = buf; + self.cur_w = w; + self.cur_h = h; + if !self.got_frame { + // First frame of this session: DRM capture works for this display, clear its + // failure streak. + self.got_frame = true; + DRM_DISPLAY_FAILURES.lock().unwrap().remove(&self.display); + } + } else { + let err = slot + .ended + .clone() + .unwrap_or_else(|| "drm stream ended".to_owned()); + if !self.got_frame { + // This session never produced a frame for THIS display. If enough sessions in a + // row fail this way for the same display, its scanout is effectively ungrababble; + // count it so get_capturer_info() will refuse that display and the video service + // falls back to PipeWire for it (other displays are unaffected). + let mut map = DRM_DISPLAY_FAILURES.lock().unwrap(); + let e = map.entry(self.display).or_insert((0, Instant::now())); + e.0 += 1; + e.1 = Instant::now(); + if e.0 >= DRM_GRAB_MAX_FAILURES { + log::warn!( + "drm: display {} produced no frame in {} sessions; falling back to PipeWire for it", + self.display, + e.0 + ); + } + } + return Err(io::Error::new(io::ErrorKind::Other, err)); + } + } + Ok(Frame::PixelBuffer(PixelBuffer::new( + &self.cur, + Pixfmt::BGRA, + self.cur_w, + self.cur_h, + ))) + } +} + +// Background receive loop. Owns the `_drm` connection and the async runtime; keeps the newest frame +// in `shared.slot`. Runs on its own thread because `frame()` is sync and one blocking consumer is +// enough for DRM. +#[tokio::main(flavor = "current_thread")] +async fn recv_thread( + display: i32, + shared: Arc, + stop: Arc, + tx: std::sync::mpsc::Sender>>, +) { + // Handshake: connect, receive the display list, request the display. + let mut conn = match connect_drm(1000).await { + Ok(c) => c, + Err(err) => { + let _ = tx.send(Err(err)); + return; + } + }; + let displays = match conn.next_timeout(HANDSHAKE_TIMEOUT_MS).await { + Ok(Some(Data::DrmDisplayList(v))) => v, + Ok(other) => { + let _ = tx.send(Err(anyhow!("expected DrmDisplayList, got {:?}", other))); + return; + } + Err(err) => { + let _ = tx.send(Err(err)); + return; + } + }; + if let Err(err) = conn.send(&Data::DrmStart { display }).await { + let _ = tx.send(Err(err)); + return; + } + let _ = tx.send(Ok(displays)); + + // Stream until stopped or the connection ends. Poll the header read with a short timeout (rather + // than blocking indefinitely on `next()`) so a dropped capturer re-checks `stop` and tears down + // promptly even when the producer has stalled (no frames arriving). A header is always followed + // immediately by its `next_raw()` body, so only the header read needs the poll. + let end_reason = loop { + if stop.load(Ordering::SeqCst) { + break "stopped".to_owned(); + } + let msg = match conn.next_timeout2(200).await { + None => continue, // timeout: re-check stop at the loop top + Some(Ok(Some(d))) => d, + Some(Ok(None)) => break "desynchronized frame".to_owned(), + Some(Err(err)) => break format!("recv: {err}"), + }; + match msg { + Data::DrmFrame { width, height } => match conn.next_raw().await { + Ok(raw) => { + let mut slot = shared.slot.lock().unwrap(); + slot.latest = Some((width as usize, height as usize, raw.to_vec())); + shared.cv.notify_one(); + } + Err(err) => break format!("frame body: {err}"), + }, + Data::DrmCursor { + id, + width, + height, + hotx, + hoty, + } => match conn.next_raw().await { + Ok(raw) => set_drm_cursor( + display, + DrmCursorData { + id, + width: width as i32, + height: height as i32, + hotx, + hoty, + colors: raw.to_vec(), + }, + ), + Err(err) => break format!("cursor body: {err}"), + }, + _ => {} // ignore any unexpected control message + } + }; + log::info!("drm capture stream ended: {end_reason}"); + // Drop only THIS stream's cursor entry so a torn-down monitor does not erase the cursor state of + // other still-active streams. + remove_drm_cursor(display); + let mut slot = shared.slot.lock().unwrap(); + slot.ended = Some(format!("drm stream ended ({end_reason})")); + shared.cv.notify_one(); +} + +// The latest DRM hardware-cursor snapshots, published by recv_thread and read by the cursor service +// (platform::linux::get_cursor / get_cursor_data). Keyed by display index because a multi-monitor +// client runs one recv_thread per display and the hardware cursor lives on whichever CRTC the +// pointer is over (the others report the hidden sentinel). Keying per stream — instead of a single +// last-writer-wins global — stops one stream's hidden sentinel from clobbering another stream's +// visible cursor, and lets a torn-down stream drop only its own entry. +#[derive(Clone)] +pub struct DrmCursorData { + pub id: u64, + pub width: i32, + pub height: i32, + pub hotx: i32, + pub hoty: i32, + pub colors: Vec, +} + +static DRM_CURSOR: Mutex> = Mutex::new(BTreeMap::new()); + +fn set_drm_cursor(display: i32, c: DrmCursorData) { + DRM_CURSOR.lock().unwrap().insert(display, c); +} + +fn remove_drm_cursor(display: i32) { + DRM_CURSOR.lock().unwrap().remove(&display); +} + +// Pick the cursor to present: prefer the visible one (the pointer is over exactly one captured CRTC +// at a time), else fall back to any (hidden) entry so the client still gets the hidden sentinel when +// the pointer is off every captured monitor. `None` only when no stream is active. +fn pick_drm_cursor() -> Option { + let map = DRM_CURSOR.lock().unwrap(); + map.values() + .find(|c| c.id != scrap::drm_reader::HIDDEN_CURSOR_ID) + .or_else(|| map.values().next()) + .cloned() +} + +/// The id of the current DRM hardware cursor (None if no stream). The cursor service polls this to +/// detect shape changes (a change triggers a `get_cursor_data` fetch). +pub fn drm_cursor_id() -> Option { + pick_drm_cursor().map(|c| c.id) +} + +/// The current DRM hardware-cursor snapshot (RGBA), or None. +pub fn drm_cursor() -> Option { + pick_drm_cursor() +} + +// --------------------------------------------------------------------------- +// Server capture-path integration (the parallel, gated DRM path) +// +// The `--server` selects DRM/KMS capture over PipeWire when the root service offers the `_drm` +// channel. Availability + the display list are probed once and cached: the `_drm` listener now +// serves consumers concurrently (one connection per captured display), but re-probing on every +// enumeration still churns connections needlessly and briefly tripped a restart loop in testing, so +// the result is cached durably. The cache is seeded before capture starts (display enumeration) and +// by the capturer handshake, and only reset by `clear()` on teardown. +// --------------------------------------------------------------------------- + +enum ProbeState { + Unknown, + // Timestamped so a negative verdict expires instead of permanently disabling DRM (see + // is_available): displays that appear after startup (a headless boot settling, a monitor + // hotplug, or a --service restart) can then re-enable it without restarting the --server. + Unavailable(Instant), + Available(Vec), +} + +static DRM_STATE: Mutex = Mutex::new(ProbeState::Unknown); +// How long a negative availability verdict is trusted before is_available re-probes. +const NEGATIVE_TTL: Duration = Duration::from_secs(30); + +/// Query the service for the current DRM display list without starting a stream: connect, read the +/// list the service sends on connect, then drop the connection (the service closes it when we do +/// not send `DrmStart`). Runs the async work on a throwaway thread so it is safe to call from any +/// context (a nested `#[tokio::main]` would panic when called from inside a runtime). +fn query_displays() -> ResultType> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(query_displays_async()); + }); + rx.recv_timeout(Duration::from_millis(HANDSHAKE_TIMEOUT_MS + 1000)) + .map_err(|_| anyhow!("drm display query timed out"))? +} + +#[tokio::main(flavor = "current_thread")] +async fn query_displays_async() -> ResultType> { + let mut conn = connect_drm(1000).await?; + match conn.next_timeout(HANDSHAKE_TIMEOUT_MS).await? { + Some(Data::DrmDisplayList(v)) => Ok(v), + other => Err(anyhow!("expected DrmDisplayList, got {:?}", other)), + } +} + +// Transient-failure budget for the cold probe: a `_drm` probe can fail transiently (the producer +// is not up yet, a connection race), so we retry across a few connections before durably giving up. +// This keeps one cold-start hiccup from permanently disabling DRM capture for the session, while +// still settling to `Unavailable` on a genuinely DRM-less host. +static DRM_PROBE_FAILURES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +const DRM_PROBE_MAX_FAILURES: u32 = 5; +// Single-flight guard: exactly one caller runs the blocking availability probe at a time, so +// is_available() never calls query_displays() (up to ~4s of IPC) while holding DRM_STATE. +static DRM_PROBE_IN_FLIGHT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Whether the root service offers DRM/KMS capture. The positive result and a definitive negative +/// (connected, but no displays) are cached; a transient probe error stays `Unknown` for a few +/// retries. Normally the cache is warmed at `--server` startup (`warm_availability`), so the first +/// client connection hits the fast `Available` path. +pub(super) fn is_available() -> bool { + // Fast path under the lock: read the cached verdict, expiring a stale negative so a host that had + // no displays at probe time can still enable DRM once displays appear (without a --server + // restart). NEVER call the blocking probe while holding DRM_STATE: a cold or expired probe would + // otherwise serialize every async caller for the whole query_displays() timeout (~4s). + { + let mut st = DRM_STATE.lock().unwrap(); + if let ProbeState::Unavailable(since) = &*st { + if since.elapsed() >= NEGATIVE_TTL { + *st = ProbeState::Unknown; + DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); + } + } + match &*st { + ProbeState::Available(_) => return true, + ProbeState::Unavailable(_) => return false, + ProbeState::Unknown => {} // fall through and probe with the lock released + } + } + // Single-flight: exactly one caller probes at a time. While a probe is in flight, others return + // the current cache-only verdict instead of stacking redundant `_drm` probes or blocking on the + // mutex across the I/O. warm_availability normally seeds `Available` before clients connect, so + // this cold path is rare. + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(_)); + } + let t = Instant::now(); + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + let available = match result { + Ok(list) if !list.is_empty() => { + log::debug!( + "drm: availability probe -> available ({} displays) in {:?}", + list.len(), + t.elapsed() + ); + *st = ProbeState::Available(list); + true + } + Ok(_) => { + log::info!("drm: availability probe -> no displays in {:?}", t.elapsed()); + *st = ProbeState::Unavailable(Instant::now()); + false + } + Err(err) => { + let n = DRM_PROBE_FAILURES.fetch_add(1, Ordering::Relaxed) + 1; + if n >= DRM_PROBE_MAX_FAILURES { + log::info!("drm: availability probe failed {n}x ({err}); disabling DRM"); + *st = ProbeState::Unavailable(Instant::now()); + } else { + // Stay Unknown so the next connection re-probes (cold-start race). + log::info!( + "drm: availability probe failed ({err}), attempt {n}/{DRM_PROBE_MAX_FAILURES}; will retry" + ); + } + false + } + }; + drop(st); + DRM_PROBE_IN_FLIGHT.store(false, Ordering::Release); + available +} + +/// Warm the availability cache at `--server` startup so the first client connection does not race a +/// cold `_drm` probe. A cold probe blocks display enumeration, and if it has not settled when the +/// peer info is built the display list goes out empty and the client shows "No displays" and +/// retries (the "connects on the Nth try" symptom). Probes with a short retry budget and only caches +/// the positive result; a genuinely DRM-less host just falls through to the lazy `is_available()`. +pub(super) fn warm_availability() { + for _ in 0..10 { + if matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(_)) { + return; + } + match query_displays() { + Ok(list) if !list.is_empty() => { + log::info!("drm: consumer cache warmed ({} displays) at startup", list.len()); + *DRM_STATE.lock().unwrap() = ProbeState::Available(list); + return; + } + // Producer not ready yet (or no DRM): back off and retry; never cache a negative here. + _ => std::thread::sleep(Duration::from_millis(300)), + } + } + log::info!("drm: consumer cache warm found no producer at startup (will probe lazily)"); +} + +/// The cached DRM displays as protobuf `DisplayInfo`, augmented with the compositor's logical layout +/// (per-monitor position + scale). `None` until probed/available. +pub(super) fn get_display_infos() -> Option> { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(list) => list.clone(), + _ => return None, + }; + Some(augment_with_wayland_geometry(&list)) +} + +/// Index (into the cached DRM display list) of the compositor's PRIMARY output. DRM connector order +/// is not the compositor's primary, so match the compositor's primary (from the same Wayland source +/// the geometry augmentation uses) to the DRM list by normalized connector name; fall back to 0 when +/// unknown. Without this the first DRM connector is always streamed, which is the wrong initial +/// display whenever the primary is not connector 0. +pub(super) fn get_primary_index() -> usize { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(list) => list.clone(), + _ => return 0, + }; + let wl = scrap::wayland::display::get_displays(); + if let Some(pw) = wl.displays.get(wl.primary) { + let pn = normalize_connector(&pw.name); + if let Some(idx) = list.iter().position(|d| normalize_connector(&d.name) == pn) { + return idx; + } + } + 0 +} + +/// The DRM enumeration reports every monitor at physical size and origin (0,0) — it deliberately +/// does not know the compositor's logical desktop layout. On a multi-monitor host that leaves the +/// client stacking all displays at (0,0), and input/cursor coordinates (mapped through each +/// display's logical origin + scale) land on the wrong output. So we augment here from the Wayland +/// outputs — the same source the uinput desktop-rect uses — matching by connector name (normalized: +/// DRM "HDMI-A-1" vs compositor "HDMI-1") and falling back to a unique physical resolution. This is +/// the "server augments the DRM geometry with the Wayland logical geometry" step. A single display +/// (already at 0,0, scale 1.0) needs no augmentation, matching the PipeWire path's logical-scale gate. +fn augment_with_wayland_geometry(drm: &[DrmDisplayInfo]) -> Vec { + let wl = scrap::wayland::display::get_displays(); + let multi = drm.len() > 1 && wl.displays.len() > 1; + drm.iter() + .map(|d| { + let mut info = display_info_from_drm(d); + if multi { + if let Some(w) = match_wayland_display(d, &wl.displays) { + info.x = w.x; + info.y = w.y; + if let Some((lw, lh)) = w.logical_size { + if lw > 0 && lh > 0 { + info.scale = d.width as f64 / lw as f64; + // original_resolution is the logical size (physical / scale). + info.original_resolution = super::display_service::get_original_resolution( + &d.name, + lw as usize, + lh as usize, + ); + } + } + } + } + info + }) + .collect() +} + +/// Match a DRM display to its compositor output: by normalized connector name first, then by a +/// uniquely-matching physical resolution. +fn match_wayland_display<'a>( + d: &DrmDisplayInfo, + wl: &'a [hbb_common::platform::linux::WaylandDisplayInfo], +) -> Option<&'a hbb_common::platform::linux::WaylandDisplayInfo> { + let dn = normalize_connector(&d.name); + if let Some(w) = wl.iter().find(|w| normalize_connector(&w.name) == dn) { + return Some(w); + } + let same_res: Vec<_> = wl + .iter() + .filter(|w| w.width == d.width as i32 && w.height == d.height as i32) + .collect(); + if same_res.len() == 1 { + return Some(same_res[0]); + } + None +} + +/// Normalize a connector name for cross-source matching: DRM inserts a single-letter type +/// discriminator that the compositor drops ("HDMI-A-1" -> "HDMI-1", "DVI-D-1" -> "DVI-1"); names +/// like "DP-1" / "eDP-1" pass through unchanged. +fn normalize_connector(name: &str) -> String { + let parts: Vec<&str> = name.split('-').collect(); + if parts.len() == 3 && parts[1].len() == 1 { + format!("{}-{}", parts[0], parts[2]) + } else { + name.to_string() + } +} + +/// Reset the probe cache so the next session re-probes (called on capture teardown). +pub(super) fn clear() { + *DRM_STATE.lock().unwrap() = ProbeState::Unknown; +} + +fn display_info_from_drm(d: &DrmDisplayInfo) -> DisplayInfo { + let original_resolution = + super::display_service::get_original_resolution(&d.name, d.width as usize, d.height as usize); + DisplayInfo { + x: d.x, + y: d.y, + width: d.width as i32, + height: d.height as i32, + name: d.name.clone(), + online: d.active, + cursor_embedded: false, + original_resolution, + scale: 1.0, + ..Default::default() + } +} + +/// Build a `CapturerInfo` backed by a DRM-IPC capturer for `display_idx`, refreshing the cached +/// display list from the capturer's handshake so mid-capture enumeration uses fresh geometry. +pub(super) fn get_capturer_info( + display_idx: usize, +) -> ResultType { + // Refuse a display already demoted (repeated zero-frame sessions, or a detected flap below), so + // the video service uses PipeWire for it instead of rebuilding onto DRM forever. Per-display, not + // a global DRM disable. + { + // Refuse a demoted display UNLESS its demotion has aged past DEMOTE_COOLDOWN, in which case + // drop it so the display retries DRM (recoverable, and releases a stale index-pinned verdict). + let mut map = DRM_DISPLAY_FAILURES.lock().unwrap(); + if let Some((count, since)) = map.get(&(display_idx as i32)).copied() { + if count >= DRM_GRAB_MAX_FAILURES { + if since.elapsed() >= DEMOTE_COOLDOWN { + map.remove(&(display_idx as i32)); + } else { + return Err(anyhow!( + "drm capture for display {display_idx} repeatedly produced no frame; using PipeWire" + )); + } + } + } + } + // Build the capturer FIRST. A transient `_drm` outage (e.g. the root --service restarting) makes + // this fail, and such a failure must NOT count toward the flap threshold — it self-heals once the + // service returns. Only a SUCCESSFUL (re)build reaches the rapid-rebuild guard below. + let (capturer, displays) = IpcDrmCapturer::new(display_idx as i32)?; + // Rapid-rebuild guard (defense-in-depth): a display whose capturer is successfully rebuilt many + // times in a short window is flapping (delivering a first frame then failing downstream every + // cycle, which the got_frame streak alone cannot catch). Count the cadence of successful builds + // and, past the threshold, demote it to PipeWire. A build spaced further apart than the window + // resets the count, so a healthy display (built once, streams long) never accumulates. The + // initial build counts 0, so demotion fires on the RAPID_REBUILD_MAX-th rapid rebuild — i.e. + // the (RAPID_REBUILD_MAX + 1)-th build inside the window. + { + let now = Instant::now(); + let mut rebuilds = DRM_DISPLAY_REBUILDS.lock().unwrap(); + let count = match rebuilds.get(&(display_idx as i32)) { + Some((last, c)) if now.duration_since(*last) < RAPID_REBUILD_WINDOW => c + 1, + _ => 0, + }; + rebuilds.insert(display_idx as i32, (now, count)); + if count >= RAPID_REBUILD_MAX { + log::warn!( + "drm: display {display_idx} rebuilt {count} times within {RAPID_REBUILD_WINDOW:?}; flapping, falling back to PipeWire" + ); + DRM_DISPLAY_FAILURES + .lock() + .unwrap() + .insert(display_idx as i32, (DRM_GRAB_MAX_FAILURES, Instant::now())); + return Err(anyhow!( + "drm capture for display {display_idx} is flapping; using PipeWire" + )); + } + } + let ndisplay = displays.len(); + let d = displays + .get(display_idx) + .ok_or_else(|| anyhow!("drm display index {display_idx} out of range ({ndisplay})"))? + .clone(); + // Publish the compositor's LOGICAL origin (the same augmentation get_display_infos advertises) + // so the video service's origin matches the reported display geometry on multi-monitor / scaled + // layouts; keep the raw physical dimensions for the capture buffer. + let origin = augment_with_wayland_geometry(&displays) + .get(display_idx) + .map(|di| (di.x, di.y)) + .unwrap_or((d.x, d.y)); + *DRM_STATE.lock().unwrap() = ProbeState::Available(displays); + Ok(super::video_service::CapturerInfo { + origin, + width: d.width as usize, + height: d.height as usize, + ndisplay, + current: display_idx, + privacy_mode_id: 0, + _capturer_privacy_mode_id: 0, + capturer: Box::new(capturer), + }) +} diff --git a/src/server/input_service.rs b/src/server/input_service.rs index 1d4deeb65..2b13d59bd 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -396,11 +396,24 @@ fn run_cursor(sp: MouseCursorService, state: &mut StateCursor) -> ResultType<()> if let Some(hcursor) = crate::get_cursor()? { if hcursor != state.hcursor { let msg; + // On the DRM path get_cursor_data() may return a snapshot whose id has advanced past the + // requested `hcursor` (it returns the latest hardware cursor); file it in the cache AND + // record state.hcursor under the id ACTUALLY served, so a later reappearance of that exact + // shape dedupes correctly instead of being suppressed. Everything below is fully + // `#[cfg(feature = "drm")]`-gated so the drm-off build stays byte-identical to upstream. + #[cfg(feature = "drm")] + let mut drm_served_id = hcursor; if let Some(cached) = state.cached_cursor_data.get(&hcursor) { super::log::trace!("Cursor data cached, hcursor: {}", hcursor); msg = cached.clone(); } else { let mut data = crate::get_cursor_data(hcursor)?; + #[cfg(feature = "drm")] + let hcursor = data.id; + #[cfg(feature = "drm")] + { + drm_served_id = hcursor; + } data.colors = hbb_common::compress::compress(&data.colors[..]).into(); let mut tmp = Message::new(); tmp.set_cursor_data(data); @@ -408,7 +421,14 @@ fn run_cursor(sp: MouseCursorService, state: &mut StateCursor) -> ResultType<()> state.cached_cursor_data.insert(hcursor, msg.clone()); super::log::trace!("Cursor data updated, hcursor: {}", hcursor); } - state.hcursor = hcursor; + #[cfg(not(feature = "drm"))] + { + state.hcursor = hcursor; + } + #[cfg(feature = "drm")] + { + state.hcursor = drm_served_id; + } sp.send_shared(msg.clone()); state.cursor_data = msg; } diff --git a/src/server/wayland.rs b/src/server/wayland.rs index dacce9485..7a5b16cd0 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -107,8 +107,38 @@ struct CapDisplayInfo { capturer: CapturerPtr, } +/// Set the uinput absolute-pointer range to the whole logical desktop so the compositor maps +/// injected coordinates 1:1 instead of stretching a single-monitor range across all outputs. The +/// PipeWire path does this inline in `check_init`; the DRM path bypasses check_init so it must do it +/// too, otherwise on a multi-monitor host the injected pointer lands on the wrong output — and the +/// hardware cursor, which lives on whichever CRTC the pointer is over, never appears on the captured +/// CRTC (the "cursor not visible" symptom). Reads the layout from the Wayland outputs, so it is +/// independent of the capture backend. DRM-only: check_init keeps its own inline copy so the +/// drm-off build stays byte-identical to upstream. +#[cfg(feature = "drm")] +async fn update_uinput_resolution() { + if crate::input_service::wayland_use_uinput() { + if let Some((minx, maxx, miny, maxy)) = + scrap::wayland::display::get_desktop_rect_for_uinput() + { + log::info!("update mouse resolution: ({minx}, {maxx}), ({miny}, {maxy})"); + allow_err!(input_service::update_mouse_resolution(minx, maxx, miny, maxy).await); + } else { + log::warn!("Failed to get desktop rect for uinput"); + } + } +} + #[tokio::main(flavor = "current_thread")] pub(super) async fn ensure_inited() -> ResultType<()> { + // DRM/KMS capture (opt-in): the root service owns the reader and the capturer self-inits over + // IPC, so there is no PipeWire recorder to initialize here. But we still must set the uinput + // desktop rect (check_init does this on the PipeWire path, and the DRM path skips check_init). + #[cfg(feature = "drm")] + if super::drm_capturer::is_available() { + update_uinput_resolution().await; + return Ok(()); + } check_init().await } @@ -116,6 +146,10 @@ pub(super) fn is_inited() -> Option { if is_x11() { None } else { + #[cfg(feature = "drm")] + if super::drm_capturer::is_available() { + return None; + } if CAP_DISPLAY_INFO.read().unwrap().is_empty() { let mut msg_out = Message::new(); let res = MessageBox { @@ -242,6 +276,14 @@ pub(super) async fn check_init() -> ResultType<()> { } pub(super) async fn get_displays_and_primary() -> ResultType<(Vec, usize)> { + #[cfg(feature = "drm")] + if super::drm_capturer::is_available() { + if let Some(displays) = super::drm_capturer::get_display_infos() { + // DRM connector order is not the compositor's primary; resolve the real primary from + // the compositor layout (matched by normalized connector name), not a hardcoded index 0. + return Ok((displays, super::drm_capturer::get_primary_index())); + } + } check_init().await?; // Keep one read guard so clear/reinitialization cannot split these across cache snapshots. let cap_map = CAP_DISPLAY_INFO.read().unwrap(); @@ -260,6 +302,19 @@ pub fn clear() { if is_x11() { return; } + // The DRM path augments its geometry from the compositor's Wayland outputs (logical origin + + // scale), which scrap caches process-wide. The PipeWire path clears that cache on session close, + // but the DRM path opens no PipeWire session, so without this it would keep matching DRM outputs + // against STALE geometry after a monitor hotplug/rotation/scale change. Invalidate it on teardown + // so the next session re-reads fresh geometry (lazily, on the next enumeration) and self-heals. + #[cfg(feature = "drm")] + if super::drm_capturer::is_available() { + scrap::wayland::display::clear_wayland_displays_cache(); + } + // NOTE: intentionally do NOT reset the DRM probe cache here. `clear()` runs on every capturer + // teardown (which happens on each video-service restart), and re-probing `_drm` from the async + // enumeration path blocks the executor long enough to trip "deadline has elapsed" and spiral + // into a restart loop. DRM availability is fixed at service start, so the cache stays valid. let mut write_lock = CAP_DISPLAY_INFO.write().unwrap(); for (_, addr) in write_lock.iter() { let cap_display_info: *mut CapDisplayInfo = *addr as _; @@ -280,6 +335,12 @@ pub(super) fn get_capturer_for_display( if is_x11() { bail!("Do not call this function if not wayland"); } + // DRM/KMS capture path: build the capturer straight from the service `_drm` stream, bypassing + // the PipeWire CAP_DISPLAY_INFO machinery entirely. + #[cfg(feature = "drm")] + if super::drm_capturer::is_available() { + return super::drm_capturer::get_capturer_info(display_idx); + } let cap_map = CAP_DISPLAY_INFO.read().unwrap(); if let Some(addr) = cap_map.get(&display_idx) { let cap_display_info: *const CapDisplayInfo = *addr as _;