From d876ea0ce4cfae81ecc59c29472a4ed4c15d49b8 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 12 Sep 2026 03:27:53 +0800 Subject: [PATCH] fix(cursor): report X11 and Wayland cursor density --- Cargo.toml | 2 +- src/platform/linux.rs | 41 +++++++- src/platform/linux/cursor.rs | 124 +++++++++++++++++++++++ src/platform/linux/cursor/xsettings.rs | 135 +++++++++++++++++++++++++ src/server/drm_capturer.rs | 32 ++++++ 5 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 src/platform/linux/cursor.rs create mode 100644 src/platform/linux/cursor/xsettings.rs diff --git a/Cargo.toml b/Cargo.toml index 8f254438e..e829d13ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ inline = [] use_samplerate = ["samplerate"] use_rubato = ["rubato"] use_dasp = ["dasp"] -flutter = ["flutter_rust_bridge", "scrap/cursor"] +flutter = ["flutter_rust_bridge", "scrap/cursor", "dep:x11rb"] default = ["use_dasp"] hwcodec = ["scrap/hwcodec"] vram = ["scrap/vram"] diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 7b52a3571..af18aef2d 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -35,6 +35,9 @@ use std::{ use terminfo::{capability as cap, Database}; use wallpaper; +#[cfg(feature = "flutter")] +mod cursor; + pub const PA_SAMPLE_RATE: u32 = 48000; static mut UNMODIFIED: bool = true; @@ -570,7 +573,12 @@ pub fn get_cursor() -> ResultType> { // polled there is a live session, which is the case the latch reads correctly. #[cfg(feature = "drm")] if !is_x11() { - if let Some(id) = crate::server::drm_capturer::drm_cursor_id() { + #[cfg(feature = "flutter")] + let cursor = cursor::drm_snapshot(|c| c.id)? + .map(|(id, scale)| cursor::cache_id(id, scale)); + #[cfg(not(feature = "flutter"))] + let cursor = crate::server::drm_capturer::drm_cursor_id(); + if let Some(id) = cursor { // In a mixed DRM + PipeWire session the DRM streams only cover the DRM-backed displays; // when the pointer sits on a PipeWire-served display every DRM stream reports the hidden // sentinel. Returning that sentinel here would hide the cursor globally, including on the @@ -598,6 +606,10 @@ pub fn get_cursor() -> ResultType> { } } }); + #[cfg(feature = "flutter")] + let res = res + .map(|id| cursor::x11_scale().map(|scale| cursor::cache_id(id, scale))) + .transpose()?; Ok(res) } @@ -610,7 +622,13 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType { // agree anyway, since a caller that took the DRM branch there has to take it here. #[cfg(feature = "drm")] if !is_x11() { - if let Some(c) = crate::server::drm_capturer::drm_cursor() { + #[cfg(feature = "flutter")] + let cursor = cursor::drm_snapshot(Clone::clone)?; + #[cfg(not(feature = "flutter"))] + let cursor = crate::server::drm_capturer::drm_cursor(); + if let Some(c) = cursor { + #[cfg(feature = "flutter")] + let (c, scale) = c; // See get_cursor(): a hidden DRM sentinel is authoritative only in a pure-DRM session. In // a mixed DRM + PipeWire session fall through so the PipeWire display's cursor is served // by the normal path instead of being hidden everywhere. @@ -624,24 +642,39 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType { cd.hotx = c.hotx; cd.hoty = c.hoty; cd.colors = c.colors.into(); + #[cfg(feature = "flutter")] + { + cd.id = cursor::cache_id(cd.id, scale); + cd.scale = scale; + } return Ok(cd); } } } + #[cfg(feature = "flutter")] + let scale = cursor::x11_scale()?; + #[cfg(feature = "flutter")] + let matches = |id| cursor::cache_id(id, scale) == hcursor; + #[cfg(not(feature = "flutter"))] + let matches = |id| id == hcursor; let mut res = None; DISPLAY.with(|conn| { if let Ok(ref mut d) = conn.try_borrow_mut() { if !d.is_null() { unsafe { let img = XFixesGetCursorImage(**d); - if !img.is_null() && hcursor == (*img).cursor_serial as u64 { + if !img.is_null() && matches((*img).cursor_serial as u64) { let mut cd: CursorData = Default::default(); cd.hotx = (*img).xhot as _; cd.hoty = (*img).yhot as _; cd.width = (*img).width as _; cd.height = (*img).height as _; // to-do: how about if it is 0 - cd.id = (*img).cursor_serial as _; + cd.id = hcursor; + #[cfg(feature = "flutter")] + { + cd.scale = scale; + } let pixels = std::slice::from_raw_parts((*img).pixels, (cd.width * cd.height) as _); // cd.colors.resize(pixels.len() * 4, 0); diff --git a/src/platform/linux/cursor.rs b/src/platform/linux/cursor.rs new file mode 100644 index 000000000..c3ce05fb7 --- /dev/null +++ b/src/platform/linux/cursor.rs @@ -0,0 +1,124 @@ +use hbb_common::{anyhow::Context, bail, ResultType}; +use std::{ + cell::RefCell, + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, +}; +use x11rb::{protocol::xproto::ConnectionExt, rust_connection::RustConnection, NONE}; + +mod xsettings; + +thread_local! { + static SETTINGS: RefCell> = const { RefCell::new(None) }; +} + +pub(super) fn cache_id(id: u64, scale: f64) -> u64 { + if scale == 0.0 { + return id; + } + let mut hash = DefaultHasher::new(); + (id, scale.to_bits()).hash(&mut hash); + hash.finish() +} + +pub(super) fn x11_scale() -> ResultType { + if !super::is_x11() { + return Ok(0.0); + } + SETTINGS.with(|settings| { + let mut state = settings.try_borrow_mut()?; + if state.is_none() { + *state = Some(x11rb::connect(None)?); + } + let (connection, screen) = state.as_ref().context("Missing XSETTINGS connection")?; + let result = read_settings(connection, *screen); + if result.is_err() { + *state = None; + } + result + }) +} + +fn read_settings(connection: &RustConnection, screen: usize) -> ResultType { + let selection = connection + .intern_atom(true, format!("_XSETTINGS_S{screen}").as_bytes())? + .reply()? + .atom; + if selection == NONE { + return Ok(0.0); + } + let owner = connection.get_selection_owner(selection)?.reply()?.owner; + if owner == NONE { + return Ok(0.0); + } + let property = connection + .intern_atom(true, b"_XSETTINGS_SETTINGS")? + .reply()? + .atom; + let reply = connection + .get_property(false, owner, property, property, 0, u32::MAX)? + .reply()?; + if reply.format != 8 || reply.bytes_after != 0 { + bail!("Incomplete XSETTINGS property"); + } + // Xft/DPI includes text scaling; it is not the cursor's pixel density. + // Zero explicitly keeps the existing policy on desktops without a window scale. + Ok(xsettings::scale(&reply.value)?.unwrap_or(0.0)) +} + +#[cfg(feature = "drm")] +pub(super) fn drm_snapshot( + f: impl Fn(&crate::server::drm_capturer::DrmCursorData) -> T, +) -> ResultType> { + crate::server::drm_capturer::drm_cursor_snapshot(f) + .map(|(cursor, display)| { + // A hidden cursor or an unavailable display probe has no density metadata. + let scale = display + .as_ref() + .map(wayland_scale) + .transpose()? + .unwrap_or(0.0); + Ok((cursor, scale)) + }) + .transpose() +} + +#[cfg(feature = "drm")] +fn wayland_scale(display: &base::platform::linux::WaylandDisplayInfo) -> ResultType { + // Missing logical geometry means unknown density, as with older senders. + let Some((logical_width, logical_height)) = display.logical_size else { + return Ok(0.0); + }; + if logical_width <= 0 || logical_height <= 0 || display.width <= 0 || display.height <= 0 { + bail!("Invalid Wayland cursor display dimensions"); + } + // Logical geometry is already rotated; the physical mode dimensions are not. + let width = if matches!(display.transform, 90 | 270) { + display.height + } else { + display.width + }; + Ok(f64::from(width) / f64::from(logical_width)) +} + +#[cfg(all(test, feature = "drm"))] +mod tests { + use super::*; + + #[test] + fn cursor_density_tracks_fractional_rotation_and_cache_identity() { + let display = base::platform::linux::WaylandDisplayInfo { + name: "test".into(), + x: 0, + y: 0, + width: 1280, + height: 800, + logical_size: Some((600, 960)), + refresh_rate: 60000, + transform: 90, + }; + assert_eq!(wayland_scale(&display).unwrap(), 4.0 / 3.0); + assert_ne!(cache_id(1, 1.0), cache_id(1, 2.0)); + assert_eq!(cache_id(1, 0.0), 1); + } +} diff --git a/src/platform/linux/cursor/xsettings.rs b/src/platform/linux/cursor/xsettings.rs new file mode 100644 index 000000000..cf29c98f3 --- /dev/null +++ b/src/platform/linux/cursor/xsettings.rs @@ -0,0 +1,135 @@ +use std::{convert::TryInto, io}; + +const HEADER_SIZE: usize = 12; +const ALIGNMENT: usize = 4; +const INTEGER: u8 = 0; +const STRING: u8 = 1; +const COLOR: u8 = 2; +const WINDOW_SCALE: &[u8] = b"Gdk/WindowScalingFactor"; + +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, + little_endian: bool, +} + +impl<'a> Reader<'a> { + fn take(&mut self, length: usize) -> io::Result<&'a [u8]> { + let end = self.offset.checked_add(length).ok_or_else(invalid)?; + let bytes = self.bytes.get(self.offset..end).ok_or_else(invalid)?; + self.offset = end; + Ok(bytes) + } + + fn number(&mut self, length: usize) -> io::Result { + let bytes = self.take(length)?; + Ok(if self.little_endian { + bytes + .iter() + .rev() + .fold(0, |n, byte| (n << 8) | *byte as u32) + } else { + bytes.iter().fold(0, |n, byte| (n << 8) | *byte as u32) + }) + } + + fn string(&mut self, length: usize) -> io::Result<&'a [u8]> { + let bytes = self.take(length)?; + self.take((ALIGNMENT - length % ALIGNMENT) % ALIGNMENT)?; + Ok(bytes) + } +} + +fn invalid() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + "Invalid XSETTINGS cursor density", + ) +} + +pub(super) fn scale(bytes: &[u8]) -> io::Result> { + let little_endian = match bytes.first() { + Some(0) => true, + Some(1) => false, + _ => return Err(invalid()), + }; + let mut reader = Reader { + bytes, + offset: 0, + little_endian, + }; + reader.take(HEADER_SIZE - ALIGNMENT)?; + let count = reader.number(ALIGNMENT)?; + let mut scale = None; + for _ in 0..count { + let kind = reader.number(1)? as u8; + reader.take(1)?; + let length = reader.number(2)? as usize; + let name = reader.string(length)?; + reader.take(ALIGNMENT)?; // Last-change serial. + match kind { + INTEGER => { + let value = reader.number(ALIGNMENT)? as i32; + if name == WINDOW_SCALE { + if value <= 0 { + return Err(invalid()); + } + scale = Some(f64::from(value)); + } + } + STRING => { + let length = reader + .number(ALIGNMENT)? + .try_into() + .map_err(|_| invalid())?; + reader.string(length)?; + } + COLOR => { + reader.take(ALIGNMENT * 2)?; + } + _ => return Err(invalid()), + } + } + Ok(scale) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn window_scale_ignores_text_dpi_and_checks_the_entire_property() { + for little in [false, true] { + let mut bytes = vec![u8::from(!little), 0, 0, 0]; + let number = |n: u32| { + if little { + n.to_le_bytes() + } else { + n.to_be_bytes() + } + }; + bytes.extend(number(1)); + bytes.extend(number(2)); + for (name, value) in [(b"Xft/DPI".as_slice(), 196608), (WINDOW_SCALE, 2)] { + bytes.extend([INTEGER, 0]); + let length = name.len() as u16; + bytes.extend(if little { + length.to_le_bytes() + } else { + length.to_be_bytes() + }); + bytes.extend(name); + bytes.resize(bytes.len().div_ceil(ALIGNMENT) * ALIGNMENT, 0); + bytes.extend(number(1)); + bytes.extend(number(value)); + } + assert_eq!(scale(&bytes).unwrap(), Some(2.0)); + for length in 0..bytes.len() { + assert!(scale(&bytes[..length]).is_err()); + } + let end = bytes.len(); + bytes[end - ALIGNMENT..].copy_from_slice(&number(0)); + assert!(scale(&bytes).is_err()); + } + } +} diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs index 23d7a6720..76c2a3d09 100644 --- a/src/server/drm_capturer.rs +++ b/src/server/drm_capturer.rs @@ -974,6 +974,38 @@ pub fn drm_cursor() -> Option { with_drm_cursor(|c| c.clone()) } +#[cfg(feature = "flutter")] +pub fn drm_cursor_snapshot( + f: impl Fn(&DrmCursorData) -> T, +) -> Option<(T, Option)> { + // Keep cursor identity and output together, then release the map before DRM_STATE. + let (value, display, hidden) = { + let map = DRM_CURSOR.lock().unwrap(); + let (display, (_, cursor)) = map + .iter() + .find(|(_, (_, cursor))| cursor.id != scrap::drm_reader::HIDDEN_CURSOR_ID) + .or_else(|| map.iter().next())?; + ( + f(cursor), + *display, + cursor.id == scrap::drm_reader::HIDDEN_CURSOR_ID, + ) + }; + let monitor = if hidden { + None + } else { + display_info_of(display).and_then(|display| { + let wayland = scrap::wayland::display::get_displays(); + let index = identity_matches(&[display], &wayland.displays) + .into_iter() + .next() + .flatten()?; + wayland.displays.get(index).cloned() + }) + }; + Some((value, monitor)) +} + enum ProbeState { Unknown, Unavailable(Instant),