mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-05 15:41:23 +03:00
feat(linux): DRM/KMS direct capture for Wayland — no portal consent required (#15420)
* 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
* feat(drm): phase-2 split, pass the dma-buf fd instead of the converted frame
move the egl detile and rgba pack out of the root --service and into the
unprivileged --server. the root now calls only drmtap_open + drmtap_grab_desc
and exports a raw dma-buf fd; the fd rides the _drm channel over SCM_RIGHTS with
a small descriptor (geometry, per-plane offsets/pitches, modifier, hdr) instead
of the full rgba frame, dropping the per-frame copy. the --server imports the fd
with drmtap_open_render + drmtap_convert_dmabuf, keyed by the import-once egl
cache, and the render context is created and dropped on the recv thread.
the _drm transport moves off Framed<BytesCodec> (which cannot carry a fd) to a
bespoke sendmsg/recvmsg framing (DrmConn) that attaches one SCM_RIGHTS cmsg only
when a fd is present and rejects a truncated ancillary message. the split
symbols are bound optionally so an older libdrmtap still loads the cpu path, and
the whole thing degrades to the cpu BGRA path or PipeWire when no render node is
available. pins libdrmtap-sys to =0.4.13 with the Cargo.lock checksum. folds in
the DP-MST, ldconfig-restart and per-display PipeWire-fallback review fixes and a
udev hotplug refresh.
* drm: address the phase-2 split review
1- do not depend on the libdrmtap-sys crate for the pin: its build.rs statically
compiles the whole libdrmtap C tree and a CAP_SYS_ADMIN helper and links
-ldrm/-lseccomp/-lcap, which defeats the runtime-dlopen model. keep drm a pure
dlopen backend and pin the .so by the build.py DRMTAP_REF release tag, guarded by
a strict vX.Y.Z regex. drops the now-moot Cargo.lock freshness CI checks.
2- render-node-less consumers no longer lose the stream: the --server signals
need_cpu on DrmStart when it cannot open a convert context, and the --service
streams the CPU-converted frame path for that connection instead of a dma-buf fd
the consumer cannot detile (which used to fall through to a PipeWire path nobody
can approve on an unattended seat).
3- mark PipeWire initialized only after every per-display capturer is created, so
a partial failure retries instead of the flag falsely reporting a complete init.
4- reject a degenerate (zero width/height) or short CPU frame before it reaches
PixelBuffer::new (which derives stride as data.len()/height, dividing by zero).
5- keep the export-ledger epoch at DRM_DISPLAY_GENERATION so a hotplug invalidates
cached buffers (elision stays off until the recycled-fb_id inode case is handled).
6- validate the udev uevent source (kernel nl_pid, multicast) with recvmsg so a
local process cannot unicast a spoofed drm-change event to the root listener.
* drm: second review pass on the phase-2 split
1- make PipeWire init atomic: build every per-display capturer into owned staging
first and publish them to CAP_DISPLAY_INFO only after all succeed, so a mid-loop
Capturer::new failure neither leaves partial entries (which the next check_init
would treat as already-initialized) nor leaks the raw pointers already created.
2- pin the immutable libdrmtap commit, not just the tag: git clone --branch
follows a mutable tag, so verify the cloned HEAD equals DRMTAP_SHA in both the CI
workflow and build.py, failing on a moved/compromised tag.
3- drop the stale comment claiming a libdrmtap-sys crate pin (the drm backend has
no such dependency).
* drm: harden the libdrmtap source pin
1- verify the commit-SHA pin on a reused checkout too, not only on a fresh clone:
a stale or mismatched third_party/libdrmtap (e.g. from a failed clone) is now
removed and the build fails instead of silently reusing unpinned source.
2- default DRMTAP_REPO to the fork that actually publishes the pinned tag, so a
clean git clone --branch v0.4.13 resolves (and to the expected commit) instead of
failing on a repo that does not carry the tag.
* ci: make the pinned libdrmtap commit SHA literal
do not let an inherited DRMTAP_SHA override the verified commit in CI, so the
tag/commit pair is immutable there. build.py keeps the env override for local
forks.
* drm: only SHA-verify a git libdrmtap checkout, not a local source tree
gate the commit-SHA pin check on third_party/libdrmtap being a git checkout, so a
clone (fresh, reused, or a stale/failed one) is still verified, but a non-git
source tree a developer placed there on purpose to build unreleased local
libdrmtap is used as-is (it has no tag to verify).
* build: request the libdrmtap shared_library target explicitly
since libdrmtap 0.4.11 the project builds both a shared object and a static
archive, so 'meson compile drmtap' is ambiguous. ask for drmtap:shared_library
(rustdesk dlopens the .so and never links the archive).
* drm: do not reject a non-BGRA scanout on the export side
grab_desc exports the raw scanout dma-buf; the unprivileged converter handles
every format libdrmtap supports (10-bit XR30/AR30 with tone mapping, HDR, CCS)
down to RGBA. The fourcc gate copied from the CPU-mapped grab() wrongly closed
the _drm stream for a 10-bit XR30 primary (0x30335258) that convert_dmabuf
converts fine -- observed live on an i915 seat scanning out XRGB2101010. Keep
the gate only on grab(), whose frame.format is already the converted BGRA.
* drm: do not restart-loop a demoted display PipeWire cannot serve
DRM and PipeWire do not share a display-index space: DRM enumerates one entry
per connector while the portal often exposes a single whole-desktop stream at
index 0. When a per-display DRM capture was demoted to PipeWire for a non-primary
DRM index, cap_map.get(&display_idx) was None and the bail Err made
ServiceTmpl::run retry get_capturer every 1s forever (a multi-monitor restart
loop, latent until a display demotes). Degrade to the whole-desktop stream
(index 0) PipeWire does provide instead of spinning. Healthy DRM displays return
before this and are unaffected.
* ci: build the libdrmtap shared_library target explicitly
the CI .so-prebuild step used the same bare 'drmtap' meson target that is
ambiguous since libdrmtap became both_libraries (0.4.11); ask for
drmtap:shared_library, matching build.py.
* drm: stop altering the stock (drm-off) Wayland path (review 3.2, 4.6)
3.2: get_capturer_for_display no longer falls back to cap_map[0] for a missing
index. CapturerPtr is a bare *mut Capturer cloned by raw-pointer copy, so aliasing
one entry to two display_idx values let two video-service threads call frame() on
the same Recorder unsynchronised (data race / UB), reachable in a plain build via
CaptureDisplays{set:[0,3]}. Restore the exact-index lookup + bail; a demoted DRM
index is dropped from the advertised list at the source instead.
4.6: revert check_init to upstream (flag set before the per-display loop, direct
insert). The staged-all-or-nothing variant turned a partial per-display failure
into a permanent 1Hz retry loop and was not drm-gated. Both restore the drm-off
build to byte-identical with upstream.
* drm: address review findings 3.1, 4.2, 4.3, 4.4, 4.7 + minors
3.1: snapshot the stock flutter bundle before the CI drm relink and restore it
before makepkg, so the official Arch package ships the stock cdylib, not the
drm-enabled one. 4.2: wrap the drm block in a failure-tolerant subshell so a
drm-only failure no longer aborts the stock deb/rpm/arch publish. 4.3: narrow the
publish glob to rustdesk-[0-9]*.deb so the consent-bypass unattended-wayland deb
stays an artifact, not on the public release. 4.4: rewrite the three stale
DRM_CAPTURE_SECURITY.md statements to the split (default path passes a read-only
scanout dma-buf fd over SCM_RIGHTS with an import-once cache; export validation is
metadata-only; BGRA-over-the-wire is the fallback) and document that grab_desc's
fd is O_RDONLY (DRM_RDWR dropped upstream, dup preserves it). 4.7: only
short-circuit to the DRM cursor when it is authoritative (visible, or hidden in a
pure-DRM session); fall through to the normal cursor path in a mixed
DRM+PipeWire session. minors: thread the deb variant by feature not glob; TODO
for the ld.so.conf.d system path; drop a stray blank line. All gated or
whitespace so the drm-off build stays byte-identical.
* drm: re-authorize the _drm stream per frame and auth the producer (review 3.3, 4.1)
3.3: DRM/KMS capture is not session-scoped -- the worker grabs a CRTC's physical
scanout regardless of which session owns the display -- but the peer was
authorized only once at accept. Capture the peer uid and re-check it at the top of
the forward loop: root is always allowed, any other peer must still be the
active-session uid, fail closed otherwise. A session change now tears the stream
down within one frame (~33ms) instead of leaking the incoming user's screen to the
outgoing user's --server.
4.1: connect_drm accepted any producer. Reject a non-root peer (peer_uid != 0) so a
process that won the socket-path race cannot feed the consumer a display list,
frames and dma-buf fds while the DRM path suppresses the portal consent prompt.
* drm: validate cursor body length and coalesce _drm frames to latest-wins (review 4.1, 4.8)
4.1: the DrmCursor consumer handed the wire body straight to the client, which
renders width*height*4 RGBA bytes. Reject a body shorter than that so a truncated
cursor cannot make the client read past the buffer. The hidden-cursor sentinel is
0x0 with an empty body, for which the bound is 0 and the check is a no-op.
4.8: the _drm socket is a FIFO, so a consumer that drains slower than we produce
(a 4K convert on a modest GPU) fell seconds behind stale frames. Drain the producer
channel without blocking each tick and forward only the newest frame; replaced
frames drop in place, closing the zero-copy OwnedFd and freeing the CPU-path pixel
buffer. Cursor updates stay in order and are never coalesced away.
* drm: keep the demoted-display list consistent instead of stretching PipeWire (review 4.5)
A DRM display demoted to PipeWire has no geometry-consistent per-connector stream
on a multi-monitor host -- the portal exposes a single whole-desktop stream. The
fallthrough served that whole-desktop frame while the list still advertised the
demoted connector geometry, so the client stretched the frame and offset all input
by the connector origin (the primary-index-0 demotion reaches this even after the
get_capturer_for_display exact-index fix).
Dropping the display from the list is not an option: its position IS the capturer
index, so a drop would shift every later display and desync get_capturer_info. So
instead: get_display_infos advertises a multi-monitor demoted display OFFLINE at its
stable index, and get_capturer_for_display serves the PipeWire fallback only when
its rect matches the advertised geometry, else bails. A single-display host still
falls through (whole-desktop == that display). All new logic is drm-gated.
* drm: bound the _drm body read, stream-scope cursor teardown, refresh a stale verdict, drop dead clear (review 5)
- recv_msg_timeout2 only gated the wait for the first byte, so a peer that sent one
byte then stalled pinned the task forever. The same budget now also bounds the body
read; a body that overruns is a hard error that tears the stream down (recv_msg
bodies are small JSON, so a healthy peer never trips it).
- The cursor cache is keyed by display index, which a rebuilt stream reuses, so a
predecessor exiting after its replacement published a fresh cursor erased it. Stamp
each entry with a monotonic per-stream epoch and compare-and-remove on teardown.
- ProbeState::Available had no TTL, so an idle hotplug left a phantom display in
enumeration. Give it a timestamp and refresh the list off the hot path once it ages
past POSITIVE_TTL. The verdict stays true across the refresh (never bounces a live
session to the portal) and the probe runs on a background thread (never blocks the
async enumeration).
- Remove the dead clear(): it is unreferenced, and wiring it into teardown would force
the blocking re-probe on the next enumeration that swap_available_displays exists to
avoid.
* drm: unit-test the bespoke _drm SCM_RIGHTS framing (review 6)
The _drm wire format is hand-rolled (length prefix plus an fd bound to the frame
first byte) because Framed/BytesCodec cannot carry ancillary data, so it had zero
tests. Add pure-userspace coverage over a socketpair:
- a control message round-trips with and without an attached fd, and the received fd
refers to the same open file (a byte written into the source is read back through it)
- a raw length-prefixed body (cursor / CPU-fallback path) round-trips byte-for-byte
- a forged length prefix past the JSON cap is rejected at the prefix
- surplus fds packed into one cmsg keep only the first and close the rest
- a control message truncated past DRM_CMSG_CAP is rejected (MSG_CTRUNC), not consumed
- peer_uid_from_fd reads the socket peer credential the producer-auth path relies on
* drm: address the self-review findings on the review rework
Five defects an adversarial pass found in the previous commits:
- refresh_available_async set the single-flight probe guard, then relied on the
detached thread to clear it; if thread creation failed (EAGAIN) or the closure
unwound, the guard leaked true and froze every future probe. Release it via RAII
inside the closure and on a Builder::spawn error.
- The _drm per-frame re-auth called the cached active_uid(), which on a cache miss
(exactly during a session switch) falls back to a blocking loginctl seat0 lookup --
on the single-threaded _drm runtime, once per frame, a subprocess storm. Use a new
cache-only accessor that never blocks and fails closed on a miss, and correct the
comment: the stop is bounded by the active-uid cache cadence, not one frame.
- set_drm_cursor inserted unconditionally, so a still-draining predecessor stream
could overwrite (then delete on teardown) the cursor a replacement stream published
for the same index. Make it a compare-and-set that ignores an older epoch.
- recv_msg_timeout2 treated a spurious readable() wakeup with nothing consumed as a
mid-frame stall and tore the stream down. Track whether any byte was consumed
(drm_read_full sets it) and map a zero-progress deadline back to None (re-poll),
reserving the hard error for a genuine partial-frame stall.
* drm: release the probe single-flight guard via RAII on the cold path too
The cold availability probe in is_available acquired DRM_PROBE_IN_FLIGHT and released
it with a plain store(false) after a synchronous body; a panic there (e.g. a poisoned
DRM_STATE lock) would leak the guard true and freeze both future probes and the
refresh path hardened in the previous commit, since they share the guard. Hoist the
release into a shared ProbeInFlightGuard used by both the cold probe and the refresh
closure, so any exit -- normal, early, or unwinding -- clears it.
* drm: source libdrmtap from rustdesk-org, pinned by sha (review 3.4)
The dlopened .so is loaded into the CAP_SYS_ADMIN root service, so it should come
from the maintainer-owned repo, not a personal fork. rustdesk-org/libdrmtap main is
already synced to the exact commit we pin (c9cf0938 = v0.4.13) but carries no release
tag, so point both build.py and the CI job at rustdesk-org and track main with the
immutable commit pinned via DRMTAP_SHA. The post-clone sha check makes this
fail-closed: main moving off the pinned commit fails the build instead of silently
swapping the .so. The CI ref guard now accepts a vX.Y.Z tag or main (a loose branch is
still rejected). Switch DRMTAP_REF to a tag if rustdesk-org later publishes one.
* drm: dlopen libdrmtap by absolute path + unit-test the _drm admission and re-auth (review 5e, 6a)
5e: the deb dropped /usr/lib/rustdesk into /etc/ld.so.conf.d so the private libdrmtap
could be found by soname -- a system-wide search-path entry that lets it shadow a
system library for every binary on the host, which Debian Policy 10.2 forbids. Resolve
it by absolute path (/usr/lib/rustdesk/libdrmtap.so.0) at the dlopen site instead, with
the bare sonames kept only as a dev fallback, and drop the ld.so.conf.d file and the
ldconfig/try-restart postinst entirely (the .so is present at its absolute path right
after unpack, so the pre-warm resolves with no linker-cache step). The dlopen site is
this PR's own code, so this is in scope, not a follow-up.
6a: extract the _drm admission bound and the per-frame re-auth decision into pure
helpers (drm_conn_admitted, drm_peer_authorized) and unit-test them: admission admits
strictly below MAX_DRM_CONNS and rejects at/above it; re-auth passes root always,
passes a non-root peer only while it equals the active-session uid, and fails closed on
a switched-away, unknown-session, or unknown-peer case. (The /proc/exe-mismatch
rejection is exercised by the accept-time authorize call; unit-testing it in isolation
would need a second process with a different exe, so it stays an integration concern.)
* ci: run the _drm unit tests on every PR (review 6)
The _drm unit tests are behind the opt-in drm feature, which the default workspace
test job does not build, so they would sit in the tree unrun -- no better than no
tests. Add a Linux step to the per-PR ci.yml that runs them with the feature on,
alongside the existing ipc/auth tests. drm is a pure runtime-dlopen backend with no
link-time deps (no libdrm/EGL/gbm) and the tests are pure userspace (socketpair
framing, SCM_RIGHTS, the peer-auth/admission decisions), so this needs no GPU and no
extra system packages. The main build/test stays on default features, so the shipped
drm-off config remains the primary verified one.
* drm: bump the pinned libdrmtap to v0.4.14
Point the DRM capture build at the libdrmtap v0.4.14 release commit
(816766dedaba3140c613712ce97aa2614e8899e7) instead of v0.4.13, in build.py and
the flutter-build workflow, and correct the scrap Cargo.toml note to describe
the actual DRMTAP_SHA anchor. 0.4.14 keeps the same public API, so the dlopen
consumer needs no change.
* drm: address the consumer review (login-screen uid, frame flow control, hotplug)
- Start the login-screen --server as the active seat0 greeter account instead
of root, so the DRM capture GPU/EGL convert never loads the vendor GPU
userspace in a privileged process. A genuine root graphical session has no
lower uid to drop to and stays root, and if the greeter spawn fails we fall
back to a root --server so the login screen stays remotable. Gated on the drm
feature so the non-drm build is unchanged.
- Bound the number of frames in flight on the `_drm` channel: the consumer acks
each frame it finishes converting and the producer only sends while it holds
credit, waiting on the socket otherwise. Without this the producer kept
writing descriptors into the socket faster than a slow convert drained them
and the consumer worked through an ever-growing backlog of stale frames. A
zero-byte read or write on the ack path is treated as a closed peer rather
than as success.
- Forward a display list that became empty (last monitor unplugged) instead of
dropping it, so the availability cache leaves Available rather than keep
advertising removed displays.
- On a topology change, invalidate the Wayland geometry cache and reapply the
uinput mouse range for the new layout. The refresh runs off the frame-receive
loop and is coalesced across the per-display receivers, so a multi-monitor
hotplug runs one worker and the final layout wins.
- Clear the prefer-CPU-convert hints on a topology change: display indices can
be renumbered, so a hint learned for an old index no longer refers to the same
physical display. Re-learned on the next convert failure.
- Report a non-DRM-backed display when the DRM list is shorter than the sync
list or any entry is offline, covering the present-but-demoted case.
* drm: log why the uinput refresh worker could not start
The worker released its coalescing slot and returned silently when the runtime
failed to build, leaving the uinput range stale for the new layout with nothing
in the log to explain it.
* drm: gate only frames on send credit, never cursor or topology updates
The credit check sat at the top of the producer loop and continued on exhaustion,
so while a slow convert withheld its ack the loop never reached the code that
forwards cursor updates and pushes a changed display list: the remote cursor
froze and a hotplug went unreported until credit returned. The comment claimed
those were not credit-gated; structurally they were.
The loop now always receives and processes producer messages. Only the frame send
is gated: when credit is exhausted the newest frame is held back (latest-wins,
matching the existing coalescing) and flushed as soon as an ack lands, while
cursors and the topology push go out unimpeded. While a frame is held the loop
also waits on the socket, so an ack wakes it promptly rather than only when the
next frame arrives; both select arms are cancel-safe.
* drm: fix three defects in the frame credit gate
Follow-up to the previous commit, from an adversarial review of it.
- The ack wake-up skipped the coalescing drain. When the socket arm of the
select won, there was no message to seed the drain loop with, so the channel
was never polled that iteration: a held frame could be sent while a strictly
newer one already sat queued, and a queued cursor waited for the next producer
message. Seed the loop from the channel when we woke on an ack instead.
- The loop could wait while holding a frame it was allowed to send. Credit
replenished by the top-of-loop drain was not consulted before entering the
select, so the frame waited for the worker's next message; if capture then
returned WouldBlock it sat there until the stall teardown. Take whatever is
queued without blocking in that case and fall through to the send.
- The capture worker no longer had any backpressure. Draining the channel every
iteration (needed so cursors keep flowing) means a full channel no longer
parks it, so a consumer converting at a fraction of the capture rate made the
privileged service keep grabbing frames that were then discarded -- a packed
copy per frame on the CPU path, a PRIME export on the dma-buf path. The worker
now skips the grab while the task is holding an undeliverable frame, and keeps
polling the cursor so the remote pointer stays live. The gate is deliberately
conditioned on holding a frame, not merely on having no credit: with nothing
held the task blocks in recv() and cannot observe an ack, so gating there
would stop the worker feeding it at all.
The comment claiming the bounded channel backpressures the worker is corrected.
* drm: gate capture on credit alone, and bound the no-credit wait
Follow-up to the previous commit, from an adversarial review that modelled the
loop with a real runtime, socket pair and worker thread.
Gating the worker only while a frame was already held was wrong: those grabs are
not wasted work, they keep the held frame fresh, because the coalescing below
lets each newer frame supersede it. Pinning the worker at that moment therefore
froze whatever frame happened to be in hand when credit ran out and shipped it
stale once the ack landed -- measured at ~91ms average staleness against ~2ms
with no gate at all. Gating on lack of credit alone, and waiting on the socket
whenever credit is out rather than only while holding a frame, keeps the CPU
saving (the worker still stops grabbing) with no staleness: the ack resumes the
worker and what goes out is a fresh grab. Modelled at 0ms staleness and the same
delivered-frame count, with 31 grabs versus 588 ungated. It is deadlock-free
because the socket is watched in exactly the states where the gate is set.
The no-credit wait is now bounded (5s). While gated the worker does not grab, so
it cannot advance its own MAX_STALLED watchdog; a consumer that stopped acking
without closing the socket could otherwise hold this connection, its worker
thread and the privileged DRM context open indefinitely.
* drm: measure the no-credit deadline from the last ack, not the last wake-up
The bound added in the previous commit was a timeout on the wait itself, so any
wake renewed it -- and cursor messages keep arriving while frames are gated, so
a consumer that had stopped acking but still moved its pointer would renew the
deadline forever and never be torn down. Track when we last held credit instead
and enforce the deadline against that, keeping the wait capped only so we still
wake to re-evaluate it when nothing arrives at all.
* drm: drop to Unavailable when the background refresh finds no displays
The review asked for two things when the last CRTC disappears: push the empty
topology to consumers, and stop advertising the removed displays. Only the first
was done. The positive-TTL refresh still discarded an empty probe result and kept
the previous list, so on an idle host -- where there is no live stream to carry
the hotplug push -- enumeration kept reporting displays that were gone, exactly
as described. It now transitions to Unavailable on an empty result, matching the
hotplug path, while a failed probe (transient open/EACCES, not evidence the
displays are gone) keeps the verdict and only restamps it.
* drm: do not let a stale availability probe overwrite a newer verdict
query_displays() in the background refresh runs unlocked because it is slow, so
a hotplug push can publish a newer verdict while it is in flight; the refresh
then overwrote it with its own older result. Harmless while it only replaced the
list, but the previous commit made an empty result drop to Unavailable, so a
probe that started while the monitors were gone could disable DRM on a host
whose monitor had since come back.
The refresh now samples the stamp of the verdict it is refreshing and publishes
only if that stamp is still current. Every publish stamps a fresh Instant, so an
unchanged stamp means nothing republished in between -- equivalent to threading a
revision counter through every publish site, without having to keep all of them
in sync.
* drm: track availability publishes with a generation, and hold the probe guard across the whole path
Two defects in the previous commit's staleness check.
The single-flight guard was still created inside the spawned closure, but that
commit added a DRM_STATE lock before the spawn. A poisoned lock there would
unwind past the flag with nothing to clear it, leaving DRM_PROBE_IN_FLIGHT set
and freezing every future probe. The guard is now taken immediately after the
flag is acquired and moved into the closure, so it covers the lock, the probe,
and a failed spawn alike. The explicit release on spawn failure is gone with it:
it was not merely redundant but wrong, since by then another refresh may have
acquired the flag and clearing it would let two probes run at once.
The staleness check itself compared Instant stamps, which made correctness
depend on an implicit invariant -- that every publish restamps -- spread across
ten call sites; a future publish that reused a stamp would defeat it silently.
DRM_STATE now carries an explicit generation, bumped by publish_probe_state,
which every write to the state goes through. Instants are left to serve only the
TTL checks. The failed-probe branch deliberately restamps without bumping: it
touches the TTL, not the verdict, so a concurrent probe loses nothing by
publishing over it.
* drm: convert each display on the GPU that exports it
The unprivileged converter opened its render context with
drmtap_open_render(NULL), letting libdrmtap auto-select. On a multi-GPU host
that can land on a different GPU than the one driving the display, and importing
a scanout across vendors can fail permanently on an incompatible tiling
modifier.
The service already knows the exporting device, so it now names its render node
(drmtap_render_node, libdrmtap 0.4.15) in each DrmDisplayInfo, and the consumer
opens the converter on that node. The field is serde(default) and empty means
auto-select, so a service and a server from mismatched builds still interoperate
and a pre-0.4.15 .so degrades to exactly the previous behaviour. The path is
realpath-gated to /dev/dri before it is opened, the same gate the capture device
gets, since it arrives over IPC. When the named node cannot be opened the
converter returns None and the existing need_cpu fallback runs the convert on the
exporting GPU service-side, which is the most correct place for it anyway.
Added a wire-compat test that a pre-render_node DrmDisplayInfo payload still
decodes (empty node) and a current one round-trips the node.
* drm: advertise the displays of every GPU, not just the first card
A drmtap context is bound to a single DRM device, so the service enumerated one
auto-detected card and advertised only its monitors. On a multi-GPU host every
display driven by another card was invisible to the client, and its card-local
CRTC id could not have been opened through the wrong device anyway.
The service now enumerates every card (drmtap_list_devices, libdrmtap 0.4.15),
opens one reader per device, and merges their displays into the one list, each
tagged with its own card node and render node. DrmStart resolves the chosen
index to that display's device + CRTC and the worker reopens the right card;
the converter already binds the display's render node. Both new fields are
serde(default) and empty means the single auto-detected device, so a pre-0.4.15
.so and a mismatched-build peer keep the previous behaviour exactly.
Enumeration replaces the single-reader open in the pre-warm, the udev hotplug
refresh, and the per-connection handshake, so a hotplug on any card is picked up
and an all-monitors-off state now correctly publishes an empty list. The
per-connection cache refresh re-enumerates all cards rather than only the
connection's device, so serving one display never drops the others from the
next handshake.
Verified on a Jetson Orin (its two DRM devices, only card2 driving a display):
list_devices reports card2/renderD129 with one display, enumeration produces
exactly that display tagged to card2, and card1 (no active CRTC) is skipped -
no phantom, no regression on the single-display case.
* drm: bump the pinned libdrmtap to v0.4.15
* drm: do not guess the exporting GPU when the host has several render nodes
The converter binds the render node the service names for a display, and falls
back to auto-selection when that name is empty. An empty name is what an older
libdrmtap produces: the service resolves it with drmtap_render_node, which only
exists since 0.4.15, and rustdesk dlopens libdrmtap.so.0 by soname, so the
runtime library can be older than the one the build was pinned to.
Auto-selecting is not safe there. On a single-SoC multi-device host the wrong
choice does not fail: a Jetson Orin exports the scanout from nvidia-drm while
the first render node belongs to tegra, and importing the scanout on the tegra
node SUCCEEDS and yields corrupted pixels. There is no convert error, so the
prefer-cpu bit never learns anything and the stream simply looks broken with a
clean log.
Request the CPU-converted path instead whenever the exporter is unnamed and the
host exposes more than one render node: the service converts on the device it
already has open, which is correct by construction. Hosts with a single render
node have nothing to pick wrong and keep the dma-buf path untouched.
Verified on a Jetson Orin Nano, the two-device host: with a libdrmtap that
lacks drmtap_render_node the capture used to come through visibly corrupted,
and now falls back to the cpu path and renders correctly. With 0.4.15 the
service names renderD129 and the dma-buf path is used as before.
* drm: name the libdrmtap that was really loaded, and say so when it is stale
Two hours went into a corrupted capture whose only symptom was a clean log
saying "libdrmtap loaded: /usr/lib/rustdesk/libdrmtap.so.0 (v0.4.15)". The
library behind that soname symlink was a pre-release 0.4.15 that reported the
version but did not export drmtap_render_node, so the service silently stopped
naming the exporting GPU. The log named the symlink it asked for, which is not
evidence of anything, and the version it printed came from the library itself,
which was the part that lied.
Log the file the absolute candidate actually resolves to, and warn when a
library reports 0.4.15 or newer while missing drmtap_render_node or
drmtap_list_devices, naming that file: a version that claims features the
symbols do not back means a stale or pre-release build, and the effect is
invisible otherwise. Only the absolute candidate is resolved, because dlopen
does not search the process CWD for a bare soname while canonicalize would.
Also correct two places that no longer matched the code: the security document
still described an /etc/ld.so.conf.d drop-in and an ldconfig trigger that
build.py deliberately does not ship (the .so is dlopened by absolute path and
the package makes the soname symlink itself), and the comment above the render
node lookup still said an unnamed exporter always falls back to auto-selection.
* drm: tighten the render-node count and the loader diagnostics
Four corrections from a review pass over the previous two commits.
Count only a render node whose name is renderD followed by a numeric minor.
The prefix test also matched something like renderD.backup, which would have
inflated the count and pushed a genuinely single-GPU host onto the CPU path.
Log the load only after every required symbol resolved. load() still returns
None when one is missing, so announcing success first could print "libdrmtap
loaded" and then "libdrmtap not available" for the same library.
Name only the capability each absent symbol costs: a library missing just
drmtap_render_node loses exporting-GPU selection, one missing just
drmtap_list_devices loses multi-GPU enumeration, and the previous wording
claimed both were gone in either case.
Fix the security document's audit step. The dlopen names the symlink by
absolute path and the package registers no linker directory, so a leftover
object beside it is not loaded on its own; what matters is where the symlink
points, and a leftover only matters as what a stray ldconfig would repoint it
to. Ask the auditor to read the symlink target instead.
* docs: list every case that selects the CPU-converted frame path
The security document described the CPU fallback without saying when it is
taken, and the multi-GPU safety fallback added in this branch was not mentioned
at all. Enumerate the four cases, including the one where the service could not
name the exporting GPU on a host with several render nodes, and note that a
single-render-node host keeps the DMA-BUF path.
* drm: fetch libdrmtap by commit sha instead of cloning a branch
`git clone --depth 1 --branch main` fetches only the tip of that branch, so the
moment upstream pushes to libdrmtap `main` the pinned commit is no longer present
in the shallow clone at all: the build fails on an unreachable object rather than
on a mismatched pin, and it fails for a reason that has nothing to do with the
checkout being wrong. In the release workflow the whole block is wrapped so the
job stays green, which means the drm deb would simply stop being produced without
anyone noticing.
Fetch the sha directly instead. No branch or tag name takes part in the build now,
so it survives every upstream push and cannot be affected by a ref being moved or
repointed. DRMTAP_REF is gone, along with the regex that validated it.
The post-fetch sha check stays, with a narrower job: a fetch by sha cannot resolve
to anything else, so it now guards a reused checkout left at a different pin, which
is exactly what a version bump leaves behind. It still removes that tree so the
next run re-fetches cleanly.
build.py is now the single source of truth for the pin.
* drm: move the drm CI out of the stock workflow, and stop touching scrap/Cargo.toml
The instruction was that nothing outside the feature should change while the
feature is off, and the runtime code honors that, but the build plumbing did not.
Start undoing that.
ci.yml goes back to upstream byte for byte. The drm test step it carried now lives
in a new workflow that only fires when a drm path changes, so a PR that does not
touch this backend pays nothing for it.
That new workflow also runs the whole rustdesk-crate test set with the feature on
rather than filtering by the `_drm` test names, because the name filter skipped
the sibling assertion that bounds `size_of::<Data>()`, which the new DmabufDesc
variant grows.
It gains a second job that fetches libdrmtap at the pinned commit, builds the .so
and then asserts the contract the runtime depends on: every symbol the loader
resolves, derived from the loader source so the two cannot drift, plus evidence
that the EGL detile path is really compiled in. libdrmtap degrades to a CPU-only
stub when the egl/glesv2 pkg-config files are absent on a build host, and nothing
downstream noticed. Note the check looks for the dlopen target name and the import
call, not for DT_NEEDED: EGL is loaded lazily on purpose so the privileged process
never links the vendor GL stack, so an ELF-level check reports a false negative on
a correct library.
libs/scrap/Cargo.toml keeps only the added feature: the unrelated blank line before
[dependencies.hwcodec] is restored, and the comment no longer describes DRMTAP_REF,
which no longer exists. The feature is now drm = ["wayland"] because all three drm
modules live inside the wayland arm of common/mod.rs, so scrap/drm alone compiled
nothing; it worked only because the root crate always enables scrap/wayland.
* drm: build the unattended-wayland deb in its own workflow, not in the release job
flutter-build.yml goes back to upstream byte for byte. Three separate changes to
the stock release path disappear with it: the drm variant built inside the release
container, the snapshot and restore of the stock flutter bundle that existed only
to keep the drm relink out of the archlinux package, and the narrowing of the
publish glob to keep the consent-free deb off the public release.
The deb now builds in the drm workflow instead, which also removes the failure
mode the old placement forced: the whole block had to run in a subshell ending in
`|| echo WARN` so a drm-only breakage could not abort the stock publish steps,
which meant every failure in it, from the fetch to meson to packaging, kept the
job green and silently stopped producing the deb. A separate job can just fail.
The bridge generator is a reusable workflow, so this calls the stock one rather
than duplicating the codegen.
The deb is asserted rather than trusted: build.py can exit 0 without producing a
package, so the job checks the file exists and that it carries both the real
libdrmtap object and its soname symlink. It stays an artifact and never a release
deliverable, and it is built on the runner rather than in the old container the
stock debs use, so its glibc floor is higher than a released package.
* drm: stop refactoring the shared packaging path in build.py
generate_control_file goes back to upstream byte for byte: no extra parameters, no
conditional inside it. The variant instead rewrites the control file that function
just produced, so everything specific to the consent-free package lives in added
code rather than in the shared one. That rewrite fails loudly if either anchor line
stops matching, so a future upstream change to the control layout cannot quietly
yield a variant deb wearing the stock package name.
finalize_deb is gone. It had pulled the tail of both deb builders into one shared
helper, which is a refactor of a path the feature has no business touching. Both
builders now carry their upstream tail verbatim, with the drm work added as three
guarded blocks: stage the library, retarget the control, rename the output. With
the feature off, every line is upstream's.
Verified rather than argued, by building both packages with this script:
the drm deb is Package: rustdesk-unattended-wayland, carries Conflicts, Replaces
and Provides on rustdesk, has libdrm2, libegl1 and libgles2 appended to Depends,
and ships libdrmtap.so.0.4.15 plus its soname symlink. The stock deb is
Package: rustdesk, carries none of those three fields, and contains no libdrmtap
file at all.
* drm: key per-display state by connector identity, and end a stream whose index moved
The service binds a stream to (device, crtc_id), which survives a topology change.
Everything on the consumer side addressed it by list index, which does not:
drm_enumerate_all_displays concatenates per-card lists, so plugging or unplugging a
monitor renumbers every display after it. Two consequences, one live and one
remembered.
Live: a running stream kept sending monitor A while the advertised list, and so the
client layout and the injected-input rect, had come to mean monitor B. It only
resolved if the stream happened to fail on its own. The stream now records what it
was bound to and ends itself when its index stops meaning that, which routes the
change through the rebuild the video service already does.
Remembered: the zero-frame failure counts and the prefer-cpu verdicts were keyed by
index too, so after a renumbering one monitor could inherit another's demotion or be
forced onto the CPU convert path for a mismatch that was never its own. Both are now
keyed by device plus connector name. The reasoning was already written down for one
of these, in the comment above the prefer-cpu clear, and applied only there.
That bulk clear is gone with it. It existed to limit the damage of index aliasing;
with identity keys it would instead throw away a correct verdict, which costs a real
convert failure to relearn, on every unrelated hotplug.
Also fixes the drm workflow to skip the two tests the stock CI already skips. Both
need a display server and fail on any headless runner, so the job would have gone
red for a reason that has nothing to do with this feature. Verified by running the
exact command: 88 tests, including the size_of::<Data>() assertion that the old
name filter was hiding.
* drm: end the session when the captured display changes geometry mid-stream
A resolution DECREASE wedged the stream. The encoder is sized once, from
CapturerInfo at capturer build time; check_display_changed returns None on Wayland,
so the periodic display-changed broadcast never fires there; and convert_to_yuv only
bails when the source is LARGER than the destination. A smaller frame therefore
passed all three and was encoded into the previous canvas, leaving stale content
along the right and bottom edges for the rest of the connection. An increase
recovered only by accident, because convert then refused and the service rebuilt.
This is ours to contain rather than merely inherited: the DrmDisplaysChanged
handler re-broadcasts the new geometry through SYNC_DISPLAYS, so the client layout
and the pixels it receives actively disagree, where before there was no topology
signal at all.
The capturer now records the geometry its session was built with and returns a hard
error from frame() when a dequeued frame differs, which routes a shrink through the
same rebuild an enlargement already takes. got_frame is set first so a session that
did deliver frames is not counted as one of the zero-frame sessions that demote a
display to PipeWire.
The general fix belongs to the Wayland path rather than to this backend, and is
filed separately as #15695.
Four tests cover it, the first in this file: the matching size is delivered, a
smaller and a larger frame both end the session, and an unknown session size stays
out of the way instead of rejecting everything.
* drm: refuse a libdrmtap that cannot do the split export
The root --service must never load libEGL/libGLESv2: the point of the split is
that it exports the scanout dma-buf and the unprivileged --server converts. Two
paths could still break that, both because the loader accepted a library too
old to export.
drm_prewarm() called grab() when the loaded .so had no drmtap_grab_desc, and
grab() maps and detiles, so the privileged process pulled in the vendor GL stack
at startup, before any consumer had asked for a frame. The per-connection
capture loop then did the same for every frame, through the CPU fallback.
The version guard could not prevent it: it compared the ABI major only, and this
library is still 0.x, so every release it has ever made passed. Add a floor at
0.4.9, where the split entry points landed, and require the three split symbols,
which also rejects a build that reports a new enough version without carrying
them. That is not hypothetical: a pre-release stamped 0.4.15 shipped without the
multi-GPU accessors. Both refusals fall back to PipeWire/portal and say which
file and which symbols, at warn level.
The split symbols are no longer Options, so the type system carries the
guarantee instead of a convention. What is left of the CPU path is only what it
was meant to be: the consumer has no render node of its own, or the seat exports
no transferable dma-buf. Both are facts about the hardware, with no alternative
that keeps the stream, and neither is a property of which file was on the load
path.
Verified against the real library on i915. With 0.4.15 the export path captures
a tiled XR30 scanout and libEGL stays out of /proc/self/maps, while the old
grab() branch maps it, so the finding reproduces. A stub reporting 0.4.8 and a
stub reporting 0.4.15 without the split symbols are both refused, each with its
own diagnostic. The mirrored repr(C) layouts are unchanged across 0.4.9 to
0.4.15, checked field by field against include/drmtap.h at both ends, so the
floor costs no compatibility that was real.
* drm: move the _drm channel and its producer into src/ipc/drm.rs
src/ipc.rs is the file every unrelated IPC change has to be read through, and
this branch had grown it from 2227 lines to 4112. Move the DRM half out, into
the same #[path] submodule form the file already uses for ipc/auth.rs and
ipc/fs.rs, so it lands as ipc/drm.rs beside them.
What moves: the two payload structs, the producer that runs in the root
--service, and the bespoke SCM_RIGHTS framing the channel needs because
Framed/BytesCodec cannot carry ancillary data, plus their tests. What stays is
the Data variants, which belong to a shared enum and cannot live anywhere else,
and three re-exports so every existing call site keeps the path it already uses.
ipc.rs is 2285 lines now, 58 above upstream instead of 1885. The move is
content-identical: the only edits are the 39 per-item cfg attributes, redundant
now that the module is gated once at its declaration, and the test module cfg
that becomes a plain cfg(test). Checked by extracting the moved ranges from the
previous commit and comparing them line by line against the new file. Both
configs build with no new warnings and the same 92 tests pass, 14 of them the
drm ones that moved.
* drm: bound the _drm accept path (M1, M2, M8)
M1: authorization is now done on the blocking pool. It reads the active session
uid, which on a cache miss forks loginctl, and the socket is 0666 so any local
uid can make us do it. The same call exists for _service, but this runtime is
shared by every live capture stream, so a stall here hitches frames instead of
delaying one config sync.
M2: the handshake was a loop that ignored unexpected messages, which restarted
the ten second budget on each one, so a peer sending junk just inside the timeout
held a worker thread and one of the eight connection slots for as long as it
liked, and eight of them denied DRM capture entirely. It is one receive now, and
anything that is not DrmStart closes the connection: the consumer answers the
display list with DrmStart and nothing else, so there is nothing legitimate to
skip past.
M8: dropped the extra unauthorized-connection warn. log_rejected_service_connection
inside the authorization already logs the rejection with the peer and active uid
and rate limits it to one line per five seconds, which is exactly what a
world-connectable socket needs; the second line had no throttle and handed anyone
who can connect an unbounded log write.
Both configs build, 92 tests pass.
* drm: stop the two states that never settle (M4, M6)
M4: a dead producer left the availability verdict positive forever. The
background refresh keeps a positive verdict on a failed probe, which is right for
one failure and wrong for a run of them: if the root --service dies while this
--server lives, every probe fails, the cached list keeps being advertised, and
every display restart-loops. Three consecutive failures now drop the verdict to
Unknown, not to Unavailable, because the evidence is about the producer and not
about the hardware, so the next enumeration probes from scratch. The cold probe
also resets its own failure budget on success: it was never reset, so the five
strike allowance was spent once per process and a later probe demoted on its
first failure.
M6: a display that can never be grabbed churned PeerInfo about every 35 seconds
for the life of the process, because the cooldown was flat: demote, wait 30 s,
get advertised online, burn four sessions in a few seconds, demote again. The
cooldown now doubles per demote cycle up to 8 minutes. Recovery is unchanged in
the way that matters, since the count is erased the moment the display delivers a
frame rather than decaying with time, so a monitor that comes back is served
immediately.
Also, while changing that map: a zero-frame session on a display with no
connector identity was recorded under the empty key, which is the same aliasing
H2 removed for indexes, one unidentifiable display would have demoted the next
one. It is skipped now, as the comment above it always claimed.
Two new tests cover the backoff schedule and the reported 35 second cycle. 94
tests pass, both configs build.
* drm: give the DRM uinput update the timeout and the bookkeeping (M3, M6)
The DRM path sets the uinput absolute range itself, because it bypasses
check_init. That copy awaited update_mouse_resolution raw, and it was missing
three things check_init has sixty lines above it.
No timeout: uinput set_resolution reads its reply with no timeout of its own, so
a hung uinput socket blocked every video-service start on this branch, and wedged
the hotplug worker inside rt.block_on with UINPUT_REFRESH_BUSY latched true,
after which every later hotplug refresh was silently skipped for the process
lifetime. It is bounded at 3 s now, the same bound check_init uses.
No bookkeeping: it never called set_wayland_uinput_rect or
set_wayland_layout_baseline, which is why the #15601 layout-drift remap never
activated on the DRM path. Both are recorded now, and only after a successful
apply, so a transient failure is retried rather than remembered as applied.
No cache invalidation: the cached Wayland layout can predate compositor changes
made while no session was active, which is the case #15601 is about. Dropped
first, as check_init does.
It also stops reprogramming the device when the range has not changed (M6): a
display in a rebuild loop called this about once a second, and reapplying an
identical range is an IPC roundtrip plus a uinput reconfiguration under a user who
may be at the console. The layout baseline is still re-snapshotted on every call,
since it is what the client coordinates are measured against.
Left as a separate copy rather than folded into check_init: check_init ships in
every Linux build and the standing rule for this feature is that the drm-off
build does not change by a line. Both configs build, 94 tests pass.
* drm: check the greeter server is alive, not just spawned (M5, M10)
M5: the greeter fallback tested the wrong thing. start_server reports whether the
SPAWN succeeded, so a greeter account that cannot actually run the server, a
nologin shell or a hardened home, leaves a child that exits at once; the loop
sees only that the child is gone and respawns it as the greeter forever, never
reaching the root fallback, and the login screen becomes un-remotable on a host
where it used to work. It now requires the child to still be alive after a one
second grace before accepting it. A server that dies later than that is a
different, transient failure and the existing restart throttle already bounds it.
While there: the whole greeter branch is now inside the drm cfg, so the drm-off
build is upstream's single start_server line again rather than a run_as_greeter
variable that is always false.
M10: two monitors of the same model and resolution whose names do not normalize
to the compositor's matched no output at all, so both kept the DRM origin, which
is (0,0) for independent CRTCs. The client stacks them and injected coordinates
hit the wrong monitor with certainty. Unmatched connectors now take the next free
output in layout order, preferring one of the same physical size, and say so in
the log. That is at worst a swap of two identically sized rectangles, and the
layout stays coherent. The same pass also stops one output being claimed by two
connectors, which the unique-resolution rule allowed.
The assignment is now a pure function, so the cases are testable without a
compositor: five tests cover the naming difference, the identical-monitor case,
the double claim, name match beating the fallback, and more connectors than
outputs. 99 tests pass, both configs build.
* drm: stop reallocating and recopying whole frames (M9)
The CPU fallback moved a scanout four times: the producer packed it, the kernel
carried it, next_raw allocated and zeroed a fresh buffer to read it into, and the
consumer copied that into the slot. At 4K30 the last two are about 8 GB/s of
memory traffic that does nothing.
next_raw_into reads the body straight into a buffer the caller owns, so the
kernel copy lands where the frame is going to live, and resize costs nothing once
a buffer has seen one frame of that size. The frame buffers then circulate
instead of being freed and reallocated: whatever a new frame displaces goes back
on offer, both when the encoder consumes one and when a frame is superseded
before anyone reads it. The dma-buf path still copies once, because the convert
output is borrowed from the render context and only lives until the next convert,
but it copies into a recycled buffer and does it outside the slot lock, so a
multi-megabyte memcpy no longer holds the encoder off the slot.
Steady state is now one allocation for the whole session on both paths, and the
CPU path carries the pixels twice instead of four times.
The cursor body reads into its own buffer and is moved into the cursor cache
rather than copied; it is small and rare, so it stays out of the frame recycler.
Two tests: the raw body round trip now also covers a shorter body reusing the
buffer, so a stale tail cannot survive into it, and a new test asserts the frame
buffers circulate by allocation identity rather than by inspection. 100 tests
pass, both configs build.
* drm: the polish list, and a correction to my own ABI floor
The version floor I added two commits ago was one release too low.
drmtap_open_render and drmtap_convert_dmabuf are 0.4.9, but drmtap_grab_desc is
0.4.10, so a genuine 0.4.9 library passed the version gate and was then refused
by the symbol gate with a message that called it a stale or pre-release build,
which it is not. The floor is 0.4.10 now, the release where the whole split API
exists, and the test lists 0.4.9 among the rejected versions with the reason.
ExportLedger is deleted. DRM_FD_ELISION was false, so should_send_fd returned
true at its first branch and about sixty lines of eviction and epoch machinery
were unreachable, untested, in a security sensitive file. Why it was disabled
is worth keeping, so here it is: eliding the fd on an fb_id the converter has
already imported looks free, but the kernel can recycle an fb_id onto a
different buffer with identical geometry and modifier, and the exporter cannot
see the dma-buf inode that would tell the difference, so the elision can serve
a stale EGLImage. Sending it is cheap, the converter imports once per buffer and
closes the surplus fd, and libdrmtap's own cache keys on fb_id AND inode and can
only re-import when it is handed a real fd. That reasoning now lives here
instead of in dead code.
The rest:
- num_planes is clamped on the consumer before it reaches the C descriptor. The
producer normalizes it and must be root, so this is only defense in depth, but
the wire is the one place the value arrives from another process.
- warm_availability returns early on X11. Nothing there can consume a DRM
stream, and probing makes the ROOT service open DRM readers, so an X11 host
running a drm build was paying that at every startup for a path it can never
take.
- drm_cursor_id no longer clones the cursor. The cursor service polls it at
frame cadence to compare eight bytes, and a 256x256 cursor is 256 KiB.
- The premultiplied ARGB pass-through is now documented as matching the XFixes
path, since that is why it is correct rather than an oversight.
- cfg hygiene: input_service.rs uses all(target_os = "linux", feature = "drm")
like every other site, and active_uid_cached is gated with the feature too,
which also removes a dead-code warning from drm-off Linux builds.
- Nits: DrmConn is pub(crate) like its constructors, new_drm_listener is no
longer async with nothing to await, and the two anyhow! plus return Err pairs
are bail! as the codebase writes them.
- DRM_CAPTURE_SECURITY.md moves to docs/ with the other docs, and its "no
privileged child process is ever spawned" claim is corrected: an empty
helper_path is not a disable switch in the C, find_helper searches six fixed
paths and would exec one if the direct export ever failed. It is unreachable
here for two independent reasons, the root service holds CAP_SYS_ADMIN so the
direct path succeeds and the package builds no helper at all, and the paths
are root-writable only, so the accurate statement is that this package never
installs one, not that it can never happen.
- The comments that narrated the review rather than the code are rewritten to
say what the code does. One of them had also drifted: the convert context is
opened before we answer with DrmStart, not before the handshake.
Both configs build with no new warnings, 100 tests pass.
* drm: one DisplayHealth per connector, and the last index-keyed map
The three per-display verdicts are three answers to one question, can this
display be captured over DRM right now, and they already fed each other: the
rebuild cadence and the zero-frame streak end in the same demotion, and the
convert verdict is what keeps a multi-GPU display off the dma-buf path so it
never gets there. They are one struct now, keyed by connector identity.
This also closes a real leftover from H2. Two of the three maps were re-keyed by
identity then; the rapid-rebuild map was not, and stayed keyed by list index. A
hotplug that renumbers the list therefore moved a flap verdict onto whichever
monitor took that slot, which is the same defect in the third map. There is no
index-keyed per-display state left.
Behaviour is otherwise the same, with one improvement that falls out of the
merge: when a demotion cooldown expires, clearing the streak now keeps the
display's other state rather than replacing the whole entry, so a build cadence
and a convert verdict survive a retry the way they always should have.
One test for the demoted predicate, including that a higher demote count still
holds a display that a lower one would have released. 101 tests pass, both
configs build.
* drm: bound the GITHUB_TOKEN in the drm workflow
CodeQL flagged the new workflow for not declaring permissions, which is fair:
every job here only checks out, builds and tests, and the artifact up/download
in the deb job authenticates with the runtime token rather than this one, so
contents: read is the whole requirement. Declared at the workflow level so the
reusable bridge workflow it calls inherits the same bound.
The stock workflows do not declare it either, but they are upstream's and this
feature does not touch them; a new file can start out right.
* drm: make the outer handshake budget dominate the inner one
Two findings from the review bot on our own fork, both worth taking.
The caller waited HANDSHAKE_TIMEOUT_MS + 500 for the receive thread to hand back
the display list, but that thread is allowed to spend more than that: the connect
budget, and then recv_msg_timeout2 applies its argument twice in the worst case,
once waiting for the first byte and once for the body. So on a slow connect the
outer timer fired first and abandoned a handshake that was still inside its own
budget. The wait is now derived from those parts rather than written as a
constant, so changing either one cannot silently invert the relationship again,
and the two connect sites use the named constant instead of a literal.
The cursor cache insert shadowed hcursor under a cfg, so the same line meant the
requested id in one build and the served id in the other. It is a separate name
now, with the reason on it.
Not taken, and why: the bot also suggested making DrmCursorData carry width and
height as u32 to match the wire. They are i32 because that is what they feed,
protobuf CursorData declares both as int32 and platform/linux.rs assigns them
straight across. One cast has to exist somewhere, and it belongs at the boundary
where the values are already being validated, not at the consumer.
101 tests pass, both configs build.
* drm: bound the body read, and stop the empty key from aliasing displays
From the second review bot on our fork. Two of these are real and one of them is
mine from earlier today.
A raw body read had no deadline. Only the header was bounded, and drm_read_full
loops on readable() until it has the exact length, so a producer that wrote a
header and then stopped (crashed, stopped, wedged) pinned the consumer receive
thread forever. That thread is also the one that observes the stop flag, so every
capturer rebuild would have stranded another thread and its render context. The
whole body is bounded now, and an overrun is a hard error because the header is
already consumed and the frame cannot be resumed.
get_capturer_info collapsed an unknown connector identity to the empty string and
then read and wrote the health map under it, so two unidentifiable displays shared
one entry and one could demote the other. That is exactly the aliasing frame()
refuses to take part in; I fixed one side of it this morning and left the other.
The key is an Option now and both blocks skip when it is None: a display with no
identity simply carries no health.
Also from the same pass, smaller:
- build.py validates the shape of DRMTAP_SHA and DRMTAP_REPO before they reach a
shell command. Both are env-overridable and get interpolated, and beyond the
injection argument, an abbreviated sha would defeat the point of pinning while
failing in a much less obvious place.
- the workflow's push path list is now identical to the pull_request one. It was
missing four paths, so a push to master touching only those would have skipped
re-verification.
- the checkouts set persist-credentials: false, so the token does not stay in
.git/config for the rest of the job.
- a concurrency group supersedes a stale PR run, but never cancels a master run,
whose whole purpose is to record that a commit was verified.
Not taken: reading VCPKG_COMMIT_ID and FLUTTER_VERSION from a shared .env. There
is no .env at the repo root, and the stock ci.yml and flutter-build.yml hardcode
those same two values, so this matches what is already there.
101 tests pass, both configs build.
* drm: test the half of the accept-time authorization that had none
The review called the accept-time authorization decision the single most
important invariant in this PR, and noted it has no test. Half of it did:
drm_peer_authorized_matrix covers the uid rule. The other half, the
/proc/<pid>/exe identity match that stops a DIFFERENT program running as the
right uid from being handed the screen, did not.
We said last round that testing it needs a second process with a different
executable, so it was integration rather than unit work. That was too
pessimistic: the negative case needs ANY foreign executable, not a second build
of rustdesk, and /bin/sleep is one. So the test covers all three outcomes: our
own pid matches, a live process running another binary is rejected, and a peer
whose pid cannot be resolved is rejected rather than admitted.
The test synchronizes on the child having exec'd before it looks. spawn returns
while the child is still a copy of us, and until exec completes /proc/<pid>/exe
points at OUR binary, so reading it too early sees a match and the assertion
passes for the wrong reason. It failed exactly that way under the parallel suite
and passed when run alone. A real peer has necessarily exec'd and connected
before it can be authorized, so the window exists only in the test.
102 tests pass, three consecutive full runs, both configs build.
* drm: make the refresh decision a pure function, and test it
The review named two untested things: the accept-time authorization decision,
covered by the previous commit, and the availability/demotion state machine. The
demotion half got tests with the backoff work; this is the other half, what a
completed background refresh decides.
It is extracted rather than tested in place on purpose. The effects touch
process-global state, DRM_STATE and the failure counter, which parallel tests
cannot share, so a test driving them would be intermittent by construction, which
is the kind of test nobody ends up trusting. The decision itself has no such
problem, so it is now a total function over the probe result and the consecutive
failure count, and the closure applies it.
Two tests: the decision table, including that a run short of the threshold keeps
a working verdict and the threshold gives it up; and the symptom the policy
exists for, a root service that dies while this server lives, where every probe
fails from then on and the verdict has to be given up in bounded time, to Unknown
rather than Unavailable, because what we learned is about the producer and not
about the hardware.
104 tests pass, both configs build.
* drm: count a display whose frames never match its advertised size
The display list carries the CRTC mode and a frame carries the scanout
framebuffer. Those are two different numbers whenever a CRTC scales a
smaller buffer up to its mode, so such a display fails the geometry guard
on the FIRST frame of every session, having delivered nothing.
That path marked the session as having produced frames, which is what the
zero-frame streak uses to decide a display cannot be served over DRM at
all. So the demotion to PipeWire never armed and the display rebuilt until
the rapid-rebuild guard caught it seconds later, under a message about a
mid-session change that never happened.
Count it instead, through the same bookkeeping the stream-died path uses
(now one helper, so the two cannot drift), and say which of the two cases
the error is. The unit test asserted the old behaviour on a capturer that
had never delivered a frame, so it is split into the mid-session case it
meant to cover and the first-frame case it was silently locking in.
* drm: make an unpinned libdrmtap deliberate, and reject --drm off Linux
Three ways to build a different libdrmtap than the pinned one (DRMTAP_REPO,
DRMTAP_SHA, DRMTAP_PREBUILT_DIR) were each silent, and the last skips the
sha verification entirely. The claim this feature rests on is that the
privileged capture library is the reviewed object at the pinned sha, so any
build that is not that one now has to say so: the overrides still work and
still cover local work and cross-builds, but they need
DRMTAP_ALLOW_UNPINNED=1 alongside them and the build prints what it did.
--drm on Windows or macOS was accepted and then dropped by get_features(),
so it produced a stock build that looked like a DRM one. Reject it.
Also test the _drm body-read deadline, which nothing exercised: the header
and the body are separate reads, so the caller budget does not cover the
second one and a regression there would silently reopen the stall.
* drm: treat an empty DRMTAP_PREBUILT_DIR as unset in the pin gate
build_libdrmtap_so() tests it for truthiness, so an empty value means no
prebuilt directory. The gate compared it against None instead, and would
have demanded the opt-in for an override that was never going to happen.
* drm: never latch the uinput refresh slot, and bound the source stride
The uinput refresh worker released UINPUT_REFRESH_BUSY on its two normal
exits only. The body locks several process-wide mutexes and does a Wayland
roundtrip, so an unwind there left the flag set for the process lifetime,
and every later hotplug then skipped the spawn and never reapplied the
uinput ABS range: the stale-range, wrong-output symptom the refresh exists
to prevent. This file already had the answer for the probe flag, one screen
away, and the hazard is called out in wayland.rs. Fixing one site and not
the other is the same miss as the hotplug maps.
The slot is deliberately handed back and re-taken mid-loop, so the guard
tracks ownership rather than releasing unconditionally: a plain RAII drop
would clear a flag a replacement worker owns.
drm_reader bounded only the destination (w*4*h) while the row loop reads up
to (h-1)*stride + w*4, so a large stride read past the mapping and could
overflow usize in y*stride. drm_render::convert already bounds stride*h;
the privileged half must not be the weaker of the two.
Also give the drm CI jobs a timeout, so a hung meson or vcpkg step fails in
an hour instead of six.
* drm: refuse to ship a libdrmtap built without the EGL backend
libdrmtap treats egl/glesv2 as OPTIONAL: without their headers and
pkg-config files meson silently builds a CPU-only stub. The stub still
exports every symbol the loader gates on, so nothing downstream notices,
and the split capture depends entirely on the unprivileged side
EGL-detiling the scanout it receives. The result is a build where DRM
capture quietly degrades to PipeWire on every tiled-scanout host, which is
most of them. Our CI asserts this on the .so it builds; a developer or
packager running build.py got no such check.
Assert on the artifact rather than passing -Degl=enabled: that option only
exists in libdrmtap past the pinned 0.4.15, and checking what was actually
produced also catches a stale or substituted object, which a build flag
cannot. Same two markers CI looks for, and for the same reason an ELF-level
check does not work: EGL is reached by lazy dlopen so there is no
DT_NEEDED.
* drm: gate the libdrmtap ABI on the minor, and skip the warm probe on X11
Two items from the review that I had recorded as done and were not.
The ABI check had a floor and no ceiling, so 0.5.0 and 0.9.9 passed. Under
0.x semver the minor is the breaking axis, and libdrmtap freezes only
drmtap_device and drmtap_dmabuf_desc: drmtap_frame_info, drmtap_display,
drmtap_config and drmtap_cursor_info are not frozen. A 0.5.0 adding one
field to drmtap_frame_info still reports major 0, so we would have loaded it
and read every field at the wrong offset, in the root service. It now
requires the verified minor; a 0.5.x needs a deliberate bump after comparing
the layouts.
The unit test asserted the opposite of this, in as many words ("0.5.0 must
pass"), so it was holding the hazard in place. Replaced.
warm_availability ran on X11 too, where every consumer of the verdict sits
behind an !is_x11() check, so the root service opened DRM readers for a path
the session can never use.
* drm: close the full-review findings (a third latched flag, and two escapees)
The one that matters: the display-cache refresh worker was the THIRD copy of
the wedged-flag hazard. catch_unwind covered only the enumeration, and
thread::spawn panics on EAGAIN after RUNNING was already swapped true, so
either path parked the flag for the process lifetime and every later refresh
- including every udev hotplug - returned early forever. Same ownership
guard as UINPUT_REFRESH_BUSY (the flag is handed back and re-taken mid-loop,
so an unconditional RAII release would clear a replacement worker's flag),
plus a fallible spawn whose failure drops the closure and releases the slot.
DRM_PROBE_IN_FLIGHT, UINPUT_REFRESH_BUSY, now this: the lesson stays
'grep for every site with the shape', and twice was not enough.
Two findings had been flagged in an earlier round and escaped the ledger:
- an unrecognized convert-output fourcc fell through to 'present as BGRA'
with a debug log, where every sibling validation in that function is a
hard error that lets the caller fall back to PipeWire. A 64bpp output
passes the stride check and encodes garbage. Hard error now.
- the trust-boundary validation constants (fourccs, MAX_DIM,
MAX_FRAME_BYTES) were declared independently on both sides of the split.
Hoisted into drm_reader, imported by the converter, so the two halves
cannot drift apart about what data they will touch.
The rest:
- the CI symbol extraction dropped any loader symbol containing a digit and
degraded to a pass-with-zero-iterations no-op if the b"..." literals were
ever refactored; digits allowed, count asserted, notice de-hardcoded.
- 'drm' in features was a substring test on the comma-joined string, so a
future drm-lease feature would have shipped the consent-bypass deb
without --drm. Exact membership now.
- the security doc claimed the deb is built on an ubuntu18.04 container;
the only deb job runs on ubuntu-24.04. The 18.04 sentence now says what
is true: 2.4.95 is an API floor, the binary floor is the build host's.
- DRM_DISPLAY_CACHE poison handling was recover-in-the-writer,
panic-in-the-readers; both readers now recover like the writer.
- the producer prewarm ran on X11 where no consumer can connect, the same
inconsistency just fixed for warm_availability. The listener still starts
(the service outlives sessions; a later Wayland login must find the
socket), only the prewarm is skipped.
* drm: measure the verification deb glibc floor and put it in the artifact name
The workflow already said in a comment that this deb is a verification build
with a higher glibc floor than the release debs, because it builds on the
runner rather than in the ubuntu18.04 container the stock job uses. A comment
in this file is not visible to whoever downloads the artifact from the Actions
UI, and the name was a bare rustdesk-unattended-wayland-x86_64.deb, so it read
like something installable anywhere.
The floor is now read off the built object with objdump and goes into the
artifact name, so the constraint travels with the file. Measured rather than
stated: a hardcoded number would drift the next time the runner image moves.
Verified the pipeline against a real deb here (2.39).
Restoring the container build is the other option and is cheap to do -- the
recipe including the two 18.04 traps is still in this repo's history -- but it
belongs with a deb that is actually distributed, not with a job whose contents
are already asserted in-place.
* drm: the same latched-flag bug a fourth time, in my own fix for the third
I built UinputRefreshGuard INSIDE the spawned closure, so it only covered
paths where the closure ran. thread::spawn panics on EAGAIN after the swap,
so no guard existed and the flag stayed set for the process lifetime, which
is the exact failure the guard was introduced to prevent. I then wrote
RefreshSlot correctly - constructed before the spawn, moved in - two hours
later and did not go back to fix its sibling. Both are right now, and the
spawn is fallible in both.
Also from the review:
- DRMTAP_PREBUILT_DIR returned before the EGL-stub assertion, so the check
only guarded the source build. That is backwards: prebuilt-dir is the
widest override (no fetch, no sha check, an object this script never sees),
the likeliest to hand over a stub, and the path our aarch64 cross-build
actually uses. Verified the assertion accepts a real .so and rejects one
built with -Degl=disabled.
- convert() bounded only the frame libdrmtap returns, not the descriptor going
in. offsets/pitches address plane ranges inside the dma-buf, so those are
what a malformed pair would reach past. Bounded per populated plane, the
same way the export side is. Defense in depth (the producer is
root-authenticated and libdrmtap validates against the fd since 0.4.12),
but the two halves should agree before the C sees the data, not after.
- the flutter patch step used '[[ test ]] && git apply' as its last command,
so the step would FAIL rather than skip the first time FLUTTER_VERSION
moves off 3.24.5. Explicit if/else, and the values now come from the
environment instead of ${{ }} interpolation, which also clears zizmor's
template-injection warning. Checked both branches.
Declined: the cursor id/cache-key convergence finding. Both accessors use one
selection over one map, so they can only disagree across a publish race, and
state.hcursor is already set to the id ACTUALLY served (drm_served_id), which
is the sync the finding asks for - added in an earlier round.
* drm: stop routing gates from paying for the availability probe
A Major finding I skipped twice, and the file already argued against itself:
wayland.rs's own NOTE says re-probing _drm from the async enumeration path
blocks the executor long enough to trip 'deadline has elapsed' and spiral
into a restart loop -- and then six routing gates called is_available(),
which runs query_displays() inline whenever the state is Unknown (cold start,
or a NEGATIVE_TTL expiry mid-session). ensure_inited, is_inited,
get_displays_and_primary and clear() are exactly the paths the NOTE names.
is_available_cached() is a single mutex read: KNOWN-available or not. The six
gates use it, which is safe because they are routing decisions, not
capability ones -- a cold cache answers 'not DRM' and the caller takes the
PipeWire path it would have taken anyway.
Switching all seven, which is what the finding literally suggested, would
have introduced a worse bug: warm_availability calls query_displays()
directly, so is_available() would have had ZERO callers and nothing would
ever probe lazily again. A --server that started before the root service
would then never see DRM for the rest of its life. get_capturer_for_display
keeps the probing form -- it is sync, on the plain video thread, it is the
capture-build path where a definitive answer is the point, and it is what
makes a cold cache recoverable.
* drm: stop leaking the authorized _drm fd into forked children
libc::dup() does not copy the close-on-exec flag, so the dup'd _drm socket fd
was inherited by every child this process forks. This process is the ROOT
service and it does fork synchronously elsewhere (the loginctl active-uid
lookup), and that fd is an ALREADY-AUTHORIZED channel to the one thing on the
box that hands out scanout dma-bufs. F_DUPFD_CLOEXEC instead. Measured the
difference rather than assuming it: dup() leaves FD_CLOEXEC clear,
F_DUPFD_CLOEXEC sets it.
Also the last two artifact sources without the stub check:
- --package + --drm stages the .so straight out of a bundle somebody else
produced, with no _assert_so_has_egl. Third source, same exposure as
DRMTAP_PREBUILT_DIR, now asserted like the other two. All three artifact
paths are covered.
- the workflow triggers omitted src/server.rs, src/server/input_service.rs and
src/platform/linux.rs, which all carry DRM wiring (warm_availability, the
cursor path in run_cursor, the producer start and get_cursor/get_cursor_data),
so a PR touching only those skipped the entire drm verification. Added to
BOTH mirrored lists and asserted equal (15 == 15).
* drm: decide x11 inside the prewarm, with a bounded re-check
the one-shot is_x11() gate at the call site misfired during boot:
get_display_server() falls back to "x11" while loginctl cannot name the
seat0 session yet, so on a wayland host with the service enabled at boot
the prewarm was skipped for the life of the service and only ever ran
after a manual restart, which is how every deploy happened to exercise
it.
move the gate inside drm_prewarm and re-ask every 2s for up to 30s. a
genuine x11 or headless host exhausts the budget having opened no
DrmReader and no drm fd; a wayland boot proceeds as soon as the session
reads as wayland. measured on a boot: the skip used to fire 0.8s in
while loginctl reported the wayland greeter in that same second, and
graphical-session.target only arrived at +5s.
* drm: wake idle-disabled displays and settle the topology before the client is promised a list
a compositor that idles long enough does not merely blank a panel: it
disables the connector, leaving no scanout for any capture backend to
read - not drm, not pipewire, not x11. on an unattended box that meant
connecting to whatever was still scanning out (on an apple t2, the
60x2170 touch bar strip) with the real panel sitting disabled next to
it, or a stale cached list advertising a display with nothing behind it
("waiting for image").
the fix has three parts, and where the wake runs is the load-bearing
one:
- the root service answers every _drm handshake with a fresh, settled
enumeration (drm_enumerate_settled): enumerate, and if a CONNECTED
display has no crtc, inject one synthetic 1px pointer round trip over
uinput (rate limited to one per 20s, one winner via compare_exchange)
and hold the answer until nothing wakeable is left undriven or a 3s
deadline passes. rate-limited losers wait for the outcome too while a
wake is recent - answering with the pre-wake list is exactly the
mid-transition state that produced duplicate, misindexed monitors.
connectors a wake could not bring back are latched by connector
identity (device:connector) and the latch is self-refuting: an entry
later seen scanning out is dropped, so one slow modeset cannot
disable the wake for the life of the service, and a dummy plug cannot
suppress the wake for a different panel that idles later.
- the login path refreshes the cached display list over a live
handshake (refresh_displays_for_login) before peer info is built, so
the list the client is promised is the post-wake truth and never
changes under it seconds later. the publish is generation-checked
against concurrent writers; every failure mode keeps the previous
cache, so a login can never get harder than before, only truer.
- the capture handshake resolves the display index the client chose by
connector identity against the handshake list (the service enumerates
fresh per connection, so an index alone is only meaningful against
the list it came from), fails the build cleanly when that monitor is
gone, and no longer republishes its handshake list into the
availability cache - that unordered write could clobber a newer
settled list with pre-wake data and re-advertise a reordered list
under a live session.
the display-list read timeout grows to cover the settle budget
(DISPLAY_LIST_TIMEOUT_MS), or a wake that needs the full recheck would
turn into a spurious handshake timeout on exactly the host it exists
for. removing the display cache from the handshake path also retires
DRM_CACHE_WARMED; the cache still feeds the topology push and the udev
listener.
measured on the t2 (amdgpu panel idle-disabled, appletbdrm touch bar
still scanning out): connect -> wake fires with undriven=1 -> panel
returns in ~330ms -> the same probe answers 2 displays -> the client
starts on the panel. with the panel awake: zero wakes. the root service
still never maps libEGL/libGLESv2.
* drm: close the round-7 review findings
- the renumbering probe in the DrmDisplaysChanged handler now reads the
pushed list at wire_idx, the slot our monitor held in the service's
index space, instead of at the index the client chose. the pushed
list shares the handshake list's construction, so probing the client
index compared two different index spaces whenever a wake or hotplug
had renumbered entries - tearing down a healthy stream or missing a
real renumbering.
- both message-body reads (cpu frame, cursor pixels) now run under a
deadline. only the header read re-checked `stop`, so a producer dying
between a header and its body pinned the receive thread forever and
every rebuild leaked a thread plus its render context.
- the drm cursor cache gets a size ceiling (drm ids are derived from
the shape's content, so an animated pointer minted a new key per
shape and the map grew for the life of the service; x11 ids come
from a small serial set, so the ceiling is gated and the stock build
is untouched).
- has_non_drm_backed_display reads a two-scalar accessor instead of
cloning and geometry-augmenting the whole display list on every
cursor tick.
- the libdrmtap pin validation moved out of import time into
build_libdrmtap_so(), so leftover DRMTAP_* environment variables or a
malformed sha cannot fail a stock build that never touches libdrmtap.
- reworded a workflow comment whose literal expression marker broke
actionlint.
* drm: close the round-8 review findings
- the .so contract check in the drm workflow runs under strict mode:
without set -e the trailing ::notice echo returned 0 and masked the
`test "$missing" -eq 0` assertion, so the step passed even with a
missing loader symbol or a CPU-only stub. the two extraction
pipelines get an explicit rescue so a zero-match grep still reaches
the ::error guard that explains WHY instead of dying silently.
- the pipewire-fallback geometry guard no longer compares the physical
drm size against the portal rect on a single-display host: the rect
is the compositor's LOGICAL size, so on a scaled output the two
legitimately disagree (2880x1800 vs 1440x900) and the guard rejected
the one valid fallback, restart-looping the display instead of
degrading. on a single-display host the whole-desktop stream is that
display by construction, so only the position has to agree; the size
check stays on multi-monitor hosts, where it is what tells one
connector apart from the full-desktop rect.
* drm: close the round-9 review findings
- strict mode on the remaining two assert steps of the drm workflow
(the deb-contents assert and the glibc-floor measurement): same
masking pattern as the .so contract step fixed last round - without
set -e only the last command's status counts and the mid-script
checks were decorative. the floor extraction gets an explicit rescue
so a no-match grep still reaches the `test -n` reporter.
- the security doc states the whole accepted version window (exactly
the pinned minor with a patch floor; a NEWER minor is refused too,
because the mirrored struct layouts are only verified against the
pinned one), and the auditing section carries the command matching
its leftover-object comment.
- the uinput-missing warning literal lost the embedded space runs a
reflow had left in it (it is the sole, once-per-process diagnostic
for that failure and it read as a run-on line with gaps).
- the geometry-mismatch path in frame() hands the taken buffer back to
the recycler before erroring; dropping it made every rebuild cycle
re-allocate a scanout-sized buffer.
* drm: document the display wake in the threat model
the wake is deliberate input injection by privileged code, which is
exactly the kind of thing this document exists to state precisely
rather than leave to be discovered in the diff: why it must run in the
root service (uinput is root-only and the compositor holds drm master),
what it can reach (only an already-authorized _drm connection triggers
it), how narrow the trigger is (a connected-but-undriven connector,
with a self-refuting per-connector memory for the hopeless ones), the
rate bound (one wake per 20s process-wide, single winner), the device
lifetime (created and destroyed around the emit), and that a host
without /dev/uinput loses nothing it had (such a session was already
view-only).
* drm: close the round-10 review findings
- the /dev/dri gate returns the CANONICAL path instead of a bool, and
both callers open that value. answering yes/no meant the caller
handed the original string to libdrmtap, which re-resolved every
symlink component after the check - a check-then-use window, in the
root service. this is the whole point of the gate, so it should
never have been able to hand back an unresolved path.
- `--package <folder> --drm` builds the capture library instead of
demanding it inside the bundle. no build path puts libdrmtap in a
bundle folder (the flutter deb builds it straight into the staged
deb), so that check made the flag combination impossible to satisfy.
the safety property it stood in for is now asserted directly and
better: the staged BINARY must carry the drm dlopen path, so a stock
binary can never be packaged under the consent-bypass name. a bundle
that does carry a .so keeps its existing EGL assertion, and the
variant naming keys on the explicit request rather than on what
happened to be staged.
- the deb assert step globs into an array and asserts the count: under
set -e `ls` aborted before its own `test -n` could report, and
several matches produced a multi-line value whose mv failed with an
unrelated error.
* drm: finish the logical-geometry comparison, and chain a re-raise
the pipewire-fallback guard now normalizes BOTH sides to logical before
comparing. last round fixed only the single-display case, which left
the same defect on the shape that actually has it: on a multi-monitor
scaled host the advertised geometry carries the PHYSICAL drm mode plus
the compositor scale, while the portal rect is already logical, so a
scaled output disagreed with itself (2880x1800 against 1440x900) and a
per-connector stream that really was that display was rejected,
leaving it advertised offline instead of degrading. the size check
itself stays: on a multi-monitor host it is what tells one connector
apart from the whole-desktop rect. the failure message reports the
logical numbers, the ones actually compared.
also chains the libdrmtap read failure with `from err` so the original
OSError survives (ruff B904).
* drm: fix two review-suggested changes that were wrong, and stop overclaiming in the docs
an adversarial sweep over the whole batch, aimed at the failure that
kept recurring here (a hazard identified and only some instances
fixed), found that two changes made on review advice were themselves
defects. both are reverted with the trace written down so they do not
get "fixed" again:
- the hotplug renumbering probe reads the pushed list at the CLIENT
index again, not the service one. `bound_to` is an IDENTITY,
(device, crtc_id), so comparing it against a slot is not a
cross-index-space comparison; and `swap_available_displays` installs
that same list as DRM_STATE two lines later, which IS the client
space - display_service re-advertises it, input is mapped through
it, the next rebuild reads `expected` out of it. Probing the service
index answered a question nothing downstream consumes and went quiet
in exactly the case the guard exists for: a stream whose wire_idx
differs from its client index kept running while that index came to
mean another monitor, so the client rendered monitor A believing it
was monitor B and routed every click accordingly.
- the pipewire-fallback guard compares raw sizes again. BOTH sides are
physical: `Display::width()` on the wayland variant returns
`physical_width()`, and `try_fix_logical_size` only repairs the
capturable's separate logical_size field. Scaling the drm side
therefore compared logical against physical and rejected the valid
stream on precisely the scaled outputs it was meant to rescue. The
single-display carve-out now needs BOTH sides to be single, since a
monitor on a card the service cannot open is missing from the drm
list while the compositor still drives it.
also from the sweep:
- a capture build whose index is out of range of the advertised list
now fails instead of falling back to the raw index, which the wake
can have grown the service list back past - that bound a second
video service to a monitor already being served and recorded its
health under the wrong identity.
- the security doc no longer claims the privileged process never loads
GL. That is true of the DEFAULT path and measured there, but the CPU
fallback converts in-process, and a tiled scanout can only be
decoded through the GPU, so libdrmtap dlopens libEGL in the calling
process when the frame needs it. The doc now says which property
belongs to the path and which to the process, and bounds the cases
instead of overclaiming.
- the wake latch is described honestly: it self-clears when the
display is next driven by anything, but nothing retries it, so a
transient failure can leave it latched on an unattended host.
- the wake's uinput device DECLARES two axes and BTN_LEFT (libinput
ignores a device that does not look like a mouse) while EMITTING
only the net-zero axis round trip. the doc said one axis and no
keys, describing the emit as if it were the declaration.
- the drm CI never ran for a change to the root Cargo.toml, where the
top-level `drm` feature is defined, or to Cargo.lock, which every
`--locked` build here resolves against. both triggers list them now.
- the deb assertion checks the packaged BINARY carries the libdrmtap
dlopen path, not just that the library was staged beside it.
* drm: close the round-13 review findings
- the ABI refusal message has a branch for an unverified MINOR. It had
only two, so a library NEWER than the pinned minor was told it
"predates the split-capture API" - the opposite of its problem, and
the kind of message that sends someone looking in the wrong place.
the warn line names the accepted minor too.
- the libdrm floor no longer claims 18.04 ships 2.4.101: base bionic
shipped 2.4.91, which is BELOW the 2.4.95 the GetFB2 API needs, and
only the updates/HWE stack clears it. read as "18.04 with updates,
or newer".
- the drm-build marker scan reads the staged binaries chunked inside a
`with`, overlapping by len(marker)-1 so a marker cannot fall across
a chunk boundary, instead of pulling a 45 MB librustdesk.so into
memory and leaning on refcounting to close the file. verified
against a real drm build (found) and an unrelated binary (not
found).
* drm: close the round-14 review findings
- the .so contract and deb assertions no longer pipe into grep. under
`set -o pipefail`, `producer | grep -q` reports a FALSE FAILURE once
the producer outruns the 64 KB pipe buffer: grep -q exits at the
first match, the producer dies on SIGPIPE, and pipefail makes that
the pipeline's status - so a library that HAS the symbol is reported
as missing it and the step fails on a good build. measured on a real
EGL-enabled .so (101 KB of strings, both markers present): the piped
form reported both missing. this was introduced by the strictness
fix two rounds ago and only passes today because a release-sized .so
fits in the buffer. NOTE the obvious repair does not work either -
materializing the output and piping the variable keeps the pipe and
fails identically (measured), so these now match with bash's own
pattern operator and no subprocess at all. verified with positive
and negative controls.
- warm_availability decides X11 for itself, inside its retry loop,
with the UNMEMOISED `scrap::is_x11()`. this is the same one-shot-at
-startup bug the pre-warm had, in its sibling call site, left behind
when that one was fixed: the check ran during startup, where
loginctl cannot yet name the seat0 session and the answer defaults
to "x11", so a Wayland host that came up slowly skipped the warm for
the life of the process and got back the cold-probe "No displays"
symptom the warm exists to remove. the memoised form would have
moved the bug rather than fixed it, since it latches its first
answer.
- the grab_desc SAFETY comment says what the frame protocol actually
is instead of promising a release on every return path: traced in
the C, a failing grab_desc leaves nothing to release (-EINVAL
returns before allocating, a failed inner grab has already cleaned
up, and -ENOTSUP releases the frame itself), so releasing on those
paths would be a double free.
* drm: bound the work an unauthenticated peer can make the root service do
the `_drm` socket is world-connectable by design (the unprivileged
--server has to reach it), and every accepted peer got a spawn_blocking
authorization - which forks `loginctl` whenever the active-uid cache
misses - BEFORE any admission bound applied. MAX_DRM_CONNS does not
help there: it only counts peers that already passed. So a local uid
that will be rejected could still open connections in a loop and keep
the shared blocking pool busy, and that pool is shared by every live
capture stream, which is exactly the stall the comment above the
authorization warns about.
add a separate, small in-flight bound around the authorization step,
deliberately NOT the same counter as MAX_DRM_CONNS: sharing one would
let a rejected flood eat the capacity the real consumer needs. the
guard is taken before the spawn and released as soon as the verdict is
in, so the slot covers the authorization only. the rejection logs at
debug rather than warn for the same reason the existing rejection is
silent - anything reachable by any local uid must not be an unbounded
log-write primitive. unit-tested like its sibling, including that the
pre-auth bound stays the tighter of the two.
* drm: reject an out-of-range num_planes on the import side instead of clamping it
the incoming descriptor's plane count was clamped to 1..=4 for the
validation loop but passed to libdrmtap RAW, so a wire descriptor
claiming 7 planes was checked as if it had 4 and then handed over
claiming 7. the pinned libdrmtap refuses >4 itself, so this was not an
overflow today - but the stated purpose of that block is that the two
halves of the split agree about what they will touch BEFORE the C sees
it, and that only holds if the count travelling with the descriptor is
the count this side bounded. it also stops this half depending on an
internal check in a library pinned from another repo.
reject and normalize instead, which is what the EXPORT half already
does in grab_desc; the two sides now have the same shape.
* drm: close the round-17 review findings
- the scanout dma-buf fd is duplicated with F_DUPFD_CLOEXEC. `dup(2)`
never copies close-on-exec, so this fd was inherited by every child
the ROOT service forks (it forks synchronously for the loginctl
active-uid lookup) - and what this fd names is the live screen
contents. this is the SAME defect already closed on the `_drm`
socket fd in ipc/drm.rs; fixing that one and not grepping for the
siblings is how this survived. there is exactly one dup in the drm
path now and it is this one, verified by grep. measured that
F_DUPFD_CLOEXEC sets FD_CLOEXEC and preserves the O_RDONLY access
mode the read-only export depends on; SCM_RIGHTS delivery is
unaffected since the receiver gets its own descriptor.
- Desktop::refresh resolves HOME on the login-Wayland path too, since
the drm build now starts a --server as the greeter uid there and a
child with no HOME has nowhere to put its config. the compositor
variables stay blank deliberately: the drm path talks to the root
service and a render node, never to the compositor or the portal,
which is why it works at a login screen at all. reasoned, not
measured: a current GDM runs its greeter as `gdm-greeter`, which
`is_gdm_user` does not match, so that path is not reachable on our
hardware - measured there, the greeter server gets a fully populated
environment through the branch below.
- the glibc-floor step globs into an array and asserts the count, like
its sibling assert step. that sibling was fixed two rounds ago and
this one was left behind.
* drm: put the display wake behind its own compile gate and a runtime option
everything else in this backend READS: it captures a scanout. the wake
WRITES, injecting one synthetic pointer event from the root service
into the user's session. that is a different kind of operation and it
should be switchable on its own, at both levels.
- compile: a `drm-wake` feature on top of `drm`. every wake-only item
is gated and drm_enumerate_settled has two definitions, so
`--features drm` builds the same capture path with no wake code in
the binary. verified on a RELEASE artifact with both controls: the
drm markers are present (Started drm ipc server) and the wake string
is gone. the unattended deb passes drm-wake, so answering an
objection is one word in build.py rather than a revert.
- runtime: `enable-drm-display-wake`, server-side, the same shape
rustdesk already uses for the closest thing it does to this
(keep-awake-during-incoming-sessions, which PREVENTS sleep where
this RECOVERS from it, and is acquired only once a connection
exists, which is too late for a host that cannot be reached).
the `enable-` prefix is load-bearing: option2bool reads an absent
value as ON, and a host whose screen went dark is the case the
unattended package exists for. set it to "N" and the service stays
read-only with respect to input.
the key is declared in this file rather than in hbb_common's `keys`
module, where rustdesk's own option constants live: hbb_common is a
submodule of a repo we do not control, so a constant there could only
land after an upstream change plus a submodule bump. the option system
reads by string, so registration is not required; the cost is that the
key is set in the config file rather than the settings UI, which is
how an unattended host is configured anyway.
* drm: enumerate /dev/dri by path instead of trusting one auto-detected card
when `list_devices` gives us nothing to work with, the fallback was a
single auto-detected reader. that is the wrong unit of enumeration on a
multi-card host, and the reason is worth keeping: libdrmtap's
auto-detect picks a card that is SCANNING OUT, so when the interesting
display is asleep it picks a DIFFERENT card and we enumerate only that
one. the asleep display is then invisible - not as a display, and not
as an undriven connector either, which is what the wake keys on.
measured on the t2 with the panel idle-disabled, through a direct
libdrmtap call: auto-detect succeeds and binds card0, the touch bar,
because the touch bar is what is still scanning out; the 2880x1800
panel on card2 is invisible to that reader, while opening card2 by
explicit path in the same instant reports `eDP-1 crtc=0 active=0`
exactly as needed.
so walk /dev/dri/card* and ask each, with auto-detect demoted to a last
resort for the case where no card opens by path. this path is reached
only when list_devices is unavailable (a pre-0.4.15 .so) or opened
nothing, so it costs nothing on the normal path - it is defensive, not
a fix for anything observed with the pinned library.
the enumeration result is logged UNCONDITIONALLY, including the empty
case, because a silent "found nothing" gives no way to tell an empty
host from a failed enumeration.
* docs: state the per-frame reauthz and the wake's one-shot bound
Two things the security doc left implicit, both measured on 2026-07-31.
The `_drm` authorization is described as per-connection, which undersells it.
DRM/KMS capture is not session-scoped - it grabs the physical scanout of a CRTC
no matter which session owns the display - so the check is re-run on every
frame, and when a user logs in at a greeter the greeter's stream is closed
rather than continued. That is the property that stops an outgoing greeter
process from capturing the screen of the user who just logged in, and it is
worth stating where a reader is looking for exactly that confinement.
And the wake section never said what happens after the wake. It resets the
compositor's idle timer; it does not hold the display on. Left alone, the
connector idles off again one full idle period later: 30.3 s at a GDM greeter,
70.3 s in a user session with idle-delay=60. Saying so makes the existing
"useless as a way to keep a screen lit" clause concrete, and points at the
component whose job that actually is.
* drm: ship the wake in the CI deb, and assert the artifact on both package paths
Three findings from the round on the wake-gate commits, all the same shape: the
gate made "what was asked for" and "what was produced" diverge, and two places
still trusted the first.
CI built the unattended-wayland deb with `--features ...,drm` and then packaged
it with `--skip-cargo`. build.py appends `drm-wake` for `--drm`, but skipping
cargo means whatever that explicit line compiled is what ships, so the deb had
no wake code in it at all while being named and documented as the variant that
has it. The feature list has to be complete on the line that actually builds.
The marker assertion that catches exactly this class only guarded one of the two
packaging paths. `build_deb_from_folder` asserts that the staged binary carries
the libdrmtap dlopen path before it takes the unattended-wayland name; the
flutter path did not, and `--skip-cargo` reaches that one. A stock binary could
therefore be packaged under a name that conflicts with and replaces the stock
package, and then never capture. Hoisted the check to module level and called it
from both, before the bundle is renamed.
And the security doc described the synthetic input injection as an unconditional
property of a drm build. It is behind its own compile feature and a runtime
option, which is exactly what an operator auditing the deb needs to know.
* drm: stop a delivered frame from erasing the two verdicts it says nothing about
A deep review pass over the whole branch, run because a maintainer once found
two bugs here that nineteen rounds of an automated reviewer had missed. Three
findings, two of them the same root cause, all confirmed by re-reading the code.
The first frame of a session dropped the display's whole health entry. That is
right for the zero-frame streak, which is exactly the verdict a delivered frame
refutes, and wrong for the other two:
- `last_build`/`rapid_builds` exist for a display that delivers a first frame
and then fails downstream every cycle. Wiping the cadence on that frame meant
the flap guard could never reach RAPID_REBUILD_MAX in the one case its own doc
comment describes. It was a guard that could not fire.
- `prefer_cpu` records which GPU exports a monitor, a property of the host, and
is documented as following the monitor for the process run. Erasing it on the
first frame it made possible meant every rebuild re-paid a dead dma-buf
session: fail, learn, take the CPU path, forget, fail again. It never demotes,
because the CPU session clears the streak each time, so it repeats for the
process lifetime. Worse, the bit is set on the recv thread and was deleted on
the encoder thread, so a convert failure racing a queued frame could destroy
it inside the very session that learned it.
So reset only the streak. Only a topology change, where the GPU mapping really
can have changed, may still clear the convert verdict.
Second, `get_primary_index` was a second, weaker copy of the connector-to-output
matcher: name-only, with neither the unique-resolution step nor the layout-order
fallback the augmentation grew. On a compositor whose names do not normalize to
the DRM names it answered 0 while the geometry augmentation had matched that
display to a different output, so the advertised primary and the advertised
geometry disagreed. It now asks the same assignment, which makes them agree by
construction.
Third, packaging asserted half of what the deb claims. `assert_staged_binary_is_drm`
looked for the libdrmtap dlopen path, which `--features drm` alone also carries,
so a bundle built without `drm-wake` could still be named and documented as the
variant that wakes an idle-disabled display; it now requires the wake marker too.
And nothing anywhere checked that the libdrmtap being shipped is one the runtime
would accept: `abi_accepted` is the only validation of the pinned version and it
runs at dlopen time on the user's machine, so the pin and the gate could drift
and every existing assertion would still pass -- EGL markers say nothing about
the version, the CI symbol contract never calls drmtap_version(), and the deb
regex matches any version. Staging now applies the gate parsed out of the Rust,
so a green build cannot produce a deb whose capture can never start.
* drm: fix the ABI cross-check's path, and stop panicking on a failed spawn
The ABI cross-check added in the previous commit could never run: both callers
of stage_libdrmtap_into_deb chdir into flutter/ first, and the check opened
drmtap_dl.rs by a path relative to the cwd, so every --drm packaging run died
with FileNotFoundError. CI caught it. It is anchored on __file__ now, and read
through a context manager.
Worth naming why the test missed it: the check was exercised from the repository
root, which is the one directory where the bug is invisible. A control that does
not reproduce the call site's conditions is not a control.
Three more, all the same class the previous commit was already fixing - a
hazard closed at one site and left at its siblings:
- `std::thread::spawn` panics when the thread cannot be created, and the panic
unwinds into whoever called it. The two hardened workers used Builder; the
five remaining DRM threads did not. The startup ones now log and degrade (a
lost pre-warm costs one cold probe, a lost udev listener costs the mid-session
push, a lost warm costs the first session), and the two per-session ones live
in functions that already return ResultType, so they fail that one connection
cleanly instead of unwinding through the handler.
- The wire descriptor's `num_planes` was clamped to 1..=4 here while
`drm_render::convert` rejects an out-of-range count on purpose, so that the
count the C reads is the count this side validated. Clamping made that reject
unreachable: a descriptor claiming 7 planes arrived as 4 and passed. The two
guards were added by different review rounds and had been quietly cancelling
each other. The raw value is passed through now, leaving one validation site,
next to the code that dereferences it.
- A SAFETY comment claimed the cursor is released only on success. It is
released on every path after a successful get_cursor; only a failed get_cursor
returns without releasing, because then there is nothing to release. The
release protocol is the reason that block is unsafe, so the comment describing
it has to be right.
* drm: convert the last panicking spawn, and resolve geometry outside the lock
The spawn conversion in the previous commit missed one. `query_displays` still
used `std::thread::spawn`, which panics when a thread cannot be created, and it
is reached from both `get_capturer_info` and `warm_availability` - so the panic
would land on the capture-build path rather than being reported as the failed
probe every caller already handles. There are now none left in the two DRM
files.
Worth writing down how it survived a pass whose whole purpose was to find it:
the previous commit enumerated the siblings with a grep piped through `head`,
there were eleven matches, and `head` printed ten. The one it cut is the one
that was missed. Same shape as a build log read through `tail` and a `find`
given `-xdev`: the tool truncated the survey and the survey looked complete.
When enumerating sites for a class fix, do not pipe the enumeration.
Also, `get_capturer_for_display` resolved the advertised DRM geometry while
holding the `CAP_DISPLAY_INFO` read guard. That lookup runs a compositor output
roundtrip, and `clear()` takes the write guard on every capturer teardown -
which is what is happening when a display is demoted or flapping, i.e. exactly
when this path runs. The value does not depend on anything inside the guard, so
it is resolved before taking it.
And the security doc listed the unattended package's `Conflicts`/`Replaces` but
not its `Provides: rustdesk`, which is the field that lets a third-party package
depending on `rustdesk` be satisfied by the consent-free variant. An operator
auditing that metadata needs all three.
* drm: test that a delivered frame keeps the cadence and the convert verdict
The guard this locks in could never fire before: a delivered frame dropped the
whole DisplayHealth entry, which took last_build/rapid_builds with it, and those
exist precisely for a display that delivers a first frame and then fails
downstream every cycle. prefer_cpu went the same way, erased by the first frame
it had made possible.
The test drives the real frame() path through the existing harness rather than
simulating the bookkeeping, and it was checked against the old behaviour: with
the entry removed again it fails on "the entry must SURVIVE a delivered frame".
A test that has not been seen failing is not evidence.
* drm: bound the two waits a peer could hold open in the root service
A review pass over the privileged side, reading src/ipc/drm.rs as a local
unprivileged attacker. Two findings, both confirmed by tracing every link.
The wire had a deadline in one direction only. Every read has been bounded since
the beginning, and next_raw_into even carries the argument for it: a peer that
writes a header and then stops pins the other end forever on a readiness wait.
The write side had no deadline at all. That asymmetry costs more here, because
the parked task is in the root service: a peer that simply stops reading - a
kill -STOP on its own --server, a ptrace stop, a frozen cgroup - leaves the send
blocked inside the forward loop, so the loop top is never reached again. The
credit stall, the per-frame reauthorization and the topology-generation check
all live at that loop top, and the connection slot, the worker thread and its
DRM context stay pinned until the peer chooses to resume. drm_write_all is the
single funnel for both directions, so one deadline there covers every send; the
consumer's frame-ack write had the same shape and gets the same bound.
And drain_frame_acks looped until WouldBlock, which is a promise the peer gets
to keep. It is synchronous on the single-threaded _drm runtime, so a peer that
writes a continuous stream instead of one ack byte per frame keeps the receive
queue non-empty, never yields, and pins that thread at 100% CPU - starving every
other stream on it, which on a multi-monitor client means one connection wedging
its own siblings. Capped per call, with an early return once the credit budget
is full; anything left stays queued for the next pass.
Three comments were describing a mechanism that no longer exists. Two still said
a delivered frame drops the whole health entry, which stopped being true when
that was narrowed to zeroing the streak; the third, written in that same change,
pointed at drm_clear_prefer_cpu, a function deleted several commits earlier. The
convert verdict having no clearing site is correct and now says why: it is keyed
by connector identity, so a monitor that moves to another GPU arrives under a new
key and starts clean.
Also, the new regression test held the process-wide health mutex across its
assertions, so the one failure it exists to report would have poisoned that mutex
and buried itself under unrelated PoisonErrors in its sibling tests. It copies
the record out and releases the guard first, as the module's own helper does.
* drm: clear the stale _drm entry by fd, and fix three comments that argue backwards
new_drm_listener cleared the stale socket with std::fs::remove_file, which is
unlink(2). Against a directory-typed squatter that returns EISDIR and leaves the
entry in place, and endpoint.incoming() then fails EADDRINUSE, so DRM capture
falls back to the portal for the rest of the boot over an entry we could have
removed. The _service listener has never had that hole: it removes entries
through a no-follow fd on the parent directory, fstatting the entry first and
choosing AT_REMOVEDIR when it needs to. That helper now takes a path instead of
a postfix, so the _drm listener - which deliberately stays outside hbb_common's
postfix machinery - can use the same one on the directory it just hardened. The
precondition is narrow (an unprivileged process has to win the creation race
before the root service first hardens the dir on a fresh boot), which is why the
failure is a warn and not a bail.
Three comments stated their reason backwards or more strongly than the code
supports. None of them changes behaviour; all three would send the next reader
to verify the wrong thing.
The wake's 20 s rate limit was justified as being short enough to be useless as
a way to keep a screen lit. That is inverted: a shorter gap would make relighting
easier, not harder, and 20 s is below every idle period we have measured (30.3 s
at a greeter, 70.3 s in a session). What actually bounds it is that the wake is
one-shot, which the next sentence of the same doc already says. Fixed at both
sites, the constant and the security doc.
The doc block above drm_enumerate_settled reads as one paragraph but spans a cfg
split, so its shared contract and the wake-less specialisation looked like one
statement about the arm below it. Marked explicitly.
And get_primary_index claimed its answer agrees with the advertised geometry by
construction, which is true only where augment_with_wayland_geometry runs the
same assignment - it declines below two connectors or two outputs, and in that
band the two functions run different code. The answer is still never worse than
the documented fallback there, and now the comment says which.
* drm: test that the fd-based removal clears a directory squatter
The regression this pins is the one the previous commit fixed: a stale entry in
the IPC parent directory is not necessarily a socket, and unlink(2) refuses a
directory. The test asserts remove_file fails on it FIRST, so a passing run
cannot be vacuous, and it checks the second call succeeds too, since this runs
before every bind.
Confirmed red against a neutralised helper before being kept.
* drm: fix what the previous commit's own comments got wrong
A review pass over 08d311d60 - the commit whose stated job was correcting three
comments that argued backwards - found that four of its replacements were wrong
in turn. Two independent passes agreed on each. This is the correction.
The 20 s gap paragraph was never attached to the constant. It is the first
paragraph of a doc block that runs on to OPTION_ENABLE_DRM_DISPLAY_WAKE, so it
documented a config-key string, while DRM_WAKE_MIN_GAP three lines below had no
doc at all. That misplacement predates the previous commit; expanding the
paragraph from one line to five without noticing does not. Moved onto the
constant.
Its content was also wrong for the second time. Saying the limit is not what
stops a screen being held on was right; naming the one-shot property as the
thing that does bound it was not. One-shot cannot bound a repeated relight when
the permitted repeat interval is shorter than the idle period, which is exactly
what the sentence before it establishes: 20 s against a measured 30 s. An
authorized peer that keeps reconnecting can have the panel relit shortly after
each idle-off, and what makes that acceptable is the authorization itself - root
or the active session's own uid, who can hold their screen on with
systemd-inhibit and need nothing from us. Both the constant and the security doc
now say that, and the constant carries a note not to write the old claim a third
time.
The shared contract of drm_enumerate_settled sat on the arm the shipped build
compiles out. build.py --drm adds drm-wake, so a maintainer opening the real
function found it undocumented while a doc comment marked "shared" hung off its
dead twin. A doc comment cannot attach to two cfg arms, so the shared part is
now a plain comment above both and each arm keeps a short doc of its own.
get_primary_index claimed a sole compositor output is matched to the lowest
connector. It is not: pass 1 matches by normalised name and by unique resolution
before any layout-order fallback, so the answer in that band can be any index.
The conclusion survives - a name match is better evidence than a blind 0 - but
the reason given for it was false, and the reason is what the next reader uses.
And the directory case is narrower than it was written. AT_REMOVEDIR is rmdir,
so what the previous commit closes is the EMPTY squatter; a non-empty one still
returns ENOTEMPTY and still blocks the bind. Left that way on purpose - the cure
would be root recursively deleting a tree an unprivileged process planted in a
world-writable directory - and now stated at all three sites plus pinned by the
test, which also stops claiming to cover the call site it does not reach.
* drm: say less in these comments, since saying more keeps being wrong
Third pass over the same comments, and the third set of errors in them. The
pattern is not that any one sentence was careless, it is that every additional
explanatory sentence is another falsifiable claim, and the ones that keep
failing are the ones that reach past what the file can support. So this is
mostly deletion: net fifteen lines fewer.
The "SHARED CONTRACT" header was wrong about its own first paragraph. That
paragraph describes waking, waiting and a rate-limit race, none of which the
wake-less arm does - and the previous commit went further and pointed the
wake-less arm's own doc at it, so that arm now claimed to do the thing the very
next line said it does not. Only the second paragraph, on why an idle-disabled
output is the trigger, is genuinely common to both. That stays above the pair as
a plain comment; the wake behaviour moves onto the wake arm, where it is true.
DRM_WAKE_MIN_GAP no longer argues about why unbounded relighting is acceptable.
It named the wrong actor: the _drm peer is always our own unprivileged --server,
while the party whose reconnects drive the relight is the remote client, which
is neither root nor the local uid and cannot inhibit anything. The constant now
states what it bounds and what it does not, and stops there. The security
document makes the acceptability argument instead, and makes it about the right
party: a peer already authorized to watch that screen gets it lit, which is
visible to a person standing there, not additional access.
Two narrower ones. The helper said a non-empty squatter yields a named error
"instead of" EADDRINUSE; the caller gets both, and the sibling comment in the
listener already said "ahead of", so the same commit disagreed with itself. And
get_primary_index claimed the two functions disagree across the whole band where
augmentation declines, which is false for zero outputs and for a single
connector - it now names the one case that matters.
Not touched, and pre-existing: MAX_DRM_CONNS's doc block has the same wrong-item
defect (it opens on a function and ends on the cap), and drm_enumerate_all_displays
runs two paragraphs together. Both predate this branch's comment work and neither
belongs in a commit about it.
* drm: give the send deadline one budget for the whole write, not one per wait
The earlier commit put the timeout inside the loop, so the budget restarted on
every iteration. A peer that accepts a byte just inside each window, or that
keeps the socket flapping back to WouldBlock, re-arms it forever and the root
task stays parked exactly as it did before - which is the stall the constant's
own doc says it bounds. The diagnosis was right and the fix did not implement
it. Both send paths now take one deadline before the loop and wait with
timeout_at.
Swept the rest of the file for the same shape. The credit wait re-arms a 1 s
poll on purpose and is fine: its total bound is CREDIT_STALL, measured at the
loop top from credit_since, and its comment already says the deadline is
enforced there and not in the poll. That is the pattern the write path was
missing. The read paths are single-shot bounded, not loops.
Not covered by a test. Reproducing it needs a peer that accepts a little data
just inside each window, so the scenario runs longer than the 5 s budget itself
and a no-progress peer - the case a simple test would build - times out
correctly under both the old code and the new.
* drm: stop claiming the wake-less build cannot inject input
It can. Dropping drm-wake removes injection from the CAPTURE path and nothing
else: start_os_service calls start_uinput_service unconditionally, with no
feature gate, so the root service runs RustDesk's keyboard and mouse uinput
backends on every build, drm or not. That is how remote control works on
Wayland and is not ours to change - but a maintainer auditing "is the injection
path present in this build?" was being told no by a comment in the file most
likely to be read for that question. The line now says what is actually true of
the capture path and points at the ungated call, so the next reader is not sent
to verify the wrong claim.
The sentence is inherited: it came in with 2648ad0a2 and survived two review
rounds because both were reading the comments I had just CHANGED, and this one
I only re-wrapped. Re-wrapping is re-asserting.
Also narrowed the wake arm's "the wait applies to every handshake that saw an
undriven display": four early returns skip it - option off, nothing wakeable, no
uinput, no recent wake to settle - and the same block asserts the first of them
four lines later, so the paragraph contradicted itself. And "the trigger" in the
shared block lost its antecedent when the wake paragraph moved onto the wake
arm; it is "the signal" now, which is true for both arms.
* drm: put two doc blocks on the items they describe
Both pre-existing, both found by walking every doc run in the file down to the
item it attaches to rather than by reading prose.
handle_drm_conn's description was stranded: the block opened on the function and
ended on the connection cap, so it attached to MAX_DRM_CONNS while the function
itself had no doc at all. Moved the function's paragraph onto the function; the
cap keeps its own.
And drm_enumerate_all_displays ran its enumeration paragraph and its return-value
paragraph together with no separator, so they read as one. Blank doc line between
them. No text changed in either case - this is placement only.
* drm: pin the send deadline with a test, and close five review findings
The send deadline had no test, and I had written down that it could not have
one: a peer that never reads times out correctly under the broken per-wait form
too, so the obvious test proves nothing. That is true and it is not the whole
answer. A peer that DRIPS separates them, and the first version I wrote still
did not - draining a kilobyte at a time never makes the socket writable again,
because Linux asserts POLLOUT on a stream socket only once a decent fraction of
the send buffer is free, so the sender saw one long readiness wait and both
forms timed out identically. At 64 KiB the socket really does re-arm and the two
diverge. Measured both ways: the test passes in five seconds against the fix and
fails at twenty against the per-wait form, with the message it exists to print.
The chunk size is documented in the test for exactly that reason.
Four more, all verified against the code before touching it:
grab()'s SAFETY block claimed the frame is "released on every path". The ret < 0
arm returns without releasing, because a failed grab_mapped leaves nothing to
release. Its two siblings, grab_desc and cursor, already state the distinction
precisely; this was the loose copy, and the release protocol is the reason the
block is unsafe in the first place.
drmtap_dl.rs still said minor bumps are additive and compatible. abi_accepted
requires an exact minor match and the block below it explains why, so the file
argued both sides and the stale half is an invitation to widen the gate.
grab_desc validated width, height and plane count but not pitch or offset, while
the converter bounds pitch * height + offset per plane. Same bound on the export
side now, so both halves refuse the same descriptors - the principle grab()
already states. No pixel access happens there, so this is not an out-of-bounds
fix; it keeps a bogus pitch off the wire and puts the rejection on the side that
can name the device.
And the deb staging interpolated so_path unquoted, which breaks on a path with a
space (DRMTAP_PREBUILT_DIR is user-supplied).
Also covers the regular-file case through the new removal helper - the stale
socket every restart hits, which the existing file test reaches by another path.
* build: quote the rest of the path interpolations, not just the two that were named
The previous commit quoted so_path and stopped there, which left the six shell
commands that build libdrmtap interpolating src and build_dir bare. Both derive
from repo_root, which is built from __file__, so a checkout under a path with a
space splits the argument and git init, git remote add, git fetch, git checkout,
meson setup and meson compile all fail with an error that says nothing about the
real cause. Same defect, same fix, and quoting one pair while leaving its
siblings is the shape a reviewer finds next.
* fix(drm): close the review items on the capture backend
Guard the producer thread, surface a swallowed spawn error, stop the CI
feature list from drifting from build.py, and four smaller ones.
Should-fix:
- `start_os_service` started the DRM producer with a bare `thread::spawn`,
the one spawn in this feature that was not built with `thread::Builder`.
`spawn` panics if the thread cannot be created (EAGAIN under a thread or
memory limit), and that panic unwinds out of `start_os_service` and takes
the root service with it -- for a feature whose failure should only cost
DRM capture. Builder + warn, like the other four.
- `refresh_available_async` dropped the spawn result on the floor. There is
no wedge (the single-flight guard moved into the closure and is dropped
with it), but a refresh that can never start was invisible: the cached
verdict just keeps being served past its TTL. The sibling spawn already
logged; now both do.
- The drm workflow hardcoded the cargo feature list because it packages with
`--skip-cargo`, so `get_features()` in build.py and the CI line were two
definitions of the same thing and only the drm/drm-wake half was asserted
afterwards. Adds `build.py --print-features`, which prints the list those
flags select and exits, so CI asks instead of repeating; the same flags now
drive the compile and the packaging. The step asserts the answer really is
a drm build before handing it to cargo, matching whole comma-separated
tokens so a future feature merely containing "drm" cannot satisfy it.
Smaller:
- The ENOTSUP fallback in `drm_capture_worker` switched to the CPU path
without clearing `stalled`, so stalls charged to the dma-buf path could
trip MAX_STALLED early and close a connection the fallback was about to
serve.
- `FrameSlot` kept one recycled buffer and claimed at most one is idle at a
time, which does not hold: the receive path supersedes an unconsumed frame
while the encoder returns its borrow, and those two writers do not even
share a lock, since the receive path takes a buffer and publishes in two
separate acquisitions. The later write freed a scanout-sized allocation the
recycler exists to keep. Two slots is the exact bound for three in-flight
buffers. The existing test passed against this, so the new one counts the
offers rather than asking whether any came back.
- `get_cursor`/`get_cursor_data` use the memoised `is_x11()` while the
capture path deliberately uses the unmemoised `scrap::is_x11()`. That is
the right trade at cursor cadence, since the unmemoised form forks
`loginctl` per call -- say so, because the surrounding code argues the
opposite for its own callers.
* docs(drm): cut the changelog prose out of the comments
Removes passages that document this patch's own revision history rather
than the code, including the four quoted in review. Deletions and one
misplaced comment moved to the field it describes; no comment was
reworded, so nothing here can state something new.
- `drm_capturer.rs`: the `drm_clear_prefer_cpu` parenthetical (that
function does not exist), "same mistake, same shape, as the two flags
before it" (it names no identifier, and both sites it gestures at carry
their own hazard comments), and "the comment was right and the code used
the probing accessor anyway".
- `drmtap_dl.rs`: "this test replaces one that asserted the opposite", and
"that sentence used to live here" -- the instruction not to widen the
gate on the strength of "minor bumps are additive" stays, since that is a
live constraint rather than history.
- `platform/linux.rs`: the "NOT REPRODUCIBLE ON OUR HARDWARE" provenance
label. What it introduced survives and is the better form of the same
warning: on the test host `is_gdm_user` does not match `gdm-greeter`, so
that branch is dead there and the code is for display managers whose
greeter user does match.
- `ipc/drm.rs`: "and that sentence has already been wrong here twice". The
warning it trailed stays, because a shorter gap really would make
relighting easier and the constant should not be described as bounding
how long a screen stays lit.
- `build.py`: "the answer to an objection is one word, not a revert".
Also moves the comment describing `cur` off `display`, where a field
reorder had left it sitting above that field's own comment.
Most of the remaining density is mechanism, measurement or a hazard, and
is left alone: the pipe/SIGPIPE analysis, the physical-vs-logical rect
comparison, the `wire_idx` vs `display` argument, the wake measurements
(REL_X alone did not wake the panel; the device bind window), the
F_DUPFD_CLOEXEC privilege-leak argument, and the SAFETY blocks.
* docs(drm): condense the capture comments from 35% of lines to 6%
The five DRM files were 2319 comment lines against 4181 of code. The rest
of this repository runs at 3%, so they were roughly twelve times the
surrounding density, and that was the fair reading of the review: the
volume itself is what makes an 8k-line addition hard to review.
They are now 302 lines. What went is rationale: alternatives considered
and rejected, arguments for why a design is acceptable, restatements of
what the next line of code plainly says, and the same fact repeated at
several sites.
What stayed is what a reader cannot recover from the code, kept to one or
two lines each:
- every SAFETY comment on an unsafe block (none was dropped)
- ownership and release contracts with the libdrmtap C API, including
which grabs own a frame and which must not release it
- ordering requirements: announce a pending refresh before claiming the
single-flight slot, take the busy flag before the spawn rather than
inside the closure, never hold DRM_STATE while taking a per-display map
- the flow-control protocol, both ends of it
- wire-format and units conventions, and the cmsghdr alignment the
control-buffer type exists to provide
- measured facts, reduced to the measurement: which synthetic events wake
an idle panel and which do not, and the device bind window
- hazards on the world-connectable listener, including why the rejection
paths log at debug or not at all
No code changed: with comments and blank lines stripped, all five files
are byte-identical to their previous contents. Tests are 111 in the
rustdesk crate and 20 in scrap.
* docs(drm): restore the wire_idx argument on the hotplug guard
The condensation cut this one too far. Within minutes of the shortened
version going up for review, a reviewer read the remaining line and
proposed changing the probe from `display` to `wire_idx` -- which is the
change that was already tried here and was wrong.
So the argument is not rationale prose, it is what stops a plausible and
incorrect edit to a guard in the capture path, and it goes back in at six
lines: `bound_to` is an identity rather than a position, the swap below
installs this list as the client-space DRM_STATE, and probing `wire_idx`
would go quiet in precisely the case the guard exists to catch.
* docs(drm): correct what an empty render_node means on the wire
The condensed doc said "Empty = auto-select", which is false on the host
that field exists for. `drm_capture_worker` computes
`ambiguous_gpu = render_node.is_empty() && render_node_count() > 1` and
folds it into `force_cpu`, so an unnamed exporter on a machine with
several render nodes takes the CPU path rather than auto-selecting. It
auto-selects only where there is a single node.
* docs(drm): fix comment claims that do not match the code
An audit that verified every comment claim against the CODE (rather than
against the pre-condensation text, which is what the earlier pass did)
found twenty that were false or unqualified. Some came from the
condensation dropping a qualifier; several predate it.
The ones that mattered most:
- `drm_render.rs` said libEGL/libGLESv2 are loaded "never in the
privileged root service". That is true of the split path only: the CPU
fallback calls `drmtap_grab_mapped`, whose auto-process step reaches
`drmtap_gpu_egl_convert` in the CALLING process. `DRM_CAPTURE_SECURITY.md`
already documents this precisely, and `drm_reader.rs` already said "on
this path"; this one comment had lost the qualifier.
- "A miss is fail-closed" on the per-frame reauthorization: true for a
non-root peer only, since `drm_peer_authorized` returns true for uid 0
before it compares against the active session.
- The cursor body check was described as a no-op because the hidden
sentinel supposedly arrives 0x0 with an empty body. It arrives 1x1 with
four bytes, so the check is live.
- "EVERY write to DRM_STATE goes through here": the TTL restamp writes
directly, and the comment on that arm says so.
- `open(crtc=0)` was described as selecting the "primary" CRTC; libdrmtap
picks the first CRTC with a valid mode, and in that library "primary"
names a plane.
- `list_devices() == None` was described as leaving the caller on
single-device auto-detect; the caller scans /dev/dri/card* itself.
- The framing note claimed the whole channel is length-prefixed; the
reverse-direction frame acks are bare bytes.
Also corrects `buffer_id`, which was documented as the producer's stable
pool key: it is fb_id tagged with a per-connection epoch and no consumer
reads it today.
No behaviour changes. One executable line is touched: the message string
of a unit-test `assert!` that asserted the auto-select claim being
corrected here.
* docs(drm): fix the second primary-CRTC occurrence the audit flagged
Same correction as the enumeration-side comment: libdrmtap auto-selects
the first CRTC with a valid mode, and primary names a plane there. The
audit had flagged both sites and only one was fixed.
* feat(drm): move the libdrmtap pin to 0.5.2 and the ABI gate with it
libdrmtap 0.5.2 is now on rustdesk-org, so the pin can move. It fixes the
padded-framebuffer read: a scanout whose pitch exceeds width*bpp was
decoded at the wrong stride, which is why the Touch Bar strip on an Apple
T2 produced no image and was listed as a known limitation.
The three parts have to land together, and build.py enforces it: the
staged .so is cross-checked against the ABI constants parsed out of
drmtap_dl.rs, so a pin without the gate (or a gate without the pin) fails
the build rather than producing a deb whose capture can never start.
- pin: cbc5e6af5 (0.4.15) -> 653de8c (0.5.2), in build.py, which is the
single source of truth, plus the informational version comment in
libs/scrap/Cargo.toml.
- gate: DRMTAP_ABI_MINOR 4 -> 5 and the patch floor (4, 10) -> (5, 0).
0.4.x is now refused even though it carries the whole split API, because
of the stride bug above.
- the newer-minor rejection test now derives its cases from
DRMTAP_ABI_MINOR rather than hardcoding 5, so the next bump cannot leave
it asserting that the newly verified minor must be refused. That is
exactly what the hardcoded list would have done here.
- DRM_CAPTURE_SECURITY.md: the vetted window is now 0.5.x with x >= 0.
Verified: the build fetches 653de8c by sha and meson produces
libdrmtap.so.0.5.2, which the runtime gate accepts. Tests 111 in the
rustdesk crate, 20 in scrap.
* fix(drm): refuse --drm on the packaging paths that cannot honour it
Blocking finding from review. `get_features()` gated only on `windows or
osx`, but Linux has four packaging branches and only the deb one is
drm-aware. On a host with pacman, yum or zypper, `--drm` compiled in
`drm,drm-wake` and then packaged through a path that does not bundle
libdrmtap, does not rename, adds no Conflicts/Provides and never runs
`assert_staged_binary_is_drm()` -- emitting a package NAMED `rustdesk`
carrying the consent-bypass backend and the root-side uinput injection.
The distinctly named package is the informed consent this feature rests
on, so those branches now refuse the flag instead. `linux_packaging_branch()`
mirrors the elif chain in main() and is the single place that decides,
so the check cannot silently disagree with the branch actually taken.
Also from the same review:
- the bare-soname dlopen fallback is no longer offered when running as
root. It exists so an unpackaged development build can load a locally
built .so, but it was also the one place where which file happens to be
on the ld.so path decided what gets mapped into the CAP_SYS_ADMIN
process. The packaged service finds the absolute path first regardless,
and a root process that reaches the fallback has no bundled library at
all, which is the PipeWire-fallback case rather than a reason to search.
- `rm -f {so}` is quoted, like the neighbouring `cp` already was.
- `Cargo.lock` is dropped as a CI path trigger. Measured over the last 100
commits it alone would have fired this workflow 13 times and the pair 24
times, each about two job-hours of vcpkg + flutter release build, almost
always for a dependency the drm path never touches.
- `abi_gate_rejects_a_library_from_before_the_split` no longer implies the
patch floor is what refuses those versions; the minor mismatch is. The
floor is vacuous by construction while it sits at patch 0 of the
verified minor, so a second test asserts exactly that and turns into a
tripwire the next time a floor lands mid-minor, as (4, 10) did.
This commit is contained in:
449
.github/workflows/drm-capture.yml
vendored
Normal file
449
.github/workflows/drm-capture.yml
vendored
Normal file
@@ -0,0 +1,449 @@
|
||||
name: DRM capture (opt-in drm feature)
|
||||
|
||||
# Least-privilege GITHUB_TOKEN. Every job here only checks out, builds and tests; the artifact
|
||||
# up/download used by the deb job authenticates with the runtime token, not this one. Declared at
|
||||
# the workflow level so the reusable bridge workflow called below inherits the same bound.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Supersede a stale run when a PR is pushed again; never cancel a master run, whose whole job is to
|
||||
# record that a given commit on master was verified.
|
||||
concurrency:
|
||||
group: drm-capture-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Everything CI-side about the opt-in `drm` backend lives here, so the stock CI and release workflows
|
||||
# stay byte-identical to a build with the feature off. Nothing in this file runs unless a drm-related
|
||||
# path changes (or someone dispatches it by hand), so a PR that does not touch the backend pays nothing.
|
||||
#
|
||||
# The stock `CI` workflow deliberately does NOT compile with `--features drm`: the shipped default is
|
||||
# the drm-off configuration and that stays the primary verified one.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "libs/scrap/src/common/drm_reader.rs"
|
||||
- "libs/scrap/src/common/drm_render.rs"
|
||||
- "libs/scrap/src/common/drmtap_dl.rs"
|
||||
- "libs/scrap/src/common/mod.rs"
|
||||
- "libs/scrap/Cargo.toml"
|
||||
# The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes
|
||||
# what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here:
|
||||
# measured over the last 100 commits, it alone would have fired this workflow 13 times and
|
||||
# the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release
|
||||
# build, almost always for a dependency the drm path never touches. A lockfile bump that
|
||||
# does affect it arrives with a manifest or source change, which is triggered above.
|
||||
- "Cargo.toml"
|
||||
- "src/ipc.rs"
|
||||
- "src/ipc/**"
|
||||
- "src/server/drm_capturer.rs"
|
||||
- "src/server/wayland.rs"
|
||||
- "src/server/display_service.rs"
|
||||
# These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the
|
||||
# producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the
|
||||
# whole drm verification.
|
||||
- "src/server.rs"
|
||||
- "src/server/input_service.rs"
|
||||
- "src/platform/linux.rs"
|
||||
- "build.py"
|
||||
- ".github/workflows/drm-capture.yml"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# Deliberately the SAME list as the pull_request trigger above: a shorter one here means a push
|
||||
# that touches only the missing paths (a squash merge, a direct push) skips re-verification.
|
||||
paths:
|
||||
- "libs/scrap/src/common/drm_reader.rs"
|
||||
- "libs/scrap/src/common/drm_render.rs"
|
||||
- "libs/scrap/src/common/drmtap_dl.rs"
|
||||
- "libs/scrap/src/common/mod.rs"
|
||||
- "libs/scrap/Cargo.toml"
|
||||
# The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes
|
||||
# what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here:
|
||||
# measured over the last 100 commits, it alone would have fired this workflow 13 times and
|
||||
# the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release
|
||||
# build, almost always for a dependency the drm path never touches. A lockfile bump that
|
||||
# does affect it arrives with a manifest or source change, which is triggered above.
|
||||
- "Cargo.toml"
|
||||
- "src/ipc.rs"
|
||||
- "src/ipc/**"
|
||||
- "src/server/drm_capturer.rs"
|
||||
- "src/server/wayland.rs"
|
||||
- "src/server/display_service.rs"
|
||||
# These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the
|
||||
# producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the
|
||||
# whole drm verification.
|
||||
- "src/server.rs"
|
||||
- "src/server/input_service.rs"
|
||||
- "src/platform/linux.rs"
|
||||
- "build.py"
|
||||
- ".github/workflows/drm-capture.yml"
|
||||
|
||||
env:
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
FLUTTER_VERSION: "3.24.5"
|
||||
|
||||
jobs:
|
||||
drm-tests:
|
||||
name: drm unit tests (linux)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
swap-storage: false
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get -y update
|
||||
sudo apt-get install -y \
|
||||
clang cmake curl gcc git g++ \
|
||||
libpam0g-dev libasound2-dev libunwind-dev \
|
||||
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \
|
||||
libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
|
||||
libxdo-dev libxfixes-dev nasm wget
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
|
||||
- name: Install vcpkg dependencies
|
||||
shell: bash
|
||||
run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
# The whole rustdesk-crate test set with the feature ON, not just the `_drm` ones by name: a name
|
||||
# filter would skip the sibling asserts that also matter in this configuration, notably the one
|
||||
# bounding `size_of::<Data>()`, which the new DmabufDesc variant grows.
|
||||
# The two skips are the same ones the stock CI applies: both need a real display server and fail
|
||||
# on a headless runner regardless of this feature.
|
||||
- name: Run rustdesk crate tests with the drm feature
|
||||
shell: bash
|
||||
run: |
|
||||
cargo test --locked --target x86_64-unknown-linux-gnu -p rustdesk --features drm \
|
||||
--no-fail-fast -- --skip test_get_cursor_pos --skip test_get_key_state
|
||||
|
||||
# The capture backend itself lives in the scrap crate, so its unit tests are a separate
|
||||
# package. `--lib` keeps this to unit tests; none of them touch a device or a display server.
|
||||
- name: Run scrap crate tests with the drm feature
|
||||
shell: bash
|
||||
run: |
|
||||
cargo test --locked --target x86_64-unknown-linux-gnu -p scrap --features drm --lib
|
||||
|
||||
libdrmtap:
|
||||
name: libdrmtap pin, build and .so contract
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install libdrmtap build deps
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get -y update
|
||||
sudo apt-get install -y meson ninja-build pkg-config libdrm-dev \
|
||||
libegl1-mesa-dev libgles2-mesa-dev
|
||||
|
||||
# Exercises the real fetch-and-build path in build.py, which pins the commit by sha, so a bad or
|
||||
# moved pin fails here rather than in a release job.
|
||||
- name: Fetch the pinned libdrmtap and build the .so
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import importlib.util, sys
|
||||
spec = importlib.util.spec_from_file_location("b", "build.py")
|
||||
b = importlib.util.module_from_spec(spec)
|
||||
sys.argv = ["build.py"]
|
||||
spec.loader.exec_module(b)
|
||||
so = b.build_libdrmtap_so()
|
||||
print(f"::notice::built {so}")
|
||||
open("so_path", "w").write(so)
|
||||
PY
|
||||
|
||||
# The shipped hot path is the EGL detile. libdrmtap degrades to a CPU-only stub when the egl or
|
||||
# glesv2 pkg-config files are missing on the build host, and nothing else in the pipeline notices,
|
||||
# so assert here that the object we would ship really carries EGL and really exports every symbol
|
||||
# the runtime loader resolves.
|
||||
- name: Assert the .so contract (EGL enabled, loader symbols present)
|
||||
shell: bash
|
||||
run: |
|
||||
# Strict mode is load-bearing here: without it the trailing ::notice echo would return 0
|
||||
# and mask the `test "$missing" -eq 0` assertion, so the step would pass with a missing
|
||||
# loader symbol or a CPU-only stub. (pipefail also keeps the grep -c pipelines honest.)
|
||||
set -euo pipefail
|
||||
SO="$(cat so_path)"
|
||||
echo "checking $SO"
|
||||
missing=0
|
||||
# Every symbol drmtap_dl.rs resolves, derived from the loader itself so the two cannot
|
||||
# drift. The character class allows digits (a drmtap_grab_desc2 would otherwise be
|
||||
# silently dropped from the loop), and the count is asserted below so a refactor of the
|
||||
# loader away from b"..." literals cannot quietly turn this whole check into a no-op that
|
||||
# iterates zero times and passes.
|
||||
# `|| true` on the extraction pipelines: under set -e/pipefail a zero-match grep would
|
||||
# abort the script before the explicit ::error guard below can say WHY it failed; the
|
||||
# guard on nsyms is the intended reporter for that case.
|
||||
syms=$(grep -oE 'b"drmtap_[a-z0-9_]+"' libs/scrap/src/common/drmtap_dl.rs \
|
||||
| sed 's/^b"//; s/"$//' | sort -u || true)
|
||||
nsyms=$(echo "$syms" | grep -c . || true)
|
||||
if [ "$nsyms" -lt 13 ]; then
|
||||
echo "::error::extracted only $nsyms loader symbols from drmtap_dl.rs (expected >= 13); the extraction pattern no longer matches the loader"
|
||||
missing=1
|
||||
fi
|
||||
# Inspect the object ONCE into a variable, then match with bash's own pattern operator --
|
||||
# NO PIPE ANYWHERE IN THESE CHECKS. `anything | grep -q` under `set -o pipefail` reports a
|
||||
# FALSE FAILURE as soon as the producer outruns the 64 KB pipe buffer: grep -q exits at the
|
||||
# first match, the producer dies on SIGPIPE (141), and pipefail makes that the pipeline's
|
||||
# status, so a library that HAS the symbol is reported as missing it. Measured on a real
|
||||
# EGL-enabled .so (101 KB of `strings`, both markers present): the piped form reported both
|
||||
# missing and failed the step. Note the obvious repair does NOT work -- materializing the
|
||||
# output and then doing `printf '%s\n' "$var" | grep -q` keeps the pipe and just swaps the
|
||||
# producer, and it fails identically (measured). Today's release-sized .so happens to fit in
|
||||
# the buffer, which is the only reason this has not fired yet.
|
||||
exported="$(nm -D --defined-only "$SO")"
|
||||
strs="$(strings "$SO")"
|
||||
for sym in $syms; do
|
||||
# Line-anchored: wrap in newlines so the pattern can require a whole line, the same
|
||||
# thing `grep " T $sym$"` was expressing.
|
||||
if [[ $'\n'"$exported"$'\n' != *$'\n'*" T $sym"$'\n'* ]]; then
|
||||
echo "::error::libdrmtap does not export $sym, which the runtime loader resolves"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
# EGL is reached by lazy dlopen, on purpose, so that the privileged process never links the
|
||||
# vendor GL stack. That means there is NO DT_NEEDED entry and no undefined egl* symbol to look
|
||||
# for: the naive ELF check reports "no EGL" on a perfectly good library. What a CPU-only stub
|
||||
# build really lacks is the dlopen target name and the import call itself.
|
||||
for s in "libEGL.so.1" "eglCreateImageKHR"; do
|
||||
if [[ "$strs" != *"$s"* ]]; then
|
||||
echo "::error::libdrmtap looks like a CPU-only stub (no $s): the EGL detile hot path is missing"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
test "$missing" -eq 0
|
||||
echo "::notice::libdrmtap .so contract ok ($nsyms loader symbols, EGL detile present)"
|
||||
|
||||
# The bridge generator is a reusable workflow, so this calls the stock one instead of duplicating it.
|
||||
generate-bridge:
|
||||
uses: ./.github/workflows/bridge.yml
|
||||
|
||||
drm-deb:
|
||||
name: unattended-wayland deb (verification build)
|
||||
needs: generate-bridge
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
swap-storage: false
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
persist-credentials: false
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Install prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get -y update
|
||||
# Same list the stock linux job needs, plus the flutter desktop toolchain and the three
|
||||
# libdrmtap build deps (libdrm and the mesa-specific EGL/GLES dev packages).
|
||||
sudo apt-get install -y \
|
||||
clang cmake curl gcc git g++ ninja-build meson pkg-config \
|
||||
libpam0g-dev libasound2-dev libunwind-dev liblzma-dev \
|
||||
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \
|
||||
libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
|
||||
libxdo-dev libxfixes-dev nasm wget \
|
||||
libdrm-dev libegl1-mesa-dev libgles2-mesa-dev
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
|
||||
- name: Install vcpkg dependencies
|
||||
shell: bash
|
||||
run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Setup flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Patch flutter
|
||||
shell: bash
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
# `[[ ... ]] && cmd` as the last line makes the STEP fail once FLUTTER_VERSION moves off
|
||||
# the pinned value, because the failed test becomes the script's exit status. An explicit
|
||||
# if/else skips instead. Reading the values from the environment rather than interpolating
|
||||
# github expressions into the script also keeps this off zizmor's template-injection list.
|
||||
# (spelled out in prose: a literal expression marker here, even in a comment, is parsed by
|
||||
# actionlint and breaks workflow linting.)
|
||||
if [[ "$FLUTTER_VERSION" == "3.24.5" ]]; then
|
||||
git apply "$GITHUB_WORKSPACE/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff"
|
||||
else
|
||||
echo "::notice::flutter $FLUTTER_VERSION is not 3.24.5; skipping the dropdown patch"
|
||||
fi
|
||||
|
||||
- name: Build the unattended-wayland deb
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The features have to be on the cargo line HERE, because the packaging line below passes
|
||||
# --skip-cargo and never rebuilds: whatever this compiles is what ships. ASK build.py for
|
||||
# the list rather than repeating it -- get_features() is the single definition of what
|
||||
# these flags mean, and a hardcoded copy silently ships something other than what
|
||||
# `build.py --drm` produces the moment that function changes. The flags must be the same
|
||||
# on both lines for that to hold, so keep them in one variable.
|
||||
DRM_BUILD_FLAGS=(--flutter --drm --hwcodec --unix-file-copy-paste)
|
||||
FEATURES="$(python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --print-features)"
|
||||
echo "features from build.py: $FEATURES"
|
||||
# Assert rather than trust: an empty or error-shaped value would otherwise become a cargo
|
||||
# line that builds a stock binary, which only the staged-binary marker check would catch.
|
||||
# Match whole comma-separated TOKENS, one feature at a time. A substring test would depend
|
||||
# on the order get_features happens to append them (failing a correct build the day they
|
||||
# are reordered) and would also match a future feature that merely contains "drm", the same
|
||||
# trap build.py avoids by splitting on commas rather than testing a substring.
|
||||
for want in drm drm-wake; do
|
||||
case ",$FEATURES," in
|
||||
*",$want,"*) ;;
|
||||
*) echo "::error::build.py --print-features returned no '$want' feature: $FEATURES"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
cargo build --locked --lib --release --features "$FEATURES"
|
||||
python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --skip-cargo
|
||||
|
||||
# build.py exits 0 on some inner failures, so assert the artifact instead of trusting the status,
|
||||
# and assert the two things that make it the drm variant at all.
|
||||
- name: Assert the deb is a real drm build
|
||||
shell: bash
|
||||
run: |
|
||||
# Strict mode so the mid-script checks can fail the step (without it only the LAST
|
||||
# command's status counts and the greps above it are decorative).
|
||||
set -euo pipefail
|
||||
# Glob into an array and assert the COUNT. `deb="$(ls ...)"` aborted on zero matches
|
||||
# before its own `test -n` could report, and on several matches produced a multi-line
|
||||
# value whose `mv` failed with something unrelated to the real problem.
|
||||
shopt -s nullglob
|
||||
debs=(rustdesk-unattended-wayland-*.deb)
|
||||
if [ "${#debs[@]}" -ne 1 ]; then
|
||||
echo "::error::expected exactly one rustdesk-unattended-wayland-*.deb, found ${#debs[@]}: ${debs[*]-none}"
|
||||
exit 1
|
||||
fi
|
||||
deb="${debs[0]}"
|
||||
echo "::notice::built $deb ($(stat -c %s "$deb") bytes)"
|
||||
# Pipe-free for the same reason as the .so contract step above (see the comment there:
|
||||
# a producer feeding a grep that can exit early is a SIGPIPE reported as a failure under
|
||||
# pipefail). `grep -E` without -q reads to EOF so these two happen to be safe, but the
|
||||
# shape is the hazard and the next `-q` added here would inherit it silently.
|
||||
contents="$(dpkg -c "$deb")"
|
||||
if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then
|
||||
echo "::error::the deb does not contain a versioned libdrmtap.so.0.x.y"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then
|
||||
echo "::error::the deb does not contain the libdrmtap.so.0 soname symlink"
|
||||
exit 1
|
||||
fi
|
||||
# The library alone does not make this a drm build: build.py stages it whenever --drm is
|
||||
# passed, independently of what was compiled, and the deb name is what tells a user this
|
||||
# is the consent-bypass variant. Assert the BINARY too, by the absolute dlopen path that
|
||||
# only exists when the feature is compiled in -- otherwise a stock binary could ship
|
||||
# under the unattended-wayland name with a library it can never reach.
|
||||
rm -rf /tmp/debassert && dpkg-deb -R "$deb" /tmp/debassert
|
||||
if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/debassert/usr/share/rustdesk/lib/librustdesk.so; then
|
||||
echo "::error::the packaged librustdesk.so has no libdrmtap dlopen path; this is not a drm build"
|
||||
exit 1
|
||||
fi
|
||||
mv "$deb" "${deb%.deb}-x86_64.deb"
|
||||
|
||||
# MEASURE the glibc floor rather than describing it. This job builds on the runner instead of the
|
||||
# ubuntu18.04 container the stock release debs use, so the artifact only runs on a host at least
|
||||
# as new as the runner -- and that number belongs in the artifact NAME, because a comment in this
|
||||
# file is not visible to whoever downloads it from the Actions UI.
|
||||
- name: Measure the deb glibc floor
|
||||
id: floor
|
||||
shell: bash
|
||||
run: |
|
||||
# Strict mode for the same reason as the assert step above. The floor extraction gets an
|
||||
# explicit rescue so a no-match grep reaches the `test -n` reporter instead of dying as a
|
||||
# bare pipeline failure.
|
||||
set -euo pipefail
|
||||
# Same nullglob array + count assertion as the assert step above, for the same two
|
||||
# reasons: under set -e a zero-match `ls` aborts before anything can report WHY, and
|
||||
# several matches make `deb` multi-line so dpkg-deb fails with an unrelated error. (This
|
||||
# was the sibling left behind when that one was fixed.)
|
||||
shopt -s nullglob
|
||||
debs=(rustdesk-unattended-wayland-*-x86_64.deb)
|
||||
if [ "${#debs[@]}" -ne 1 ]; then
|
||||
echo "::error::expected exactly one renamed deb to measure, found ${#debs[@]}: ${debs[*]-none}"
|
||||
exit 1
|
||||
fi
|
||||
deb="${debs[0]}"
|
||||
rm -rf /tmp/debfloor && dpkg-deb -R "$deb" /tmp/debfloor
|
||||
floor="$(objdump -T /tmp/debfloor/usr/share/rustdesk/lib/librustdesk.so \
|
||||
| grep -oE 'GLIBC_2\.[0-9]+' | sort -uV | tail -1 || true)"
|
||||
test -n "$floor"
|
||||
echo "floor=${floor#GLIBC_}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::deb requires ${floor} or newer (built on the runner, not the ubuntu18.04 release container)"
|
||||
|
||||
# Verification artifact, deliberately NOT a release deliverable. The consent-free variant stays
|
||||
# out of the published release either way; the name states the floor so nobody installs it on an
|
||||
# older distro and hits a bare loader error.
|
||||
- name: Upload the deb
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-unattended-wayland-x86_64-verification-glibc${{ steps.floor.outputs.floor }}.deb
|
||||
path: rustdesk-unattended-wayland-*-x86_64.deb
|
||||
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,13 @@ default = ["use_dasp"]
|
||||
hwcodec = ["scrap/hwcodec"]
|
||||
vram = ["scrap/vram"]
|
||||
mediacodec = ["scrap/mediacodec"]
|
||||
drm = ["scrap/drm"]
|
||||
# The display wake, as its OWN compile gate on top of `drm`. Everything else in the drm backend
|
||||
# READS (it captures a scanout); the wake WRITES, injecting one synthetic pointer event from the
|
||||
# root service so a compositor that idle-disabled its outputs re-enables them. That is a different
|
||||
# kind of operation and deserves a switch that can remove it from the binary entirely, without
|
||||
# giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in.
|
||||
drm-wake = ["drm"]
|
||||
plugin_framework = []
|
||||
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
|
||||
unix-file-copy-paste = [
|
||||
|
||||
458
build.py
458
build.py
@@ -1,12 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import glob
|
||||
import contextlib
|
||||
import pathlib
|
||||
import platform
|
||||
import zipfile
|
||||
import urllib.request
|
||||
import shutil
|
||||
import hashlib
|
||||
import re
|
||||
import subprocess
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -130,6 +134,19 @@ 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(
|
||||
'--print-features',
|
||||
action='store_true',
|
||||
help='Print the cargo feature list these flags select, and exit without building. For a '
|
||||
'caller that runs its own cargo line and then packages with --skip-cargo: it can ask '
|
||||
'for the list rather than repeat it, so the two cannot drift.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skip-cargo',
|
||||
action='store_true',
|
||||
@@ -272,6 +289,24 @@ def external_resources(flutter, args, res_dir):
|
||||
shutil.copytree(f, f'{flutter_build_dir_2}{f.stem}')
|
||||
|
||||
|
||||
def linux_packaging_branch():
|
||||
"""Which packaging path `main()` will take on THIS host.
|
||||
|
||||
MUST mirror the elif chain in main() (pacman / yum / zypper / else), and exists so `--drm` can
|
||||
refuse a branch that is not drm-aware instead of silently producing a stock-named package with
|
||||
the capture backend compiled in. Only the final `deb` branch reaches `build_flutter_deb`, which
|
||||
is what bundles libdrmtap, renames the package, adds Conflicts/Provides and asserts the staged
|
||||
binary really is a drm build.
|
||||
"""
|
||||
if os.path.isfile('/usr/bin/pacman'):
|
||||
return 'pacman'
|
||||
if os.path.isfile('/usr/bin/yum'):
|
||||
return 'yum'
|
||||
if os.path.isfile('/usr/bin/zypper'):
|
||||
return 'zypper'
|
||||
return 'deb'
|
||||
|
||||
|
||||
def get_features(args):
|
||||
features = ['inline'] if not args.flutter else []
|
||||
if args.hwcodec:
|
||||
@@ -282,6 +317,30 @@ def get_features(args):
|
||||
features.append('flutter')
|
||||
if args.unix_file_copy_paste:
|
||||
features.append('unix-file-copy-paste')
|
||||
if args.drm:
|
||||
# Say so rather than quietly handing back a stock build: the backend is Linux-only, so on
|
||||
# any other host the flag cannot be honoured and the resulting binary would look like a
|
||||
# DRM build without being one.
|
||||
if windows or osx:
|
||||
raise Exception('--drm is Linux only')
|
||||
# And only on the deb branch. The other three Linux paths (pacman/yum/zypper) package
|
||||
# straight from `target/release` without bundling libdrmtap, without the rename, without
|
||||
# Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a
|
||||
# package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput
|
||||
# injection. The separate package name is the informed consent this feature rests on (see
|
||||
# docs/DRM_CAPTURE_SECURITY.md), so refuse rather than ship a stock-named build of it.
|
||||
branch = linux_packaging_branch()
|
||||
if branch != 'deb':
|
||||
raise Exception(
|
||||
f'--drm is only supported on the deb packaging path; this host would package via '
|
||||
f'{branch}, which cannot bundle libdrmtap or name the package distinctly')
|
||||
features.append('drm')
|
||||
# The display wake is its own compile gate on top of `drm`, and the unattended package is
|
||||
# exactly where it belongs: that variant exists to reach a machine nobody is sitting at,
|
||||
# and a machine whose screen went dark is the case it is for. Dropping `drm-wake` from
|
||||
# this line builds the same capture backend with no wake code in the binary at all.
|
||||
# It is ALSO switchable at runtime; see OPTION_ENABLE_DRM_DISPLAY_WAKE.
|
||||
features.append('drm-wake')
|
||||
if osx:
|
||||
if args.screencapturekit:
|
||||
features.append('screencapturekit')
|
||||
@@ -316,6 +375,271 @@ 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 from the rustdesk-org fork at a pinned
|
||||
# commit — the same way rustdesk sources its other native build deps (vcpkg,
|
||||
# flutter_rust_bridge, ...), rather than carrying a git submodule. It is the ONLY
|
||||
# pin for the drm backend: rustdesk dlopens this .so at runtime and does not depend on
|
||||
# the libdrmtap-sys crate (whose build.rs would statically link the C tree, a helper and
|
||||
# libdrm/seccomp/cap). DRMTAP_REPO, DRMTAP_SHA and DRMTAP_PREBUILT_DIR override it for local testing
|
||||
# or another fork, and each requires DRMTAP_ALLOW_UNPINNED=1 alongside it (see below).
|
||||
# The commit is fetched directly by sha, so no branch or tag name takes part in the build: see
|
||||
# build_libdrmtap_so(). This is the SINGLE source of truth for the pin, deliberately not duplicated in
|
||||
# any workflow, so a bump is one edit here (plus the informational version comment in
|
||||
# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.2.
|
||||
LIBDRMTAP_REPO_PINNED = 'https://github.com/rustdesk-org/libdrmtap'
|
||||
LIBDRMTAP_SHA_PINNED = '653de8c774bc245eaf960611ca7a136f7a544d21'
|
||||
LIBDRMTAP_REPO = os.environ.get('DRMTAP_REPO', LIBDRMTAP_REPO_PINNED)
|
||||
LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED)
|
||||
# Every way of getting a different .so than the pin needs the same explicit opt-in. Otherwise the
|
||||
# claim this feature rests on -- that the privileged capture library is the reviewed object at
|
||||
# LIBDRMTAP_SHA_PINNED -- would hold only as long as nobody happened to have one of these set, and a
|
||||
# build that silently used something else would be indistinguishable from one that did not.
|
||||
# DRMTAP_PREBUILT_DIR is in the list because it is the widest of the three: it skips both the fetch
|
||||
# and the sha verification and hands over an object built from nothing this script can see.
|
||||
DRMTAP_UNPINNED_OK = os.environ.get('DRMTAP_ALLOW_UNPINNED') == '1'
|
||||
|
||||
|
||||
def _validate_libdrmtap_pin():
|
||||
# Called from build_libdrmtap_so(), NOT at import: a stock (non --drm) build must stay
|
||||
# byte-identical to upstream in behaviour too, and leftover DRMTAP_* variables in the
|
||||
# environment (or a malformed sha) must not be able to fail a build that never touches
|
||||
# libdrmtap.
|
||||
overridden = [
|
||||
name
|
||||
for name, value, pinned in (
|
||||
('DRMTAP_REPO', LIBDRMTAP_REPO, LIBDRMTAP_REPO_PINNED),
|
||||
('DRMTAP_SHA', LIBDRMTAP_SHA, LIBDRMTAP_SHA_PINNED),
|
||||
# `or None` so an empty value reads as unset here exactly as it does in
|
||||
# build_libdrmtap_so(), which tests it for truthiness. Otherwise `DRMTAP_PREBUILT_DIR=`
|
||||
# would demand the opt-in for an override that is not going to happen.
|
||||
('DRMTAP_PREBUILT_DIR', os.environ.get('DRMTAP_PREBUILT_DIR') or None, None),
|
||||
)
|
||||
if value != pinned
|
||||
]
|
||||
if overridden and not DRMTAP_UNPINNED_OK:
|
||||
raise Exception(
|
||||
f'{", ".join(overridden)} would build libdrmtap from something other than the pinned '
|
||||
f'{LIBDRMTAP_REPO_PINNED} at {LIBDRMTAP_SHA_PINNED}. That is supported for local work and '
|
||||
'cross-builds, but it has to be deliberate: set DRMTAP_ALLOW_UNPINNED=1 as well.')
|
||||
if overridden:
|
||||
print(f'WARNING: libdrmtap is NOT the pinned build ({", ".join(overridden)} set)')
|
||||
# Both are interpolated into shell commands below, and both are env-overridable, so validate
|
||||
# their SHAPE before they get there. This is not only about a hostile environment: a truncated
|
||||
# or abbreviated sha would otherwise reach `git fetch` and fail with something far less obvious
|
||||
# than saying so here, and an abbreviated one would defeat the point of pinning.
|
||||
if not re.fullmatch(r'[0-9a-f]{40}', LIBDRMTAP_SHA):
|
||||
raise Exception(
|
||||
f'DRMTAP_SHA must be a full 40-character commit sha, got {LIBDRMTAP_SHA!r}')
|
||||
if not re.fullmatch(r'(https://|git@)[A-Za-z0-9._~:/@-]+', LIBDRMTAP_REPO):
|
||||
raise Exception(f'DRMTAP_REPO does not look like a git remote url: {LIBDRMTAP_REPO!r}')
|
||||
|
||||
|
||||
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, fetched at the pinned LIBDRMTAP_SHA. 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).
|
||||
_validate_libdrmtap_pin()
|
||||
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.*'))
|
||||
so = _single_real_so(prebuilt, f'DRMTAP_PREBUILT_DIR={prebuilt_dir}')
|
||||
# Check the stub case HERE too, not only on the source path below. This is the widest
|
||||
# override of the three -- no fetch, no sha verification, an object built by something this
|
||||
# script cannot see -- so it is the likeliest to hand over a CPU-only build, and skipping the
|
||||
# assertion on exactly this path would leave the check guarding only the case that was
|
||||
# already trustworthy.
|
||||
_assert_so_has_egl(so)
|
||||
return so
|
||||
# Fetch the pinned source if it is not already present. third_party/libdrmtap is not a submodule
|
||||
# anymore; it is git-ignored. The commit is fetched BY SHA rather than by cloning a branch:
|
||||
# `clone --depth 1 --branch main` only ever fetches the tip, so the moment upstream pushes to
|
||||
# `main` the pinned commit is not in the shallow clone at all and the build fails on an unreachable
|
||||
# object. Fetching the sha needs no branch name, so it keeps working across every upstream push and
|
||||
# is immune to a ref being moved or repointed.
|
||||
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(src, exist_ok=True)
|
||||
system2(f'git -C "{src}" init -q')
|
||||
system2(f'git -C "{src}" remote add origin {LIBDRMTAP_REPO}')
|
||||
system2(f'git -C "{src}" fetch --depth 1 origin {LIBDRMTAP_SHA}')
|
||||
system2(f'git -C "{src}" checkout -q FETCH_HEAD')
|
||||
# Verify the pin whenever the source is a GIT checkout. A fetch by sha cannot resolve to anything
|
||||
# else, so this now guards the OTHER case: a reused checkout left by an earlier build at a
|
||||
# different pin, which is what a bump leaves behind. Reject and remove it so the next run re-fetches
|
||||
# cleanly. A NON-git tree placed here on purpose (a developer building unreleased local libdrmtap
|
||||
# source) has nothing to verify and is used as-is.
|
||||
if os.path.isdir(os.path.join(src, '.git')):
|
||||
got_sha = subprocess.check_output(
|
||||
['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
|
||||
if got_sha != LIBDRMTAP_SHA:
|
||||
shutil.rmtree(src, ignore_errors=True)
|
||||
raise Exception(
|
||||
f'libdrmtap at {src} is {got_sha}, expected {LIBDRMTAP_SHA} '
|
||||
f'(stale checkout from a different pin; removed, re-run to re-fetch)')
|
||||
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, not the bundled helper binary or the static archive. Since
|
||||
# libdrmtap 0.4.11 the project is `both_libraries` (a version-scripted .so + a static .a), so the
|
||||
# bare `drmtap` target is ambiguous ("drmtap:shared_library" vs "drmtap:static_library"); ask for
|
||||
# the shared one explicitly (rustdesk dlopens the .so and never needs the archive).
|
||||
system2(f'meson compile -C "{build_dir}" drmtap:shared_library')
|
||||
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.
|
||||
so = _single_real_so(sos, f'the libdrmtap meson build dir {build_dir}')
|
||||
_assert_so_has_egl(so)
|
||||
return so
|
||||
|
||||
|
||||
def _assert_so_has_egl(so_path):
|
||||
# libdrmtap treats egl/glesv2 as OPTIONAL dependencies: without their headers and pkg-config
|
||||
# files, meson silently builds a CPU-only stub. That stub still exports every symbol the loader
|
||||
# checks for, so nothing downstream notices -- and the split architecture depends entirely on the
|
||||
# unprivileged side EGL-detiling the scanout it receives. The result is a build where DRM capture
|
||||
# quietly degrades to PipeWire on every tiled-scanout host, which is most of them.
|
||||
#
|
||||
# Assert on the ARTIFACT rather than passing an option that demands it: `-Degl=enabled` exists
|
||||
# only in libdrmtap past 0.4.15, and checking what was actually produced also catches a stale or
|
||||
# hand-substituted object, which a build flag cannot.
|
||||
#
|
||||
# EGL is reached by lazy dlopen, on purpose, so that the privileged service never links the GPU
|
||||
# stack. That means there is no DT_NEEDED to look for and an ELF-level check reports "no EGL" on a
|
||||
# perfectly good library; the dlopen name and an extension symbol are what a CPU-only stub really
|
||||
# lacks. Same two markers the drm-capture workflow asserts in CI.
|
||||
try:
|
||||
with open(so_path, 'rb') as f:
|
||||
blob = f.read()
|
||||
except OSError as err:
|
||||
raise Exception(f'cannot read the built libdrmtap at {so_path}: {err}') from err
|
||||
missing = [m for m in (b'libEGL.so.1', b'eglCreateImageKHR') if m not in blob]
|
||||
if missing:
|
||||
raise Exception(
|
||||
f'{so_path} looks like a CPU-only libdrmtap stub (missing '
|
||||
f'{", ".join(m.decode() for m in missing)}): the EGL detile path the split capture '
|
||||
'depends on is not in it, and DRM capture would silently fall back to PipeWire. '
|
||||
'Install the EGL development packages and rebuild (Debian/Ubuntu: libegl-dev '
|
||||
'libgles2-mesa-dev; Arch: mesa libglvnd).')
|
||||
|
||||
|
||||
DRM_PACKAGE_NAME = 'rustdesk-unattended-wayland'
|
||||
|
||||
|
||||
def assert_so_satisfies_the_runtime_abi_gate(so_path):
|
||||
"""The .so we are about to ship must be one the RUNTIME will actually accept.
|
||||
|
||||
`abi_accepted` in libs/scrap/src/common/drmtap_dl.rs is the only place the pinned library's
|
||||
version is ever validated, and it runs at dlopen time on the USER's machine. Nothing in the
|
||||
build or in CI compared the two, so the pin and the gate could drift apart and every existing
|
||||
assertion would still pass: the EGL check does not look at the version, the CI symbol contract
|
||||
does not call drmtap_version(), and the deb-contents regex matches any `libdrmtap.so.0.X.Y`.
|
||||
A green pipeline could therefore produce a deb in which DRM capture can never start, and the
|
||||
only symptom on the host is one log line before it falls back to the portal.
|
||||
|
||||
So parse the gate out of the Rust and apply it here, to the object being staged. This is the
|
||||
same rule, not a copy of the numbers: if someone bumps the constants, this reads the new ones.
|
||||
"""
|
||||
m = re.search(r'libdrmtap\.so\.(\d+)\.(\d+)\.(\d+)', os.path.basename(so_path))
|
||||
if not m:
|
||||
# Not a versioned soname (a local dev build, say). The gate cannot be evaluated, and
|
||||
# inventing a verdict would be worse than saying so.
|
||||
print(f'[drm] cannot read a version out of {so_path}; skipping the ABI-gate cross-check')
|
||||
return
|
||||
so_ver = tuple(int(g) for g in m.groups())
|
||||
# Anchored on THIS file, not on the cwd: both callers of stage_libdrmtap_into_deb have already
|
||||
# chdir'd into flutter/ by the time they get here, so a cwd-relative path raises FileNotFoundError
|
||||
# and fails every --drm packaging run. (It did; CI caught it.)
|
||||
gate_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs')
|
||||
with open(gate_path) as f:
|
||||
gate_src = f.read()
|
||||
|
||||
def _const(name):
|
||||
mm = re.search(rf'const {name}: c_int = (\d+);', gate_src)
|
||||
return int(mm.group(1)) if mm else None
|
||||
|
||||
major, minor = _const('DRMTAP_ABI_MAJOR'), _const('DRMTAP_ABI_MINOR')
|
||||
mm = re.search(r'const DRMTAP_MIN_MINOR_PATCH: \(c_int, c_int\) = \((\d+), (\d+)\);', gate_src)
|
||||
floor = (int(mm.group(1)), int(mm.group(2))) if mm else None
|
||||
if major is None or minor is None or floor is None:
|
||||
raise Exception(
|
||||
'could not parse the libdrmtap ABI gate out of drmtap_dl.rs (DRMTAP_ABI_MAJOR / '
|
||||
'DRMTAP_ABI_MINOR / DRMTAP_MIN_MINOR_PATCH). The gate moved and this check did not; '
|
||||
'fix the check rather than removing it, or the pin and the gate can drift silently.')
|
||||
accepted = so_ver[0] == major and so_ver[1] == minor and (so_ver[1], so_ver[2]) >= floor
|
||||
if not accepted:
|
||||
raise Exception(
|
||||
f'the libdrmtap being packaged is {so_ver[0]}.{so_ver[1]}.{so_ver[2]}, which the '
|
||||
f'runtime loader would REFUSE: drmtap_dl.rs accepts exactly major {major}, minor '
|
||||
f'{minor}, patch >= {floor[1]}. Shipping it produces a deb whose DRM capture can never '
|
||||
'start. Move the build pin and the gate together, or fix whichever one is wrong.')
|
||||
print(f'[drm] libdrmtap {so_ver[0]}.{so_ver[1]}.{so_ver[2]} satisfies the runtime ABI gate '
|
||||
f'(major {major}, minor {minor}, patch >= {floor[1]})')
|
||||
|
||||
|
||||
def stage_libdrmtap_into_deb(so_path):
|
||||
# Put the built libdrmtap object plus its soname symlink into the staged deb. Only the soname
|
||||
# symlink is needed: libdrmtap is resolved by ABSOLUTE path (/usr/lib/rustdesk/libdrmtap.so.0) at
|
||||
# the in-process dlopen site (drmtap_dl.rs), so the deb does NOT drop /usr/lib/rustdesk into the
|
||||
# system-wide /etc/ld.so.conf.d search path, which would let this private library shadow a system
|
||||
# library for every binary on the host (Debian Policy 10.2 forbids that). No ld.so.conf.d drop-in
|
||||
# and no ldconfig trigger are shipped, so the stock postinst is used unchanged.
|
||||
assert_so_satisfies_the_runtime_abi_gate(so_path)
|
||||
so_basename = os.path.basename(so_path)
|
||||
system2('mkdir -p tmpdeb/usr/lib/rustdesk')
|
||||
# Quoted: so_path comes from the repo root or from DRMTAP_PREBUILT_DIR, either of which can
|
||||
# contain a space, and an unquoted interpolation would split the argument and fail obscurely.
|
||||
system2(f'cp "{so_path}" tmpdeb/usr/lib/rustdesk/')
|
||||
system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0')
|
||||
|
||||
|
||||
def retarget_control_to_drm_variant():
|
||||
# Rewrite the control file that generate_control_file just produced, instead of parameterizing that
|
||||
# function: the stock packaging path stays exactly as upstream wrote it, and everything specific to
|
||||
# this variant lives here. The variant installs the same files as the stock package, so it must
|
||||
# conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's
|
||||
# own runtime deps, which the stock package has no reason to carry.
|
||||
path = '../res/DEBIAN/control'
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
out = []
|
||||
for line in lines:
|
||||
if line.startswith('Package: rustdesk'):
|
||||
out.append(f'Package: {DRM_PACKAGE_NAME}\n')
|
||||
out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n')
|
||||
elif line.startswith('Depends:'):
|
||||
out.append(line.rstrip('\n') + ', libdrm2, libegl1, libgles2\n')
|
||||
else:
|
||||
out.append(line)
|
||||
body = ''.join(out)
|
||||
# Fail loudly rather than silently shipping a package that says `rustdesk`: a stock control file
|
||||
# that stopped matching either anchor would otherwise produce a variant deb wearing the stock name.
|
||||
if f'Package: {DRM_PACKAGE_NAME}\n' not in body or 'libegl1' not in body:
|
||||
raise Exception(f'could not retarget {path} to the drm variant; upstream control layout changed')
|
||||
with open(path, 'w') as f:
|
||||
f.write(body)
|
||||
|
||||
|
||||
def build_flutter_deb(version, features):
|
||||
if not skip_cargo:
|
||||
system2(f'cargo build --locked --features {features} --lib --release')
|
||||
@@ -352,9 +676,22 @@ 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")
|
||||
# Bundle libdrmtap.so only when this build actually enabled the `drm` feature, so stock packages
|
||||
# stay exactly what they were. The root service dlopens it in-process by absolute path.
|
||||
# `features` is the comma-joined string, so split it: a bare substring test would also match any
|
||||
# future feature merely containing "drm" (drm-lease, vaapi-drm) and rename the deb to the
|
||||
# consent-bypass variant without --drm ever being passed.
|
||||
ships_so = 'drm' in features.split(',')
|
||||
if ships_so:
|
||||
# Same artifact assertion as the --package path. Under --skip-cargo nothing here rebuilt the
|
||||
# binary, so `features` says what was ASKED for while the staged bundle can be anything.
|
||||
assert_staged_binary_is_drm()
|
||||
stage_libdrmtap_into_deb(build_libdrmtap_so())
|
||||
|
||||
system2('mkdir -p tmpdeb/DEBIAN')
|
||||
generate_control_file(version)
|
||||
if ships_so:
|
||||
retarget_control_to_drm_variant()
|
||||
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
|
||||
md5_file_folder("tmpdeb/")
|
||||
system2('dpkg-deb -b tmpdeb rustdesk.deb;')
|
||||
@@ -362,10 +699,68 @@ def build_flutter_deb(version, features):
|
||||
system2('/bin/rm -rf tmpdeb/')
|
||||
system2('/bin/rm -rf ../res/DEBIAN/control')
|
||||
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
|
||||
if ships_so:
|
||||
# Named apart from the stock package so installing the consent-free variant is a deliberate act.
|
||||
os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb')
|
||||
os.chdir("..")
|
||||
|
||||
|
||||
def build_deb_from_folder(version, binary_folder):
|
||||
DRMTAP_DLOPEN_MARKER = b'/usr/lib/rustdesk/libdrmtap.so.0'
|
||||
# Present only when `drm-wake` is compiled in: the runtime option constant is itself
|
||||
# #[cfg(feature = "drm-wake")] (src/ipc/drm.rs). The dlopen marker above cannot stand in for it -
|
||||
# `--features drm` alone produces a binary that carries the dlopen path and NO wake code, and that
|
||||
# is exactly the deb this assertion is here to refuse.
|
||||
DRMTAP_WAKE_MARKER = b'enable-drm-display-wake'
|
||||
|
||||
|
||||
def _carries_drmtap_marker(path, marker=DRMTAP_DLOPEN_MARKER):
|
||||
# Chunked, with an overlap of len(marker)-1 so the marker cannot be missed at a chunk boundary:
|
||||
# librustdesk.so is ~45 MB and there is no reason to hold it all in memory, and the `with`
|
||||
# closes deterministically instead of relying on refcounting.
|
||||
with open(path, 'rb') as f:
|
||||
tail = b''
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
return False
|
||||
if marker in tail + chunk:
|
||||
return True
|
||||
tail = chunk[-(len(marker) - 1):]
|
||||
|
||||
|
||||
def assert_staged_binary_is_drm():
|
||||
"""The staged BINARY must really be a drm build before it is named the unattended-wayland
|
||||
variant. That package conflicts with and replaces the stock one, so shipping a stock binary
|
||||
under that name produces something that can never capture and cannot be installed alongside
|
||||
what it replaced. The marker is the absolute dlopen path from drmtap_dl.rs, present only when
|
||||
the feature is compiled in -- assert what was produced, not what was asked for.
|
||||
|
||||
Called from BOTH packaging paths. It used to guard only one of them, and `--skip-cargo` (which
|
||||
is how CI packages) reaches the other, where nothing had rebuilt the binary at all.
|
||||
"""
|
||||
binaries = [p for p in glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
|
||||
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') if os.path.isfile(p)]
|
||||
if not any(_carries_drmtap_marker(p) for p in binaries):
|
||||
raise Exception(
|
||||
f'--drm was requested but the staged bundle does not look like a drm build (no '
|
||||
f'{DRMTAP_DLOPEN_MARKER.decode()} dlopen path in {binaries or "any staged binary"}); '
|
||||
'refusing to package it as the unattended-wayland variant, which conflicts with and '
|
||||
'replaces the stock package but could never capture')
|
||||
# And the WAKE half. `--drm` enables `drm-wake` too (see get_features), and the deb is named and
|
||||
# documented as the variant that can reach a machine whose screen has gone dark. The dlopen
|
||||
# marker above does not distinguish them: `--features drm` alone carries it and has no wake code
|
||||
# at all. Asserting only the first half is how a deb can be named for a feature it does not have.
|
||||
if not any(_carries_drmtap_marker(p, DRMTAP_WAKE_MARKER) for p in binaries):
|
||||
raise Exception(
|
||||
f'--drm was requested but the staged binary has no {DRMTAP_WAKE_MARKER.decode()} '
|
||||
f'marker in {binaries or "any staged binary"}, so it was built without `drm-wake`; '
|
||||
'refusing to package it as the unattended-wayland variant, which is named and '
|
||||
'documented as the build that can wake an idle-disabled display. If this fired under '
|
||||
'--skip-cargo, the cargo line that produced the bundle is missing the feature: '
|
||||
'--features ...,drm,drm-wake')
|
||||
|
||||
|
||||
def build_deb_from_folder(version, binary_folder, want_drm=False):
|
||||
os.chdir('flutter')
|
||||
system2('mkdir -p tmpdeb/usr/bin/')
|
||||
system2('mkdir -p tmpdeb/usr/share/rustdesk')
|
||||
@@ -389,9 +784,53 @@ 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")
|
||||
# Where the capture library comes from for a `--package <folder> --drm` build. Two shapes are
|
||||
# supported, because two exist in practice: a bundle that already carries libdrmtap.so.0.*
|
||||
# (someone staged it, e.g. a CI artifact), and a plain bundle, which is what every build path
|
||||
# here actually produces -- the flutter deb builds the library straight into the staged deb, so
|
||||
# nothing ever puts it inside the bundle folder. Demanding it in the bundle made this flag
|
||||
# combination impossible to satisfy.
|
||||
bundled_glob = glob.glob('tmpdeb/usr/share/rustdesk/libdrmtap.so.0.*')
|
||||
bundle_carries_so = any(os.path.isfile(p) and not os.path.islink(p) for p in bundled_glob)
|
||||
# The variant must be decided by the EXPLICIT --drm request, not merely by what happens to be
|
||||
# staged: a bundle that carries the .so must NOT be shipped as the consent-bypass variant when
|
||||
# --drm was never passed.
|
||||
if bundle_carries_so and not want_drm:
|
||||
raise Exception(
|
||||
'the staged bundle carries libdrmtap.so.0.* but --drm was not passed; refusing '
|
||||
'to silently ship the consent-bypass unattended-wayland variant (pass --drm to '
|
||||
'build it deliberately)')
|
||||
if want_drm:
|
||||
# Whichever shape we are in, the staged BINARY must really be a drm build. This is the
|
||||
# property the old presence-of-the-.so test stood in for, badly: a stock binary packaged as
|
||||
# the unattended-wayland variant would carry the consent-bypass name, conflict with and
|
||||
# replace the stock package, and never be able to capture. The marker is the absolute
|
||||
# dlopen path from drmtap_dl.rs, present only when the feature is compiled in -- the same
|
||||
# kind of artifact assertion as _assert_so_has_egl, and for the same reason: assert what
|
||||
# was produced, not what was asked for.
|
||||
assert_staged_binary_is_drm()
|
||||
if bundle_carries_so:
|
||||
so = _single_real_so(bundled_glob, 'the staged --drm bundle')
|
||||
# The THIRD artifact source, and the last one that was missing the check: --package
|
||||
# takes the .so straight out of a bundle somebody else produced, so it has the same
|
||||
# exposure as DRMTAP_PREBUILT_DIR (see the comment on that branch). A CPU-only stub
|
||||
# would ship, the loader would accept it, and capture would degrade to PipeWire
|
||||
# without a word.
|
||||
_assert_so_has_egl(so)
|
||||
stage_libdrmtap_into_deb(so)
|
||||
system2(f'rm -f "{so}"')
|
||||
system2('rm -f tmpdeb/usr/share/rustdesk/libdrmtap.so tmpdeb/usr/share/rustdesk/libdrmtap.so.0')
|
||||
else:
|
||||
# Build it here, exactly as the flutter deb path does (build_libdrmtap_so asserts the
|
||||
# EGL backend itself). The library is independent of the staged binary.
|
||||
stage_libdrmtap_into_deb(build_libdrmtap_so())
|
||||
|
||||
system2('mkdir -p tmpdeb/DEBIAN')
|
||||
generate_control_file(version)
|
||||
# Keyed on the EXPLICIT request, not on what happened to be staged: by here a --drm build has
|
||||
# its library in tmpdeb whichever of the two shapes it came from.
|
||||
if want_drm:
|
||||
retarget_control_to_drm_variant()
|
||||
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
|
||||
md5_file_folder("tmpdeb/")
|
||||
system2('dpkg-deb -b tmpdeb rustdesk.deb;')
|
||||
@@ -399,6 +838,8 @@ def build_deb_from_folder(version, binary_folder):
|
||||
system2('/bin/rm -rf tmpdeb/')
|
||||
system2('/bin/rm -rf ../res/DEBIAN/control')
|
||||
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
|
||||
if want_drm:
|
||||
os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb')
|
||||
os.chdir("..")
|
||||
|
||||
|
||||
@@ -473,6 +914,19 @@ def main():
|
||||
parser = make_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# Before anything with a side effect: this is a query, and a caller uses it to build the very
|
||||
# binary it will then package. `get_features` stays the single definition of what a flag
|
||||
# combination means; a caller that hardcodes the list instead is one edit away from compiling
|
||||
# something other than what it ships.
|
||||
if args.print_features:
|
||||
# stdout carries the list and nothing else, so a caller can use it directly in a command
|
||||
# substitution. `get_features` prints a human-readable line of its own; send that to stderr
|
||||
# for this call rather than silencing it, which would change what every other path prints.
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
feats = ','.join(get_features(args))
|
||||
print(feats)
|
||||
return
|
||||
|
||||
if os.path.exists(exe_path):
|
||||
os.unlink(exe_path)
|
||||
if os.path.isfile('/usr/bin/pacman'):
|
||||
@@ -488,7 +942,7 @@ def main():
|
||||
portable = args.portable
|
||||
package = args.package
|
||||
if package:
|
||||
build_deb_from_folder(version, package)
|
||||
build_deb_from_folder(version, package, args.drm)
|
||||
return
|
||||
res_dir = 'resources'
|
||||
external_resources(flutter, args, res_dir)
|
||||
|
||||
255
docs/DRM_CAPTURE_SECURITY.md
Normal file
255
docs/DRM_CAPTURE_SECURITY.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# 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. On the **default (split) path** the root service does not touch
|
||||
pixels: it exports the active scanout as a DMA-BUF and passes just that
|
||||
**read-only** fd to the unprivileged user `--server` over a dedicated
|
||||
service-scoped IPC channel (`_drm`) via `SCM_RIGHTS`. The `--server` keeps an
|
||||
**import-once EGLImage cache** (keyed on the buffer, so a given scanout buffer is
|
||||
imported once and re-imports are elided), detiles/converts it to linear RGBA in
|
||||
its own unprivileged address space, and feeds the encoder — so **on that path**
|
||||
the root service never copies scanout pixels and never loads libEGL/libGLESv2
|
||||
(measured on the running service, see *Auditing*). Only the **CPU fallback path**
|
||||
(used when the seat/driver cannot produce a transferable DMA-BUF, or the consumer
|
||||
has no render node of its own, see *When the CPU fallback is chosen* below)
|
||||
copies the scanout to packed BGRA inside the root service and streams those bytes
|
||||
over `_drm`.
|
||||
|
||||
**The no-GL property is a property of the default path, not of the process.** Be
|
||||
precise about it, because the CPU fallback is the whole reason the split exists:
|
||||
converting a scanout in-process means decoding whatever layout it is in, and a
|
||||
tiled scanout (the common case on modern Intel and AMD) can only be decoded
|
||||
through the GPU. `drmtap_grab_mapped` therefore reaches libdrmtap's auto-process
|
||||
step, which lazily `dlopen`s libEGL/libGLESv2 **in the calling process** when the
|
||||
scanout needs a GPU detile. So a host that has fallen back to the CPU path can
|
||||
map the GL stack inside the `CAP_SYS_ADMIN` service. What the design does about
|
||||
that is bound the cases: the fallback is entered only for the three reasons
|
||||
listed below, never as a silent degradation of the split path (the loader refuses
|
||||
a `libdrmtap` that cannot export the fd at all, precisely so "old library" cannot
|
||||
turn into "convert in the privileged process"), and a linear or CPU-mappable
|
||||
scanout is converted without touching GL. Every host measured here runs the split
|
||||
path with zero GL regions in the service; a CPU-fallback host is a different
|
||||
posture and is worth measuring separately. 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 loader also **refuses a library that cannot do the split** — and, more
|
||||
broadly, any version outside the vetted window. Accepted is exactly the pinned
|
||||
minor with a patch floor (currently `0.5.x`, `x >= 0`): an older minor is
|
||||
refused (`0.4.x` included, even though it carries the split entry points, because
|
||||
it decodes a padded scanout pitch at the wrong stride), and a **newer minor is
|
||||
refused too** (`0.6.x` onward), because the loader mirrors C struct layouts that are only
|
||||
field-by-field verified against the pinned minor; widening the window is a
|
||||
deliberate act done together with re-verifying the layouts and moving the
|
||||
build pin. Independently of the version report, a library that does not
|
||||
actually export
|
||||
`drmtap_grab_desc` / `drmtap_open_render` / `drmtap_convert_dmabuf` (a stale or
|
||||
pre-release build) is refused as well. The only way to capture with such a library is the
|
||||
in-process convert, which in the root service means loading the vendor GL stack
|
||||
there, so it is refused and the caller falls back to PipeWire/portal. The
|
||||
privileged process therefore never loads GL because of which file happened to
|
||||
be on the load path; the CPU fallback below is entered only for a fact about
|
||||
the seat or the consumer.
|
||||
- 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`). **No `drmtap-helper` binary is built, shipped, or
|
||||
installed by this package**: there is no `setcap`, no capability-bearing file,
|
||||
and no capture group in this deployment. Being precise about what that does
|
||||
and does not guarantee: an empty `helper_path` is not by itself a "helper
|
||||
disabled" switch in the C. `find_helper` (`privilege_helper.c`) searches six
|
||||
hardcoded paths, one of which is `/usr/lib/rustdesk/drmtap-helper`, the
|
||||
directory this package installs into, and `fork`/`exec`s the first executable
|
||||
it finds if the direct export ever returns `EACCES`/`EPERM`. Here that path is
|
||||
unreachable for two independent reasons: the root service holds
|
||||
`CAP_SYS_ADMIN` so the direct export succeeds, and the package builds only the
|
||||
shared library, so no helper exists at any of those paths. They are all
|
||||
root-writable-only, so a helper appearing there would not be an escalation
|
||||
either, but the honest statement is "a privileged child is spawned only if a
|
||||
helper binary exists at one of those fixed root-owned paths, and this package
|
||||
never installs one", not "never".
|
||||
- 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 attack surface. On the
|
||||
**default (split) path** the root service does only a **metadata-only** parse
|
||||
of the scanout descriptor and exports the DMA-BUF fd; the untrusted-framebuffer
|
||||
detile / pixel-format conversion runs in the **unprivileged `--server`**,
|
||||
outside `CAP_SYS_ADMIN`. Export-side validation is therefore metadata-only —
|
||||
geometry bounded to `<= MAX_DIM` (16384) and `num_planes` in `1..=4`
|
||||
(`drm_reader.rs` `grab_desc`); there is **no fourcc gate** on the export side,
|
||||
because the format check is delegated to the unprivileged converter, which
|
||||
handles every format `libdrmtap` supports (XRGB/ARGB8888, 10-bit XR30/AR30,
|
||||
HDR, CCS-compressed). The exported fd is **read-only**: `libdrmtap` exports the
|
||||
DMA-BUF via `drmPrimeHandleToFD` with `DRM_RDWR` dropped (`O_RDONLY`), and
|
||||
`drm_reader` `dup()`s it — which shares the same open file description and so
|
||||
preserves that access mode — so the unprivileged consumer can map the scanout
|
||||
for reading but never write into the live framebuffer. On the **CPU fallback
|
||||
path** the pixel-format conversion / detile instead runs inside the
|
||||
`CAP_SYS_ADMIN` service without a seccomp cage; there the frame copy has
|
||||
format / stride / geometry and integer-overflow guards (`drm_reader.rs`
|
||||
`grab`), and non-32bpp scanouts are rejected before the copy. The device is
|
||||
realpath-gated to `/dev/dri/` on both paths.
|
||||
- **`_drm` is a screen-content channel.** It is authorized per connection (see
|
||||
above); without that authz any local process could read the screen. Authorization
|
||||
is also **re-checked on every frame**, not only at accept, because DRM/KMS
|
||||
capture is not session-scoped: it grabs the physical scanout of a CRTC no matter
|
||||
which session owns the display. So when the active session changes -- a user
|
||||
logging in at a greeter -- the greeter's `_drm` stream is CLOSED rather than
|
||||
continued (`drm: _drm peer no longer matches the active session`; observed with
|
||||
peer_uid=60578 against active_uid=1000, and the greeter's uinput channel goes
|
||||
with it). That is what stops an outgoing greeter process from capturing the
|
||||
logged-in user's screen. The cost is a reconnect, not the session: the client
|
||||
re-establishes itself against the new session's `--server` on its own in about
|
||||
2.5 s (~3.6 s of dark screen, measured 2026-07-31). On the
|
||||
**default (split) path** the channel carries the scanout DMA-BUF fd, passed to
|
||||
the unprivileged `--server` over `SCM_RIGHTS` as a **read-only** descriptor
|
||||
(the `--server` holds an import-once EGLImage cache, so a given scanout buffer
|
||||
is imported once and re-imports are elided); the peer can map the scanout for
|
||||
reading but cannot write it. The **CPU fallback path** instead carries plain
|
||||
packed-BGRA bytes over the same authorized socket (no fd passing, no shared
|
||||
memory).
|
||||
- **When the CPU fallback is chosen.** The split path is the default; the
|
||||
consumer asks the service for the CPU-converted frame in two cases: no render
|
||||
node can be opened for this seat, or a previous convert on this display
|
||||
already failed. A third case is a **multi-GPU safety fallback**: if
|
||||
the service could not name the render node of the GPU that exports the scanout
|
||||
(an older `libdrmtap` without `drmtap_render_node`) and the host has more than
|
||||
one render node, the consumer refuses to guess one, because importing a scanout
|
||||
on a device that did not export it can succeed and return corrupted pixels
|
||||
rather than fail. The conversion then happens in the service, on the device it
|
||||
already has open, so it is correct by construction. Hosts with a single render
|
||||
node have nothing to pick wrong and keep the DMA-BUF fast path.
|
||||
- **The display wake injects synthetic input from the root service.** It is
|
||||
compiled in only with the `drm-wake` feature, which `build.py --drm` adds on
|
||||
top of `drm`, and it can be switched off at runtime with
|
||||
`enable-drm-display-wake=N`. Building with `--features drm` alone leaves no
|
||||
wake code in the binary at all, so an operator auditing the deb can answer
|
||||
"is the injection path even present here?" from the artifact. A
|
||||
compositor that idles long enough DISABLES a connector, leaving no scanout for
|
||||
any backend, so on a `_drm` handshake that finds a CONNECTED display with no
|
||||
CRTC the service emits one synthetic pointer round trip over `/dev/uinput` to
|
||||
make the compositor re-enable it. The virtual device **declares** two relative
|
||||
axes and `BTN_LEFT`, because libinput classifies a device before it will treat
|
||||
its events as pointer activity at all and a single axis with no buttons is
|
||||
ignored outright (measured three ways on the same idle machine). What it
|
||||
actually **emits** is `+1` then `-1` on one axis: net-zero displacement, no
|
||||
button press, no key events. This is deliberate input injection by privileged
|
||||
code, so its bounds are worth stating precisely:
|
||||
- it can only be reached through an **already-authorized** `_drm` connection
|
||||
(same per-connection authz as every other use of the channel), so it grants
|
||||
nothing to a local attacker that the channel itself does not;
|
||||
- it runs in the root service because that is the only place it can:
|
||||
`/dev/uinput` is root-only here, and a modeset of our own is not an option
|
||||
since the compositor holds DRM master (the sysfs `dpms` attribute is
|
||||
read-only). Session-bus routes (`org.gnome.ScreenSaver`) authenticate by
|
||||
uid, refuse root, and are desktop-specific;
|
||||
- the trigger is narrow — a connected-but-undriven connector, not "no
|
||||
frames" — and connectors a wake demonstrably cannot bring back are
|
||||
remembered by connector identity and stop triggering. That memory is
|
||||
per-connector rather than global, so a permanently dark connector cannot
|
||||
suppress the wake for a different panel, and it drops any entry later seen
|
||||
scanning out. Note what that recovery rule does and does not give you: it
|
||||
clears the moment the display is driven **by anything**, but nothing else
|
||||
retries, so a connector latched after a wake that failed for a transient
|
||||
reason stays latched until that display comes back some other way — on an
|
||||
unattended host, typically not until the service restarts. It is a
|
||||
deliberate trade against waking on every connection forever for a display
|
||||
that is never coming;
|
||||
- it is rate limited to **one wake per 20 s process-wide** with exactly one
|
||||
concurrent winner (compare-exchange claim), so a reconnect storm cannot
|
||||
become an input-injection storm. That bounds the injection RATE. It does
|
||||
not bound how long a screen stays lit, and neither does the one-shot
|
||||
property below: 20 s is shorter than every idle period measured below, so a
|
||||
remote peer that reconnects in a loop can have the panel relit after each
|
||||
idle-off. What that peer gains is a lit panel on a machine whose screen it
|
||||
is already authorized to watch: it is visible to someone standing there,
|
||||
not additional access;
|
||||
- the wake is **one-shot: it resets the compositor's idle timer, it does not
|
||||
hold the display on**. If nothing else keeps the session awake, the connector
|
||||
idles off again one full idle period later -- measured 2026-07-31: 30.3 s at
|
||||
a GDM greeter, 70.3 s in a user session with `idle-delay=60`. Keeping a
|
||||
screen lit for the length of a session is the job of RustDesk's existing
|
||||
keep-awake inhibitor, not of this wake, which only recovers a connector that
|
||||
is *already* dark;
|
||||
- the uinput device is created and destroyed around the emit — nothing
|
||||
persists in the input stack between wakes;
|
||||
- without `/dev/uinput` the wake is skipped and latched off. Such a session
|
||||
was already view-only (input injection on Wayland needs uinput too), so
|
||||
this adds no new failure mode.
|
||||
|
||||
## 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/**Provides** `rustdesk` --
|
||||
`Provides` is what lets a third-party package that depends on `rustdesk` be satisfied by the
|
||||
consent-free variant, so it belongs in an audit of this metadata), so
|
||||
enabling consent-free capture is an explicit install choice.
|
||||
- **Bundled library, no capabilities.** The package installs the versioned
|
||||
`libdrmtap.so.0.<minor>.<patch>` plus a `libdrmtap.so.0` soname symlink under
|
||||
`/usr/lib/rustdesk/`, and the in-process `dlopen` names that absolute path
|
||||
(`/usr/lib/rustdesk/libdrmtap.so.0`). The package deliberately does **not**
|
||||
register the directory with the dynamic linker: no
|
||||
`/etc/ld.so.conf.d/` drop-in and no `ldconfig` trigger are shipped, so a
|
||||
private library cannot shadow a system one for unrelated binaries
|
||||
(Debian Policy 10.2). The bare-soname lookups remain only as a fallback for a
|
||||
development build reached through `LD_LIBRARY_PATH`.
|
||||
|
||||
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 libdrm: 2.4.95.** `libdrmtap` needs the DRM `GetFB2` framebuffer API, which
|
||||
landed in libdrm 2.4.95. Ubuntu 18.04 is the oldest distribution worth naming here, and it
|
||||
straddles the floor: base bionic shipped 2.4.91, below it, while the updates/HWE stack
|
||||
(2.4.101) is above — so read this as "18.04 with updates, or anything newer", not as
|
||||
"any 18.04". That is an API statement, not a binary-compatibility one:
|
||||
the `rustdesk-unattended-wayland` deb in this repo's CI is built on an ubuntu-24.04 runner, so the
|
||||
shipped binaries carry that build host's glibc floor. Running on an older distribution means
|
||||
building the deb there (or in a matching container), which the libdrm floor above permits.
|
||||
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 and its soname symlink — no capabilities are set on either
|
||||
ls -l /usr/lib/rustdesk/libdrmtap.so.0*
|
||||
# the dlopen names the symlink by absolute path, so what matters is where the symlink points:
|
||||
readlink /usr/lib/rustdesk/libdrmtap.so.0 # expect: the versioned object shipped by the package
|
||||
# and there should be no other object left beside it (a leftover is not loaded on its own, but it
|
||||
# is what a stray ldconfig over this directory would repoint the symlink to):
|
||||
ls /usr/lib/rustdesk/libdrmtap.so.0.* # expect: exactly one versioned object
|
||||
ls /etc/ld.so.conf.d/ | grep -i rustdesk # expect: no output (none is shipped)
|
||||
# confirm no privileged helper is present (there should be none)
|
||||
getcap -r /usr/lib/rustdesk 2>/dev/null # expect: no output
|
||||
```
|
||||
@@ -11,6 +11,16 @@ edition = "2018"
|
||||
|
||||
[features]
|
||||
wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"]
|
||||
# `drm` is a pure runtime-dlopen backend: rustdesk loads `libdrmtap.so.0` at runtime (`drmtap_dl.rs`)
|
||||
# and NEVER link-time links it, so the graceful PipeWire fallback when the .so or EGL is absent is
|
||||
# preserved and the drm build pulls in no libdrm/seccomp/cap/EGL link-time deps. The .so is pinned by
|
||||
# `DRMTAP_SHA` in build.py, which fetches that exact commit (libdrmtap v0.5.2). We deliberately do
|
||||
# NOT depend on the `libdrmtap-sys` crate: its build.rs statically compiles the whole libdrmtap C tree
|
||||
# and a CAP_SYS_ADMIN helper and emits `-ldrm -lseccomp -lcap`, which would defeat the dlopen model.
|
||||
# Depends on `wayland`: the three drm modules live inside the `#[cfg(feature = "wayland")]` arm of
|
||||
# common/mod.rs, so `scrap/drm` on its own would compile nothing. The root crate happens to always
|
||||
# enable `scrap/wayland`, which is what hid this.
|
||||
drm = ["wayland"]
|
||||
mediacodec = ["ndk"]
|
||||
linux-pkg-config = ["dep:pkg-config"]
|
||||
hwcodec = ["dep:hwcodec"]
|
||||
|
||||
477
libs/scrap/src/common/drm_reader.rs
Normal file
477
libs/scrap/src/common/drm_reader.rs
Normal file
@@ -0,0 +1,477 @@
|
||||
// Service-side DRM/KMS read engine, in the ROOT `--service`: libdrmtap reads the scanout in-process (direct mode). The DRM_DEVICE env is not consulted here.
|
||||
|
||||
use super::drmtap_dl::{
|
||||
self, drmtap_config, drmtap_ctx, drmtap_cursor_info, drmtap_device, drmtap_display,
|
||||
drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib,
|
||||
};
|
||||
use hbb_common::log;
|
||||
use std::ffi::CString;
|
||||
use std::io;
|
||||
use std::os::fd::{FromRawFd, OwnedFd};
|
||||
|
||||
// Trust-boundary limits and formats `drm_render` (the unprivileged converter) imports: two copies that drift apart would weaken one side.
|
||||
// 16384 covers 8K+ with headroom; anything larger is rejected as a bogus/hostile geometry.
|
||||
pub(crate) const MAX_DIM: u32 = 16384;
|
||||
// 256 MiB covers an 8K BGRA frame (7680x4320x4 ~= 127 MiB) with margin.
|
||||
pub(crate) const MAX_FRAME_BYTES: usize = 256 * 1024 * 1024;
|
||||
// XRGB/ARGB are little-endian B,G,R,{X,A} in memory == `Pixfmt::BGRA`; XBGR/ABGR are R,G,B,{X,A} == `Pixfmt::RGBA`.
|
||||
pub(crate) const DRM_FORMAT_XRGB8888: u32 = 0x3432_5258; // 'XR24'
|
||||
pub(crate) const DRM_FORMAT_ARGB8888: u32 = 0x3432_5241; // 'AR24'
|
||||
pub(crate) const DRM_FORMAT_XBGR8888: u32 = 0x3432_4258; // 'XB24'
|
||||
pub(crate) const DRM_FORMAT_ABGR8888: u32 = 0x3432_4241; // 'AB24'
|
||||
|
||||
/// Cursor id published when the plane reports the cursor hidden, so the id changes and, where the DRM cursor is authoritative, the client drops the last shape.
|
||||
pub const HIDDEN_CURSOR_ID: u64 = u64::MAX;
|
||||
|
||||
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 overlays the Wayland logical origin/scale where it can match one).
|
||||
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,
|
||||
}
|
||||
|
||||
pub struct DrmDevice {
|
||||
pub path: String,
|
||||
/// Render node, or empty if this device has none.
|
||||
pub render_node: String,
|
||||
pub display_count: u32,
|
||||
}
|
||||
|
||||
/// Copy a fixed C char array into a `String`, stopping at the first NUL WITHIN the array, so a
|
||||
/// field libdrmtap failed to terminate cannot read past it.
|
||||
fn cstr_field(buf: &[std::os::raw::c_char]) -> String {
|
||||
// SAFETY: c_char and u8 share size/alignment; the slice is the exact length of `buf`.
|
||||
let bytes: &[u8] =
|
||||
unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, buf.len()) };
|
||||
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
String::from_utf8_lossy(&bytes[..end]).into_owned()
|
||||
}
|
||||
|
||||
/// Enumerate every DRM device with KMS resources. `None` = unavailable, too old, or failed (the caller then scans /dev/dri/card* itself); empty `Vec` = none found.
|
||||
pub fn list_devices() -> Option<Vec<DrmDevice>> {
|
||||
let lib = drmtap_dl::get()?;
|
||||
let f = lib.list_devices?;
|
||||
const MAX: usize = 16;
|
||||
let mut raw: [drmtap_device; MAX] = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: `raw` is MAX valid, zeroed drmtap_device slots; the call fills up to MAX and returns the count.
|
||||
let n = unsafe { f(raw.as_mut_ptr(), MAX as std::os::raw::c_int) };
|
||||
if n < 0 {
|
||||
log::warn!("drmtap_list_devices failed ({n}); using single-device auto-detect");
|
||||
return None;
|
||||
}
|
||||
let n = (n as usize).min(MAX);
|
||||
Some(
|
||||
raw[..n]
|
||||
.iter()
|
||||
.map(|d| DrmDevice {
|
||||
path: cstr_field(&d.path),
|
||||
render_node: cstr_field(&d.render_node),
|
||||
display_count: d.display_count,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The CANONICAL path, when `path` canonicalizes to a node directly under /dev/dri/, else `None`.
|
||||
/// Callers must open the value returned: opening the original re-resolves every symlink component after the check.
|
||||
pub(super) fn device_under_dev_dri(path: &str) -> Option<std::path::PathBuf> {
|
||||
let p = std::fs::canonicalize(path).ok()?;
|
||||
if p.parent() == Some(std::path::Path::new("/dev/dri")) {
|
||||
Some(p)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// An open DRM read context. Not Send/Sync deliberately (the raw ctx is used on one thread).
|
||||
pub struct DrmReader {
|
||||
lib: &'static DrmtapLib,
|
||||
ctx: *mut drmtap_ctx,
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl DrmReader {
|
||||
/// Open the DRM device. `device = None` auto-detects, `Some(path)` is realpath-gated to /dev/dri/. `crtc_id = 0` auto-selects the first active CRTC.
|
||||
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) => {
|
||||
let Some(canonical) = device_under_dev_dri(d) else {
|
||||
log::warn!("DRM device {d:?} is not under /dev/dri; refusing to open");
|
||||
return None;
|
||||
};
|
||||
match canonical.to_str().and_then(|s| CString::new(s).ok()) {
|
||||
Some(c) => Some(c),
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
};
|
||||
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, tightly packed as BGRA (`w*4*h` bytes), into the internal buffer; valid until the next grab.
|
||||
pub fn grab(&mut self) -> io::Result<(&[u8], usize, usize)> {
|
||||
// SAFETY: ctx is valid; frame is zeroed before the call. The frame is released on every return path that OWNS one: a failing
|
||||
// `drmtap_grab_mapped` leaves nothing to release, and releasing anyway would be a double free.
|
||||
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;
|
||||
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;
|
||||
// The row copy reads w*4 bytes from a source only stride*height bytes: reject sub-32bpp / insane geometry to avoid an OOB read.
|
||||
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",
|
||||
));
|
||||
}
|
||||
// XBGR8888 passes the stride check but, labeled BGRA downstream, would ship red and blue swapped; a zero fourcc falls through to the stride invariant (kept for libdrmtap builds that do not set it).
|
||||
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);
|
||||
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",
|
||||
));
|
||||
}
|
||||
};
|
||||
// Bound the SOURCE extent too: the row loop reads up to (h-1)*stride + w*4, and `y * stride` can overflow.
|
||||
match stride.checked_mul(h) {
|
||||
Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => {}
|
||||
other => {
|
||||
log::warn!(
|
||||
"DRM scanout stride {stride} x {h} rows is out of range ({other:?} bytes); falling back"
|
||||
);
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"DRM scanout stride out of range",
|
||||
));
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// Render node of the GPU this reader captures from, so the converter binds to the device that EXPORTS the scanout:
|
||||
/// importing across vendors can fail on an incompatible tiling modifier. `None` if the symbol is absent or the device is display-only.
|
||||
pub fn render_node(&mut self) -> Option<String> {
|
||||
let f = self.lib.render_node?;
|
||||
// SAFETY: self.ctx is valid; the returned pointer is owned by the context and stays valid until it is closed.
|
||||
let ptr = unsafe { f(self.ctx) };
|
||||
if ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
unsafe { std::ffi::CStr::from_ptr(ptr) }
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(|s| s.to_owned())
|
||||
}
|
||||
|
||||
/// Zero-copy EXPORT grab: fills a `drmtap_dmabuf_desc` (dma-buf fd, plane layout, HDR metadata) WITHOUT mapping, detiling or copying pixels, so on this
|
||||
/// path the root process never loads libEGL/libGLESv2. The exported fd is READ-ONLY (libdrmtap drops `DRM_RDWR` and `dup` shares that open file
|
||||
/// description), so the `--server` that receives it can map the scanout but never write the live framebuffer. Validation here is METADATA ONLY.
|
||||
pub fn grab_desc(&mut self) -> io::Result<(OwnedFd, drmtap_dmabuf_desc)> {
|
||||
let grab_desc = self.lib.grab_desc;
|
||||
// SAFETY: self.ctx is valid; desc/frame are zeroed before the call. Only paths that reach a populated frame release it: on `-EINVAL`
|
||||
// libdrmtap returns before allocating, a failed inner grab has already cleaned up, and on `-ENOTSUP` libdrmtap releases the frame itself.
|
||||
unsafe {
|
||||
let mut desc: drmtap_dmabuf_desc = std::mem::zeroed();
|
||||
let mut frame: drmtap_frame_info = std::mem::zeroed();
|
||||
let ret = grab_desc(self.ctx, &mut desc, &mut frame);
|
||||
if ret < 0 {
|
||||
let errno = -ret;
|
||||
if errno == hbb_common::libc::EAGAIN
|
||||
|| errno == hbb_common::libc::EBUSY
|
||||
|| errno == hbb_common::libc::EINTR
|
||||
{
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
if errno == hbb_common::libc::ENOTSUP {
|
||||
// A distinct error so the caller degrades to the mapped/PipeWire path instead of tight-looping a rebuild.
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"drmtap_grab_desc: no transferable dma-buf (ENOTSUP)",
|
||||
));
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("drmtap_grab_desc failed: errno {errno}"),
|
||||
));
|
||||
}
|
||||
// `desc.dma_buf_fd` is the canonical fd (what split_capture.c sends); `frame` owns it too and `frame_release` closes the library's copy.
|
||||
let raw_fd = if desc.dma_buf_fd >= 0 {
|
||||
desc.dma_buf_fd
|
||||
} else {
|
||||
frame.dma_buf_fd
|
||||
};
|
||||
if raw_fd < 0 {
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
let w = desc.width;
|
||||
let h = desc.height;
|
||||
if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM {
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("DRM scanout geometry {w}x{h} out of range"),
|
||||
));
|
||||
}
|
||||
// No fourcc gate here: the converter handles every format libdrmtap supports, and gating here dropped convertible scanouts such as XR30.
|
||||
let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes };
|
||||
if planes > 4 {
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("DRM scanout num_planes {} out of range (1..=4)", desc.num_planes),
|
||||
));
|
||||
}
|
||||
for p in 0..(planes as usize) {
|
||||
let extent = (desc.pitches[p] as usize)
|
||||
.checked_mul(h as usize)
|
||||
.and_then(|rows| rows.checked_add(desc.offsets[p] as usize));
|
||||
match extent {
|
||||
Some(end) if end <= MAX_FRAME_BYTES => {}
|
||||
other => {
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!(
|
||||
"DRM scanout plane {p} out of range (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})",
|
||||
desc.offsets[p], desc.pitches[p]
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// dup BEFORE releasing the frame: after release the library may recycle its handle, while an independent fd on the same open dma-buf
|
||||
// keeps the buffer alive for the peer. F_DUPFD_CLOEXEC, not dup(): `dup` never copies close-on-exec and this root service forks elsewhere.
|
||||
let dup_fd = hbb_common::libc::fcntl(raw_fd, hbb_common::libc::F_DUPFD_CLOEXEC, 0);
|
||||
if dup_fd < 0 {
|
||||
let e = io::Error::last_os_error();
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(e);
|
||||
}
|
||||
let owned = OwnedFd::from_raw_fd(dup_fd);
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
desc.num_planes = planes;
|
||||
desc.dma_buf_fd = -1;
|
||||
Ok((owned, desc))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the hardware cursor plane: the hidden sentinel when the plane reports the cursor invisible, the real shape when visible, and `None` when the read fails.
|
||||
pub fn cursor(&mut self) -> Option<CursorSnapshot> {
|
||||
// SAFETY: ctx valid; c zeroed; released on EVERY path after a successful get_cursor. Only a failed get_cursor returns without releasing, because then there is nothing to release.
|
||||
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: identical pixels with 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
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
184
libs/scrap/src/common/drm_render.rs
Normal file
184
libs/scrap/src/common/drm_render.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
// Unprivileged half of the split DRM/KMS capture path: the root `--service` exports a scanout
|
||||
// dma-buf fd + descriptor, this side imports it and EGL-detiles. libEGL/libGLESv2 are dlopen'd
|
||||
// in the UNPRIVILEGED process on this path; the root service loads them only if it falls back to
|
||||
// its own CPU-mapped grab (`drmtap_grab_mapped`). See docs/DRM_CAPTURE_SECURITY.md.
|
||||
|
||||
use super::drmtap_dl::{self, drmtap_ctx, drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib};
|
||||
use super::Pixfmt;
|
||||
use hbb_common::log;
|
||||
use std::ffi::CString;
|
||||
use std::io;
|
||||
use std::os::fd::RawFd;
|
||||
|
||||
use super::drm_reader::{
|
||||
DRM_FORMAT_ABGR8888, DRM_FORMAT_ARGB8888, DRM_FORMAT_XBGR8888, DRM_FORMAT_XRGB8888,
|
||||
MAX_DIM, MAX_FRAME_BYTES,
|
||||
};
|
||||
|
||||
/// Unprivileged DRM render-node convert context. !Send/!Sync via the raw ctx pointer: the context
|
||||
/// and libdrmtap's thread-local EGL state must be created, used (`convert`) and closed on ONE thread.
|
||||
pub struct RenderConverter {
|
||||
lib: &'static DrmtapLib,
|
||||
ctx: *mut drmtap_ctx,
|
||||
}
|
||||
|
||||
impl RenderConverter {
|
||||
/// `node` is the render node of the GPU that exports the scanout; `None`/invalid path falls back to libdrmtap auto-selection.
|
||||
pub fn open_render(node: Option<&str>) -> Option<RenderConverter> {
|
||||
let lib = drmtap_dl::get()?;
|
||||
let open_render = lib.open_render;
|
||||
let node_cstr = match node.filter(|n| !n.is_empty()) {
|
||||
None => None,
|
||||
// Open the CANONICAL path the gate resolved: opening the IPC string would re-walk its symlinks after the check.
|
||||
Some(n) => match super::drm_reader::device_under_dev_dri(n) {
|
||||
None => {
|
||||
log::warn!("drm: render node {n:?} is not under /dev/dri; auto-selecting");
|
||||
None
|
||||
}
|
||||
Some(canonical) => canonical.to_str().and_then(|s| CString::new(s).ok()),
|
||||
},
|
||||
};
|
||||
// SAFETY: resolved C entry point; `node_cstr` outlives the call, NULL requests auto-selection.
|
||||
let ctx = unsafe {
|
||||
open_render(node_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()))
|
||||
};
|
||||
if ctx.is_null() {
|
||||
log::info!(
|
||||
"drmtap_open_render({}) failed; no usable DRM render node",
|
||||
node_cstr.as_ref().map_or("NULL".to_owned(), |c| format!("{c:?}"))
|
||||
);
|
||||
return None;
|
||||
}
|
||||
match node_cstr {
|
||||
Some(c) => log::info!(
|
||||
"drm: opened unprivileged convert context on the exporting GPU ({c:?})"
|
||||
),
|
||||
None => log::info!(
|
||||
"drm: opened unprivileged render-node convert context (auto-selected)"
|
||||
),
|
||||
}
|
||||
Some(RenderConverter { lib, ctx })
|
||||
}
|
||||
|
||||
/// Returns context-owned linear pixels valid ONLY until the next `convert()`; row stride is `len / height`.
|
||||
pub fn convert(
|
||||
&mut self,
|
||||
desc: &mut drmtap_dmabuf_desc,
|
||||
received_fd: RawFd,
|
||||
) -> io::Result<(&[u8], u32, u32, Pixfmt)> {
|
||||
{
|
||||
let (w, h) = (desc.width, desc.height);
|
||||
if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("drm: refusing a dma-buf descriptor with geometry {w}x{h}"),
|
||||
));
|
||||
}
|
||||
// Reject, do not clamp, and write the normalized count back so the C reads the count bounded here.
|
||||
let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes };
|
||||
if planes > 4 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"drm: refusing a dma-buf descriptor with num_planes {} (1..=4)",
|
||||
desc.num_planes
|
||||
),
|
||||
));
|
||||
}
|
||||
desc.num_planes = planes;
|
||||
let planes = planes as usize;
|
||||
for p in 0..planes {
|
||||
let extent = (desc.pitches[p] as usize)
|
||||
.checked_mul(h as usize)
|
||||
.and_then(|rows| rows.checked_add(desc.offsets[p] as usize));
|
||||
match extent {
|
||||
Some(end) if end <= MAX_FRAME_BYTES => {}
|
||||
other => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"drm: refusing dma-buf plane {p} (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})",
|
||||
desc.offsets[p], desc.pitches[p]
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let convert_dmabuf = self.lib.convert_dmabuf;
|
||||
// LOAD-BEARING: the fd the exporter serialized was process-local; -1 means reuse the cached import for `fb_id`.
|
||||
desc.dma_buf_fd = received_fd;
|
||||
// SAFETY: self.ctx is a valid render context; `desc` is fully initialized; `frame` is zeroed
|
||||
// before the call. libdrmtap OWNS `frame.data`: no release/free from this side (drmtap.h).
|
||||
unsafe {
|
||||
let mut frame: drmtap_frame_info = std::mem::zeroed();
|
||||
let ret = convert_dmabuf(self.ctx, &*desc as *const drmtap_dmabuf_desc, &mut frame);
|
||||
if ret < 0 {
|
||||
let errno = -ret;
|
||||
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_convert_dmabuf failed: errno {errno}"),
|
||||
));
|
||||
}
|
||||
if frame.data.is_null() || frame.width == 0 || frame.height == 0 || frame.stride == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"drmtap_convert_dmabuf produced an empty frame",
|
||||
));
|
||||
}
|
||||
let w = frame.width;
|
||||
let h = frame.height;
|
||||
let stride = frame.stride as usize;
|
||||
// A stride below 32bpp under-sizes the row and, read as BGRA downstream, discloses adjacent memory.
|
||||
if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!(
|
||||
"drmtap_convert_dmabuf bad geometry {w}x{h} stride {stride} fourcc {:#010x}",
|
||||
frame.format
|
||||
),
|
||||
));
|
||||
}
|
||||
let len = match stride.checked_mul(h as usize) {
|
||||
Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz,
|
||||
other => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("drmtap_convert_dmabuf frame size out of range ({other:?} bytes)"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let pixfmt = match frame.format {
|
||||
DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => Pixfmt::BGRA,
|
||||
DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => Pixfmt::RGBA,
|
||||
// Unset by an older convert -> libdrmtap's normalized BGRA.
|
||||
0 => Pixfmt::BGRA,
|
||||
other => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("drmtap_convert_dmabuf produced an unsupported output fourcc {other:#010x}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let data = std::slice::from_raw_parts(frame.data as *const u8, len);
|
||||
Ok((data, w, h, pixfmt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RenderConverter {
|
||||
fn drop(&mut self) {
|
||||
if !self.ctx.is_null() {
|
||||
// SAFETY: ctx came from drmtap_open_render and is non-null; the !Send ctx pointer keeps
|
||||
// this drop on the thread that created and used it (thread-local EGL + cached imports).
|
||||
unsafe { (self.lib.close)(self.ctx) };
|
||||
self.ctx = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
410
libs/scrap/src/common/drmtap_dl.rs
Normal file
410
libs/scrap/src/common/drmtap_dl.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
// Runtime loader for libdrmtap.so (the DRM/KMS capture engine), dlopen'd so the binary carries no hard libdrm/libEGL/libGLESv2 dependency.
|
||||
|
||||
use hbb_common::{libloading::Library, log};
|
||||
use std::os::raw::{c_char, c_int, c_void};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
// C ABI structs: must match libdrmtap include/drmtap.h.
|
||||
|
||||
#[repr(C)]
|
||||
pub struct drmtap_ctx {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct drmtap_config {
|
||||
pub device_path: *const c_char, // NULL = auto-detect /dev/dri/card*
|
||||
pub crtc_id: u32, // 0 = auto-select first active CRTC
|
||||
pub helper_path: *const c_char, // only consulted if the direct DRM export is denied (no CAP_SYS_ADMIN)
|
||||
pub debug: c_int,
|
||||
}
|
||||
|
||||
impl Default for drmtap_config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
device_path: std::ptr::null(),
|
||||
crtc_id: 0,
|
||||
helper_path: std::ptr::null(),
|
||||
debug: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct drmtap_display {
|
||||
pub crtc_id: u32,
|
||||
pub connector_id: u32,
|
||||
pub name: [c_char; 32],
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_hz: u32,
|
||||
pub active: c_int,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct drmtap_device {
|
||||
pub path: [c_char; 64],
|
||||
pub render_node: [c_char; 64],
|
||||
pub driver: [c_char; 32],
|
||||
pub display_count: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct drmtap_frame_info {
|
||||
pub data: *mut c_void,
|
||||
pub dma_buf_fd: c_int,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub stride: u32,
|
||||
pub format: u32,
|
||||
pub modifier: u64,
|
||||
pub fb_id: u32,
|
||||
pub _priv: *mut c_void,
|
||||
}
|
||||
|
||||
// Descriptor of an externally-supplied scanout DMA-BUF: the privileged exporter fills it via
|
||||
// `drmtap_grab_desc`; the converter overwrites `dma_buf_fd` with the fd it got via SCM_RIGHTS.
|
||||
// Mirrors `drmtap_dmabuf_desc` EXACTLY (field order + widths); a mismatch mis-reads CCS/HDR scanouts.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct drmtap_dmabuf_desc {
|
||||
pub dma_buf_fd: c_int, // scanout DMA-BUF; -1 for an already-imported fb_id
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub format: u32, // DRM fourcc of the scanout
|
||||
pub modifier: u64, // DRM format modifier (tiling/compression)
|
||||
pub fb_id: u32, // import-once cache key; 0 disables caching
|
||||
pub num_planes: u32, // used entries in offsets/pitches (1..4); 0 => 1
|
||||
pub offsets: [u32; 4], // per-plane byte offsets (CCS main+aux+clear-color)
|
||||
pub pitches: [u32; 4], // per-plane strides; pitches[0] = main stride
|
||||
pub hdr_eotf: u32, // DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3)
|
||||
pub hdr_max_nits: u32, // mastering/content peak luminance cd/m2; 0=unknown
|
||||
}
|
||||
|
||||
impl Default for drmtap_dmabuf_desc {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dma_buf_fd: -1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
format: 0,
|
||||
modifier: 0,
|
||||
fb_id: 0,
|
||||
num_planes: 0,
|
||||
offsets: [0; 4],
|
||||
pitches: [0; 4],
|
||||
hdr_eotf: 0,
|
||||
hdr_max_nits: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct drmtap_cursor_info {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub hot_x: i32,
|
||||
pub hot_y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: *mut u32,
|
||||
pub visible: c_int,
|
||||
pub _priv: *mut c_void,
|
||||
}
|
||||
|
||||
// Resolved symbol typedefs.
|
||||
|
||||
type FnVersion = unsafe extern "C" fn() -> c_int;
|
||||
type FnOpen = unsafe extern "C" fn(*const drmtap_config) -> *mut drmtap_ctx;
|
||||
type FnClose = unsafe extern "C" fn(*mut drmtap_ctx);
|
||||
type FnListDisplays = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_display, c_int) -> c_int;
|
||||
type FnListDevices = unsafe extern "C" fn(*mut drmtap_device, c_int) -> c_int;
|
||||
type FnGrabMapped = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info) -> c_int;
|
||||
type FnFrameRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info);
|
||||
type FnGetCursor = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info) -> c_int;
|
||||
type FnCursorRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info);
|
||||
// Split-capture entry points (libdrmtap >= 0.4.10), required: `grab_desc` runs on the privileged
|
||||
// export side, `open_render`/`convert_dmabuf` on the unprivileged converter side.
|
||||
type FnGrabDesc =
|
||||
unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int;
|
||||
type FnOpenRender = unsafe extern "C" fn(*const c_char) -> *mut drmtap_ctx;
|
||||
// libdrmtap >= 0.4.15; returns a ctx-owned string, or NULL if it has none.
|
||||
type FnRenderNode = unsafe extern "C" fn(*mut drmtap_ctx) -> *const c_char;
|
||||
type FnConvertDmabuf =
|
||||
unsafe extern "C" fn(*mut drmtap_ctx, *const drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int;
|
||||
|
||||
/// The dlopen'd libdrmtap; the `Library` is kept alive for the process lifetime, so the raw fn pointers stay valid.
|
||||
pub struct DrmtapLib {
|
||||
_lib: Library,
|
||||
pub open: FnOpen,
|
||||
pub close: FnClose,
|
||||
pub list_displays: FnListDisplays,
|
||||
pub list_devices: Option<FnListDevices>,
|
||||
pub grab_mapped: FnGrabMapped,
|
||||
pub frame_release: FnFrameRelease,
|
||||
pub get_cursor: FnGetCursor,
|
||||
pub cursor_release: FnCursorRelease,
|
||||
pub grab_desc: FnGrabDesc,
|
||||
pub open_render: FnOpenRender,
|
||||
pub convert_dmabuf: FnConvertDmabuf,
|
||||
pub render_node: Option<FnRenderNode>,
|
||||
pub version: (c_int, c_int, c_int),
|
||||
}
|
||||
|
||||
// SAFETY: the resolved fn pointers are plain C entry points with no interior mutability;
|
||||
// libdrmtap contexts are used single-threaded by the caller. The Library handle is never moved out.
|
||||
unsafe impl Send for DrmtapLib {}
|
||||
unsafe impl Sync for DrmtapLib {}
|
||||
|
||||
const DRMTAP_ABI_MAJOR: c_int = 0;
|
||||
|
||||
// Lowest (minor, patch) accepted. 0.5.0 is the floor because it fixes the padded-framebuffer read
|
||||
// (a scanout whose pitch exceeds width*bpp was decoded at the wrong stride); the whole split API
|
||||
// has been present since 0.4.10.
|
||||
const DRMTAP_MIN_MINOR_PATCH: (c_int, c_int) = (5, 0);
|
||||
|
||||
// The MINOR series this build's mirrored structs were verified against: libdrmtap's header freezes
|
||||
// only `drmtap_device` and `drmtap_dmabuf_desc`, so an unverified minor could be read at wrong offsets.
|
||||
const DRMTAP_ABI_MINOR: c_int = 5;
|
||||
|
||||
/// Whether a library reporting `major.minor.patch` may be loaded (major and minor exact, patch at or above the floor).
|
||||
fn abi_accepted(major: c_int, minor: c_int, patch: c_int) -> bool {
|
||||
major == DRMTAP_ABI_MAJOR
|
||||
&& minor == DRMTAP_ABI_MINOR
|
||||
&& (minor, patch) >= DRMTAP_MIN_MINOR_PATCH
|
||||
}
|
||||
|
||||
impl DrmtapLib {
|
||||
fn load() -> Option<Self> {
|
||||
// Absolute path FIRST: the deb bundles the .so privately under /usr/lib/rustdesk and does NOT register that dir with ld.so.
|
||||
const INSTALLED: &str = "/usr/lib/rustdesk/libdrmtap.so.0";
|
||||
// Bare sonames exist so an unpackaged development build can load a locally built .so from
|
||||
// the normal ld.so search path. They are NOT offered when running as root: this is the one
|
||||
// place where which file happens to be on the load path decides what gets mapped into the
|
||||
// CAP_SYS_ADMIN process, and the packaged service always finds the absolute path first
|
||||
// anyway. A root process that reaches the fallback has no bundled library, which is the
|
||||
// PipeWire-fallback case, not a reason to search.
|
||||
const DEV_ONLY: [&str; 2] = ["libdrmtap.so.0", "libdrmtap.so"];
|
||||
let is_root = unsafe { hbb_common::libc::geteuid() } == 0;
|
||||
let candidates: Vec<&str> = if is_root {
|
||||
vec![INSTALLED]
|
||||
} else {
|
||||
std::iter::once(INSTALLED).chain(DEV_ONLY).collect()
|
||||
};
|
||||
unsafe {
|
||||
let (lib, name) = candidates
|
||||
.iter()
|
||||
.find_map(|n| Library::new(*n).ok().map(|l| (l, *n)))?;
|
||||
// Canonicalize the absolute candidate only: `dlopen` does not search the CWD for a bare
|
||||
// soname, while `canonicalize` resolves a relative name against it.
|
||||
let real = std::path::Path::new(name)
|
||||
.is_absolute()
|
||||
.then(|| std::fs::canonicalize(name).ok())
|
||||
.flatten();
|
||||
let version: FnVersion = *lib.get(b"drmtap_version").ok()?;
|
||||
let v = version();
|
||||
let (major, minor, patch) = ((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff);
|
||||
if !abi_accepted(major, minor, patch) {
|
||||
let why = if major != DRMTAP_ABI_MAJOR {
|
||||
"the struct layouts this build mirrors track the ABI major, so reading a \
|
||||
frame descriptor through a mismatched one would mis-decode it"
|
||||
} else if minor != DRMTAP_ABI_MINOR {
|
||||
"this build mirrors the struct layouts of one minor and only that one; \
|
||||
under 0.x semver the minor is the breaking axis, so an unverified minor \
|
||||
could be read at the wrong offsets. Widening it is a deliberate act, done \
|
||||
with the layouts re-checked field by field"
|
||||
} else {
|
||||
"it predates the split-capture API, so its only capture path converts \
|
||||
in-process, which in the root service means loading the GL stack there"
|
||||
};
|
||||
let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH;
|
||||
log::warn!(
|
||||
"libdrmtap {name} reports v{major}.{minor}.{patch}, which this build cannot \
|
||||
use (needs ABI major {DRMTAP_ABI_MAJOR}, minor {DRMTAP_ABI_MINOR}, at least \
|
||||
v{DRMTAP_ABI_MAJOR}.{min_minor}.{min_patch}): {why}. Refusing to load; \
|
||||
falling back to PipeWire/portal."
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let open: FnOpen = *lib.get(b"drmtap_open").ok()?;
|
||||
let close: FnClose = *lib.get(b"drmtap_close").ok()?;
|
||||
let list_displays: FnListDisplays = *lib.get(b"drmtap_list_displays").ok()?;
|
||||
let list_devices: Option<FnListDevices> =
|
||||
lib.get(b"drmtap_list_devices").ok().map(|s| *s);
|
||||
let grab_mapped: FnGrabMapped = *lib.get(b"drmtap_grab_mapped").ok()?;
|
||||
let frame_release: FnFrameRelease = *lib.get(b"drmtap_frame_release").ok()?;
|
||||
let get_cursor: FnGetCursor = *lib.get(b"drmtap_get_cursor").ok()?;
|
||||
let cursor_release: FnCursorRelease = *lib.get(b"drmtap_cursor_release").ok()?;
|
||||
let grab: Option<FnGrabDesc> = lib.get(b"drmtap_grab_desc").ok().map(|s| *s);
|
||||
let open_r: Option<FnOpenRender> = lib.get(b"drmtap_open_render").ok().map(|s| *s);
|
||||
let conv: Option<FnConvertDmabuf> =
|
||||
lib.get(b"drmtap_convert_dmabuf").ok().map(|s| *s);
|
||||
let (grab_desc, open_render, convert_dmabuf) = match (grab, open_r, conv) {
|
||||
(Some(g), Some(o), Some(c)) => (g, o, c),
|
||||
(grab, open_r, conv) => {
|
||||
let mut missing = Vec::new();
|
||||
if grab.is_none() {
|
||||
missing.push("drmtap_grab_desc");
|
||||
}
|
||||
if open_r.is_none() {
|
||||
missing.push("drmtap_open_render");
|
||||
}
|
||||
if conv.is_none() {
|
||||
missing.push("drmtap_convert_dmabuf");
|
||||
}
|
||||
log::warn!(
|
||||
"libdrmtap {name} reports v{major}.{minor}.{patch} but does not export \
|
||||
{}: it is a stale or pre-release build, not the version it claims. \
|
||||
Refusing to load; falling back to PipeWire/portal.",
|
||||
missing.join(", ")
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let render_node: Option<FnRenderNode> =
|
||||
lib.get(b"drmtap_render_node").ok().map(|s| *s);
|
||||
// Log the load only now that every required symbol resolved: this fn still returns None on a missing one.
|
||||
let loaded_from = real
|
||||
.as_ref()
|
||||
.map_or_else(|| name.to_owned(), |p| p.display().to_string());
|
||||
if loaded_from == name {
|
||||
log::info!("libdrmtap loaded: {name} (v{major}.{minor}.{patch})");
|
||||
} else {
|
||||
log::info!("libdrmtap loaded: {name} -> {loaded_from} (v{major}.{minor}.{patch})");
|
||||
}
|
||||
let (no_node, no_devices) = (render_node.is_none(), list_devices.is_none());
|
||||
if (minor, patch) >= (4, 15) && (no_node || no_devices) {
|
||||
let missing = if no_node && no_devices {
|
||||
"drmtap_render_node and drmtap_list_devices"
|
||||
} else if no_node {
|
||||
"drmtap_render_node"
|
||||
} else {
|
||||
"drmtap_list_devices"
|
||||
};
|
||||
let effect = if no_node && no_devices {
|
||||
"Multi-GPU display enumeration and exporting-GPU selection stay disabled."
|
||||
} else if no_node {
|
||||
"Exporting-GPU selection stays disabled."
|
||||
} else {
|
||||
"Multi-GPU display enumeration stays disabled."
|
||||
};
|
||||
log::warn!(
|
||||
"libdrmtap at {loaded_from} reports v{major}.{minor}.{patch} but is missing \
|
||||
{missing}: it is a stale or pre-release build. Check what the soname symlink \
|
||||
points at and remove any leftover libdrmtap.so.0* beside it. {effect}"
|
||||
);
|
||||
}
|
||||
Some(DrmtapLib {
|
||||
_lib: lib,
|
||||
open,
|
||||
close,
|
||||
list_displays,
|
||||
list_devices,
|
||||
grab_mapped,
|
||||
frame_release,
|
||||
get_cursor,
|
||||
cursor_release,
|
||||
grab_desc,
|
||||
open_render,
|
||||
convert_dmabuf,
|
||||
render_node,
|
||||
version: (major, minor, patch),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static DRMTAP_LIB: OnceLock<Option<DrmtapLib>> = OnceLock::new();
|
||||
|
||||
/// The loaded libdrmtap, or None if the .so (or a runtime dep) is absent or its version/exports fall outside the ABI gate. Loaded once; a failure is remembered.
|
||||
pub fn get() -> Option<&'static DrmtapLib> {
|
||||
DRMTAP_LIB
|
||||
.get_or_init(|| {
|
||||
let lib = DrmtapLib::load();
|
||||
if lib.is_none() {
|
||||
log::info!("libdrmtap not available or not usable; DRM capture disabled");
|
||||
}
|
||||
lib
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{abi_accepted, DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, DRMTAP_MIN_MINOR_PATCH};
|
||||
|
||||
#[test]
|
||||
fn abi_gate_rejects_a_library_from_before_the_split() {
|
||||
// These are refused because their MINOR differs from the verified one, which is the only
|
||||
// reason the gate needs. Naming the pre-split releases keeps the intent readable, but do
|
||||
// not read this as the floor doing the work: see the test below.
|
||||
for (minor, patch) in [(3, 3), (4, 0), (4, 8), (4, 9)] {
|
||||
assert!(
|
||||
!abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
|
||||
"v0.{minor}.{patch} is not the verified minor and must be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_patch_floor_is_currently_vacuous_and_that_is_deliberate() {
|
||||
// With MIN_MINOR_PATCH.0 == DRMTAP_ABI_MINOR the floor can never reject anything: the
|
||||
// minor equality already forces `(minor, patch) >= (minor, 0)`. It is kept because it is
|
||||
// the mechanism that WOULD do the work the next time a floor lands mid-minor, as (4, 10)
|
||||
// did for the split API. This test exists so nobody reads the pre-split test above as
|
||||
// evidence that the floor is live -- if that ever matters, this assert is the tripwire.
|
||||
let (floor_minor, floor_patch) = DRMTAP_MIN_MINOR_PATCH;
|
||||
assert_eq!(
|
||||
floor_minor, DRMTAP_ABI_MINOR,
|
||||
"the floor is inside the verified minor; a floor in a DIFFERENT minor is unreachable"
|
||||
);
|
||||
if floor_patch == 0 {
|
||||
assert!(
|
||||
abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, 0),
|
||||
"patch 0 of the verified minor must be accepted while the floor is 0"
|
||||
);
|
||||
} else {
|
||||
assert!(!abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, floor_patch - 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abi_gate_accepts_the_floor_and_later_patches_of_the_same_minor() {
|
||||
let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH;
|
||||
assert!(abi_accepted(DRMTAP_ABI_MAJOR, min_minor, min_patch));
|
||||
for (minor, patch) in [(DRMTAP_ABI_MINOR, min_patch + 15), (DRMTAP_ABI_MINOR, 200)] {
|
||||
assert!(
|
||||
abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
|
||||
"v0.{minor}.{patch} is a patch of the verified minor and must be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abi_gate_rejects_an_unknown_newer_minor() {
|
||||
// Relative to DRMTAP_ABI_MINOR, so the next bump cannot leave this test asserting that the
|
||||
// NEW verified minor must be refused -- which is what a hardcoded list did before.
|
||||
let verified = DRMTAP_ABI_MINOR;
|
||||
for (minor, patch) in [
|
||||
(verified - 1, 99),
|
||||
(verified + 1, 0),
|
||||
(verified + 1, 99),
|
||||
(verified + 4, 9),
|
||||
] {
|
||||
assert!(
|
||||
!abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
|
||||
"v0.{minor}.{patch} is an unverified minor and must be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abi_gate_rejects_another_major_in_both_directions() {
|
||||
assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 0, 0));
|
||||
assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 99, 99));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,12 @@ 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;
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub mod drm_render;
|
||||
pub use self::linux::*;
|
||||
pub use self::wayland::set_map_err;
|
||||
pub use self::x11::PixelBuffer;
|
||||
|
||||
63
src/ipc.rs
63
src/ipc.rs
@@ -3,6 +3,21 @@ mod ipc_auth;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[path = "ipc/fs.rs"]
|
||||
mod ipc_fs;
|
||||
// The DRM/KMS capture producer, the `_drm` channel and its SCM_RIGHTS framing live in their own
|
||||
// module, declared the same way as the other pieces of this file, so the opt-in feature adds a
|
||||
// bounded, self-contained surface here instead of ~1800 lines in the middle of the shared IPC.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
#[path = "ipc/drm.rs"]
|
||||
mod ipc_drm;
|
||||
// Re-exported so the paths callers already use (`crate::ipc::start_drm`, `crate::ipc::connect_drm`,
|
||||
// `crate::ipc::DrmDisplayInfo`) keep working, and so the `Data` variants can name the two
|
||||
// payload types.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub use ipc_drm::{start_drm, DmabufDesc, DrmDisplayInfo};
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub(crate) use ipc_drm::DrmConn;
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub(crate) use ipc_drm::connect_drm;
|
||||
|
||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
@@ -60,6 +75,9 @@ use ipc_fs::{
|
||||
check_pid, ensure_secure_ipc_parent_dir, scrub_secure_ipc_parent_dir,
|
||||
should_scrub_parent_entries_after_check_pid, write_pid,
|
||||
};
|
||||
// Gated with the module that uses it, so a `drm`-less build does not carry an unused import.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
use ipc_fs::remove_ipc_entry_via_secure_parent_fd;
|
||||
use parity_tokio_ipc::{
|
||||
Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes,
|
||||
};
|
||||
@@ -481,6 +499,51 @@ 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. The zero-copy `DrmFrameDmabuf(DmabufDesc)` sibling below carries only a
|
||||
// small JSON metadata descriptor; the scanout dma-buf fd rides an SCM_RIGHTS ancillary message on
|
||||
// the same `DrmConn` send (see `DrmConn::send_msg`), so it has NO trailing `send_raw()` body.
|
||||
/// Client -> service: begin streaming the chosen display.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
// `need_cpu` is set by an unprivileged consumer that could not open a render-node convert context
|
||||
// (drmtap_open_render failed, e.g. no /dev/dri/renderD* access). The service then streams the
|
||||
// CPU-converted `DrmFrame` path for this connection instead of a dma-buf fd the consumer cannot
|
||||
// detile, so a render-node-less seat still captures instead of losing the stream.
|
||||
DrmStart { display: i32, need_cpu: bool },
|
||||
/// Service -> client: the enumerated DRM displays (sent once, before frames).
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
DrmDisplayList(Vec<DrmDisplayInfo>),
|
||||
/// Service -> client: the connector topology changed mid-stream (a monitor hotplug/unplug/modeset,
|
||||
/// observed by the service's udev DRM-uevent listener). Carries the freshly-enumerated list so the
|
||||
/// consumer can swap its sticky positive availability cache off the hot path, WITHOUT re-probing
|
||||
/// `_drm` (which would trip the enumeration restart loop). Interleaved with frames on the same
|
||||
/// stream; carries no `send_raw()` body and no fd.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
DrmDisplaysChanged(Vec<DrmDisplayInfo>),
|
||||
/// Service -> client: a frame header; the packed BGRA pixels follow via `send_raw()`.
|
||||
/// CPU-fallback path (no render node, or no transferable dma-buf): pixels cross the wire.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
DrmFrame { width: u32, height: u32 },
|
||||
/// Service -> client: a zero-copy dma-buf frame descriptor. The scanout fd is NOT a field; when
|
||||
/// `desc.has_fd` it rides an SCM_RIGHTS ancillary message on the same `DrmConn::send_msg`, and
|
||||
/// there is NO trailing `send_raw()` body. The unprivileged `--server` imports the fd and does
|
||||
/// the EGL detile/convert itself (see `DmabufDesc`).
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
DrmFrameDmabuf(DmabufDesc),
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
|
||||
@@ -208,6 +208,17 @@ pub(crate) fn active_uid() -> Option<u32> {
|
||||
active_uid_strict()
|
||||
}
|
||||
|
||||
/// The active session uid read ONLY from the service-loop cache, never from a fresh (blocking) seat0
|
||||
/// lookup. `None` on a cache miss. For hot, latency-sensitive, fail-closed re-auth on an async runtime
|
||||
/// thread (the `_drm` per-frame re-auth), where a blocking `loginctl` per frame would stall the stream.
|
||||
// Gated with the feature, not just the OS: the `_drm` per-frame re-auth is its only caller, so a
|
||||
// drm-off Linux build would carry it as dead code and warn about it.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
#[inline]
|
||||
pub(crate) fn active_uid_cached() -> Option<u32> {
|
||||
crate::platform::linux::get_active_userid_cached()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[inline]
|
||||
pub(crate) fn peer_uid_from_fd(fd: RawFd) -> Option<u32> {
|
||||
|
||||
1799
src/ipc/drm.rs
Normal file
1799
src/ipc/drm.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -164,9 +164,25 @@ fn scrub_preexisting_ipc_parent_entries(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> {
|
||||
let path = config::Config::ipc_path(postfix);
|
||||
let parent_dir = Path::new(&path)
|
||||
/// Remove one entry from the IPC parent directory through a no-follow fd on that directory.
|
||||
///
|
||||
/// Prefer this over `std::fs::remove_file` for anything about to be bound: `remove_file` is
|
||||
/// `unlink(2)`, which returns EISDIR against a directory-typed squatter and leaves it in place,
|
||||
/// and the bind that follows then fails EADDRINUSE. `remove_parent_entry_via_fd` fstats the
|
||||
/// entry first and picks `AT_REMOVEDIR` when it needs to.
|
||||
///
|
||||
/// `AT_REMOVEDIR` is `rmdir(2)`, so the directory case this closes is the EMPTY one; a non-empty
|
||||
/// squatter still yields ENOTEMPTY and still blocks the bind that follows. That is deliberate, and
|
||||
/// the "obvious" fix is worse than the bug: removing it recursively would be root deleting a tree
|
||||
/// an unprivileged process planted. What the caller gains there is a named error to log ahead of
|
||||
/// the bind's own failure, not a successful bind.
|
||||
pub(crate) fn remove_ipc_entry_via_secure_parent_fd(path: &str) -> ResultType<()> {
|
||||
let entry_name = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?
|
||||
.to_owned();
|
||||
let parent_dir = Path::new(path)
|
||||
.parent()
|
||||
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?;
|
||||
let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?;
|
||||
@@ -179,8 +195,8 @@ fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> {
|
||||
return Err(Error::new(
|
||||
open_err.kind(),
|
||||
format!(
|
||||
"failed to open ipc parent dir for stale socket cleanup (no-follow): postfix={}, parent={}, err={}",
|
||||
postfix,
|
||||
"failed to open ipc parent dir for stale socket cleanup (no-follow): path={}, parent={}, err={}",
|
||||
path,
|
||||
parent_dir.display(),
|
||||
open_err
|
||||
),
|
||||
@@ -189,7 +205,11 @@ fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> {
|
||||
}
|
||||
};
|
||||
let _fd_guard = FdGuard(fd);
|
||||
remove_parent_entry_via_fd(fd, parent_dir, &format!("ipc{}", postfix))
|
||||
remove_parent_entry_via_fd(fd, parent_dir, &entry_name)
|
||||
}
|
||||
|
||||
fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> {
|
||||
remove_ipc_entry_via_secure_parent_fd(&config::Config::ipc_path(postfix))
|
||||
}
|
||||
|
||||
// Purpose:
|
||||
@@ -686,6 +706,64 @@ pub(crate) fn should_scrub_parent_entries_after_check_pid(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// Pins the HELPER's contract, which is all `new_drm_listener` consists of at that line -- not
|
||||
// the call site itself. Binding the real `/tmp/<app>-service/ipc_drm` from a test would collide
|
||||
// with a live root service, so "the listener still calls this" is not covered here.
|
||||
#[test]
|
||||
fn test_remove_ipc_entry_via_secure_parent_fd_clears_an_empty_directory_squatter() {
|
||||
let unique = format!(
|
||||
"rustdesk-ipc-entry-remove-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let base = std::env::temp_dir().join(unique);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let squatter = base.join("ipc_drm");
|
||||
std::fs::create_dir(&squatter).unwrap();
|
||||
|
||||
// Positive control for the defect this closes: `remove_file` is `unlink(2)` and cannot
|
||||
// remove a directory. That is why the listener could not clear one, and then failed to
|
||||
// bind over it. Without this line a passing test would prove nothing.
|
||||
assert!(
|
||||
std::fs::remove_file(&squatter).is_err(),
|
||||
"remove_file must fail on a directory, or this test is vacuous"
|
||||
);
|
||||
assert!(squatter.is_dir());
|
||||
|
||||
super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap();
|
||||
assert!(
|
||||
!squatter.exists(),
|
||||
"the fd-based removal picks AT_REMOVEDIR and clears it"
|
||||
);
|
||||
|
||||
// Idempotent: this runs before every bind, so a path that is already gone is not an error.
|
||||
super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap();
|
||||
|
||||
// The ORDINARY case, and the one the listener hits on every restart: a stale socket left by
|
||||
// the previous run, i.e. a regular file. Covered here because the other file-removal test
|
||||
// goes through `remove_parent_entry_via_fd` and the postfix path, not this entry point.
|
||||
std::fs::write(&squatter, b"stale").unwrap();
|
||||
super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap();
|
||||
assert!(!squatter.exists(), "a stale regular file is cleared too");
|
||||
|
||||
// And the documented limit, pinned so the doc cannot drift: AT_REMOVEDIR is rmdir(2), so a
|
||||
// NON-empty squatter is reported, not cleared. The caller logs that and carries on; nothing
|
||||
// here should ever start deleting a tree it did not create.
|
||||
std::fs::create_dir(&squatter).unwrap();
|
||||
std::fs::write(squatter.join("planted"), b"x").unwrap();
|
||||
assert!(
|
||||
super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref())
|
||||
.is_err(),
|
||||
"a non-empty directory must be reported, not silently left as success"
|
||||
);
|
||||
assert!(squatter.join("planted").exists(), "and not deleted");
|
||||
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_pid_file_rejects_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
@@ -361,6 +361,30 @@ 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.
|
||||
//
|
||||
// The MEMOISED `is_x11()` here, deliberately, unlike the capture-path callers that take the
|
||||
// unmemoised `scrap::is_x11()` because this one latches on first use. The tradeoff is the other
|
||||
// way round at cursor cadence: the unmemoised form forks `loginctl` per call, and this runs on
|
||||
// every cursor poll. A latch that guessed wrong costs a cursor served by the wrong source until
|
||||
// the process restarts, not a capture that cannot start -- and by the time a cursor is being
|
||||
// polled there is a live session, which is the case the latch reads correctly.
|
||||
#[cfg(feature = "drm")]
|
||||
if !is_x11() {
|
||||
if let Some(id) = crate::server::drm_capturer::drm_cursor_id() {
|
||||
// In a mixed DRM + PipeWire session the DRM streams only cover the DRM-backed displays;
|
||||
// when the pointer sits on a PipeWire-served display every DRM stream reports the hidden
|
||||
// sentinel. Returning that sentinel here would hide the cursor globally, including on the
|
||||
// PipeWire display where it is still visible, so only report a hidden DRM cursor when it
|
||||
// is authoritative -- a pure-DRM session. A visible DRM cursor is always authoritative;
|
||||
// otherwise fall through to the normal cursor path.
|
||||
if id != scrap::drm_reader::HIDDEN_CURSOR_ID
|
||||
|| !crate::server::display_service::has_non_drm_backed_display()
|
||||
{
|
||||
return Ok(Some(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut res = None;
|
||||
DISPLAY.with(|conn| {
|
||||
if let Ok(d) = conn.try_borrow_mut() {
|
||||
@@ -379,6 +403,32 @@ 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).
|
||||
//
|
||||
// Memoised `is_x11()` on purpose, for the reason spelled out in `get_cursor()`; the two must
|
||||
// agree anyway, since a caller that took the DRM branch there has to take it here.
|
||||
#[cfg(feature = "drm")]
|
||||
if !is_x11() {
|
||||
if let Some(c) = crate::server::drm_capturer::drm_cursor() {
|
||||
// See get_cursor(): a hidden DRM sentinel is authoritative only in a pure-DRM session. In
|
||||
// a mixed DRM + PipeWire session fall through so the PipeWire display's cursor is served
|
||||
// by the normal path instead of being hidden everywhere.
|
||||
if c.id != scrap::drm_reader::HIDDEN_CURSOR_ID
|
||||
|| !crate::server::display_service::has_non_drm_backed_display()
|
||||
{
|
||||
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() {
|
||||
@@ -680,6 +730,40 @@ fn start_server(desktop: Option<&Desktop>, server: &mut Option<Child>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a just-spawned `--server` is still running after a short grace period, taking ownership of
|
||||
/// the corpse (clearing `server`) when it is not. `start_server` reports only whether the SPAWN
|
||||
/// succeeded, which is not the same question: a child that execs and exits immediately still leaves
|
||||
/// `Some(child)` behind.
|
||||
///
|
||||
/// A child that exits is detected as soon as it does; a healthy one costs the full grace, once per
|
||||
/// start. A server that dies LATER than this is a different (transient) failure, and the restart
|
||||
/// throttle in `should_start_server` already bounds that case.
|
||||
#[cfg(feature = "drm")]
|
||||
fn server_survived_grace(server: &mut Option<Child>) -> bool {
|
||||
const GRACE: Duration = Duration::from_millis(1000);
|
||||
const STEP_MS: u64 = 100;
|
||||
let Some(ps) = server.as_mut() else {
|
||||
return false; // spawn itself failed
|
||||
};
|
||||
let deadline = Instant::now() + GRACE;
|
||||
while Instant::now() < deadline {
|
||||
match ps.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
log::warn!("--server exited {status} within {GRACE:?} of starting");
|
||||
*server = None;
|
||||
return false;
|
||||
}
|
||||
Ok(None) => sleep_millis(STEP_MS),
|
||||
// We cannot tell; treat it as alive rather than tearing down a possibly healthy child.
|
||||
Err(err) => {
|
||||
log::error!("error waiting on the just-started --server: {err}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn stop_server(server: &mut Option<Child>) {
|
||||
if let Some(mut ps) = server.take() {
|
||||
allow_err!(ps.kill());
|
||||
@@ -810,6 +894,29 @@ 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.
|
||||
//
|
||||
// Builder, like every other thread this feature starts: `thread::spawn` PANICS if the thread
|
||||
// cannot be created (EAGAIN under a thread-count or memory limit), and here that panic would
|
||||
// unwind out of `start_os_service` -- taking down the root service itself, for a feature whose
|
||||
// failure should only cost DRM capture. Losing the producer leaves the consumer to fall back to
|
||||
// PipeWire/X11, which is the same path a host without the feature takes.
|
||||
#[cfg(feature = "drm")]
|
||||
if let Err(err) = std::thread::Builder::new()
|
||||
.name("drm-producer".into())
|
||||
.spawn(|| {
|
||||
crate::ipc::start_drm();
|
||||
})
|
||||
{
|
||||
log::warn!(
|
||||
"failed to spawn the drm capture producer thread: {err}; DRM capture is off for \
|
||||
this boot and the consumer falls back to PipeWire/X11"
|
||||
);
|
||||
}
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let r = running.clone();
|
||||
let (mut display, mut xauth): (String, String) = ("".to_owned(), "".to_owned());
|
||||
@@ -848,7 +955,38 @@ pub fn start_os_service() {
|
||||
) {
|
||||
stop_subprocess();
|
||||
force_stop_server();
|
||||
// Run the login-screen --server as the active seat0 session user (the greeter
|
||||
// account) rather than root, so the DRM capture GPU/EGL convert never loads the
|
||||
// vendor GPU userspace in a privileged process. is_login_wayland() matches a GDM or
|
||||
// SDDM Wayland greeter (is_gdm_user covers both), and desktop.uid is that greeter's
|
||||
// uid, so this drops to whichever greeter owns seat0. A greeter is_gdm_user does not
|
||||
// recognize (e.g. LightDM) never reaches this branch -- it takes the unprivileged
|
||||
// else-branch below already. A genuine root graphical session (username=="root")
|
||||
// has no lower uid to drop to, so it stays root. The whole branch is gated on the drm
|
||||
// feature, so the drm-off build is upstream's single `start_server(None, ..)` line.
|
||||
#[cfg(not(feature = "drm"))]
|
||||
start_server(None, &mut server);
|
||||
#[cfg(feature = "drm")]
|
||||
if desktop.username != "root" && !desktop.uid.is_empty() {
|
||||
start_server(Some(&desktop), &mut server);
|
||||
// If dropping to the greeter uid did not produce a RUNNING server, fall back to a
|
||||
// root --server so the login screen stays remotable instead of looping on a
|
||||
// failing greeter spawn. This pays the GPU-in-root tradeoff only on that failure
|
||||
// path, never in the normal greeter case. Liveness, not just spawn success: a
|
||||
// greeter account that cannot actually run it (a nologin shell, a hardened home,
|
||||
// no writable config dir) leaves a child that exits at once, and the loop above
|
||||
// notices only that the child is gone and respawns it, forever, without ever
|
||||
// reaching this fallback -- so the login screen becomes permanently un-remotable
|
||||
// on a host where it used to work.
|
||||
if !server_survived_grace(&mut server) {
|
||||
log::warn!(
|
||||
"greeter --server did not stay up; falling back to a root --server"
|
||||
);
|
||||
start_server(None, &mut server);
|
||||
}
|
||||
} else {
|
||||
start_server(None, &mut server);
|
||||
}
|
||||
}
|
||||
} else if desktop.username != "" {
|
||||
// try kill subprocess "--server"
|
||||
@@ -927,6 +1065,15 @@ pub fn get_active_userid_fresh() -> String {
|
||||
get_values_of_seat0(&[1])[0].clone()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// The cached active uid as a number, or `None` when the cache is empty. Unlike `get_active_userid`
|
||||
/// this NEVER falls back to a blocking `loginctl` seat0 lookup, so it is safe to call on an async
|
||||
/// runtime thread and on a hot path (e.g. per-frame re-auth): a cache miss returns `None` for the
|
||||
/// caller to treat as "active session momentarily unknown" rather than stalling on a subprocess.
|
||||
pub fn get_active_userid_cached() -> Option<u32> {
|
||||
get_active_user_id_name_from_cache().and_then(|(uid, _)| uid.parse::<u32>().ok())
|
||||
}
|
||||
|
||||
fn get_cm() -> bool {
|
||||
// We use `CMD_PS` instead of `ps` to suppress some audit messages on some systems.
|
||||
if let Ok(output) = Command::new(CMD_PS.as_str()).args(vec!["aux"]).output() {
|
||||
@@ -1939,6 +2086,22 @@ mod desktop {
|
||||
self.display = "".to_owned();
|
||||
self.xauth = "".to_owned();
|
||||
self.is_rustdesk_subprocess = false;
|
||||
// Resolve HOME even on this path. Upstream returned without it because nothing then
|
||||
// consumed a login-Wayland Desktop, but the drm build starts a `--server` as the
|
||||
// greeter uid here, and a child with no HOME has nowhere to put its config. The
|
||||
// compositor variables (WAYLAND_DISPLAY, DBUS, DISPLAY, XAUTHORITY) are left blank
|
||||
// on purpose and are NOT an oversight: the drm capture path talks to the root
|
||||
// service over `_drm` and to a render node, never to the compositor or the portal,
|
||||
// which is the entire reason it works at a login screen. `try_start_server_` skips
|
||||
// empty entries, so the greeter child simply does not get them.
|
||||
//
|
||||
// `is_login_wayland` needs `is_gdm_user(username)`, and a current GDM runs its
|
||||
// greeter as `gdm-greeter`, which that helper does not match -- measured on the
|
||||
// test host, where the greeter server therefore takes the branch below and gets a
|
||||
// fully populated environment. This is for the display managers whose greeter user
|
||||
// does match.
|
||||
#[cfg(feature = "drm")]
|
||||
self.get_home();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,25 @@ 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).
|
||||
// X11 is skipped -- probing there makes the root service open DRM readers for a path this
|
||||
// session can never take -- but that decision belongs to `warm_availability`, which already
|
||||
// makes it, and NOT to this call site. Deciding it here is the same one-shot-at-startup
|
||||
// mistake the pre-warm had: `is_x11()` answers "x11" whenever loginctl cannot yet name the
|
||||
// seat0 session, which during a boot is exactly when this runs, and nothing revisits it --
|
||||
// so a Wayland host that came up slowly skipped the warm for the life of the process and
|
||||
// got back the cold-probe "No displays" symptom the warm exists to remove.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
if let Err(err) = std::thread::Builder::new()
|
||||
.name("drm-warm".into())
|
||||
.spawn(drm_capturer::warm_availability)
|
||||
{
|
||||
// Same reason as the root service's startup threads: `thread::spawn` panics on EAGAIN
|
||||
// and that would abort `start_server`. Skipping the warm costs the first session the
|
||||
// cold probe, which is what happened before the warm existed.
|
||||
log::warn!("drm: could not spawn the availability warm ({err}); skipping it");
|
||||
}
|
||||
input_service::fix_key_down_timeout_loop();
|
||||
#[cfg(target_os = "linux")]
|
||||
if input_service::wayland_use_uinput() {
|
||||
|
||||
@@ -65,6 +65,13 @@ pub(super) fn set_wayland_uinput_rect(rect: (i32, i32, i32, i32)) {
|
||||
WAYLAND_UINPUT_RECT.lock().unwrap().rect = Some(rect);
|
||||
}
|
||||
|
||||
// The uinput ABS range currently programmed into the device, for the DRM path's "reapply only when
|
||||
// it changed" check. The PipeWire path compares it inline in refresh_wayland_uinput_rect_if_changed.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub(super) fn wayland_uinput_rect() -> Option<(i32, i32, i32, i32)> {
|
||||
WAYLAND_UINPUT_RECT.lock().unwrap().rect
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn set_wayland_layout_baseline(baseline: Vec<scrap::wayland::display::DisplayRect>) {
|
||||
WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed);
|
||||
@@ -328,6 +335,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_cached() {
|
||||
if let Some(displays) = super::drm_capturer::get_display_infos() {
|
||||
SYNC_DISPLAYS.lock().unwrap().check_changed(&displays);
|
||||
}
|
||||
}
|
||||
return get_displays_msg();
|
||||
}
|
||||
}
|
||||
@@ -434,6 +451,33 @@ pub(super) fn get_display_info(idx: usize) -> Option<DisplayInfo> {
|
||||
SYNC_DISPLAYS.lock().unwrap().displays.get(idx).cloned()
|
||||
}
|
||||
|
||||
// True when at least one advertised (synced) display is NOT served by the DRM/KMS capture path,
|
||||
// i.e. a mixed DRM + PipeWire session. The cursor service (platform::linux::get_cursor /
|
||||
// get_cursor_data) uses this to decide whether a hidden DRM hardware-cursor sentinel is
|
||||
// authoritative: in a pure-DRM session it is (the pointer is genuinely off every captured CRTC),
|
||||
// but in a mixed session the sentinel only means the pointer moved onto a PipeWire-served display,
|
||||
// whose cursor must come from the normal path instead of being hidden everywhere.
|
||||
//
|
||||
// When DRM capture is active the advertised list is enumerated from the DRM display list, so a DRM
|
||||
// list shorter than the synced list means at least one advertised display is served by PipeWire.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub fn has_non_drm_backed_display() -> bool {
|
||||
match super::drm_capturer::display_count_and_any_demoted() {
|
||||
// A display served by PipeWire is either ABSENT from the DRM list (a shorter count, e.g. a
|
||||
// pure-portal display) or PRESENT-BUT-DEMOTED (kept in place at the same index and marked
|
||||
// offline so the index space stays aligned -- see get_display_infos). The count check alone
|
||||
// misses the demotion case (same count), so a demoted display is treated as non-DRM-backed
|
||||
// too. This is what gates the hidden-cursor sentinel: it stays authoritative only in a
|
||||
// pure-DRM session. The scalar accessor is deliberate: this is polled every cursor tick
|
||||
// while the sentinel is active, and cloning + geometry-augmenting the whole list per tick
|
||||
// (what get_display_infos does) answered the same two facts.
|
||||
Some((count, any_demoted)) => {
|
||||
count < SYNC_DISPLAYS.lock().unwrap().displays.len() || any_demoted
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
// Display to DisplayInfo
|
||||
// The DisplayInfo is be sent to the peer.
|
||||
pub(super) fn check_update_displays(all: &Vec<Display>) {
|
||||
|
||||
1670
src/server/drm_capturer.rs
Normal file
1670
src/server/drm_capturer.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -396,19 +396,62 @@ 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
|
||||
// gated on the drm feature, so the drm-off build stays byte-identical to upstream.
|
||||
#[cfg(all(target_os = "linux", 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)?;
|
||||
// File the shape under the id ACTUALLY served, not the one requested. Deliberately a
|
||||
// NEW name rather than shadowing `hcursor`: the insert below reads as the requested
|
||||
// id everywhere else in this function, and a cfg-gated shadow would make the two
|
||||
// builds disagree about what that line means.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
let served_id = data.id;
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
{
|
||||
drm_served_id = served_id;
|
||||
}
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
let cache_key = served_id;
|
||||
#[cfg(not(all(target_os = "linux", feature = "drm")))]
|
||||
let cache_key = hcursor;
|
||||
data.colors = hbb_common::compress::compress(&data.colors[..]).into();
|
||||
let mut tmp = Message::new();
|
||||
tmp.set_cursor_data(data);
|
||||
msg = Arc::new(tmp);
|
||||
state.cached_cursor_data.insert(hcursor, msg.clone());
|
||||
super::log::trace!("Cursor data updated, hcursor: {}", hcursor);
|
||||
// A DRM cursor id is derived from the shape's pixels plus geometry, so an animated
|
||||
// pointer mints a new id on every shape change and this map would grow for the life
|
||||
// of the service, each entry pinning a compressed cursor message. (Upstream's X11
|
||||
// ids come from a small set of XFixes serials, so the map is effectively bounded
|
||||
// there -- which is why the ceiling is gated and the stock build stays untouched.)
|
||||
// Past the ceiling, drop the map and start over: the next request for any evicted
|
||||
// shape just recompresses it, and the ceiling comfortably covers every static shape
|
||||
// plus a generous animation window.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
{
|
||||
const CURSOR_CACHE_MAX: usize = 64;
|
||||
if state.cached_cursor_data.len() >= CURSOR_CACHE_MAX {
|
||||
state.cached_cursor_data.clear();
|
||||
}
|
||||
}
|
||||
state.cached_cursor_data.insert(cache_key, msg.clone());
|
||||
super::log::trace!("Cursor data updated, hcursor: {}", cache_key);
|
||||
}
|
||||
#[cfg(not(all(target_os = "linux", feature = "drm")))]
|
||||
{
|
||||
state.hcursor = hcursor;
|
||||
}
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
{
|
||||
state.hcursor = drm_served_id;
|
||||
}
|
||||
state.hcursor = hcursor;
|
||||
sp.send_shared(msg.clone());
|
||||
state.cursor_data = msg;
|
||||
}
|
||||
|
||||
@@ -107,8 +107,81 @@ 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.
|
||||
///
|
||||
/// This is the DRM path's single copy of what `check_init` does inline for PipeWire, and it does the
|
||||
/// same three things, for the same reasons:
|
||||
///
|
||||
/// - drops the cached Wayland layout first, because it can predate compositor changes made while no
|
||||
/// session was active (rustdesk#15601), and on the hotplug path it is stale by definition;
|
||||
/// - bounds the IPC wait, because `uinput::client::set_resolution` reads its reply with no timeout of
|
||||
/// its own, so a hung uinput socket would otherwise block every video-service start on this branch
|
||||
/// and wedge the hotplug worker inside `rt.block_on`, leaving `UINPUT_REFRESH_BUSY` latched true so
|
||||
/// that every later hotplug refresh is silently skipped for the process lifetime;
|
||||
/// - records the applied rect and snapshots the per-display layout baseline, which is what arms the
|
||||
/// #15601 drift remap. Without it the remap never activates on the DRM path at all.
|
||||
///
|
||||
/// It stays a separate copy rather than being folded into `check_init` because `check_init` ships in
|
||||
/// every Linux build and this feature must not change the drm-off one by so much as a line.
|
||||
#[cfg(feature = "drm")]
|
||||
pub(super) async fn update_uinput_resolution() {
|
||||
if !crate::input_service::wayland_use_uinput() {
|
||||
return;
|
||||
}
|
||||
scrap::wayland::display::clear_wayland_displays_cache();
|
||||
let Some(rect) = scrap::wayland::display::get_desktop_rect_for_uinput() else {
|
||||
log::warn!("Failed to get desktop rect for uinput");
|
||||
return;
|
||||
};
|
||||
// Re-snapshot the baseline on every call: this runs at session init and after every hotplug, and
|
||||
// the baseline is what the client's coordinates are measured against.
|
||||
let snapshot_layout = || {
|
||||
super::display_service::set_wayland_layout_baseline(
|
||||
scrap::wayland::display::get_display_rects_for_uinput(),
|
||||
);
|
||||
};
|
||||
// Reprogram the device only when the range actually changes. A display stuck in a rebuild loop
|
||||
// calls this about once a second, and reapplying an identical range is an IPC roundtrip plus a
|
||||
// uinput device reconfiguration under a user who may be at the console.
|
||||
if super::display_service::wayland_uinput_rect() == Some(rect) {
|
||||
snapshot_layout();
|
||||
return;
|
||||
}
|
||||
let (minx, maxx, miny, maxy) = rect;
|
||||
log::info!("update mouse resolution: ({minx}, {maxx}), ({miny}, {maxy})");
|
||||
match timeout(
|
||||
3_000,
|
||||
input_service::update_mouse_resolution(minx, maxx, miny, maxy),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Record the rect only after a successful apply, so a transient failure is retried on the
|
||||
// next call instead of being remembered as applied.
|
||||
Ok(Ok(())) => {
|
||||
super::display_service::set_wayland_uinput_rect(rect);
|
||||
snapshot_layout();
|
||||
}
|
||||
Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err),
|
||||
Err(err) => log::error!("Failed to update mouse resolution: {}", err),
|
||||
}
|
||||
}
|
||||
|
||||
#[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_cached() {
|
||||
update_uinput_resolution().await;
|
||||
return Ok(());
|
||||
}
|
||||
check_init().await
|
||||
}
|
||||
|
||||
@@ -116,6 +189,10 @@ pub(super) fn is_inited() -> Option<Message> {
|
||||
if is_x11() {
|
||||
None
|
||||
} else {
|
||||
#[cfg(feature = "drm")]
|
||||
if super::drm_capturer::is_available_cached() {
|
||||
return None;
|
||||
}
|
||||
if CAP_DISPLAY_INFO.read().unwrap().is_empty() {
|
||||
let mut msg_out = Message::new();
|
||||
let res = MessageBox {
|
||||
@@ -242,6 +319,24 @@ 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_cached() {
|
||||
// This function runs once per login (update_get_sync_displays_on_login is its only
|
||||
// caller), and login is the moment the client is PROMISED a display list -- so refresh
|
||||
// that list over a live `_drm` handshake first. The service wakes sleeping displays and
|
||||
// answers with the settled truth, which is what makes an unattended box with an idled,
|
||||
// DISABLED panel connectable at all: the cached list would either omit the panel (probed
|
||||
// while asleep) or advertise a display with no scanout behind it (probed while awake), and
|
||||
// either way the wake then firing inside the capture handshake would change the list the
|
||||
// client had already been given. Properly async, so the executor is never blocked; on any
|
||||
// failure the cache serves as before.
|
||||
super::drm_capturer::refresh_displays_for_login().await;
|
||||
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 +355,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_cached() {
|
||||
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 _;
|
||||
@@ -274,18 +382,136 @@ pub fn clear() {
|
||||
*PIPEWIRE_INITIALIZED.write().unwrap() = false;
|
||||
}
|
||||
|
||||
/// Initialize the PipeWire/portal capture path from the plain (sync) video thread, so a DRM display
|
||||
/// that cannot be captured can fall through to PipeWire for THAT display. `ensure_inited` short-circuits
|
||||
/// to the DRM branch whenever DRM is globally available, so it never runs `check_init`; this helper
|
||||
/// drives the same async portal ScreenCast init directly (mirroring `ensure_inited`'s pattern). Needed
|
||||
/// because `is_available()` is a GLOBAL verdict — it stays true for the still-working DRM outputs — so
|
||||
/// without a per-display fallback a single failed/demoted DRM display would restart-loop the video
|
||||
/// service instead of degrading to PipeWire only for itself.
|
||||
#[cfg(feature = "drm")]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn ensure_pipewire_inited() -> ResultType<()> {
|
||||
check_init().await
|
||||
}
|
||||
|
||||
pub(super) fn get_capturer_for_display(
|
||||
display_idx: usize,
|
||||
) -> ResultType<super::video_service::CapturerInfo> {
|
||||
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. `is_available()` is a GLOBAL verdict, so a
|
||||
// per-display DRM failure (an ungrabbable/demoted CRTC, or — after the phase-2 split — a
|
||||
// render-node-absent seat or a convert failure on the unprivileged side) must NOT propagate out
|
||||
// and restart-loop this per-display video service. Instead fall THROUGH to PipeWire for just this
|
||||
// display; the other DRM outputs keep streaming over DRM.
|
||||
// The ONE gate that keeps the probing form on purpose: this runs on the plain video thread,
|
||||
// not an async executor, and it is the capture-build path, so a definitive verdict is worth
|
||||
// seconds here. It is also what makes a cold cache recoverable at all -- warm_availability
|
||||
// gives up after its attempts, so if EVERY gate were cache-only a --server that started
|
||||
// before the root service would never see DRM again for the rest of its life.
|
||||
#[cfg(feature = "drm")]
|
||||
if super::drm_capturer::is_available() {
|
||||
match super::drm_capturer::get_capturer_info(display_idx) {
|
||||
Ok(info) => return Ok(info),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"drm capturer for display {} unavailable ({:#}); falling back to PipeWire",
|
||||
display_idx,
|
||||
e
|
||||
);
|
||||
ensure_pipewire_inited()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resolved BEFORE the read guard below, deliberately. `get_display_infos` runs
|
||||
// `augment_with_wayland_geometry`, which is a compositor output roundtrip, and `clear()` takes
|
||||
// the WRITE guard on every capturer teardown -- which is exactly what is happening when a DRM
|
||||
// display is demoted or flapping, i.e. precisely when this path runs. Holding the read guard
|
||||
// across that roundtrip would stall every concurrent teardown for its duration, and the value
|
||||
// does not depend on anything inside the guard.
|
||||
#[cfg(feature = "drm")]
|
||||
let drm_advertised = if super::drm_capturer::is_available_cached() {
|
||||
match super::drm_capturer::get_display_infos() {
|
||||
Some(list) => Some((list.get(display_idx).cloned(), list.len() == 1)),
|
||||
None => Some((None, false)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cap_map = CAP_DISPLAY_INFO.read().unwrap();
|
||||
// Serve ONLY the exact PipeWire entry for this index. Do NOT fall back to another index's
|
||||
// `CapDisplayInfo`: `CapturerPtr` is a bare `*mut Capturer` cloned by raw-pointer copy, so aliasing
|
||||
// one entry to two `display_idx` values would let two video-service threads call `frame()` on the
|
||||
// same `Recorder` with no lock (data race / UB), and it would also mis-map input against the wrong
|
||||
// rect. DRM and PipeWire do not share an index space (the portal often exposes one whole-desktop
|
||||
// stream at index 0), so a demoted non-primary DRM index has no PipeWire entry here; that case is
|
||||
// handled at the source by dropping the demoted display from the advertised list (see
|
||||
// drm_capturer demotion) so the client re-enumerates against a consistent list, rather than being
|
||||
// papered over with a shared/mismatched capturer.
|
||||
if let Some(addr) = cap_map.get(&display_idx) {
|
||||
let cap_display_info: *const CapDisplayInfo = *addr as _;
|
||||
unsafe {
|
||||
let cap_display_info = &*cap_display_info;
|
||||
let rect = cap_display_info.rects[cap_display_info.current];
|
||||
// Reaching here with DRM active means get_capturer_info bailed (a demoted display) and
|
||||
// we fell through to PipeWire. Serve this stream ONLY if its rect matches the
|
||||
// geometry we advertised for this index. The portal typically exposes one whole-desktop
|
||||
// stream, so on a multi-monitor host that rect is the FULL desktop while the advertised DRM
|
||||
// geometry is a single connector -> serving it would stretch the frame and offset all
|
||||
// input. Bail instead; get_display_infos advertised the display offline, so the client
|
||||
// re-enumerates against a consistent list. A single-display host matches (whole-desktop ==
|
||||
// that display) and is served normally. On a pure-PipeWire host is_available() is false and
|
||||
// this guard is skipped, preserving upstream behavior exactly.
|
||||
#[cfg(feature = "drm")]
|
||||
if let Some((advertised, single_display)) = drm_advertised {
|
||||
if let Some(advertised) = advertised {
|
||||
// BOTH SIDES ARE PHYSICAL, so compare them raw. Traced rather than assumed,
|
||||
// because it was twice "corrected" to a scale conversion that broke it:
|
||||
// `rect` is built above from `Display::width()/height()`, and the WAYLAND
|
||||
// variant of those returns `physical_width()/physical_height()`
|
||||
// (scrap `common/wayland.rs`), i.e. `PipeWireCapturable.physical_size`.
|
||||
// `try_fix_logical_size` only repairs the capturable's SEPARATE
|
||||
// `logical_size` field and never touches `physical_size`, so the rect is not
|
||||
// logical. The advertised DRM geometry is physical too
|
||||
// (`augment_with_wayland_geometry` sets x/y/scale and deliberately leaves
|
||||
// width/height as the DRM mode). Dividing one side by the scale therefore
|
||||
// compares logical against physical and rejects the valid stream on exactly
|
||||
// the scaled outputs it was meant to rescue.
|
||||
//
|
||||
// The size check is what tells one connector apart from the whole-desktop
|
||||
// rect the portal usually exposes. It is skipped only when BOTH sides say
|
||||
// there is a single display -- the DRM list has one entry and the PipeWire
|
||||
// map has one -- because only then is "the whole-desktop stream IS this
|
||||
// display" true by construction. (The portal can report a different physical
|
||||
// size for a Full Workspace selection than the connector's mode, which is why
|
||||
// that case needs the carve-out at all.) The DRM count alone is not enough:
|
||||
// a monitor on a card the service cannot open is missing from the DRM list
|
||||
// while the compositor still drives it.
|
||||
let single_display = single_display && cap_display_info.num == 1;
|
||||
let consistent = advertised.x == rect.0 .0
|
||||
&& advertised.y == rect.0 .1
|
||||
&& (single_display
|
||||
|| (advertised.width as usize == rect.1
|
||||
&& advertised.height as usize == rect.2));
|
||||
if !consistent {
|
||||
bail!(
|
||||
"drm display {} demoted with no geometry-consistent PipeWire stream (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline",
|
||||
display_idx,
|
||||
advertised.width,
|
||||
advertised.height,
|
||||
advertised.x,
|
||||
advertised.y,
|
||||
rect.1,
|
||||
rect.2,
|
||||
rect.0 .0,
|
||||
rect.0 .1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(super::video_service::CapturerInfo {
|
||||
origin: rect.0,
|
||||
width: rect.1,
|
||||
|
||||
Reference in New Issue
Block a user