From 2a61a0955a313f0f513bdb05f3e2b1edd332ceeb Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 12 Sep 2026 03:26:52 +0800 Subject: [PATCH] fix(cursor): capture the physical Windows cursor across DPI changes --- Cargo.toml | 2 +- libs/scrap/Cargo.toml | 1 + libs/scrap/src/dxgi/cursor.rs | 259 ++++++++++++++++++++++++++++ libs/scrap/src/dxgi/cursor/tests.rs | 47 +++++ libs/scrap/src/dxgi/mod.rs | 28 +++ src/platform/windows.cc | 2 +- src/platform/windows.rs | 53 +++++- src/platform/windows/cursor.rs | 157 +++++++++++++++++ 8 files changed, 546 insertions(+), 3 deletions(-) create mode 100644 libs/scrap/src/dxgi/cursor.rs create mode 100644 libs/scrap/src/dxgi/cursor/tests.rs create mode 100644 src/platform/windows/cursor.rs diff --git a/Cargo.toml b/Cargo.toml index 00bb88fae..8f254438e 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"] +flutter = ["flutter_rust_bridge", "scrap/cursor"] default = ["use_dasp"] hwcodec = ["scrap/hwcodec"] vram = ["scrap/vram"] diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 1cc1ff619..5c963a5ac 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -10,6 +10,7 @@ authors = ["Ram "] edition = "2018" [features] +cursor = ["winapi/shellscalingapi"] wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"] # `drm` is a pure runtime-dlopen backend: rustdesk loads `libdrmtap.so.0` at runtime (`drmtap_dl.rs`) # and NEVER link-time links it, so the graceful PipeWire fallback when the .so or EGL is absent is diff --git a/libs/scrap/src/dxgi/cursor.rs b/libs/scrap/src/dxgi/cursor.rs new file mode 100644 index 000000000..dcd599b47 --- /dev/null +++ b/libs/scrap/src/dxgi/cursor.rs @@ -0,0 +1,259 @@ +use super::wrap_hresult; +use std::{ + collections::{hash_map::DefaultHasher, HashMap}, + hash::{Hash, Hasher}, + io, + sync::{Arc, Mutex, Weak}, + time::Instant, +}; +use winapi::shared::dxgi1_2::{ + IDXGIOutputDuplication, DXGI_OUTDUPL_FRAME_INFO, DXGI_OUTDUPL_POINTER_SHAPE_INFO, + DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR, DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR, + DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME, +}; +// USER handles use 32 bits; reserve a separate namespace for physical DXGI shapes. +pub const CURSOR_ID_FLAG: u64 = 1 << 63; +const CHANNELS: u32 = 4; +const BITS_PER_BYTE: u32 = 8; +const DEFAULT_DPI: f64 = 96.0; +#[derive(Clone)] +pub struct Shape { + pub id: u64, + pub scale: f64, + pub kind: u32, + pub width: u32, + pub height: u32, + pub pitch: u32, + pub hotspot: (i32, i32), + pub pixels: Vec, +} + +impl Shape { + fn new(info: DXGI_OUTDUPL_POINTER_SHAPE_INFO, pixels: Vec) -> io::Result { + let (height, minimum_pitch) = match info.Type { + DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME if info.Height % 2 == 0 => { + (info.Height / 2, info.Width.div_ceil(BITS_PER_BYTE)) + } + DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR + | DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR => ( + info.Height, + info.Width.checked_mul(CHANNELS).ok_or_else(invalid_shape)?, + ), + _ => return Err(invalid_shape()), + }; + let length = (info.Pitch as usize).checked_mul(info.Height as usize); + if info.Width == 0 + || height == 0 + || info.Width > i32::MAX as u32 + || info.Height > i32::MAX as u32 + || info.Pitch > i32::MAX as u32 + || info.Pitch < minimum_pitch + || length != Some(pixels.len()) + || info.HotSpot.x < 0 + || info.HotSpot.x as u32 >= info.Width + || info.HotSpot.y < 0 + || info.HotSpot.y as u32 >= height + { + return Err(invalid_shape()); + } + let hotspot = (info.HotSpot.x, info.HotSpot.y); + Ok(Self { + id: 0, + scale: 0.0, + kind: info.Type, + width: info.Width, + height, + pitch: info.Pitch, + hotspot, + pixels, + }) + } + + fn with_scale(self, scale: f64) -> Self { + let mut hash = DefaultHasher::new(); + ( + self.kind, + self.width, + self.height, + self.pitch, + self.hotspot, + &self.pixels, + scale.to_bits(), + ) + .hash(&mut hash); + Self { + id: hash.finish() | CURSOR_ID_FLAG, + scale, + ..self + } + } +} + +fn invalid_shape() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "Invalid DXGI cursor shape") +} + +#[derive(Clone)] +pub enum Snapshot { + Unavailable, + Pending, + Ready(Arc), + Failed(String), +} + +struct State { + updated: Instant, + snapshot: Snapshot, +} + +type SharedState = Arc>; + +lazy_static::lazy_static! { + static ref CAPTURES: Mutex>>>> = + Mutex::new(HashMap::new()); +} + +pub fn snapshot(monitor: usize) -> Snapshot { + let captures = CAPTURES.lock().unwrap(); + captures + .get(&monitor) + .into_iter() + .flatten() + .filter_map(Weak::upgrade) + .map(|state| { + let state = state.lock().unwrap(); + (state.updated, state.snapshot.clone()) + }) + .max_by_key(|(updated, _)| *updated) + .map(|(_, snapshot)| snapshot) + .unwrap_or(Snapshot::Unavailable) +} + +pub fn shape(id: u64) -> Option> { + CAPTURES + .lock() + .unwrap() + .values() + .flatten() + .filter_map(Weak::upgrade) + .find_map(|state| match &state.lock().unwrap().snapshot { + Snapshot::Ready(shape) if shape.id == id => Some(shape.clone()), + _ => None, + }) +} + +pub(super) struct Capture { + monitor: usize, + state: SharedState, +} + +impl Capture { + pub fn new(monitor: usize) -> Self { + let state = Arc::new(Mutex::new(State { + updated: Instant::now(), + snapshot: Snapshot::Pending, + })); + let capture = Self { monitor, state }; + capture.activate(); + capture + } + + pub fn activate(&self) { + let mut captures = CAPTURES.lock().unwrap(); + let states = captures.entry(self.monitor).or_default(); + let own = Arc::downgrade(&self.state); + if !states.iter().any(|state| state.ptr_eq(&own)) { + states.push(own); + } + } + + pub fn deactivate(&self) { + let mut captures = CAPTURES.lock().unwrap(); + if let Some(states) = captures.get_mut(&self.monitor) { + let own = Arc::downgrade(&self.state); + states.retain(|state| !state.ptr_eq(&own)); + if states.is_empty() { + captures.remove(&self.monitor); + } + } + } + + pub unsafe fn update( + &self, + duplication: *mut IDXGIOutputDuplication, + frame: &DXGI_OUTDUPL_FRAME_INFO, + ) { + let snapshot = match self.read(duplication, frame) { + Ok(Some(shape)) => Snapshot::Ready(Arc::new(shape)), + Ok(None) => return, + Err(error) => { + hbb_common::log::error!("DXGI cursor capture failed: {error}"); + Snapshot::Failed(error.to_string()) + } + }; + *self.state.lock().unwrap() = State { + updated: Instant::now(), + snapshot, + }; + } + + unsafe fn read( + &self, + duplication: *mut IDXGIOutputDuplication, + frame: &DXGI_OUTDUPL_FRAME_INFO, + ) -> io::Result> { + use winapi::um::shellscalingapi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI}; + // The Flutter runner is per-monitor DPI aware, so this is the output DPI. + let (mut x, mut y) = (0, 0); + wrap_hresult(GetDpiForMonitor( + self.monitor as _, + MDT_EFFECTIVE_DPI, + &mut x, + &mut y, + ))?; + if x == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Invalid monitor scale", + )); + } + let scale = x as f64 / DEFAULT_DPI; + if frame.PointerShapeBufferSize > 0 { + return read(duplication, frame.PointerShapeBufferSize) + .map(|shape| Some(shape.with_scale(scale))); + } + // A DPI change can leave a custom cursor's physical bitmap unchanged. + Ok(match &self.state.lock().unwrap().snapshot { + Snapshot::Ready(shape) if shape.scale != scale => { + Some(shape.as_ref().clone().with_scale(scale)) + } + _ => None, + }) + } +} + +unsafe fn read(duplication: *mut IDXGIOutputDuplication, size: u32) -> io::Result { + let mut pixels = vec![0; size as usize]; + let mut required = 0; + let mut info = std::mem::zeroed(); + wrap_hresult((*duplication).GetFramePointerShape( + size, + pixels.as_mut_ptr().cast(), + &mut required, + &mut info, + ))?; + if required > size { + return Err(invalid_shape()); + } + pixels.truncate(required as usize); + Shape::new(info, pixels) +} + +impl Drop for Capture { + fn drop(&mut self) { + self.deactivate(); + } +} + +#[cfg(test)] +mod tests; diff --git a/libs/scrap/src/dxgi/cursor/tests.rs b/libs/scrap/src/dxgi/cursor/tests.rs new file mode 100644 index 000000000..4e6474b4e --- /dev/null +++ b/libs/scrap/src/dxgi/cursor/tests.rs @@ -0,0 +1,47 @@ +use super::*; +use winapi::shared::windef::POINT; + +#[test] +fn cursor_state_follows_capture_lifetime_and_gdi_switches() { + const MONITOR: usize = usize::MAX; + let first = Capture::new(MONITOR); + let second = Capture::new(MONITOR); + first.deactivate(); + assert!(matches!(snapshot(MONITOR), Snapshot::Pending)); + drop(second); + assert!(matches!(snapshot(MONITOR), Snapshot::Unavailable)); + first.activate(); + first.activate(); + assert!(matches!(snapshot(MONITOR), Snapshot::Pending)); + drop(first); + assert!(matches!(snapshot(MONITOR), Snapshot::Unavailable)); +} + +#[test] +fn cursor_keeps_physical_hotspot_and_both_monochrome_planes() { + let info = DXGI_OUTDUPL_POINTER_SHAPE_INFO { + Type: DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME, + Width: 64, + Height: 128, + Pitch: 8, + HotSpot: POINT { x: 31, y: 29 }, + }; + let pixels = vec![0xa5; 1024]; + let shape = Shape::new(info, pixels.clone()).unwrap().with_scale(1.0); + assert_eq!( + (shape.width, shape.height, shape.hotspot), + (64, 64, (31, 29)) + ); + assert_eq!(shape.pixels, pixels); + let scaled = shape.clone().with_scale(2.0); + assert_eq!(scaled.scale, 2.0); + assert_ne!(shape.id, scaled.id); + assert_eq!(shape.id, scaled.with_scale(1.0).id); + assert!(Shape::new(info, vec![0; 512]).is_err()); + let mut changed = info; + changed.HotSpot.y += 1; + assert_ne!( + shape.id, + Shape::new(changed, pixels).unwrap().with_scale(1.0).id + ); +} diff --git a/libs/scrap/src/dxgi/mod.rs b/libs/scrap/src/dxgi/mod.rs index 1f5296954..90feb0abb 100644 --- a/libs/scrap/src/dxgi/mod.rs +++ b/libs/scrap/src/dxgi/mod.rs @@ -2,6 +2,8 @@ use std::{io, mem, ptr, slice}; pub mod gdi; pub use gdi::CapturerGDI; pub mod mag; +#[cfg(feature = "cursor")] +pub mod cursor; use winapi::{ shared::{ @@ -42,6 +44,8 @@ impl Drop for ComPtr { } pub struct Capturer { + #[cfg(feature = "cursor")] + cursor: Option, device: ComPtr, display: Display, context: ComPtr, @@ -158,6 +162,9 @@ impl Capturer { let rotate = Self::create_rotations(device.0, context.0, &display); Ok(Capturer { + #[cfg(feature = "cursor")] + cursor: (!duplication.is_null()) + .then(|| cursor::Capture::new(display.hmonitor() as usize)), device, context, duplication: ComPtr(duplication), @@ -316,12 +323,25 @@ impl Capturer { pub fn set_gdi(&mut self) -> bool { self.gdi_capturer = self.display.create_gdi(); + #[cfg(feature = "cursor")] + if self.is_gdi() { + if let Some(cursor) = &self.cursor { + cursor.deactivate(); + } + } self.is_gdi() } pub fn cancel_gdi(&mut self) { self.gdi_buffer = Vec::new(); self.gdi_capturer.take(); + #[cfg(feature = "cursor")] + if !self.duplication.is_null() { + let monitor = self.display.hmonitor() as usize; + self.cursor + .get_or_insert_with(|| cursor::Capture::new(monitor)) + .activate(); + } } #[cfg(feature = "vram")] @@ -336,6 +356,10 @@ impl Capturer { wrap_hresult((*self.duplication.0).AcquireNextFrame(timeout, &mut info, &mut frame))?; let frame = ComPtr(frame); + #[cfg(feature = "cursor")] + if let Some(cursor) = &self.cursor { + cursor.update(self.duplication.0, &info); + } if *info.LastPresentTime.QuadPart() == 0 { return Err(std::io::ErrorKind::WouldBlock.into()); @@ -479,6 +503,10 @@ impl Capturer { wrap_hresult((*self.duplication.0).AcquireNextFrame(timeout, &mut info, &mut frame))?; let frame = ComPtr(frame); + #[cfg(feature = "cursor")] + if let Some(cursor) = &self.cursor { + cursor.update(self.duplication.0, &info); + } if info.AccumulatedFrames == 0 || *info.LastPresentTime.QuadPart() == 0 { return Err(std::io::ErrorKind::WouldBlock.into()); diff --git a/src/platform/windows.cc b/src/platform/windows.cc index 9027d9d89..64625556c 100644 --- a/src/platform/windows.cc +++ b/src/platform/windows.cc @@ -413,7 +413,7 @@ extern "C" { auto in = in0; auto out0_end = out0 + out0_size; - auto offset = width * 4 + 4; + auto offset = (width + 2) * 4 + 4; auto out = out0 + offset; for (int y = 0; y < height; y++) { diff --git a/src/platform/windows.rs b/src/platform/windows.rs index a3c3d68f0..fb17fcb38 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -97,6 +97,8 @@ use windows_service::{ use winreg::{enums::*, RegKey}; mod acl; +#[cfg(feature = "flutter")] +mod cursor; mod installer_handoff; mod installer_shell; mod msi_registry; @@ -215,7 +217,14 @@ pub fn get_cursor() -> ResultType> { if ci.flags & CURSOR_SHOWING == 0 { Ok(None) } else { - Ok(Some(ci.hCursor as _)) + #[cfg(feature = "flutter")] + { + cursor::current(&ci) + } + #[cfg(not(feature = "flutter"))] + { + Ok(Some(ci.hCursor as _)) + } } } } @@ -260,6 +269,10 @@ impl Drop for IconInfo { // https://github.com/TurboVNC/tightvnc/blob/a235bae328c12fd1c3aed6f3f034a37a6ffbbd22/vnc_winsrc/winvnc/vncEncoder.cpp // https://github.com/TigerVNC/tigervnc/blob/master/win/rfb_win32/DeviceFrameBuffer.cxx pub fn get_cursor_data(hcursor: u64) -> ResultType { + #[cfg(feature = "flutter")] + if let Some(data) = cursor::data(hcursor)? { + return Ok(data); + } unsafe { let mut ii = IconInfo::new(hcursor as _)?; let bm_mask = get_bitmap(ii.0.hbmMask)?; @@ -4750,6 +4763,44 @@ pub(super) fn get_pids_with_first_arg_by_wmic, S2: AsRef>( mod tests { use super::*; + #[test] + fn cursor_outline_preserves_pixels_at_hotspot_offset() { + const CHANNELS: usize = 4; + const BORDER: usize = 1; + const WIDTH: usize = 3; + const HEIGHT: usize = 9; + const INK: [u8; CHANNELS] = [32, 64, 96, 255]; + let mut source = vec![0; WIDTH * HEIGHT * CHANNELS]; + for y in 0..HEIGHT { + for x in 0..WIDTH { + if x == WIDTH / 2 || y == 0 || y == HEIGHT - 1 { + let offset = (y * WIDTH + x) * CHANNELS; + source[offset..offset + CHANNELS].copy_from_slice(&INK); + } + } + } + let stride = WIDTH + BORDER * 2; + let mut outlined = vec![0; stride * (HEIGHT + BORDER * 2) * CHANNELS]; + unsafe { + drawOutline( + outlined.as_mut_ptr(), + source.as_ptr(), + WIDTH as _, + HEIGHT as _, + outlined.len() as _, + ); + } + for y in 0..HEIGHT { + for x in 0..WIDTH { + let input = (y * WIDTH + x) * CHANNELS; + if source[input + CHANNELS - 1] != 0 { + let output = ((y + BORDER) * stride + x + BORDER) * CHANNELS; + assert_eq!(&outlined[output..output + CHANNELS], &INK, "({x}, {y})"); + } + } + } + } + // Test-only reusable Win32 HANDLE RAII helper. // If a future non-test path needs the same pattern, move it out of this test module. // diff --git a/src/platform/windows/cursor.rs b/src/platform/windows/cursor.rs new file mode 100644 index 000000000..becf08b51 --- /dev/null +++ b/src/platform/windows/cursor.rs @@ -0,0 +1,157 @@ +use super::{drawOutline, handleMask, CursorData}; +use hbb_common::{anyhow::Context, bail, ResultType}; +use scrap::dxgi::cursor::{self, Shape, Snapshot, CURSOR_ID_FLAG}; +use winapi::{ + shared::dxgi1_2::{ + DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR, DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME, + }, + um::winuser::{MonitorFromPoint, CURSORINFO, MONITOR_DEFAULTTONULL}, +}; + +const CHANNELS: usize = 4; +const BORDER: i32 = 1; + +pub(super) fn current(info: &CURSORINFO) -> ResultType> { + let monitor = unsafe { MonitorFromPoint(info.ptScreenPos, MONITOR_DEFAULTTONULL) }; + match cursor::snapshot(monitor as usize) { + Snapshot::Unavailable => Ok(Some(info.hCursor as usize as u32 as u64)), + Snapshot::Pending => Ok(None), + Snapshot::Ready(shape) => Ok(Some(shape.id)), + Snapshot::Failed(error) => bail!("DXGI cursor capture: {error}"), + } +} + +pub(super) fn data(id: u64) -> ResultType> { + if id & CURSOR_ID_FLAG == 0 { + return Ok(None); + } + let shape = cursor::shape(id).context("DXGI cursor changed before export")?; + let (colors, outline) = colors(&shape)?; + let data = CursorData { + id, + colors: colors.into(), + width: shape.width as _, + height: shape.height as _, + hotx: shape.hotspot.0, + hoty: shape.hotspot.1, + scale: shape.scale, + ..Default::default() + }; + Ok(Some(if outline { outlined(data)? } else { data })) +} + +fn colors(shape: &Shape) -> ResultType<(Vec, bool)> { + let length = (shape.width as usize) + .checked_mul(shape.height as usize) + .and_then(|pixels| pixels.checked_mul(CHANNELS)) + .context("Cursor size overflow")?; + if shape.kind == DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME { + let mut colors = vec![0; length]; + let outline = unsafe { + handleMask( + colors.as_mut_ptr(), + shape.pixels.as_ptr(), + shape.width as _, + shape.height as _, + shape.pitch as _, + (shape.height * 2) as _, + ) + } > 0; + return Ok((colors, outline)); + } + let mut colors = Vec::with_capacity(length); + let mut outline = false; + for row in shape.pixels.chunks_exact(shape.pitch as usize) { + for pixel in row[..shape.width as usize * CHANNELS].chunks_exact(CHANNELS) { + let (rgba, xor) = rgba(pixel, shape.kind); + outline |= xor; + colors.extend_from_slice(&rgba); + } + } + Ok((colors, outline)) +} + +fn rgba(pixel: &[u8], kind: u32) -> ([u8; CHANNELS], bool) { + if kind != DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR { + return ([pixel[2], pixel[1], pixel[0], pixel[3]], false); + } + if pixel[3] == 0 { + return ([pixel[2], pixel[1], pixel[0], 255], false); + } + // Match the Win32 exporter's outlined replacement for background-dependent XOR. + if pixel[..3].iter().any(|value| *value != 0) { + ([0, 0, 0, 255], true) + } else { + ([0; CHANNELS], false) + } +} + +fn outlined(data: CursorData) -> ResultType { + let width = data + .width + .checked_add(BORDER * 2) + .context("Cursor width overflow")?; + let height = data + .height + .checked_add(BORDER * 2) + .context("Cursor height overflow")?; + let length = (width as usize) + .checked_mul(height as usize) + .and_then(|pixels| pixels.checked_mul(CHANNELS)) + .context("Cursor size overflow")?; + let length_i32 = + i32::try_from(length).context("Cursor outline exceeds the native buffer size")?; + let mut colors = vec![0; length]; + unsafe { + drawOutline( + colors.as_mut_ptr(), + data.colors.as_ptr(), + data.width, + data.height, + length_i32, + ); + } + Ok(CursorData { + colors: colors.into(), + width, + height, + hotx: data.hotx + BORDER, + hoty: data.hoty + BORDER, + ..data + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use winapi::shared::dxgi1_2::DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR; + + #[test] + fn physical_cursor_preserves_alpha_and_ignores_row_padding() { + let shape = Shape { + id: CURSOR_ID_FLAG, + scale: 0.0, + kind: DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR, + width: 1, + height: 2, + pitch: 8, + hotspot: (0, 1), + pixels: vec![ + 32, 64, 128, 128, 255, 255, 255, 255, 1, 2, 3, 255, 255, 255, 255, 255, + ], + }; + assert_eq!( + colors(&shape).unwrap(), + (vec![128, 64, 32, 128, 3, 2, 1, 255], false) + ); + let masked = Shape { + kind: DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR, + pixels: vec![0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 0, 0, 0, 0], + ..shape + }; + assert_eq!( + colors(&masked).unwrap(), + (vec![0, 0, 0, 255, 0, 0, 0, 255], true) + ); + } +}