mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-08 21:41:02 +03:00
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:
62
.github/workflows/flutter-build.yml
vendored
62
.github/workflows/flutter-build.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
94
DRM_CAPTURE_SECURITY.md
Normal 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
164
build.py
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import glob
|
||||
import pathlib
|
||||
import platform
|
||||
import zipfile
|
||||
@@ -130,6 +131,12 @@ def make_parser():
|
||||
action='store_true',
|
||||
help='Build with unix file copy paste feature'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--drm',
|
||||
action='store_true',
|
||||
help='Linux only: build the DRM/KMS capture backend (bundles libdrmtap.so, '
|
||||
'dlopen-ed in-process by the root service). Off by default.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skip-cargo',
|
||||
action='store_true',
|
||||
@@ -282,6 +289,8 @@ def get_features(args):
|
||||
features.append('flutter')
|
||||
if args.unix_file_copy_paste:
|
||||
features.append('unix-file-copy-paste')
|
||||
if not windows and not osx and args.drm:
|
||||
features.append('drm')
|
||||
if osx:
|
||||
if args.screencapturekit:
|
||||
features.append('screencapturekit')
|
||||
@@ -289,22 +298,29 @@ def get_features(args):
|
||||
return features
|
||||
|
||||
|
||||
def generate_control_file(version):
|
||||
def generate_control_file(version, extra_depends="", package_name="rustdesk"):
|
||||
control_file_path = "../res/DEBIAN/control"
|
||||
system2('/bin/rm -rf %s' % control_file_path)
|
||||
|
||||
content = """Package: rustdesk
|
||||
# An alternative-build package (e.g. the opt-in unattended-wayland / DRM
|
||||
# variant) installs the same files as the stock `rustdesk` package, so it
|
||||
# must conflict with / replace it: you install one OR the other, not both.
|
||||
variant_control = ""
|
||||
if package_name != "rustdesk":
|
||||
variant_control = "Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n"
|
||||
|
||||
content = """Package: %s
|
||||
Section: net
|
||||
Priority: optional
|
||||
Version: %s
|
||||
Architecture: %s
|
||||
Maintainer: rustdesk <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("..")
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
351
libs/scrap/src/common/drm_reader.rs
Normal file
351
libs/scrap/src/common/drm_reader.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
183
libs/scrap/src/common/drmtap_dl.rs
Normal file
183
libs/scrap/src/common/drmtap_dl.rs
Normal 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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
478
src/ipc.rs
478
src/ipc.rs
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
680
src/server/drm_capturer.rs
Normal 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),
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 _;
|
||||
|
||||
Reference in New Issue
Block a user