fix(cursor): capture the physical Windows cursor across DPI changes

This commit is contained in:
fufesou
2026-09-12 03:26:52 +08:00
parent db7c48e1a7
commit 2a61a0955a
8 changed files with 546 additions and 3 deletions

View File

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

View File

@@ -10,6 +10,7 @@ authors = ["Ram <quadrupleslap@gmail.com>"]
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

View File

@@ -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<u8>,
}
impl Shape {
fn new(info: DXGI_OUTDUPL_POINTER_SHAPE_INFO, pixels: Vec<u8>) -> io::Result<Self> {
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<Shape>),
Failed(String),
}
struct State {
updated: Instant,
snapshot: Snapshot,
}
type SharedState = Arc<Mutex<State>>;
lazy_static::lazy_static! {
static ref CAPTURES: Mutex<HashMap<usize, Vec<Weak<Mutex<State>>>>> =
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<Arc<Shape>> {
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<Option<Shape>> {
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<Shape> {
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;

View File

@@ -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
);
}

View File

@@ -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<T> Drop for ComPtr<T> {
}
pub struct Capturer {
#[cfg(feature = "cursor")]
cursor: Option<cursor::Capture>,
device: ComPtr<ID3D11Device>,
display: Display,
context: ComPtr<ID3D11DeviceContext>,
@@ -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());

View File

@@ -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++)
{

View File

@@ -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<Option<u64>> {
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<CursorData> {
#[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<S1: AsRef<str>, S2: AsRef<str>>(
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.
//

View File

@@ -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<Option<u64>> {
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<Option<CursorData>> {
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<u8>, 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<CursorData> {
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)
);
}
}