From e4539fc3043bc6dfb2b52656e88ee3b30b3b14b1 Mon Sep 17 00:00:00 2001 From: memory_clear <83893503+MemoryClear@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:08:30 +0800 Subject: [PATCH 1/4] Update cn.rs (#16041) --- src/lang/cn.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 8a819fd7edf..03e10097efd 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "当前不支持多个屏幕的合并截屏,请切换到单个屏幕重试。"), ("screenshot-action-tip", "请选择如何继续截屏。"), ("Save as", "另存为"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "导出"), + ("Export Logs", "导出日志"), + ("Import Folder", "导入文件夹"), ("Copy to clipboard", "复制到剪贴板"), ("Enable remote printer", "启用远程打印机"), ("Downloading {}", "正在下载 {}"), From 23a147b0dc9e92705c54faf123d0d71fe27e2e91 Mon Sep 17 00:00:00 2001 From: Xinglin Qiang Date: Thu, 3 Sep 2026 16:04:09 +0800 Subject: [PATCH 2/4] Filter detached DXGI outputs for Win+P single-display modes (#15814) When Windows is set to "Show only on 1/2", DXGI still enumerates detached outputs. Preferring that unfiltered list could select a zero-size display as primary and hang clients waiting for video. --- libs/scrap/src/common/dxgi.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/libs/scrap/src/common/dxgi.rs b/libs/scrap/src/common/dxgi.rs index f7bf167d2d2..3cf816eb4a5 100644 --- a/libs/scrap/src/common/dxgi.rs +++ b/libs/scrap/src/common/dxgi.rs @@ -132,7 +132,15 @@ impl Display { .map(Display) .collect::>(); - let displays_dxgi = Self::all_().unwrap_or(Default::default()); + let mut displays_dxgi = match Self::all_() { + Ok(displays) => displays, + Err(e) => { + hbb_common::log::error!("DXGI display enumeration failed: {e}"); + Vec::new() + } + }; + // Win+P "Show only on 1/2" still enumerates detached DXGI outputs. + displays_dxgi.retain(|d| d.is_online() && d.width() > 0 && d.height() > 0); // Return gdi displays if dxgi is not supported if displays_dxgi.is_empty() { @@ -155,7 +163,6 @@ impl Display { } // Reorder displays from dxgi - let mut displays_dxgi = displays_dxgi; let mut displays_dxgi_ordered = Vec::new(); for name in names_gdi.iter() { let pos = match displays_dxgi.iter().position(|d| d.name() == *name) { @@ -176,11 +183,11 @@ impl Display { } pub fn width(&self) -> usize { - self.0.width() as usize + self.0.width().max(0) as usize } pub fn height(&self) -> usize { - self.0.height() as usize + self.0.height().max(0) as usize } pub fn name(&self) -> String { @@ -201,7 +208,8 @@ impl Display { pub fn is_primary(&self) -> bool { // https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodea - self.origin() == (0, 0) + // Detached outputs can still report origin (0,0) with a zero size. + self.origin() == (0, 0) && self.width() > 0 && self.height() > 0 } #[cfg(feature = "vram")] From 3f93005be276f4a7c0cf36a374753a003d17a1c8 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Thu, 3 Sep 2026 06:10:01 -0300 Subject: [PATCH 3/4] fix(drm): deliver a rotated output upright (#15886) (#15889) * fix(drm): deliver a rotated output upright instead of sideways (#15886) the compositor draws a rotated desktop sideways into the landscape scanout and the physically turned monitor straightens it locally, so the raw scanout the drm path ships reads sideways in the viewer, and nothing rebroadcasts on rotation because the framebuffer size never changes. the capturer now resolves the output transform once per session from the wayland enumeration, turns accepted frames upright into its own buffer, and sizes the session in rotated dimensions. the advertised list swaps width and height for 90/270 outputs, which also makes a mid-session rotation a topology change that restarts the service, and computes scale from the post-swap width so a rotated 1:1 monitor no longer advertises scale 16/9. a non 4-byte format on a rotated session is a hard error and degrades through the existing health path. the greeter path where no compositor answers keeps today's behavior: there is no transform source there. hbb_common carries the new transform field (submodule bump). * fix(drm): drop the wayland snapshot when the live layout drifts (#15886) the advertised list is augmented from the cached wayland snapshot and nothing invalidated it mid-session, so a rotation the 1.5 s live poll plainly saw never reached check_changed: the poll reads live, the advertise kept serving the pre-rotation snapshot. measured before this commit: transform applied and held, 'desktop layout changed' logged, zero new encoders. cleared only when the poll saw an actual change, so the probe cost stays tied to real layout events; after it, the same stimulus rebuilds into a 1080x1920 encoder within a poll turn. * refactor: trim comment density to the file norm * chore: bump hbb_common to the transform field from rustdesk/hbb_common#586 pinned to the #586 commits atop the current pin rather than main tip: main also carries an unrelated config-keys refactor the app has not adopted yet, and both #586 commits are reachable upstream through the merge. * fix: advertise a lone rotated output at delivered size, one snapshot per session review findings, both real: the 90/270 swap sat below the origin-only cut, so a single rotated output advertised unrotated dimensions while the capturer delivered rotated frames; and transform and origin came from two get_displays() reads that could straddle a cache invalidation. the swap now precedes the cut (logical-scale adoption stays multi output), and new() resolves one snapshot for transform, origin and the session size, with tests for both. comments trimmed to the three-line guideline. * fix(drm): rotate every space the rotation touches, not just the pixels review findings on #15889, all verified against the code first. the uinput rect's single-display branches now serve the delivered orientation, so the pointer reaches the whole of a rotated screen (1). DisplayRect carries the transform, making 0/180 and 90/270 flips visible to the drift comparison (5), and the drift poll is an edge on live-vs-previous rather than a level against the baseline, so the cache clear fires once per real layout event instead of every 300 ms forever (9). on the drm path the baseline promotes together with the clear, so the remap and the client rebase never correct the same origin delta twice (7), and the poll now runs above the login-screen return, which was the one place with no other invalidation trigger (6). a snapshot generation gives a rotation a rebuild path at last (3, 4): clears bump it, the capturer records it at build, and a stale generation asks for a rebuild without counting against display health. the cursor bitmap and hotspot turn with the same session transform the frames use (11). original_resolution follows the 90/270 swap (12). the transform comes only from an identity match, never the layout-order fallback (13), and a missing wayland snapshot at build logs the degrade instead of silently pinning an unrotated session (10). unrotate_bgra's body is now libyuv's ARGBRotate, which the existing direction tests pin to the measured anchor (14). 180 stays master behavior: i915 advertises hardware rotate-180 and wl_output cannot tell hardware from software rotation, so undoing it blind would invert an already-upright frame; it needs the plane rotation property on the wire (2). the pipewire fallback guard's comment now states the rotated reality it compares (8). * fix(drm): one owner for the layout generation, one identity rule for rotation adversarial pass over the previous commit, three structural findings. the generation bump rode on the cache clear, which every video-service start also executes, so any session init or restart tore down every other live capturer, with no damping against a ping-pong between two displays. the bump now has a single owner: the edge-detected layout change in the display-service poll. cache clears are side-effect free again, and a two-display session survives a third session's init with zero spurious rebuilds. the advertise side swapped dimensions for a layout-order-fallback match while the capturer's transform refused such matches, splitting advertised size from delivered frames into a black screen. both sides now key off the same identity-match pass (identity_matches), so a guessed assignment rotates nothing anywhere. an edge observed while the drm verdict was transiently non-available was consumed unpromoted, leaving a rotation sideways for the session; it now stays owed until the verdict returns. an enumeration that failed at build pinned transform 0 forever with a warn promising a retry that did not exist; a missing snapshot now makes the first successful poll an edge, so the degrade is bounded by the outage. the multi-display missing-logical-size fallback serves delivered orientation, stale docs zhou named are updated, and the resolutions list stays mode-space on purpose: resolution changes ride xrandr, which is inert on this path. * fix: transpose-tolerant fallback size check, log a rejected rotate geometry whether a portal stream's caps arrive rotated on a 90/270 output is unmeasured either way (pipewiresrc does not apply SPA_META_VideoTransform), and this guard has already broken two readers who reasoned from its comment - so the size half now accepts either orientation instead of gambling a permanent offline on one. a source stride shorter than a row logs the rejected geometry instead of publishing a silent black frame. comments trimmed to the guideline and the stale sole-test claim updated. * fix(wayland): never serve a transposed PipeWire stream The fallback accepted a stream whose dimensions were the advertised display's transposed, but CapturerInfo keeps the stream dimensions, nothing on the wayland side ever reconciles the client afterwards, and the flutter renderer drops every frame whose size differs from the advertised display - a permanently blank fallback. Accept only the exact orientation; a transposed pair now falls into the existing bail, the display is advertised offline, and the client recovers by re-enumerating. * fix(drm): keep the cursor consistent with the session transform Two holes from the same review pass. The wire cursor id hashes only the plane pixels and geometry, so a stream rebuilt under a new transform resent the SAME id and the client's by-id cursor cache kept the old orientation until the shape itself changed; fold the session transform into the served id. And a cursor racing new()'s transform store was processed with transform 0 and never corrected, since the producer resends only on a shape change; hold that cursor and replay it once the transform is in - the receive loop wakes at least every 200 ms, so the replay is prompt even on an idle wire. * fix(wayland): the single-display carve-out must not forgive a transposed stream The carve-out forgives a size difference (a Full Workspace stream may report the workspace rather than the mode), but a transposed pair is the same served-vs-advertised orientation split the previous commit rejects, and it blanks the client the same way. * fix(drm): a lone display with a rejected fallback is honestly offline The transposed rejection promised 'advertised offline', but the lone-display carve-out in mark_demoted_displays kept the display online on the grounds that the whole-desktop fallback remains usable - which is exactly what the rejection just refuted. The video service then restart-looped against a stream nothing can serve, rebuilding the portal session about once a second, while the client saw a display list that lied. Record the geometry rejection in the display health and let it end the carve-out; a delivered frame or the demote-cooldown re-arm clears it, so a recovered output comes back on its own. * ci: retrigger, the previous run died in the actions outage (all root jobs at exactly 8m) * fix(drm): a blind capturer owes a rebuild, and name matches reserve globally Two of the review's findings. A capturer built during a failed wayland enumeration recorded nothing durable: a later successful enumeration refills the cache, wayland_snapshot_missing goes false, and the first live poll sees no edge - the session stays sideways until an unrelated change. The build now latches that it ran blind and the layout poll consumes the latch into the existing owed-promotion machinery. And the identity matcher ran per-connector, so a resolution guess for an earlier connector could steal a later connector's exact name match and pin its rotation on the wrong output. Names now reserve in a global first pass; resolution pairing runs on the remainder only when forced - one free output and one unmatched connector at that size. * fix(drm): consume the blind-build latch even on a live-changed poll Adversarial pass on the previous commit: the short-circuit left the latch set on exactly the poll where live_changed fired (the common blind-recovery ordering, since a failed enumeration is not cached and failed_init makes the first successful poll an edge), and the stale latch then bought a second, spurious promotion one poll later, tearing down the freshly rebuilt capturer. The latch is now taken unconditionally so both edge sources merge into one promotion. * fix(wayland): hand over a layout change the poll has not seen yet set_wayland_layout_baseline clears live, which is the edge detector's only memory of the previous layout. ensure_inited calls it at the top of every video service start, so a second monitor service starting between a rotation and the next 1.5s poll recorded the rotated layout as the baseline: the poll then found baseline == live_rects, owed no promotion, and the first capturer kept its old transform. Under mutter's software rotation the framebuffer size does not change and the wayland display-change check is disabled, so the stream stayed sideways until the next layout event. The setter now arms the promotion itself when the outgoing live differs from the incoming baseline, which is the one choke point every caller goes through. An empty incoming baseline is the DRM-union fallback and proves nothing. * fix(wayland): the edge detector needs a memory a session init cannot erase The baseline reset was also the edge detector's memory, so two session inits straddling a rotation left nothing to compare the next poll against. Keep the observed layout separate from the per-session input baseline; before the first poll the outgoing baseline seeds it. * fix(wayland): a capturer records the layout it was built on ensure_inited() runs the wayland query before the capturer exists, and a failure there saves an empty baseline. The capturer's own retry can succeed a moment later and build on that layout, and because the build was not blind nothing latched it, so a rotation before the first poll had no memory to be an edge against and the stream stayed at the old transform. The build now seeds the edge detector when nothing else has, and only then, so a capturer built later cannot overwrite what the poll is keeping. * fix(wayland): keep a capturer record that lost the race with the first poll The constructor reads its wayland snapshot and records it in the edge detector in two steps, and the layout poll can land between them. After a failed session init (empty baseline) the constructor takes layout A and publishes it, the output rotates, and the poll reads B live: nothing is recorded yet and the snapshot is present, so it is no edge, and observe() sets seen=B. The late note_capturer(A) then met a non-empty memory and was dropped, so the capturer showed A while the detector held B, and B against B never bumped the generation. note_capturer now flags a build layout that disagrees with the poll's memory instead of dropping it (overwriting is still wrong: on a multi-display session that memory is what the other capturers were built against). edge() reports the flag as an edge whatever the live layout is, observe() consumes it right after, and a session init's baseline reset leaves it alone. Regression test for the interleaving, with the promotion consuming it, a baseline reset in between, and an agreeing late record as the control. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011ZwSrP3DFA6ZiPKVHkU5dL * fix(wayland): a late capturer record from a promoted generation is not a second edge The record can also land after the poll consumed an edge but before the bump it promotes, or after the bump with a snapshot taken before it. That capturer is stale by generation and rebuilds on its own, but the flag it raised survived the promotion, and the next poll spent a second promotion on the freshly rebuilt capturers. Tag the record with the generation the capturer read before taking its snapshot and count it as an edge only while that generation is current; the newest generation wins when two records land. Regression test for the consumed-edge interleaving, with a disagreeing record at the promoted generation and a stale record after a fresh one as controls. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011ZwSrP3DFA6ZiPKVHkU5dL * chore: bump hbb_common to main tip dc95b4f -> 05ed68f, a fast-forward: the flipped-transform warning and the wlroots xdg-output positions (rustdesk/hbb_common#591, #592), 90-day logs, the webrtc session cleanup deadlock fix and the hide-general-settings option. No public API changes and no dependency changes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011ZwSrP3DFA6ZiPKVHkU5dL --------- Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- libs/hbb_common | 2 +- libs/scrap/src/wayland/display.rs | 94 ++++- src/server/display_service.rs | 323 ++++++++++++++- src/server/drm_capturer.rs | 660 +++++++++++++++++++++++++++--- src/server/wayland.rs | 43 +- 5 files changed, 1037 insertions(+), 85 deletions(-) diff --git a/libs/hbb_common b/libs/hbb_common index b2b1ac453d1..05ed68fed81 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit b2b1ac453d1d694046f63be20d792d608dac1c93 +Subproject commit 05ed68fed8198cbb72edb64c7b4ae4d38c70c6ae diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs index 1a9f29f253a..fdd296b3252 100644 --- a/libs/scrap/src/wayland/display.rs +++ b/libs/scrap/src/wayland/display.rs @@ -297,6 +297,30 @@ pub fn clear_wayland_displays_cache() { // capturer rebuild loop clears about once a second. } +// Bumped ONLY by the layout-drift edge in display_service (its single owner), never by cache +// clears: session inits and hotplug workers clear the cache too, and a bump there tears down +// every OTHER live capturer on a multi-display session. A capturer records this at build and +// treats a later bump as "the layout changed under me, rebuild" — the only trigger a rotation +// has, since it changes neither the CRTC mode nor the framebuffer size (rustdesk#15886). +static SNAPSHOT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Whether no snapshot has been cached: the signature of an enumeration that failed at session +/// build (an `Err` is deliberately not cached), as opposed to a session that started healthy. +#[cfg(feature = "drm")] +pub fn wayland_snapshot_missing() -> bool { + DISPLAYS.lock().unwrap().is_none() +} + +#[cfg(any(test, feature = "drm"))] +pub fn bump_layout_generation() { + SNAPSHOT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Release); +} + +#[cfg(feature = "drm")] +pub fn wayland_snapshot_generation() -> u64 { + SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire) +} + // Return (min_x, max_x, min_y, max_y) pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { let wayland_displays = get_displays(); @@ -332,7 +356,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3 // Otherwise, we use the logical size for `uinput`. if displays.len() == 1 { let d = &displays[0]; - return Some((d.x, d.x + d.width, d.y, d.y + d.height)); + let (w, h) = oriented_physical(d); + return Some((d.x, d.x + w, d.y, d.y + h)); } let mut min_x = i32::MAX; @@ -344,6 +369,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3 min_y = min_y.min(d.y); let size = if let Some(logical_size) = d.logical_size { logical_size + } else if d.transform == 90 || d.transform == 270 { + oriented_physical(d) } else { // When `logical_size` is None, we cannot obtain the correct desktop rectangle. // This may occur if the Wayland compositor does not provide logical size information, @@ -374,6 +401,24 @@ pub struct DisplayRect { pub y: i32, pub w: i32, pub h: i32, + // Carried so the drift comparison sees 0<->180 and 90<->270 flips, whose rects are + // otherwise identical; the remap itself matches by name and containment, never by this. + pub transform: i32, +} + +/// Physical size in delivered orientation: a 90/270 output scans out WxH but is captured, +/// advertised and pointed at as HxW. +fn oriented_physical(d: &WaylandDisplayInfo) -> (i32, i32) { + if d.transform == 90 || d.transform == 270 { + (d.height, d.width) + } else { + (d.width, d.height) + } +} + +/// The logical rectangles of a display list, for a caller that already has the list. +pub fn logical_rects_of_displays(displays: &[WaylandDisplayInfo]) -> Vec { + logical_rects_of(displays) } fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec { @@ -386,9 +431,9 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec { .iter() .map(|d| { let (w, h) = if single { - (d.width, d.height) + oriented_physical(d) } else { - d.logical_size.unwrap_or((d.width, d.height)) + d.logical_size.unwrap_or_else(|| oriented_physical(d)) }; DisplayRect { name: d.name.clone(), @@ -396,6 +441,7 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec { y: d.y, w, h, + transform: d.transform, } }) .collect() @@ -495,8 +541,8 @@ mod tests { #[test] fn test_clear_keeps_the_failure_stamp() { // The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears, - // and dropping the stamp with it would defeat the backoff. Sole test touching these - // statics; serialize before adding another. + // and dropping the stamp with it would defeat the backoff. The generation test also + // calls clear now; both only assert monotonic/unchanged state, so they can interleave. *LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now()); clear_wayland_displays_cache(); let stamp = *LAST_FAILED_LOOKUP.lock().unwrap(); @@ -519,6 +565,7 @@ mod tests { height, logical_size, refresh_rate: 60, + transform: 0, } } @@ -553,6 +600,42 @@ mod tests { assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440))); } + #[test] + fn a_single_rotated_display_swaps_the_uinput_rect() { + // Review finding 1 on rustdesk#15889: the single-display branch served the unrotated + // mode, so the pointer could not reach ~44% of a portrait screen. + let mut d = display(0, 0, 1920, 1080, None); + d.transform = 90; + assert_eq!(desktop_rect_of(&[d.clone()]), Some((0, 1080, 0, 1920))); + let rects = logical_rects_of(&[d]); + assert_eq!((rects[0].w, rects[0].h), (1080, 1920)); + } + + #[test] + fn a_transform_flip_is_visible_to_the_drift_comparison() { + // Review finding 5: 0<->180 and 90<->270 leave every rect identical; the transform + // field is what lets `baseline != live` fire on them. + let mut a = display(0, 0, 1920, 1080, Some((1920, 1080))); + let mut b = a.clone(); + a.transform = 90; + b.transform = 270; + assert_ne!(logical_rects_of(&[a.clone(), a.clone()]), logical_rects_of(&[b.clone(), b])); + } + + #[test] + fn only_the_explicit_bump_moves_the_generation() { + // A cache clear must NOT bump: session inits clear too, and a bump there rebuilds + // every other live capturer (adversarial finding on the first version of this). + let before = SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire); + clear_wayland_displays_cache(); + assert_eq!( + SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire), + before + ); + bump_layout_generation(); + assert!(SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire) > before); + } + fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect { DisplayRect { name: name.to_owned(), @@ -560,6 +643,7 @@ mod tests { y, w, h, + transform: 0, } } diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 235c7ca86a4..8d42214d29d 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -53,6 +53,82 @@ struct WaylandUinputRect { struct WaylandLayout { baseline: Vec, live: Vec, + // What the live capturers were built against. Separate from `baseline` because a session + // init resets that one, and the generation detector needs a memory that a reset cannot + // erase: two inits straddling a rotation would otherwise leave nothing to compare against. + seen: Vec, + // A capturer recorded a build layout other than `seen`, tagged with the generation it was + // built at: the poll observed the live layout between that capturer's snapshot read and its + // record, so one of the two is stale and the next poll owes an edge whatever it sees. Only + // while that generation is current: the record can also land between the poll consuming an + // edge and the bump it promotes (or after the bump, with a snapshot from before it), and that + // capturer rebuilds on its own, so a second promotion would tear the fresh ones down again. + // Consumed by `observe`, which the poll runs right after `edge`; a session init's baseline + // reset leaves it alone. + unseen_build: Option, +} + +#[cfg(target_os = "linux")] +impl WaylandLayout { + // Replace the per-session input baseline. Before the first poll the outgoing baseline is + // the only record of the layout the capturers were built against, so it seeds `seen`. + fn reset_baseline(&mut self, baseline: Vec) { + if self.seen.is_empty() { + let previous = std::mem::take(&mut self.baseline); + self.seen = previous; + } + self.baseline = baseline; + self.live.clear(); + } + + // An EDGE (live vs the layout the capturers were built against), not a level: comparing + // against the baseline latches true for the whole session. With nothing observed yet the + // baseline is that record, and a missing snapshot at init makes the first success the edge, + // or transform=0 sticks. + fn edge( + &self, + live: &[scrap::wayland::display::DisplayRect], + snapshot_missing: bool, + generation: u64, + ) -> bool { + if self.unseen_build == Some(generation) { + return true; + } + if !self.seen.is_empty() { + return self.seen != live; + } + if self.baseline.is_empty() { + return snapshot_missing; + } + self.baseline != live + } + + fn observe(&mut self, live: &[scrap::wayland::display::DisplayRect]) { + self.live = live.to_vec(); + self.seen = live.to_vec(); + self.unseen_build = None; + } + + // What a capturer was built against, which seeds the memory when nothing else has. A session + // init whose wayland query failed leaves an EMPTY baseline, and the capturer's own retry can + // then succeed - so the capturer is the only thing that knows the layout it is showing, and + // without this a rotation before the first poll is invisible to `edge`. Only when empty: a + // capturer built later must not overwrite the memory the poll is keeping, since on a + // multi-display session that memory is what the OTHER capturers were built against. A build + // that disagrees with it is flagged instead: the capturer's snapshot read and this record + // are two steps, and a poll landing between them observes the live layout first, which + // would otherwise drop the record and leave the capturer on a transform nothing compares. + fn note_capturer(&mut self, built_on: &[scrap::wayland::display::DisplayRect], built_gen: u64) { + if built_on.is_empty() { + return; + } + if self.seen.is_empty() { + self.seen = built_on.to_vec(); + } else if self.seen != built_on { + // The newest generation wins: a stale record landing late must not hide a fresh one. + self.unseen_build = Some(self.unseen_build.map_or(built_gen, |g| g.max(built_gen))); + } + } } // Whether `live` differs from `baseline`. Read on every mouse move, so it is an atomic: @@ -75,9 +151,24 @@ pub(super) fn wayland_uinput_rect() -> Option<(i32, i32, i32, i32)> { #[cfg(target_os = "linux")] pub(super) fn set_wayland_layout_baseline(baseline: Vec) { WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed); - let mut lock = WAYLAND_LAYOUT.lock().unwrap(); - lock.baseline = baseline; - lock.live.clear(); + WAYLAND_LAYOUT.lock().unwrap().reset_baseline(baseline); +} + +/// Record the layout a capturer was just built against, and the snapshot generation it read +/// before taking that layout. See `WaylandLayout::note_capturer`. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(super) fn note_capturer_layout( + displays: &[hbb_common::platform::linux::WaylandDisplayInfo], + built_gen: u64, +) { + if displays.is_empty() { + return; + } + let rects = scrap::wayland::display::logical_rects_of_displays(displays); + WAYLAND_LAYOUT + .lock() + .unwrap() + .note_capturer(&rects, built_gen); } // Remap an injected coordinate onto the live compositor layout when it has drifted from @@ -100,11 +191,6 @@ fn refresh_wayland_uinput_rect_if_changed() { if is_x11() || !crate::input_service::wayland_use_uinput() { return; } - // Nothing to poll at a login screen; the DRM path owns the rect there. - #[cfg(feature = "drm")] - if crate::platform::linux::is_login_screen_wayland_cached() { - return; - } { let mut lock = WAYLAND_UINPUT_RECT.lock().unwrap(); if let Some(last_check) = lock.last_check { @@ -120,14 +206,55 @@ fn refresh_wayland_uinput_rect_if_changed() { // Refresh the per-display layout every poll: monitor origins can shift (e.g. two // displays swap positions) without changing the overall desktop rect, and the mouse // path needs the current per-display geometry to correct coordinates. - let drifted = { + let (live_changed, mut drifted) = { let mut layout = WAYLAND_LAYOUT.lock().unwrap(); + #[cfg(feature = "drm")] + let snapshot_missing = scrap::wayland::display::wayland_snapshot_missing(); + #[cfg(not(feature = "drm"))] + let snapshot_missing = false; + #[cfg(feature = "drm")] + let generation = scrap::wayland::display::wayland_snapshot_generation(); + #[cfg(not(feature = "drm"))] + let generation = 0; + let live_changed = layout.edge(&live_rects, snapshot_missing, generation); let drifted = !layout.baseline.is_empty() && !live_rects.is_empty() && layout.baseline != live_rects; - layout.live = live_rects; - drifted + layout.observe(&live_rects); + (live_changed, drifted) }; + // Single owner of the generation bump: on the cache clear it let every session init tear + // down every other live capturer. Baseline promotes with the clear (rustdesk#15601). + #[cfg(feature = "drm")] + { + // An edge seen while DRM is transiently non-Available stays OWED rather than consumed. + static PROMOTION_OWED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + // The latch fires when a capturer was built with no wayland snapshot: a later cache + // refill makes wayland_snapshot_missing lie, so live_changed alone would miss it. Taken + // UNCONDITIONALLY: short-circuiting past it on a live_changed poll would leave it set and + // spend a second, spurious promotion one poll later on the freshly rebuilt capturer. + let blind_build = super::drm_capturer::take_unrotated_snapshot_pending(); + if live_changed || blind_build { + PROMOTION_OWED.store(true, Ordering::Release); + } + if PROMOTION_OWED.load(Ordering::Acquire) && super::drm_capturer::is_available_cached() { + PROMOTION_OWED.store(false, Ordering::Release); + scrap::wayland::display::clear_wayland_displays_cache(); + scrap::wayland::display::bump_layout_generation(); + set_wayland_layout_baseline(live_rects.clone()); + WAYLAND_LAYOUT.lock().unwrap().live = live_rects.clone(); + drifted = false; + } + } + #[cfg(not(feature = "drm"))] + let _ = live_changed; + // At a login screen the DRM path owns the rect; only the range/remap update is skipped, + // the snapshot invalidation above must still run (a greeter session has no other trigger). + #[cfg(feature = "drm")] + if crate::platform::linux::is_login_screen_wayland_cached() { + return; + } // The remap corrects for per-display origin shifts; the uinput ABS range corrects for // the overall bounding box. Only enable the remap once the range matches the live // layout, otherwise moves would be remapped into a range the device is not yet using. @@ -721,3 +848,177 @@ mod tests { assert_eq!(normalize_primary_display_idx(2, 2), 0); } } + +#[cfg(all(test, target_os = "linux"))] +mod wayland_layout_tests { + use super::WaylandLayout; + use scrap::wayland::display::DisplayRect; + + fn layout(w: i32, h: i32, transform: i32) -> Vec { + vec![DisplayRect { + name: "DP-1".into(), + x: 0, + y: 0, + w, + h, + transform, + }] + } + + // rustdesk#15886: a video service starts, the output rotates, and a retry starts before the + // 1.5 s poll. The baseline is reset on both, so it cannot be the edge detector's memory. + #[test] + fn a_rotation_between_two_session_inits_is_still_an_edge() { + let upright = layout(1920, 1080, 0); + let rotated = layout(1080, 1920, 1); + let mut l = WaylandLayout::default(); + l.reset_baseline(upright.clone()); + l.observe(&upright); + l.reset_baseline(upright.clone()); + l.reset_baseline(rotated.clone()); + assert!(l.edge(&rotated, false, 0)); + } + + // The same, with no poll ever having run: the outgoing baseline is the only record of what + // the first capturer was built against. + #[test] + fn a_rotation_between_two_inits_before_the_first_poll_is_still_an_edge() { + let upright = layout(1920, 1080, 0); + let rotated = layout(1080, 1920, 1); + let mut l = WaylandLayout::default(); + l.reset_baseline(upright.clone()); + l.reset_baseline(rotated.clone()); + assert!(l.edge(&rotated, false, 0)); + } + + // Control: without it the asserts above would pass on a detector that always fires. + #[test] + fn repeated_baseline_resets_without_a_rotation_are_not_an_edge() { + let upright = layout(1920, 1080, 0); + let mut l = WaylandLayout::default(); + l.reset_baseline(upright.clone()); + l.observe(&upright); + l.reset_baseline(upright.clone()); + l.reset_baseline(upright.clone()); + assert!(!l.edge(&upright, false, 0)); + } + + // rustdesk#15886: `ensure_inited()` runs the wayland query BEFORE the capturer exists, and a + // failure there saves an EMPTY baseline. The capturer's own retry can succeed a moment later + // and build on layout A, and that build is not blind, so nothing else records it. A rotation + // before the first poll then had no memory to be an edge against. + #[test] + fn a_capturer_built_after_a_failed_init_still_owes_a_rebuild() { + let upright = layout(1920, 1080, 0); + let rotated = layout(1080, 1920, 1); + + let mut l = WaylandLayout::default(); + l.reset_baseline(Vec::new()); + l.note_capturer(&upright, 0); + assert!(l.edge(&rotated, false, 0)); + + // The same with another baseline reset between the build and the poll. + let mut l2 = WaylandLayout::default(); + l2.reset_baseline(Vec::new()); + l2.note_capturer(&upright, 0); + l2.reset_baseline(rotated.clone()); + assert!(l2.edge(&rotated, false, 0)); + + // Control: no rotation, no edge, in both shapes. + let mut l3 = WaylandLayout::default(); + l3.reset_baseline(Vec::new()); + l3.note_capturer(&upright, 0); + assert!(!l3.edge(&upright, false, 0)); + } + + // A capturer built while the poll already has a memory must not overwrite it. + #[test] + fn a_later_capturer_does_not_overwrite_the_polls_memory() { + let upright = layout(1920, 1080, 0); + let rotated = layout(1080, 1920, 1); + let mut l = WaylandLayout::default(); + l.observe(&upright); + l.note_capturer(&rotated, 0); + assert!(l.edge(&rotated, false, 0), "the poll's memory still says upright"); + } + + // The constructor's snapshot read and its `note_capturer` are two steps, and the poll can + // land between them. After a failed init (empty baseline) the constructor takes A and + // publishes it; the output rotates; the poll reads B live, finds nothing recorded and the + // snapshot present, so no edge, and observes B. The late `note_capturer(A)` then met a + // non-empty memory and was dropped: the capturer showed A while the detector held B, and B + // against B never bumped the generation. + #[test] + fn a_capturer_record_that_lost_the_race_with_the_first_poll_is_still_an_edge() { + let upright = layout(1920, 1080, 0); + let rotated = layout(1080, 1920, 1); + let mut l = WaylandLayout::default(); + l.reset_baseline(Vec::new()); + assert!(!l.edge(&rotated, false, 0), "nothing recorded and the snapshot is present"); + l.observe(&rotated); + l.note_capturer(&upright, 0); + assert!(l.edge(&rotated, false, 0), "the capturer is built on upright, live is rotated"); + + // The promotion consumes it: the next poll sees the same layout and stays quiet. + l.observe(&rotated); + l.reset_baseline(rotated.clone()); + assert!(!l.edge(&rotated, false, 0)); + + // The same with a session init between the late record and the poll. + let mut l2 = WaylandLayout::default(); + l2.reset_baseline(Vec::new()); + l2.observe(&rotated); + l2.note_capturer(&upright, 0); + l2.reset_baseline(rotated.clone()); + assert!(l2.edge(&rotated, false, 0)); + + // Control: a late record that agrees with the poll's memory is not an edge. + let mut l3 = WaylandLayout::default(); + l3.reset_baseline(Vec::new()); + l3.observe(&upright); + l3.note_capturer(&upright, 0); + assert!(!l3.edge(&upright, false, 0)); + } + + // The late record can also land after the poll consumed the edge but before the bump that + // edge promotes, or after the bump with a snapshot taken before it. That capturer is stale + // by generation and rebuilds on its own, so its record must not buy a second promotion + // that tears the freshly rebuilt capturers down again. + #[test] + fn a_late_record_from_a_generation_already_promoted_is_not_a_second_edge() { + let upright = layout(1920, 1080, 0); + let rotated = layout(1080, 1920, 1); + let mut l = WaylandLayout::default(); + l.reset_baseline(upright.clone()); + l.observe(&upright); + // The output rotates, the poll consumes the edge, the capturer built on upright at + // generation 7 records late, and the poll promotes to 8. + assert!(l.edge(&rotated, false, 7)); + l.observe(&rotated); + l.note_capturer(&upright, 7); + l.reset_baseline(rotated.clone()); + assert!(!l.edge(&rotated, false, 8), "the capturer built at 7 rebuilds on its own"); + + // Control: a disagreeing record AT the promoted generation is a real edge. + l.observe(&rotated); + l.note_capturer(&upright, 8); + assert!(l.edge(&rotated, false, 8)); + + // A stale record landing after a fresh one must not hide the fresh one. + l.observe(&rotated); + l.note_capturer(&upright, 8); + l.note_capturer(&upright, 7); + assert!(l.edge(&rotated, false, 8)); + } + + // A promotion consumes the edge: the next poll sees the same layout and must stay quiet. + #[test] + fn a_promoted_layout_is_not_an_edge_again() { + let rotated = layout(1080, 1920, 1); + let mut l = WaylandLayout::default(); + l.reset_baseline(layout(1920, 1080, 0)); + l.observe(&rotated); + l.reset_baseline(rotated.clone()); + assert!(!l.edge(&rotated, false, 0)); + } +} diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs index fb7719b801a..e858a9e5669 100644 --- a/src/server/drm_capturer.rs +++ b/src/server/drm_capturer.rs @@ -52,9 +52,17 @@ impl FrameSlot { } } +/// `Shared.transform` before new() stores the real value: a cursor arriving this early is held +/// back and replayed once the session transform is in, because the producer will not resend it +/// until the shape changes. +const TRANSFORM_PENDING: i32 = i32::MIN; + struct Shared { slot: Mutex, cv: Condvar, + // Session transform, TRANSFORM_PENDING until new() stores it post-handshake; the receive + // thread turns cursor bitmaps with it and defers any cursor that races the store. + transform: std::sync::atomic::AtomicI32, } pub struct IpcDrmCapturer { @@ -63,7 +71,14 @@ pub struct IpcDrmCapturer { display: i32, connector: Option, // What the encoder was sized from: CapturerInfo{width,height} is read once, at build time. + // With a rotated output these are the ROTATED dimensions, matching the frames delivered. session_size: Option<(usize, usize)>, + // Output rotation in degrees: a rotated scanout holds the desktop drawn sideways, so frames + // are turned back before delivery. Fixed per session; a rotation rebuilds the capturer. + transform: i32, + // The wayland snapshot generation this session was built from: a later invalidation means + // the layout (a rotation included) may have changed, and frame() asks for a rebuild. + snapshot_gen: u64, cur: Vec, cur_w: usize, cur_h: usize, @@ -76,6 +91,102 @@ fn connector_key(d: &DrmDisplayInfo) -> String { format!("{}:{}", d.device, d.name) } +/// Frame dimensions after undoing `transform` degrees of output rotation. +fn rotated_dims(transform: i32, w: usize, h: usize) -> (usize, usize) { + if transform == 90 || transform == 270 { + (h, w) + } else { + (w, h) + } +} + +/// Hotspot of a rotated cursor bitmap: the same point mapping `unrotate_bgra` applies to +/// pixels, applied to the one coordinate that must keep naming the click point. +fn unrotate_hotspot(transform: i32, w: i32, h: i32, hotx: i32, hoty: i32) -> (i32, i32) { + match transform { + 90 => (h - 1 - hoty, hotx), + 180 => (w - 1 - hotx, h - 1 - hoty), + 270 => (hoty, w - 1 - hotx), + _ => (hotx, hoty), + } +} + +/// Turn a 4-byte-pixel frame upright into tightly packed `dst`, undoing `transform` degrees; +/// padded `src` rows ok (stride = len/h). Direction pinned by the tests to the measured anchor +/// of rustdesk#15886; libyuv walks pixels, so channel order does not matter. +fn unrotate_bgra(src: &[u8], w: usize, h: usize, transform: i32, dst: &mut Vec) { + const PX: usize = 4; + let stride = if h > 0 { src.len() / h } else { 0 }; + let (dw, dh) = rotated_dims(transform, w, h); + dst.resize( + dw.checked_mul(dh).and_then(|p| p.checked_mul(PX)).unwrap_or(0), + 0, + ); + if dst.is_empty() || stride < w * PX { + log::error!("unrotate: rejected geometry {w}x{h} stride {stride}; frame left blank"); + return; + } + let mode = match transform { + 90 => scrap::RotationMode::kRotate90, + 180 => scrap::RotationMode::kRotate180, + 270 => scrap::RotationMode::kRotate270, + _ => scrap::RotationMode::kRotate0, + }; + unsafe { + scrap::ARGBRotate( + src.as_ptr(), + stride as i32, + dst.as_mut_ptr(), + (dw * PX) as i32, + w as i32, + h as i32, + mode, + ); + } +} + +/// Transform and augmented origin for one wire entry, derived from ONE wayland snapshot so both +/// reflect the same output assignment; two `get_displays()` reads could straddle a cache +/// invalidation. `None` origin means nothing to augment with (caller keeps the DRM origin). +fn transform_and_origin( + drm: &[DrmDisplayInfo], + wire_idx: usize, + wl: &scrap::wayland::display::Displays, +) -> (i32, Option<(i32, i32)>) { + if wl.displays.is_empty() || (wl.displays.len() == 1 && drm.len() > 1) { + if wl.displays.is_empty() && !drm.is_empty() { + // A later successful enumeration refills the cache and hides this state from + // wayland_snapshot_missing, so the layout poll needs this durable record to know a + // capturer was built blind and owes a rebuild. + UNROTATED_SNAPSHOT_PENDING.store(true, Ordering::Release); + log::warn!( + "drm: no wayland snapshot at capturer build for display {:?}; assuming unrotated", + drm.get(wire_idx).map(|d| d.name.as_str()).unwrap_or("?") + ); + } + return (0, None); + } + let assignment = assign_wayland_outputs(drm, &wl.displays); + // The transform comes ONLY from an identity match (name, or unique resolution), through the + // SAME progressive-taken pass the advertise side keys its swap off: the layout-order + // fallback is fine for an origin guess, but a rotation pinned on a guess splits the + // advertised dimensions from the delivered ones. + let transform = identity_matches(drm, &wl.displays) + .get(wire_idx) + .copied() + .flatten() + .map(|j| wl.displays[j].transform) + // Hardware-rotated 180 scans out already upright (i915 advertises rotate-180 and + // mutter uses it), and wl_output cannot tell hardware from software rotation, so 180 + // keeps master behavior until the plane rotation property travels the wire. + .map(|t| if t == 90 || t == 270 { t } else { 0 }) + .unwrap_or(0); + let origin = augment_with_wayland_geometry_from(drm, wl, &assignment) + .get(wire_idx) + .map(|di| (di.x, di.y)); + (transform, origin) +} + /// Takes DRM_STATE: never call it while holding one of the per-display maps below. fn display_info_of(display: i32) -> Option { match &*DRM_STATE.lock().unwrap() { @@ -96,6 +207,9 @@ struct DisplayHealth { /// The dma-buf convert failed for this display. The COMMON cause is multi-GPU: our render node /// is not the GPU that exported the scanout. Follows the monitor for the process run. prefer_cpu: bool, + /// The PipeWire fallback for this display was rejected on geometry (a transposed stream), so + /// the lone-display carve-out in `mark_demoted_displays` must not keep advertising it online. + fallback_rejected: bool, } impl DisplayHealth { @@ -107,6 +221,7 @@ impl DisplayHealth { last_build: None, rapid_builds: 0, prefer_cpu: false, + fallback_rejected: false, } } @@ -185,6 +300,14 @@ fn render_node_count() -> usize { } static UINPUT_REFRESH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +/// A capturer was built with no wayland snapshot and runs unrotated; the layout poll consumes +/// this to bump the generation once a live snapshot exists. +static UNROTATED_SNAPSHOT_PENDING: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +pub(super) fn take_unrotated_snapshot_pending() -> bool { + UNROTATED_SNAPSHOT_PENDING.swap(false, std::sync::atomic::Ordering::AcqRel) +} static UINPUT_REFRESH_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); impl IpcDrmCapturer { @@ -193,7 +316,7 @@ impl IpcDrmCapturer { pub fn new( display: i32, expected: Option, - ) -> ResultType<(IpcDrmCapturer, Vec, usize)> { + ) -> ResultType<(IpcDrmCapturer, Vec, usize, Option<(i32, i32)>)> { let shared = Arc::new(Shared { slot: Mutex::new(FrameSlot { latest: None, @@ -201,6 +324,7 @@ impl IpcDrmCapturer { ended: None, }), cv: Condvar::new(), + transform: std::sync::atomic::AtomicI32::new(TRANSFORM_PENDING), }); let stop = Arc::new(AtomicBool::new(false)); let (tx, rx) = std::sync::mpsc::channel::, usize)>>(); @@ -220,6 +344,18 @@ impl IpcDrmCapturer { bail!("drm capture handshake timed out"); } }; + // One snapshot for the session: transform, origin and the advertised swap must all + // reflect the same output assignment. The generation is read BEFORE the snapshot, so a + // clear racing the build rebuilds once instead of running a session on stale geometry. + let snapshot_gen = scrap::wayland::display::wayland_snapshot_generation(); + let wl = scrap::wayland::display::get_displays(); + let (transform, origin) = transform_and_origin(&displays, wire_idx, &wl); + // This capturer now shows that layout. If the session init's own wayland query failed it + // saved an empty baseline, so this is the only record of what the stream is built on. + super::display_service::note_capturer_layout(&wl.displays, snapshot_gen); + shared + .transform + .store(transform, std::sync::atomic::Ordering::Release); Ok(( IpcDrmCapturer { shared, @@ -228,7 +364,9 @@ impl IpcDrmCapturer { connector: displays.get(wire_idx).map(connector_key), session_size: displays .get(wire_idx) - .map(|d| (d.width as usize, d.height as usize)), + .map(|d| rotated_dims(transform, d.width as usize, d.height as usize)), + transform, + snapshot_gen, cur: Vec::new(), cur_w: 0, cur_h: 0, @@ -237,6 +375,7 @@ impl IpcDrmCapturer { }, displays, wire_idx, + origin, )) } @@ -294,10 +433,21 @@ impl TraitCapturer for IpcDrmCapturer { } if let Some((w, h, fmt, buf)) = slot.latest.take() { drop(slot); - // convert_to_yuv only refuses a source LARGER than its destination, so a smaller - // frame leaves stale edges on screen. On the FIRST frame nothing changed: the list - // carries the CRTC mode, a frame the scanout fb, different when a CRTC scales. - if self.session_size.is_some_and(|(sw, sh)| (w, h) != (sw, sh)) { + // A layout change bumps the generation and is otherwise invisible here (mode + // and framebuffer keep their size). Rebuild for the new transform; not counted + // against health: the layout moved, the display did not fail. + if scrap::wayland::display::wayland_snapshot_generation() != self.snapshot_gen { + self.shared.slot.lock().unwrap().recycle(buf); + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drm: display {} layout changed; rebuilding", self.display), + )); + } + // Frames arrive in scanout orientation, the session was sized rotated, so the + // guard compares rotated dims. convert_to_yuv only refuses a LARGER source (a + // smaller one leaves stale edges); first frame: CRTC mode vs scanout fb. + let (fw, fh) = rotated_dims(self.transform, w, h); + if self.session_size.is_some_and(|(sw, sh)| (fw, fh) != (sw, sh)) { self.shared.slot.lock().unwrap().recycle(buf); if !self.got_frame { self.note_session_without_frame(); @@ -311,15 +461,35 @@ impl TraitCapturer for IpcDrmCapturer { return Err(io::Error::new( io::ErrorKind::Other, format!( - "drm: display {} {what} ({sw}x{sh} -> {w}x{h}); rebuilding", + "drm: display {} {what} ({sw}x{sh} -> {fw}x{fh}); rebuilding", self.display ), )); } - let previous = std::mem::replace(&mut self.cur, buf); - self.shared.slot.lock().unwrap().recycle(previous); - self.cur_w = w; - self.cur_h = h; + if self.transform == 0 { + let previous = std::mem::replace(&mut self.cur, buf); + self.shared.slot.lock().unwrap().recycle(previous); + } else if !matches!(fmt, Pixfmt::BGRA | Pixfmt::RGBA) { + // Unreachable with today's producers (the convert path emits 4-byte pixels + // and the CPU path hardcodes BGRA); kept so a future non-4-byte producer + // fails the session instead of shearing the image. + self.shared.slot.lock().unwrap().recycle(buf); + if !self.got_frame { + self.note_session_without_frame(); + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "drm: display {} delivered {fmt:?} on a rotated output; rebuilding", + self.display + ), + )); + } else { + unrotate_bgra(&buf, w, h, self.transform, &mut self.cur); + self.shared.slot.lock().unwrap().recycle(buf); + } + self.cur_w = fw; + self.cur_h = fh; self.cur_fmt = fmt; if !self.got_frame { // Clear ONLY the streak: `rapid_builds` is for a display that delivers a first @@ -330,6 +500,7 @@ impl TraitCapturer for IpcDrmCapturer { h.zero_frame_streak = 0; h.demotes = 0; h.since = Instant::now(); + h.fallback_rejected = false; } } } @@ -460,10 +631,21 @@ async fn recv_thread( } let _ = tx.send(Ok((displays, wire_idx))); + // A cursor that arrived before new() stored the session transform, held for replay. Only the + // newest matters; the 200 ms recv timeout guarantees this is retried even on an idle wire. + let mut pending_cursor: Option<(u64, u32, u32, i32, i32, Vec)> = None; let end_reason = loop { if stop.load(Ordering::SeqCst) { break "stopped".to_owned(); } + if pending_cursor.is_some() { + let t = shared.transform.load(std::sync::atomic::Ordering::Acquire); + if t != TRANSFORM_PENDING { + if let Some((id, width, height, hotx, hoty, raw)) = pending_cursor.take() { + deliver_drm_cursor(display, cursor_epoch, id, width, height, hotx, hoty, raw, t); + } + } + } let (msg, recv_fd) = match conn.recv_msg_timeout2(200).await { None => continue, // timeout: re-check stop at the loop top Some(Ok(pair)) => pair, @@ -580,18 +762,23 @@ async fn recv_thread( raw.len() ); } - set_drm_cursor( - display, - cursor_epoch, - DrmCursorData { + let t = shared.transform.load(std::sync::atomic::Ordering::Acquire); + if t == TRANSFORM_PENDING { + pending_cursor = Some((id, width, height, hotx, hoty, raw)); + } else { + pending_cursor = None; + deliver_drm_cursor( + display, + cursor_epoch, id, - width: width as i32, - height: height as i32, + width, + height, hotx, hoty, - colors: raw, - }, - ); + raw, + t, + ); + } } Ok(Err(err)) => break format!("cursor body: {err}"), } @@ -717,6 +904,56 @@ fn remove_drm_cursor(display: i32, epoch: u64) { } } +/// Unrotate a wire cursor into the session orientation and publish it. The compositor +/// pre-rotates the bitmap it programs into the cursor plane, so over the unrotated video the +/// cursor alone would stay turned and its hotspot transposed (review finding 11 on +/// rustdesk#15889). The wire id hashes only the plane pixels and geometry, so a stream rebuilt +/// under a new transform resends the SAME id and the client's by-id cursor cache would keep the +/// old orientation: fold the transform in (the producer's own FNV step) so id and orientation +/// can never disagree. The hidden sentinel must survive untouched. +#[allow(clippy::too_many_arguments)] +fn deliver_drm_cursor( + display: i32, + cursor_epoch: u64, + id: u64, + width: u32, + height: u32, + hotx: i32, + hoty: i32, + raw: Vec, + t: i32, +) { + let (width, height, hotx, hoty, colors) = if t == 90 || t == 270 { + let mut turned = Vec::new(); + unrotate_bgra(&raw, width as usize, height as usize, t, &mut turned); + let (hx, hy) = unrotate_hotspot(t, width as i32, height as i32, hotx, hoty); + (height as i32, width as i32, hx, hy, turned) + } else { + (width as i32, height as i32, hotx, hoty, raw) + }; + let id = fold_cursor_id(id, t); + set_drm_cursor( + display, + cursor_epoch, + DrmCursorData { + id, + width, + height, + hotx, + hoty, + colors, + }, + ); +} + +fn fold_cursor_id(id: u64, t: i32) -> u64 { + if id == scrap::drm_reader::HIDDEN_CURSOR_ID { + id + } else { + (id ^ t as u32 as u64).wrapping_mul(1099511628211) + } +} + fn with_drm_cursor(f: impl Fn(&DrmCursorData) -> T) -> Option { let map = DRM_CURSOR.lock().unwrap(); map.values() @@ -1183,12 +1420,22 @@ pub(super) fn display_count_and_any_demoted() -> Option<(usize, bool)> { } // A multi-display portal stream cannot replace one demoted connector. Keep its index but mark it -// offline; a single connector remains usable through the whole-desktop fallback. +// offline; a single connector remains usable through the whole-desktop fallback - unless that +// fallback itself was rejected on geometry, in which case advertising the lone display online +// would restart-loop the video service against a stream nothing can serve. fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) { + let health = DRM_DISPLAY_HEALTH.lock().unwrap(); if list.len() <= 1 { + if let (Some(display), Some(info)) = (list.first(), infos.first_mut()) { + if health + .get(&connector_key(display)) + .is_some_and(|health| health.demoted() && health.fallback_rejected) + { + info.online = false; + } + } return; } - let health = DRM_DISPLAY_HEALTH.lock().unwrap(); for (display, info) in list.iter().zip(infos.iter_mut()) { if health .get(&connector_key(display)) @@ -1199,6 +1446,21 @@ fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) { } } +/// The PipeWire fallback for this display was rejected on geometry; recorded so the lone-display +/// carve-out above stops advertising a display nothing can serve. Cleared by a delivered frame +/// and by the demote-cooldown re-arm. +pub(super) fn mark_fallback_rejected(display_idx: usize) { + let Some(expected) = display_info_of(display_idx as i32) else { + return; + }; + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .entry(connector_key(&expected)) + .or_insert_with(DisplayHealth::new) + .fallback_rejected = true; +} + fn primary_index_from_assignment(assignment: &[Option], primary: usize) -> usize { assignment .iter() @@ -1266,18 +1528,36 @@ fn augment_with_wayland_geometry_from( if origin_only && drm.len() > 1 { return infos; } + let identity = identity_matches(drm, &wl.displays); for (i, info) in infos.iter_mut().enumerate() { let Some(w) = matched[i].map(|j| &wl.displays[j]) else { continue; }; info.x = w.x; info.y = w.y; + // Rotated size before the origin-only cut: a lone rotated output still delivers rotated + // frames, so it must advertise them; only the logical-scale adoption stays multi-output. + // original_resolution follows in the same motion, or the client reads the transposed + // current size against an untransposed original as a third-party resolution change. + // Identity matches ONLY, the same rule the capturer's transform follows: swapping on a + // layout-order guess advertises dimensions the capturer will not deliver. + let is_identity = identity[i].is_some() && identity[i] == matched[i]; + if is_identity && (w.transform == 90 || w.transform == 270) { + std::mem::swap(&mut info.width, &mut info.height); + info.original_resolution = super::display_service::get_original_resolution( + &drm[i].name, + info.width as usize, + info.height as usize, + ); + } if origin_only { continue; } if let Some((lw, lh)) = w.logical_size { if lw > 0 && lh > 0 { - info.scale = drm[i].width as f64 / lw as f64; + // Post-swap width over logical width, which arrives already swapped when rotated: + // the unrotated numerator made a rotated 1:1 monitor advertise scale 16/9. + info.scale = info.width as f64 / lw as f64; info.original_resolution = super::display_service::get_original_resolution( &drm[i].name, lw as usize, @@ -1292,18 +1572,62 @@ fn augment_with_wayland_geometry_from( /// Each output goes to at most one connector; unmatched ones take the next free output of the same /// size, else the next free one in layout order, since leaving them unaugmented keeps them all at /// DRM's (0,0). -fn assign_wayland_outputs( +/// The identity half of the assignment (name, or unique resolution), same progressive `taken` +/// as the full one. Rotation keys off THIS on both sides: swapping or turning on a layout-order +/// guess splits the advertised dimensions from the delivered frames. +/// Identity assignment in two GLOBAL passes: every exact name match is reserved first, then +/// resolution pairing runs on the unmatched remainder, and only when it is forced - exactly one +/// free output AND exactly one unmatched connector at that resolution. A resolution guess for an +/// earlier connector must never steal an exact name match from a later one. +fn identity_matches( drm: &[DrmDisplayInfo], wl: &[hbb_common::platform::linux::WaylandDisplayInfo], ) -> Vec> { let mut taken = vec![false; wl.len()]; let mut matched: Vec> = vec![None; drm.len()]; for (i, d) in drm.iter().enumerate() { - if let Some(j) = match_wayland_display(d, wl, &taken) { + let dn = normalize_connector(&d.name); + if let Some((j, _)) = wl + .iter() + .enumerate() + .find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn) + { matched[i] = Some(j); taken[j] = true; } } + for (i, d) in drm.iter().enumerate() { + if matched[i].is_some() { + continue; + } + let free_same: Vec = wl + .iter() + .enumerate() + .filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32) + .map(|(j, _)| j) + .collect(); + let unmatched_same = drm + .iter() + .enumerate() + .filter(|(k, o)| matched[*k].is_none() && o.width == d.width && o.height == d.height) + .count(); + if free_same.len() == 1 && unmatched_same == 1 { + matched[i] = Some(free_same[0]); + taken[free_same[0]] = true; + } + } + matched +} + +fn assign_wayland_outputs( + drm: &[DrmDisplayInfo], + wl: &[hbb_common::platform::linux::WaylandDisplayInfo], +) -> Vec> { + let mut matched = identity_matches(drm, wl); + let mut taken = vec![false; wl.len()]; + for m in matched.iter().flatten() { + taken[*m] = true; + } for (i, d) in drm.iter().enumerate() { if matched[i].is_some() { continue; @@ -1329,30 +1653,6 @@ fn assign_wayland_outputs( matched } -fn match_wayland_display( - d: &DrmDisplayInfo, - wl: &[hbb_common::platform::linux::WaylandDisplayInfo], - taken: &[bool], -) -> Option { - let dn = normalize_connector(&d.name); - if let Some((j, _)) = wl - .iter() - .enumerate() - .find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn) - { - return Some(j); - } - let same_res: Vec = wl - .iter() - .enumerate() - .filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32) - .map(|(j, _)| j) - .collect(); - if same_res.len() == 1 { - return Some(same_res[0]); - } - None -} /// DRM inserts a single-letter type discriminator the compositor drops ("HDMI-A-1" -> "HDMI-1"). /// Only a *letter* folds: a single *digit* is an MST port index, so "DP-1-2" is not "DP-2". @@ -1413,11 +1713,13 @@ pub(super) fn get_capturer_info( } h.zero_frame_streak = 0; h.since = Instant::now(); + // The cooldown re-arms DRM for this display, so the fallback verdict restarts too. + h.fallback_rejected = false; } } } // Built FIRST: a transient `_drm` outage must NOT count toward the flap threshold below. - let (capturer, displays, wire_idx) = IpcDrmCapturer::new(display_idx as i32, expected)?; + let (capturer, displays, wire_idx, origin) = IpcDrmCapturer::new(display_idx as i32, expected)?; // The initial build counts 0, so demotion fires on the (RAPID_REBUILD_MAX + 1)-th in a window. if let Some(key) = key.clone() { let now = Instant::now(); @@ -1445,16 +1747,14 @@ pub(super) fn get_capturer_info( .get(wire_idx) .ok_or_else(|| anyhow!("drm display index {wire_idx} out of range ({ndisplay})"))? .clone(); - // Publish the compositor's LOGICAL origin (what get_display_infos advertises) so the origin - // matches the reported geometry; KEEP the raw PHYSICAL dimensions for the capture buffer. - let origin = augment_with_wayland_geometry(&displays) - .get(wire_idx) - .map(|di| (di.x, di.y)) - .unwrap_or((d.x, d.y)); + // Origin and transform come from the ONE snapshot new() resolved, so both reflect the + // same output assignment; dimensions stay PHYSICAL, rotated to frame orientation. + let origin = origin.unwrap_or((d.x, d.y)); + let (cap_w, cap_h) = rotated_dims(capturer.transform, d.width as usize, d.height as usize); Ok(super::video_service::CapturerInfo { origin, - width: d.width as usize, - height: d.height as usize, + width: cap_w, + height: cap_h, ndisplay, current: display_idx, privacy_mode_id: 0, @@ -1482,11 +1782,14 @@ mod drm_capturer_tests { ended: None, }), cv: Condvar::new(), + transform: std::sync::atomic::AtomicI32::new(0), }), stop: Arc::new(AtomicBool::new(false)), display: 0, connector, session_size: session, + transform: 0, + snapshot_gen: scrap::wayland::display::wayland_snapshot_generation(), cur: Vec::new(), cur_w: 0, cur_h: 0, @@ -1495,6 +1798,172 @@ mod drm_capturer_tests { } } + /// One BGRA pixel per label byte, so a rotation result reads as a matrix of labels. + fn px_frame(labels: &[&[u8]], pad_bytes: usize) -> (Vec, usize, usize) { + let h = labels.len(); + let w = labels[0].len(); + let mut buf = Vec::new(); + for row in labels { + for &l in *row { + buf.extend_from_slice(&[l, l, l, 255]); + } + buf.extend(std::iter::repeat(0u8).take(pad_bytes)); + } + (buf, w, h) + } + + fn labels_of(buf: &[u8], w: usize, h: usize) -> Vec> { + (0..h) + .map(|y| (0..w).map(|x| buf[(y * w + x) * 4]).collect()) + .collect() + } + + #[test] + fn a_lone_display_goes_offline_only_when_its_fallback_was_rejected() { + // Unique name = unique health key; DRM_DISPLAY_HEALTH is process-wide. + let list = vec![drm_display("TEST-lone-fallback", 1080, 1920)]; + let key = connector_key(&list[0]); + let demoted = DisplayHealth { + zero_frame_streak: DRM_GRAB_MAX_FAILURES, + demotes: 1, + ..DisplayHealth::new() + }; + // Demoted alone keeps the lone display online: the whole-desktop fallback is usable. + DRM_DISPLAY_HEALTH.lock().unwrap().insert(key.clone(), demoted); + let mut infos = vec![DisplayInfo { + online: true, + ..Default::default() + }]; + mark_demoted_displays(&list, &mut infos); + assert!(infos[0].online, "the lone-display carve-out must survive"); + // A rejected fallback ends the carve-out: advertising online would restart-loop. + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .get_mut(&key) + .expect("just inserted") + .fallback_rejected = true; + mark_demoted_displays(&list, &mut infos); + assert!(!infos[0].online, "a rejected fallback must take the lone display offline"); + // Once the demotion cooldown lapses the display is no longer demoted, and online returns + // even with the rejection still latched (the re-arm will clear it on the next build). + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .get_mut(&key) + .expect("still there") + .since = Instant::now() - demote_cooldown(1) - Duration::from_secs(1); + infos[0].online = true; + mark_demoted_displays(&list, &mut infos); + assert!(infos[0].online, "past the cooldown the verdict is DRM's to retry"); + } + + #[test] + fn the_cursor_id_names_the_orientation_too() { + // Same wire cursor under two transforms must publish as two ids, or the client's by-id + // cache serves the previous orientation after a mid-session rotation. + let wire = 0xDEAD_BEEF_u64; + assert_ne!(fold_cursor_id(wire, 0), fold_cursor_id(wire, 90)); + assert_ne!(fold_cursor_id(wire, 90), fold_cursor_id(wire, 270)); + // Deterministic per (id, transform), so an unchanged cursor is still deduped. + assert_eq!(fold_cursor_id(wire, 90), fold_cursor_id(wire, 90)); + // The hidden sentinel is compared by VALUE at the consumers, so it must pass unfolded. + let hidden = scrap::drm_reader::HIDDEN_CURSOR_ID; + assert_eq!(fold_cursor_id(hidden, 90), hidden); + } + + #[test] + fn unrotate_hotspot_follows_the_pixel_mapping() { + // 3 wide x 2 tall, hotspot at (2,0) (top-right): after the 90 turn (left column to top + // row) that pixel sits at (1,2) in the 2x3 result; 270 sends it to (0,0). + assert_eq!(unrotate_hotspot(90, 3, 2, 2, 0), (1, 2)); + assert_eq!(unrotate_hotspot(270, 3, 2, 2, 0), (0, 0)); + assert_eq!(unrotate_hotspot(180, 3, 2, 2, 0), (0, 1)); + assert_eq!(unrotate_hotspot(0, 3, 2, 2, 0), (2, 0)); + } + + #[test] + fn a_stale_snapshot_generation_asks_for_a_rebuild_without_blaming_the_display() { + let mut c = capturer_named(Some((64, 32)), Some("test:gen-rebuild")); + c.snapshot_gen = c.snapshot_gen.wrapping_sub(1); + put_frame(&c, 64, 32); + let err = match c.frame(Duration::from_millis(50)) { + Err(e) => e, + Ok(_) => panic!("a stale generation must rebuild, not deliver"), + }; + assert!(err.to_string().contains("layout changed"), "{err}"); + assert!(!c.got_frame); + assert_eq!( + zero_frame_streak_of(&c), + 0, + "a layout rebuild must not count against display health" + ); + } + + #[test] + fn unrotate_90_maps_the_left_column_to_the_top_row() { + // The measured anchor from rustdesk#15886: mutter transform=1 carries the panel bar down + // the scanout's LEFT edge, and upright means that edge becomes the TOP row. + let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0); + let mut dst = Vec::new(); + unrotate_bgra(&src, w, h, 90, &mut dst); + // src left column top-to-bottom = [1, 4]; clockwise puts it on the top row as [4, 1]. + assert_eq!(labels_of(&dst, h, w), vec![vec![4, 1], vec![5, 2], vec![6, 3]]); + } + + #[test] + fn unrotate_270_is_the_inverse_of_90() { + let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0); + let mut once = Vec::new(); + unrotate_bgra(&src, w, h, 90, &mut once); + let mut back = Vec::new(); + unrotate_bgra(&once, h, w, 270, &mut back); + assert_eq!(back, src); + } + + #[test] + fn unrotate_180_reverses_both_axes() { + let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0); + let mut dst = Vec::new(); + unrotate_bgra(&src, w, h, 180, &mut dst); + assert_eq!(labels_of(&dst, w, h), vec![vec![6, 5, 4], vec![3, 2, 1]]); + } + + #[test] + fn unrotate_reads_padded_strides_and_writes_tight() { + // Row stride is derived from len/h, so a padded source must not shear the result. + let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 8); + let mut dst = Vec::new(); + unrotate_bgra(&src, w, h, 90, &mut dst); + assert_eq!(dst.len(), w * h * 4); + assert_eq!(labels_of(&dst, h, w), vec![vec![4, 1], vec![5, 2], vec![6, 3]]); + let mut plain = Vec::new(); + unrotate_bgra(&src, w, h, 0, &mut plain); + assert_eq!(labels_of(&plain, w, h), vec![vec![1, 2, 3], vec![4, 5, 6]]); + } + + #[test] + fn a_rotated_session_delivers_rotated_frames_and_guards_in_rotated_dims() { + use scrap::TraitPixelBuffer; + let mut c = capturer_with(Some((32, 64))); // rotated session of a 64x32 scanout + c.transform = 90; + put_frame(&c, 64, 32); + match c.frame(Duration::from_millis(50)) { + Ok(Frame::PixelBuffer(pb)) => { + assert_eq!((pb.width(), pb.height()), (32, 64)); + } + Ok(_) => panic!("expected a pixel-buffer frame"), + Err(err) => panic!("expected a delivered frame, got {err}"), + } + // A scanout change still ends the session, reported in rotated dimensions. + put_frame(&c, 32, 64); + let err = match c.frame(Duration::from_millis(50)) { + Err(e) => e, + Ok(_) => panic!("a scanout change must end a rotated session too"), + }; + assert!(err.to_string().contains("(32x64 -> 64x32)"), "{err}"); + } + fn zero_frame_streak_of(c: &IpcDrmCapturer) -> u32 { let key = c.connector.clone().expect("this check needs an identity"); DRM_DISPLAY_HEALTH @@ -1525,6 +1994,7 @@ mod drm_capturer_tests { h.rapid_builds = 3; h.last_build = Some(Instant::now()); h.prefer_cpu = true; + h.fallback_rejected = true; } put_frame(&c, 64, 32); assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); @@ -1537,6 +2007,10 @@ mod drm_capturer_tests { }; assert_eq!(h.zero_frame_streak, 0, "a delivered frame refutes the zero-frame streak"); assert_eq!(h.demotes, 0, "and the demotion count that streak drove"); + assert!( + !h.fallback_rejected, + "a delivered frame also refutes the rejected-fallback verdict" + ); assert_eq!( h.rapid_builds, 3, "but it says NOTHING about the rebuild cadence: keeping it is what lets the flap guard \ @@ -1642,9 +2116,53 @@ mod drm_capturer_tests { height: h, logical_size: Some((w, h)), refresh_rate: 60, + transform: 0, } } + #[test] + fn a_lone_rotated_output_advertises_delivered_dimensions() { + // Fix for the origin-only cut: one connector, one rotated output. The capturer will + // deliver rotated frames, so the advertised size must swap even in the origin-only case, + // while the logical scale is still not adopted (stays 1.0). + let drm = [drm_display("HDMI-A-1", 1920, 1080)]; + let mut out = wl_display("HDMI-1", 0, 0, 1920, 1080); + out.transform = 90; + let wl = scrap::wayland::display::Displays { + primary: 0, + displays: vec![out], + }; + let assignment = assign_wayland_outputs(&drm, &wl.displays); + let infos = augment_with_wayland_geometry_from(&drm, &wl, &assignment); + assert_eq!((infos[0].width, infos[0].height), (1080, 1920)); + assert_eq!(infos[0].scale, 1.0); + } + + #[test] + fn transform_and_origin_come_from_the_same_snapshot() { + // Both derive from ONE Displays snapshot: the rotated output's transform and its origin + // must belong to the same assignment, and the multi-connector one-output guard zeroes + // both rather than mixing a guessed origin with a real transform. + let drm = [ + drm_display("HDMI-A-1", 1920, 1080), + drm_display("DP-1", 2560, 1440), + ]; + let mut rotated = wl_display("DP-1", 1920, 0, 2560, 1440); + rotated.transform = 270; + let wl = scrap::wayland::display::Displays { + primary: 0, + displays: vec![rotated, wl_display("HDMI-1", 0, 0, 1920, 1080)], + }; + let (t, origin) = transform_and_origin(&drm, 1, &wl); + assert_eq!(t, 270); + assert_eq!(origin, Some((1920, 0))); + let lone = scrap::wayland::display::Displays { + primary: 0, + displays: vec![wl_display("HDMI-1", 0, 0, 1920, 1080)], + }; + assert_eq!(transform_and_origin(&drm, 1, &lone), (0, None)); + } + #[test] fn one_connector_assignment_drives_geometry_and_primary() { let drm = [ @@ -1725,6 +2243,32 @@ mod drm_capturer_tests { ); } + #[test] + fn a_resolution_guess_never_steals_an_exact_name_match() { + // The review's scenario: an earlier connector with an unmatchable name shares the + // resolution of a later connector's exact name match. Names reserve globally first. + let drm = vec![ + drm_display("DSI-1", 1920, 1080), + drm_display("HDMI-A-1", 1920, 1080), + ]; + let wl = vec![ + wl_display("HDMI-1", 0, 0, 1920, 1080), + wl_display("Unknown-9", 1920, 0, 2560, 1440), + ]; + let m = identity_matches(&drm, &wl); + assert_eq!(m[1], Some(0), "the exact name match must win globally"); + assert_eq!(m[0], None, "the leftover pairing is not forced, so no identity"); + // Two unmatched connectors at the lone free resolution: ambiguous on the DRM side too, + // so rotation must not be pinned on either. + let drm2 = vec![ + drm_display("DSI-1", 1920, 1080), + drm_display("DSI-2", 1920, 1080), + ]; + let wl2 = vec![wl_display("HDMI-1", 0, 0, 1920, 1080)]; + let m2 = identity_matches(&drm2, &wl2); + assert!(m2[0].is_none() && m2[1].is_none()); + } + #[test] fn outputs_are_matched_by_name_across_the_drm_naming_difference() { let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)]; diff --git a/src/server/wayland.rs b/src/server/wayland.rs index 023e9e55947..ac803369fa4 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -108,7 +108,8 @@ struct CapDisplayInfo { } /// Uinput desktop rect from the DRM display list, for a login screen where no compositor can be -/// asked. `(minx, maxx, miny, maxy)`, in scanout pixels: no compositor here applied a scale, so +/// asked. `(minx, maxx, miny, maxy)`, in delivered-orientation physical pixels (a rotated +/// output counts transposed, matching its frames): no compositor here applied a scale, so /// unlike `desktop_rect_of` there is no logical size to handle. #[cfg(feature = "drm")] fn drm_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { @@ -521,11 +522,13 @@ pub(super) fn get_capturer_for_display( // (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. + // logical. The advertised DRM geometry is physical too, in DELIVERED + // orientation: `augment_with_wayland_geometry` transposes width/height for a + // 90/270 output (rustdesk#15886). Whether the portal's caps arrive rotated + // is UNMEASURED on a rotated display (pipewiresrc does not apply + // SPA_META_VideoTransform), so the size half accepts either orientation + // rather than gambling a permanent offline on one of them. Dividing a side + // by the scale would still be wrong: logical against physical. // // 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 @@ -537,15 +540,35 @@ pub(super) fn get_capturer_for_display( // 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; + // Exact orientation only: a transposed stream would be encoded at the + // PipeWire dimensions while the client keeps the advertised (rotated) ones, + // and no wayland path ever reconciles the two, so every frame would be + // rejected client-side. Falling into the bail instead advertises the display + // offline, which the client recovers from by re-enumerating. + let size_matches = advertised.width as usize == rect.1 + && advertised.height as usize == rect.2; + let transposed = advertised.width as usize == rect.2 + && advertised.height as usize == rect.1; + // The single-display carve-out forgives a size DIFFERENCE (a Full Workspace + // stream may report the workspace, not the mode), but never a transposed + // pair: that is the same served-vs-advertised orientation split as above, + // and it blanks the client the same way. 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)); + && (size_matches || (single_display && !transposed)); if !consistent { + // Recorded so the lone-display carve-out in `mark_demoted_displays` makes + // the "advertised offline" below true for a single display too, instead of + // restart-looping against a stream nothing can serve. + super::drm_capturer::mark_fallback_rejected(display_idx); bail!( - "drm display {} demoted with no geometry-consistent PipeWire stream (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline", + "drm display {} demoted with no geometry-consistent PipeWire stream{} (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline", display_idx, + if transposed { + " - stream is transposed vs advertised" + } else { + "" + }, advertised.width, advertised.height, advertised.x, From 82aa28f129b187e05191d557300eecf760bd12a1 Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 3 Sep 2026 19:28:54 +0800 Subject: [PATCH 4/4] fix(ci): install CMake 4.3 for ARM64 vcpkg builds (#16044) Signed-off-by: fufesou --- .github/workflows/flutter-build.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index bf1b7610cf6..cf6d74fd0b7 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -43,6 +43,7 @@ env: # https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174 # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d" + VCPKG_CMAKE_VERSION: "4.3.0" ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version VERSION: "1.5.0" NDK_VERSION: "r28c" @@ -1571,6 +1572,15 @@ jobs: name: bridge-artifact path: ./ + # vcpkg 2026.07.29's SPDX scripts require CMake 4.3+, but this ARM64 runner selects CMake 3.31. + - name: Install CMake for vcpkg on Linux ARM64 + if: matrix.job.arch == 'aarch64' && env.UPLOAD_ARTIFACT == 'true' + run: | + python3 -m pip install --user "cmake==${VCPKG_CMAKE_VERSION}" + user_base="$(python3 -m site --user-base)" + "${user_base}/bin/cmake" --version + echo "${user_base}/bin" >> "${GITHUB_PATH}" + - name: Setup vcpkg with Github Actions binary cache if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true' uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11