From be1f4e0a33d2f2fcb717a2c1c63e92ad591f53da Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 12 Sep 2026 18:30:27 +0800 Subject: [PATCH] fix(cursor): isolate optional X11 density failures --- src/platform/linux.rs | 6 +- src/platform/linux/cursor.rs | 32 ++++- src/platform/linux/cursor/x11_tests.rs | 171 +++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 src/platform/linux/cursor/x11_tests.rs diff --git a/src/platform/linux.rs b/src/platform/linux.rs index af18aef2d..7943cb345 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -607,9 +607,7 @@ pub fn get_cursor() -> ResultType> { } }); #[cfg(feature = "flutter")] - let res = res - .map(|id| cursor::x11_scale().map(|scale| cursor::cache_id(id, scale))) - .transpose()?; + let res = res.map(cursor::x11_cursor_id); Ok(res) } @@ -652,7 +650,7 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType { } } #[cfg(feature = "flutter")] - let scale = cursor::x11_scale()?; + let scale = cursor::x11_cursor_scale(); #[cfg(feature = "flutter")] let matches = |id| cursor::cache_id(id, scale) == hcursor; #[cfg(not(feature = "flutter"))] diff --git a/src/platform/linux/cursor.rs b/src/platform/linux/cursor.rs index c3ce05fb7..2a9c1b24f 100644 --- a/src/platform/linux/cursor.rs +++ b/src/platform/linux/cursor.rs @@ -1,6 +1,6 @@ -use hbb_common::{anyhow::Context, bail, ResultType}; +use hbb_common::{anyhow::Context, bail, log, ResultType}; use std::{ - cell::RefCell, + cell::{Cell, RefCell}, collections::hash_map::DefaultHasher, hash::{Hash, Hasher}, }; @@ -8,8 +8,12 @@ use x11rb::{protocol::xproto::ConnectionExt, rust_connection::RustConnection, NO mod xsettings; +#[cfg(test)] +mod x11_tests; + thread_local! { static SETTINGS: RefCell> = const { RefCell::new(None) }; + static X11_SCALE: Cell> = const { Cell::new(Some(0.0)) }; } pub(super) fn cache_id(id: u64, scale: f64) -> u64 { @@ -21,6 +25,30 @@ pub(super) fn cache_id(id: u64, scale: f64) -> u64 { hash.finish() } +pub(super) fn x11_cursor_id(id: u64) -> u64 { + let scale = X11_SCALE.with(|last| match x11_scale() { + Ok(scale) => { + last.set(Some(scale)); + scale + } + Err(err) => { + // XSETTINGS is optional; warn once per failure streak without + // turning valid XFixes cursor updates into service errors. + if last.replace(None).is_some() { + log::warn!("Failed to read XSETTINGS cursor density; using unknown density: {err}"); + } + 0.0 + } + }); + cache_id(id, scale) +} + +pub(super) fn x11_cursor_scale() -> f64 { + // The cursor service reads the ID and bitmap on the same thread. Reuse + // that poll's density even if the settings manager changes between them. + X11_SCALE.with(|last| last.get().unwrap_or(0.0)) +} + pub(super) fn x11_scale() -> ResultType { if !super::is_x11() { return Ok(0.0); diff --git a/src/platform/linux/cursor/x11_tests.rs b/src/platform/linux/cursor/x11_tests.rs new file mode 100644 index 000000000..73a949d4f --- /dev/null +++ b/src/platform/linux/cursor/x11_tests.rs @@ -0,0 +1,171 @@ +use super::super::{get_cursor, get_cursor_data}; +use hbb_common::ResultType; +use x11rb::{ + connection::Connection, protocol::xproto::*, rust_connection::RustConnection, + wrapper::ConnectionExt as _, CURRENT_TIME, +}; + +struct Settings { + connection: RustConnection, + owner: Window, + property: Atom, +} + +impl Settings { + fn new() -> ResultType { + // This test replaces the settings manager; never run on a real desktop. + assert_eq!( + std::env::var("RUSTDESK_X11_CURSOR_TEST").as_deref(), + Ok("1") + ); + let (connection, screen) = x11rb::connect(None)?; + let selection = connection + .intern_atom(false, format!("_XSETTINGS_S{screen}").as_bytes())? + .reply()? + .atom; + let property = connection + .intern_atom(false, b"_XSETTINGS_SETTINGS")? + .reply()? + .atom; + let owner = connection.generate_id()?; + connection + .create_window( + 0, + owner, + connection.setup().roots[screen].root, + 0, + 0, + 1, + 1, + 0, + WindowClass::INPUT_OUTPUT, + 0, + &CreateWindowAux::new(), + )? + .check()?; + connection + .set_selection_owner(owner, selection, CURRENT_TIME)? + .check()?; + Ok(Self { + connection, + owner, + property, + }) + } + + fn property(&self, bytes: &[u8]) { + self.connection + .change_property8( + PropMode::REPLACE, + self.owner, + self.property, + self.property, + bytes, + ) + .unwrap() + .check() + .unwrap(); + } + + fn scale(&self, value: i32) { + const ALIGNMENT: usize = 4; + let name = b"Gdk/WindowScalingFactor"; + let mut bytes = vec![0, 0, 0, 0]; + bytes.extend(1_u32.to_le_bytes()); // Serial. + bytes.extend(1_u32.to_le_bytes()); // One integer setting. + bytes.extend([0, 0]); + bytes.extend((name.len() as u16).to_le_bytes()); + bytes.extend(name); + bytes.resize(bytes.len().div_ceil(ALIGNMENT) * ALIGNMENT, 0); + bytes.extend(1_u32.to_le_bytes()); + bytes.extend(value.to_le_bytes()); + self.property(&bytes); + } + + fn arrow(&self) { + const LEFT_PTR: u16 = 68; + const WHITE: u16 = u16::MAX; + let font = self.connection.generate_id().unwrap(); + let cursor = self.connection.generate_id().unwrap(); + self.connection + .open_font(font, b"cursor") + .unwrap() + .check() + .unwrap(); + self.connection + .create_glyph_cursor( + cursor, + font, + font, + LEFT_PTR, + LEFT_PTR + 1, + 0, + 0, + 0, + WHITE, + WHITE, + WHITE, + ) + .unwrap() + .check() + .unwrap(); + self.connection + .change_window_attributes( + self.connection.setup().roots[0].root, + &ChangeWindowAttributesAux::new().cursor(cursor), + ) + .unwrap() + .check() + .unwrap(); + } +} + +fn cursor(scale: f64) -> u64 { + let id = get_cursor().unwrap().expect("Xvfb must have a cursor"); + let data = get_cursor_data(id).unwrap(); + assert_eq!((data.id, data.scale), (id, scale)); + assert!(data.width > 0 && data.height > 0 && !data.colors.is_empty()); + id +} + +#[test] +#[ignore = "requires isolated Xvfb and RUSTDESK_FORCED_DISPLAY_SERVER=x11"] +fn x11_metadata_errors_preserve_cursor_delivery_and_recover() { + let settings = Settings::new().unwrap(); + let unknown = cursor(0.0); // Selection owner exists but has no property. + settings.scale(2); + let known = cursor(2.0); + assert_ne!(known, unknown); + settings.property(&[0]); // Truncated header. + assert_eq!(cursor(0.0), unknown); + settings.scale(0); // Invalid density. + assert_eq!(cursor(0.0), unknown); + settings.scale(2); + assert_eq!(cursor(2.0), known); + settings.property(&[0]); + settings.arrow(); + assert_ne!(cursor(0.0), unknown); // New shapes still arrive during failure. +} + +#[test] +#[ignore = "requires isolated Xvfb and RUSTDESK_FORCED_DISPLAY_SERVER=x11"] +fn x11_metadata_change_between_id_and_bitmap_keeps_snapshot() { + let settings = Settings::new().unwrap(); + settings.scale(2); + let id = get_cursor().unwrap().unwrap(); + settings + .connection + .destroy_window(settings.owner) + .unwrap() + .check() + .unwrap(); + let data = get_cursor_data(id).unwrap(); + assert_eq!((data.id, data.scale), (id, 2.0)); + let unknown = get_cursor().unwrap().unwrap(); + assert_ne!(unknown, id); + let recovered = Settings::new().unwrap(); + recovered.scale(2); + let data = get_cursor_data(unknown).unwrap(); + assert_eq!((data.id, data.scale), (unknown, 0.0)); + assert_eq!(cursor(2.0), id); +}