feat(drm): opt-in DRM/KMS screen capture for Linux/Wayland

adds an opt-in `drm` feature for unattended remote access on Wayland: it
captures below the compositor via libdrmtap, so there is no
xdg-desktop-portal consent dialog and it works at the login screen.

off by default. when the feature is off the build is byte-identical.
everything is gated behind feature = "drm" or lives only in the separate
rustdesk-unattended-wayland deb, whose package name is the informed consent.

architecture (agreed with the maintainer): the capture runs inside the root
--service, which already holds the privilege it needs, and streams frames to
the user --server over a service-scoped _drm ipc channel. libdrmtap is loaded
with dlopen at runtime (no link-time dependency, so the base build is
unchanged and it still runs on ubuntu 18), and the .so is built in ci from the
rustdesk-org/libdrmtap fork and shipped only in the drm deb. no setcap helper.

- service: DrmReader reads scanout directly via the dlopen loader; an
  IpcDrmCapturer serves _drm consumers with a per-connection capture worker;
  durable availability cache + pre-warm to avoid enumerate/re-probe restarts
- capture: multi-display (targets the selected crtc), hardware cursor over
  _drm, transient-errno retry with a bounded stall, rejects non-32bpp scanouts
  before the frame copy
- robustness: only active, crtc-bound outputs are offered (an unbound
  crtc_id=0 connector is filtered and a client-selected 0 is refused, both
  fall back to pipewire); a per-display rapid-rebuild guard demotes a flapping
  display to pipewire; per-display (not global) zero-frame failure tracking
- root-service hardening: bounded frame allocation and a concurrent-connection
  cap so a malformed scanout or a buggy consumer cannot OOM or thread-exhaust
  the service; a negative availability verdict expires so displays that appear
  after startup recover without a --server restart; exactly-one .so selection
  in the packaging so a stale object is never silently shipped
- build: libdrmtap.so cloned at build time from rustdesk-org/libdrmtap main
  and bundled only for the --drm deb; ci builds a separate
  rustdesk-unattended-wayland deb (incl. an ubuntu 18.04 container)
- DRM_CAPTURE_SECURITY.md: threat model and hardening notes
This commit is contained in:
Mariano Abad
2026-07-17 20:55:26 -03:00
parent b4af82157b
commit 77a14339ad
16 changed files with 2128 additions and 26 deletions

View File

@@ -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

4
.gitignore vendored
View File

@@ -55,4 +55,6 @@ examples/**/target/
vcpkg_installed
flutter/lib/generated_plugin_registrant.dart
libsciter.dylib
flutter/web/
flutter/web/
# libdrmtap is cloned at build time by build.py (not a submodule)
/third_party/libdrmtap/

View File

