fix(cursor): report X11 and Wayland cursor density

This commit is contained in:
fufesou
2026-09-12 03:27:53 +08:00
parent 2a61a0955a
commit d876ea0ce4
5 changed files with 329 additions and 5 deletions

View File

@@ -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"]

View File

@@ -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<Option<u64>> {
// 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<Option<u64>> {
}
}
});
#[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<CursorData> {
// 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<CursorData> {
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);

View File

@@ -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<Option<(RustConnection, usize)>> = 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<f64> {
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<f64> {
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<T>(
f: impl Fn(&crate::server::drm_capturer::DrmCursorData) -> T,
) -> ResultType<Option<(T, f64)>> {
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<f64> {
// 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);
}
}

View File

@@ -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<u32> {
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<Option<f64>> {
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());
}
}
}

View File

@@ -974,6 +974,38 @@ pub fn drm_cursor() -> Option<DrmCursorData> {
with_drm_cursor(|c| c.clone())
}
#[cfg(feature = "flutter")]
pub fn drm_cursor_snapshot<T>(
f: impl Fn(&DrmCursorData) -> T,
) -> Option<(T, Option<base::platform::linux::WaylandDisplayInfo>)> {
// 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),