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 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/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")] 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/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 {}", "正在下载 {}"), 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,