mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-14 16:31:03 +03:00
Compare commits
3 Commits
e4539fc304
...
82aa28f129
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82aa28f129 | ||
|
|
3f93005be2 | ||
|
|
23a147b0dc |
10
.github/workflows/flutter-build.yml
vendored
10
.github/workflows/flutter-build.yml
vendored
@@ -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
|
||||
|
||||
Submodule libs/hbb_common updated: b2b1ac453d...05ed68fed8
@@ -132,7 +132,15 @@ impl Display {
|
||||
.map(Display)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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")]
|
||||
|
||||
@@ -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<DisplayRect> {
|
||||
logical_rects_of(displays)
|
||||
}
|
||||
|
||||
fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
@@ -386,9 +431,9 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
.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<DisplayRect> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,82 @@ struct WaylandUinputRect {
|
||||
struct WaylandLayout {
|
||||
baseline: Vec<scrap::wayland::display::DisplayRect>,
|
||||
live: Vec<scrap::wayland::display::DisplayRect>,
|
||||
// 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<scrap::wayland::display::DisplayRect>,
|
||||
// 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<u64>,
|
||||
}
|
||||
|
||||
#[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<scrap::wayland::display::DisplayRect>) {
|
||||
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<scrap::wayland::display::DisplayRect>) {
|
||||
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<DisplayRect> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FrameSlot>,
|
||||
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<String>,
|
||||
// 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<u8>,
|
||||
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<u8>) {
|
||||
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<DrmDisplayInfo> {
|
||||
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<DrmDisplayInfo>,
|
||||
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, usize)> {
|
||||
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, 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::<ResultType<(Vec<DrmDisplayInfo>, 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<u8>)> = 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<u8>,
|
||||
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<T>(f: impl Fn(&DrmCursorData) -> T) -> Option<T> {
|
||||
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<usize>], 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<Option<usize>> {
|
||||
let mut taken = vec![false; wl.len()];
|
||||
let mut matched: Vec<Option<usize>> = 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<usize> = 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<Option<usize>> {
|
||||
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<usize> {
|
||||
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<usize> = 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<u8>, 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<Vec<u8>> {
|
||||
(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)];
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user