@@ -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 = [

94
DRM_CAPTURE_SECURITY.md Normal file
View File

@@ -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/<app>-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/<pid>/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
```

164
build.py
View File

@@ -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 <info@rustdesk.com>
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<ffi.Bool Function(DartPort/ffi.NativeFunction<ffi.Uint8 Function(DartPort/g" flutter/lib/generated_bridge.dart')
# libdrmtap is fetched at build time by cloning the rustdesk-org fork at a pinned
# ref — the same way rustdesk sources its other native build deps (vcpkg,
# flutter_rust_bridge, ...), rather than carrying a git submodule. The ref can be
# a branch or a tag; rustdesk-org/libdrmtap main tracks the current release.
# Override the repo/ref via env (DRMTAP_REPO / DRMTAP_REF) for local testing or another fork.
LIBDRMTAP_REPO = os.environ.get('DRMTAP_REPO', 'https://github.com/rustdesk-org/libdrmtap')
LIBDRMTAP_REF = os.environ.get('DRMTAP_REF', 'main')
def _single_real_so(paths, where):
# Return the one real libdrmtap.so.0.* object among `paths`, failing if there are zero or several.
# glob order is arbitrary, so silently taking [0] could ship a stale or wrong-arch object left
# over from an earlier build; a mismatch should fail the build loudly instead.
real = sorted(p for p in paths if os.path.isfile(p) and not os.path.islink(p))
if len(real) != 1:
raise Exception(
f'expected exactly one real libdrmtap.so.0.* in {where}, found {len(real)}: {real}')
return real[0]
def build_libdrmtap_so():
# Build libdrmtap.so from the rustdesk-org fork cloned at LIBDRMTAP_REF. The
# pivot dlopen-s this .so in-process in the root service (which already holds
# CAP_SYS_ADMIN) — no setcap helper, no privileged child. Only the shared
# library target is built (the source also carries a helper binary we do not
# ship). Returns the path to the built versioned .so (e.g. libdrmtap.so.0.4.x).
repo_root = os.path.dirname(os.path.abspath(__file__))
# Allow a caller (e.g. CI) to build the .so ahead of time and hand it in via
# DRMTAP_PREBUILT_DIR (must contain the real libdrmtap.so.0.* object).
prebuilt_dir = os.environ.get('DRMTAP_PREBUILT_DIR')
if prebuilt_dir:
# DRMTAP_PREBUILT_DIR explicitly names the artifact source, so honor it strictly: fail
# (rather than silently falling back to a source build) if it holds no single real .so.
prebuilt = glob.glob(os.path.join(prebuilt_dir, 'libdrmtap.so.0.*'))
return _single_real_so(prebuilt, f'DRMTAP_PREBUILT_DIR={prebuilt_dir}')
# Clone the pinned source if it is not already present (a shallow clone at the
# ref). third_party/libdrmtap is not a submodule anymore; it is git-ignored.
src = os.path.join(repo_root, 'third_party', 'libdrmtap')
if not os.path.exists(os.path.join(src, 'meson.build')):
if os.path.isdir(src):
shutil.rmtree(src)
os.makedirs(os.path.dirname(src), exist_ok=True)
system2(f'git clone --depth 1 --branch {LIBDRMTAP_REF} {LIBDRMTAP_REPO} {src}')
build_dir = os.path.join(src, 'build-pkg')
if not os.path.exists(os.path.join(build_dir, 'build.ninja')):
system2(f'meson setup {build_dir} {src} --buildtype=release')
# Build only the shared library target ('drmtap'), not the bundled helper binary.
system2(f'meson compile -C {build_dir} drmtap')
sos = glob.glob(os.path.join(build_dir, 'libdrmtap.so.0.*'))
# keep the real object (libdrmtap.so.0.4.x), not the .so/.so.0 symlinks or meson's .p dir, and
# require exactly one so a stale object from an earlier build is never silently picked.
return _single_real_so(sos, f'the libdrmtap meson build dir {build_dir}')
def append_drm_ldconfig_postinst():
# The DRM package installs libdrmtap.so under a private dir; register it with the
# dynamic linker so the in-process dlopen("libdrmtap.so.0") resolves. Only the DRM
# package calls this, so the stock package's postinst stays byte-identical to upstream.
with open('tmpdeb/DEBIAN/postinst', 'a') as f:
f.write(
'\n'
'if [ "$1" = configure ] && [ -d /usr/lib/rustdesk ]; then\n'
'\tldconfig /usr/lib/rustdesk 2>/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("..")

View File

@@ -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"]

View File

@@ -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<u8>,
}
/// 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<u8>,
}
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<DrmReader> {
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<CursorSnapshot> {
// 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<DisplaySnapshot> {
// SAFETY: ctx valid; raw is a zeroed, correctly-sized array; count is
// clamped to the buffer before indexing.
unsafe {
let mut raw = vec![std::mem::zeroed::<drmtap_display>(); 16];
let cap = raw.len() as i32;
let n = (self.lib.list_displays)(self.ctx, raw.as_mut_ptr(), cap);
if n <= 0 {
return Vec::new();
}
let count = (n as usize).min(raw.len());
(0..count)
.map(|i| {
let name_bytes: Vec<u8> = raw[i]
.name
.iter()
.take_while(|&&ch| ch != 0)
.map(|&ch| ch as u8)
.collect();
DisplaySnapshot {
name: String::from_utf8_lossy(&name_bytes).to_string(),
crtc_id: raw[i].crtc_id,
x: raw[i].x as i32,
y: raw[i].y as i32,
width: raw[i].width,
height: raw[i].height,
active: raw[i].active != 0,
}
})
.collect()
}
}
}
impl Drop for DrmReader {
fn drop(&mut self) {
if !self.ctx.is_null() {
// SAFETY: ctx came from drmtap_open and is non-null.
unsafe { (self.lib.close)(self.ctx) };
self.ctx = std::ptr::null_mut();
}
}
}

View File

@@ -0,0 +1,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<Self> {
// 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<Option<DrmtapLib>> = 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()
}

View File

@@ -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;

View File

@@ -481,6 +481,47 @@ pub enum Data {
ControlPermissionsRemoteModify(Option<bool>),
#[cfg(target_os = "windows")]
FileTransferEnabledState(Option<bool>),
// --- 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<DrmDisplayInfo>),
/// 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/<app>-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<ConnectionTmpl<ConnClient>> {
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<Incoming> {
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<DrmDisplayInfo>),
/// 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<u8>,
},
}
/// 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<std::sync::atomic::AtomicBool>);
#[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<Vec<DrmDisplayInfo>> = 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<DrmDisplayInfo> {
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::<DrmProducerMsg>(2);
// task -> worker: the chosen CRTC, sent once after the client's DrmStart.
let (crtc_tx, crtc_rx) = std::sync::mpsc::channel::<u32>();
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<DrmProducerMsg>,
crtc_rx: std::sync::mpsc::Receiver<u32>,
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
) {
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<T> {
inner: Framed<T, BytesCodec>,
}

View File

@@ -361,6 +361,13 @@ pub fn get_focused_display(displays: Vec<DisplayInfo>) -> Option<usize> {
}
pub fn get_cursor() -> ResultType<Option<u64>> {
// 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<Option<u64>> {
}
pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
// 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());

View File

@@ -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() {

View File

@@ -328,6 +328,16 @@ fn check_get_displays_changed_msg() -> Option<Message> {
#[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<Display>) -> usize {
all.iter().position(|d| d.is_primary()).unwrap_or(0)
}
#[inline]
#[cfg(windows)]
fn no_displays(displays: &Vec<Display>) -> bool {

680
src/server/drm_capturer.rs Normal file
View File

@@ -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<u8>)>,
// Set once the stream ends so `frame()` returns a hard error (triggers a capturer rebuild).
ended: Option<String>,
}
struct Shared {
slot: Mutex<FrameSlot>,
cv: Condvar,
}
pub struct IpcDrmCapturer {
shared: Arc<Shared>,
stop: Arc<AtomicBool>,
// 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<u8>,
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<BTreeMap<i32, (u32, Instant)>> = 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<BTreeMap<i32, (Instant, u32)>> = 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<DrmDisplayInfo>)> {
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::<ResultType<Vec<DrmDisplayInfo>>>();
{
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<Frame<'a>> {
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<Shared>,
stop: Arc<AtomicBool>,
tx: std::sync::mpsc::Sender<ResultType<Vec<DrmDisplayInfo>>>,
) {
// 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<u8>,
}
static DRM_CURSOR: Mutex<BTreeMap<i32, DrmCursorData>> = 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<DrmCursorData> {
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<u64> {
pick_drm_cursor().map(|c| c.id)
}
/// The current DRM hardware-cursor snapshot (RGBA), or None.
pub fn drm_cursor() -> Option<DrmCursorData> {
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<DrmDisplayInfo>),
}
static DRM_STATE: Mutex<ProbeState> = 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<Vec<DrmDisplayInfo>> {
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<Vec<DrmDisplayInfo>> {
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<Vec<DisplayInfo>> {
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<DisplayInfo> {
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<super::video_service::CapturerInfo> {
// 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),
})
}

View File

@@ -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;
}

View File

@@ -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<Message> {
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<DisplayInfo>, 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 _;