From 72ea38ca4f0ac02c87e436136a74c1757406ac26 Mon Sep 17 00:00:00 2001 From: fufesou Date: Fri, 11 Sep 2026 11:49:04 +0800 Subject: [PATCH] fix(cursor): preserve native pixels and hotspots across display scales Capture actual Mutter and DXGI cursor metadata, preserve Retina artwork, and invalidate cursor identities when physical pixels or DPI change. Keep straight-alpha colors during native cursor resizing and correct the Windows XOR outline offset. Verified all six Mac/Linux/Windows directions with real pointer movement, arrow/I-beam/crosshair transitions, adaptive zoom off/on/off, Original view, and live DPI changes. Requested debug builds and focused native/Flutter regressions pass. --- Cargo.toml | 2 +- flutter/lib/models/model.dart | 12 + flutter/test/cursor_dpi_scale_test.dart | 46 ++++ libs/scrap/Cargo.toml | 1 + libs/scrap/src/dxgi/cursor.rs | 248 ++++++++++++++++++++ libs/scrap/src/dxgi/mod.rs | 28 +++ src/platform/macos.rs | 24 +- src/platform/macos/cursor.rs | 174 ++++++++++++++ src/platform/windows.cc | 2 +- src/platform/windows.rs | 53 ++++- src/platform/windows/cursor.rs | 155 +++++++++++++ src/server/drm_capturer.rs | 26 +++ src/server/drm_capturer/cursor.rs | 180 +++++++++++++++ src/server/drm_capturer/cursor/ffi.rs | 232 +++++++++++++++++++ src/server/drm_capturer/cursor/metadata.rs | 255 +++++++++++++++++++++ src/server/drm_capturer/cursor/pipewire.rs | 218 ++++++++++++++++++ 16 files changed, 1644 insertions(+), 12 deletions(-) create mode 100644 libs/scrap/src/dxgi/cursor.rs create mode 100644 src/platform/macos/cursor.rs create mode 100644 src/platform/windows/cursor.rs create mode 100644 src/server/drm_capturer/cursor.rs create mode 100644 src/server/drm_capturer/cursor/ffi.rs create mode 100644 src/server/drm_capturer/cursor/metadata.rs create mode 100644 src/server/drm_capturer/cursor/pipewire.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/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 7ea638762..433d37be6 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -3470,6 +3470,13 @@ class CursorModel with ChangeNotifier { img2.Image imgOrigin = img2.Image.fromBytes( width: w, height: h, bytes: rgba.buffer, order: img2.ChannelOrder.rgba); if (isWindows) { + final pixels = + await image.toByteData(format: ui.ImageByteFormat.rawStraightRgba); + if (pixels == null) { + throw StateError('Could not read straight-alpha cursor pixels'); + } + imgOrigin = img2.Image.fromBytes( + width: w, height: h, bytes: pixels.buffer, order: img2.ChannelOrder.rgba); data = imgOrigin.getBytes(order: img2.ChannelOrder.bgra); } else { ByteData? imgBytes = @@ -3478,6 +3485,11 @@ class CursorModel with ChangeNotifier { return false; } data = imgBytes.buffer.asUint8List(); + if (isLinux || isMacOS) { + // Preserve the PNG's straight-alpha colors when resizing native cursors. + imgOrigin = img2.decodePng(data) ?? + (throw const FormatException('Invalid native cursor PNG')); + } } final cache = CursorData( peerId: peerId, diff --git a/flutter/test/cursor_dpi_scale_test.dart b/flutter/test/cursor_dpi_scale_test.dart index d70bab7b2..cafc40610 100644 --- a/flutter/test/cursor_dpi_scale_test.dart +++ b/flutter/test/cursor_dpi_scale_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart'; @@ -31,6 +32,11 @@ class _CursorModel implements CursorModel { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +class _CursorFFI implements FFI { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + CursorData _cursorData((int, int) size, (double, double) hotspot) { final image = img.Image(width: size.$1, height: size.$2, numChannels: 4); for (var y = 0; y < image.height; y++) { @@ -83,6 +89,46 @@ void _expectArtwork(img.Image native, img.Image artwork) { } void main() { + testWidgets('received cursor keeps edge colors across scale changes', + (tester) async { + const side = 4; + for (final (rgba, expected) in [ + ([128, 64, 32, 128], [255, 128, 64, 128]), + ([0, 0, 0, 0], [0, 0, 0, 0]), + ([32, 64, 128, 255], [32, 64, 128, 255]), + ]) { + final cursor = CursorModel(WeakReference(_CursorFFI())) + ..id = 'edge-colors'; + addTearDown(cursor.disposeImages); + addTearDown(cursor.dispose); + await tester.runAsync(() => cursor.updateCursorData({ + 'id': 'edge-colors', + 'hotx': '1', + 'hoty': '1', + 'width': '$side', + 'height': '$side', + 'colors': jsonEncode(List.generate(side * side, (_) => rgba) + .expand((pixel) => pixel) + .toList()), + })); + final data = cursor.cache!; + for (final scale in [1.0, 0.5, 1.0]) { + data.updateGetKey(scale); + final image = Platform.isWindows + ? img.Image.fromBytes( + width: data.scaledWidth, + height: data.scaledHeight, + bytes: data.data!.buffer, + order: img.ChannelOrder.bgra) + : img.decodePng(data.data!)!; + for (final pixel in image) { + expect([pixel.r, pixel.g, pixel.b, pixel.a], expected, + reason: 'Edge color must survive scale $scale'); + } + } + } + }); + for (final (source, hotspot, scale, size, linuxHotspot) in _cases) { testWidgets('${source.$1}x${source.$2} cursor at scale $scale', (tester) async { diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 1cc1ff619..df269633d 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -10,6 +10,7 @@ authors = ["Ram "] edition = "2018" [features] +cursor = [] 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..2fa58ed34 --- /dev/null +++ b/libs/scrap/src/dxgi/cursor.rs @@ -0,0 +1,248 @@ +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, +}; + +pub const CURSOR_ID_FLAG: u64 = 1 << 63; +const CHANNELS: u32 = 4; +const BITS_PER_BYTE: u32 = 8; + +pub struct Shape { + pub id: u64, + 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); + let mut hash = DefaultHasher::new(); + (info.Type, info.Width, height, info.Pitch, hotspot, &pixels).hash(&mut hash); + Ok(Self { + id: hash.finish() | CURSOR_ID_FLAG, + kind: info.Type, + width: info.Width, + height, + pitch: info.Pitch, + hotspot, + pixels, + }) + } +} + +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, + ) { + if frame.PointerShapeBufferSize == 0 { + return; + } + let snapshot = match read(duplication, frame.PointerShapeBufferSize) { + Ok(shape) => Snapshot::Ready(Arc::new(shape)), + 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(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 { + 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(); + assert_eq!( + (shape.width, shape.height, shape.hotspot), + (64, 64, (31, 29)) + ); + assert_eq!(shape.pixels, pixels); + 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().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/macos.rs b/src/platform/macos.rs index 82bcf9d6f..2c8d52987 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -35,12 +35,14 @@ use std::{ sync::Mutex, }; +mod cursor; + // macOS boolean_t is defined as `int` in type BooleanT = hbb_common::libc::c_int; static PRIVILEGES_SCRIPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/platform/privileges_scripts"); -static mut LATEST_SEED: i32 = 0; +static mut LATEST_SEED: (i32, f64) = (0, 0.0); #[inline] fn get_update_temp_dir() -> PathBuf { @@ -561,7 +563,7 @@ pub fn get_cursor() -> ResultType> { fn unsafe_get_cursor() -> ResultType> { unsafe { - let seed = CGSCurrentCursorSeed(); + let seed = (CGSCurrentCursorSeed(), cursor::scale()?); if seed == LATEST_SEED { return Ok(None); } @@ -573,11 +575,11 @@ fn unsafe_get_cursor() -> ResultType> { pub fn reset_input_cache() { unsafe { - LATEST_SEED = 0; + LATEST_SEED = (0, 0.0); } } -fn get_cursor_id() -> ResultType<(id, u64)> { +fn get_cursor_id() -> ResultType<(id, u64, f64)> { unsafe { let c: id = msg_send![class!(NSCursor), currentSystemCursor]; if c == nil { @@ -620,7 +622,8 @@ fn get_cursor_id() -> ResultType<(id, u64)> { hcursor += (r + g + b + a) * (255 << i) as f64; } } - Ok((c, hcursor as _)) + let scale = cursor::scale()?; + Ok((c, cursor::cache_id(hcursor as _, scale), scale)) } } @@ -631,10 +634,13 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType { // https://github.com/stweil/OSXvnc/blob/master/OSXvnc-server/mousecursor.c fn unsafe_get_cursor_data(hcursor: u64) -> ResultType { unsafe { - let (c, hcursor2) = get_cursor_id()?; + let (c, hcursor2, scale) = get_cursor_id()?; if hcursor != hcursor2 { bail!("cursor changed"); } + if scale > 1.0 { + return cursor::data(c, hcursor, scale); + } let hotspot: NSPoint = msg_send![c, hotSpot]; let img: id = msg_send![c, image]; let size: NSSize = msg_send![img, size]; @@ -668,9 +674,9 @@ fn unsafe_get_cursor_data(hcursor: u64) -> ResultType { let g: f64 = msg_send![color, greenComponent]; let b: f64 = msg_send![color, blueComponent]; let a: f64 = msg_send![color, alphaComponent]; - colors.push((r * 255.) as _); - colors.push((g * 255.) as _); - colors.push((b * 255.) as _); + colors.push((r * a * 255.).round() as _); + colors.push((g * a * 255.).round() as _); + colors.push((b * a * 255.).round() as _); colors.push((a * 255.) as _); } } diff --git a/src/platform/macos/cursor.rs b/src/platform/macos/cursor.rs new file mode 100644 index 000000000..5d2056f37 --- /dev/null +++ b/src/platform/macos/cursor.rs @@ -0,0 +1,174 @@ +use super::{CursorData, ResultType}; +use cocoa::{ + appkit::NSCompositingOperation, + base::{id, nil, NO, YES}, + foundation::{NSInteger, NSPoint, NSRect, NSSize, NSString}, +}; +use hbb_common::{anyhow::Context, bail}; +use objc::{class, msg_send, rc::StrongPtr, sel, sel_impl}; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + ptr, slice, +}; + +const CHANNELS: usize = 4; +const BITS_PER_SAMPLE: NSInteger = 8; + +pub(super) fn scale() -> ResultType { + if !*scrap::quartz::ENABLE_RETINA.lock().unwrap() { + return Ok(1.0); + } + unsafe { + let point: NSPoint = msg_send![class!(NSEvent), mouseLocation]; + let screens: id = msg_send![class!(NSScreen), screens]; + let count: usize = msg_send![screens, count]; + for index in 0..count { + let screen: id = msg_send![screens, objectAtIndex: index]; + let frame: NSRect = msg_send![screen, frame]; + // AppKit's bottom-left coordinates include the upper screen edge. + if point.x >= frame.origin.x + && point.y > frame.origin.y + && point.x < frame.origin.x + frame.size.width + && point.y <= frame.origin.y + frame.size.height + { + return Ok(msg_send![screen, backingScaleFactor]); + } + } + } + bail!("No macOS display contains the cursor") +} + +pub(super) fn cache_id(cursor: u64, scale: f64) -> u64 { + let mut hash = DefaultHasher::new(); + (cursor, scale.to_bits()).hash(&mut hash); + hash.finish() +} + +unsafe fn bitmap(size: NSSize) -> ResultType { + let color_space = StrongPtr::new(NSString::alloc(nil).init_str("NSDeviceRGBColorSpace")); + let bitmap: id = msg_send![class!(NSBitmapImageRep), alloc]; + let bitmap: id = msg_send![bitmap, + initWithBitmapDataPlanes: ptr::null_mut::<*mut u8>() + pixelsWide: size.width as NSInteger pixelsHigh: size.height as NSInteger + bitsPerSample: BITS_PER_SAMPLE samplesPerPixel: CHANNELS as NSInteger + hasAlpha: YES isPlanar: NO colorSpaceName: *color_space + bitmapFormat: 0usize bytesPerRow: (size.width as usize * CHANNELS) as NSInteger + bitsPerPixel: BITS_PER_SAMPLE * CHANNELS as NSInteger]; + if bitmap == nil { + bail!("Could not allocate the macOS cursor bitmap"); + } + Ok(StrongPtr::new(bitmap)) +} + +unsafe fn render(image: id, bitmap: id, size: NSSize) -> ResultType<()> { + let context: id = + msg_send![class!(NSGraphicsContext), graphicsContextWithBitmapImageRep: bitmap]; + if context == nil { + bail!("Could not create the macOS cursor graphics context"); + } + let (): () = msg_send![class!(NSGraphicsContext), saveGraphicsState]; + let (): () = msg_send![class!(NSGraphicsContext), setCurrentContext: context]; + // Drawing at the pixel size lets AppKit select the matching image representation. + let (): () = msg_send![image, + drawInRect: NSRect::new(NSPoint::new(0.0, 0.0), size) + fromRect: NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(0.0, 0.0)) + operation: NSCompositingOperation::NSCompositeCopy fraction: 1.0f64]; + let (): () = msg_send![class!(NSGraphicsContext), restoreGraphicsState]; + Ok(()) +} + +pub(super) unsafe fn data(cursor: id, id: u64, scale: f64) -> ResultType { + let image: id = msg_send![cursor, image]; + let logical: NSSize = msg_send![image, size]; + let size = NSSize::new( + (logical.width * scale).round(), + (logical.height * scale).round(), + ); + if !size.width.is_finite() + || !size.height.is_finite() + || size.width <= 0.0 + || size.height <= 0.0 + || size.width > i32::MAX as f64 + || size.height > i32::MAX as f64 + { + bail!("Invalid macOS cursor dimensions"); + } + let length = (size.width as usize) + .checked_mul(size.height as usize) + .and_then(|pixels| pixels.checked_mul(CHANNELS)) + .context("Cursor bitmap size overflow")?; + let bitmap = bitmap(size)?; + render(image, *bitmap, size)?; + let pixels: *const u8 = msg_send![*bitmap, bitmapData]; + if pixels.is_null() { + bail!("Could not read the macOS cursor bitmap"); + } + let hotspot: NSPoint = msg_send![cursor, hotSpot]; + Ok(CursorData { + id, + colors: slice::from_raw_parts(pixels, length).to_vec().into(), + hotx: (hotspot.x * size.width / logical.width).round() as _, + hoty: (hotspot.y * size.height / logical.height).round() as _, + width: size.width as _, + height: size.height as _, + ..Default::default() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use objc::rc::autoreleasepool; + + #[test] + fn retina_cursor_uses_complete_high_resolution_artwork() { + autoreleasepool(|| unsafe { + let logical = NSSize::new(9.0, 18.0); + let image: id = msg_send![class!(NSImage), alloc]; + let image = StrongPtr::new(msg_send![image, initWithSize: logical]); + let mut expected = Vec::new(); + for scale in [1, 2] { + let width = logical.width as usize * scale; + let height = logical.height as usize * scale; + let rep = bitmap(NSSize::new(width as f64, height as f64)).unwrap(); + let mut pixels = vec![0; width * height * CHANNELS]; + for y in 0..height { + for x in 0..width { + if y == 0 || y == height - 1 || x == width / 2 { + let color = if y == 0 { + [255, 0, 0, 255] + } else { + [0, 255, 0, 255] + }; + pixels[(y * width + x) * CHANNELS..(y * width + x + 1) * CHANNELS] + .copy_from_slice(&color); + } + } + } + let buffer: *mut u8 = msg_send![*rep, bitmapData]; + ptr::copy_nonoverlapping(pixels.as_ptr(), buffer, pixels.len()); + let (): () = msg_send![*rep, setSize: logical]; + let (): () = msg_send![*image, addRepresentation: *rep]; + if scale == 2 { + expected = pixels; + } + } + let cursor: id = msg_send![class!(NSCursor), alloc]; + let cursor = StrongPtr::new( + msg_send![cursor, initWithImage: *image hotSpot: NSPoint::new(4.0, 9.0)], + ); + let result = data(*cursor, 1, 2.0).unwrap(); + assert_eq!( + (result.width, result.height, result.hotx, result.hoty), + (18, 36, 8, 18) + ); + assert_eq!(result.colors.as_ref(), expected.as_slice()); + }); + } + + #[test] + fn cursor_cache_changes_with_display_scale() { + assert_ne!(cache_id(123, 1.0), cache_id(123, 2.0)); + } +} 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..f6d50549c --- /dev/null +++ b/src/platform/windows/cursor.rs @@ -0,0 +1,155 @@ +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, + ..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, + 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) + ); + } +} diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs index 23d7a6720..73428b930 100644 --- a/src/server/drm_capturer.rs +++ b/src/server/drm_capturer.rs @@ -14,6 +14,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::{Duration, Instant}; +mod cursor; + const HANDSHAKE_TIMEOUT_MS: u64 = 3000; const DRM_CONNECT_TIMEOUT_MS: u64 = 1000; /// The service may hold the list back while it wakes sleeping displays: ~3.6s (DRM_WAKE_*). @@ -630,6 +632,19 @@ async fn recv_thread( let _ = tx.send(Err(err)); return; } + let mutter_cursor = match cursor::Capture::start( + display, + cursor_epoch, + displays[wire_idx].name.clone(), + ) + .await + { + Ok(cursor) => cursor, + Err(error) => { + let _ = tx.send(Err(error)); + return; + } + }; let _ = tx.send(Ok((displays, wire_idx))); // A cursor that arrived before new() stored the session transform, held for replay. Only the @@ -639,6 +654,9 @@ async fn recv_thread( if stop.load(Ordering::SeqCst) { break "stopped".to_owned(); } + if let Some(error) = mutter_cursor.as_ref().and_then(cursor::Capture::error) { + break error; + } if pending_cursor.is_some() { let t = shared.transform.load(std::sync::atomic::Ordering::Acquire); if t != TRANSFORM_PENDING { @@ -763,6 +781,9 @@ async fn recv_thread( raw.len() ); } + if mutter_cursor.is_some() { + continue; + } let t = shared.transform.load(std::sync::atomic::Ordering::Acquire); if t == TRANSFORM_PENDING { pending_cursor = Some((id, width, height, hotx, hoty, raw)); @@ -858,6 +879,11 @@ async fn recv_thread( // Drop the render context on THIS thread: its EGL state + cached imports are thread-local and // a cross-thread close strands them. Never in `Drop`, which runs on the encoder thread. drop(converter); + if let Some(cursor) = mutter_cursor { + if let Err(error) = cursor.stop().await { + log::error!("drm: could not join the Mutter cursor worker: {error:#}"); + } + } remove_drm_cursor(display, cursor_epoch); let mut slot = shared.slot.lock().unwrap(); slot.ended = Some(format!("drm stream ended ({end_reason})")); diff --git a/src/server/drm_capturer/cursor.rs b/src/server/drm_capturer/cursor.rs new file mode 100644 index 000000000..9dede6c77 --- /dev/null +++ b/src/server/drm_capturer/cursor.rs @@ -0,0 +1,180 @@ +use super::DrmCursorData; +use dbus::{ + arg::{PropMap, Variant}, + blocking::Connection, + message::MatchRule, + Path, +}; +use hbb_common::{anyhow::anyhow, bail, log, tokio, ResultType}; +use std::{ + sync::{ + mpsc::{self, Receiver, Sender, TryRecvError}, + Arc, Mutex, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +mod ffi; +mod metadata; +mod pipewire; + +const BUS: &str = "org.gnome.Mutter.ScreenCast"; +const SESSION_INTERFACE: &str = "org.gnome.Mutter.ScreenCast.Session"; +const STREAM_INTERFACE: &str = "org.gnome.Mutter.ScreenCast.Stream"; +const CURSOR_METADATA_MODE: u32 = 2; +const DBUS_TIMEOUT: Duration = Duration::from_secs(2); +const START_TIMEOUT: Duration = Duration::from_secs(5); +const POLL_INTERVAL: Duration = Duration::from_millis(20); + +pub struct Capture { + stop: Option>, + thread: Option>, + error: Arc>>, +} + +impl Capture { + pub async fn start(display: i32, epoch: u64, connector: String) -> ResultType> { + if !tokio::task::spawn_blocking(mutter_available).await?? { + return Ok(None); + } + let (stop, receiver) = mpsc::channel(); + let error = Arc::new(Mutex::new(None)); + let worker_error = error.clone(); + let thread = thread::Builder::new() + .name("drm-cursor".into()) + .spawn(move || { + if let Err(error) = run((display, epoch), connector, receiver) { + log::error!("drm: Mutter cursor capture failed: {error:#}"); + *worker_error.lock().unwrap() = + Some(format!("Mutter cursor capture: {error:#}")); + } + })?; + Ok(Some(Self { + stop: Some(stop), + thread: Some(thread), + error, + })) + } + + pub fn error(&self) -> Option { + self.error.lock().unwrap().clone() + } + + pub async fn stop(mut self) -> ResultType<()> { + drop(self.stop.take()); + if let Some(thread) = self.thread.take() { + tokio::task::spawn_blocking(move || { + thread + .join() + .map_err(|_| anyhow!("Mutter cursor worker panicked")) + }) + .await??; + } + Ok(()) + } +} + +fn mutter_available() -> ResultType { + // Service-spawned servers have a session bus, but no XDG_CURRENT_DESKTOP. + let conn = Connection::new_session()?; + let (available,): (bool,) = conn + .with_proxy( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + DBUS_TIMEOUT, + ) + .method_call("org.freedesktop.DBus", "NameHasOwner", (BUS,))?; + Ok(available) +} + +fn stopped(receiver: &Receiver<()>) -> bool { + !matches!(receiver.try_recv(), Err(TryRecvError::Empty)) +} + +fn run(target: (i32, u64), connector: String, stop: Receiver<()>) -> ResultType<()> { + let session = Session::new()?; + let Some(node) = session.start(&super::normalize_connector(&connector), &stop)? else { + return Ok(()); + }; + let stream = pipewire::Stream::new(node, move |cursor| { + // These sprites already have the monitor's upright orientation and physical scale. + super::set_drm_cursor(target.0, target.1, cursor); + })?; + let started = Instant::now(); + let mut ready = false; + while !stopped(&stop) { + if stream.received()? { + if !ready { + log::info!( + "drm: using Mutter cursor metadata for display {} ({connector})", + target.0 + ); + ready = true; + } + } else if started.elapsed() >= START_TIMEOUT { + bail!("Timed out waiting for PipeWire cursor metadata"); + } + session.conn.process(POLL_INTERVAL)?; + } + // Drop order closes PipeWire before stopping its Mutter session. + Ok(()) +} + +struct Session { + conn: Connection, + path: Path<'static>, +} + +impl Session { + fn new() -> ResultType { + let conn = Connection::new_session()?; + let (path,): (Path<'static>,) = conn + .with_proxy(BUS, "/org/gnome/Mutter/ScreenCast", DBUS_TIMEOUT) + .method_call(BUS, "CreateSession", (PropMap::new(),))?; + Ok(Self { conn, path }) + } + + fn start(&self, connector: &str, stop: &Receiver<()>) -> ResultType> { + let mut options = PropMap::new(); + options.insert( + "cursor-mode".into(), + Variant(Box::new(CURSOR_METADATA_MODE)), + ); + let proxy = self.conn.with_proxy(BUS, self.path.clone(), DBUS_TIMEOUT); + let (stream,): (Path<'static>,) = + proxy.method_call(SESSION_INTERFACE, "RecordMonitor", (connector, options))?; + let (sender, receiver) = mpsc::channel(); + let rule = MatchRule::new_signal(STREAM_INTERFACE, "PipeWireStreamAdded") + .with_sender(BUS) + .with_path(stream); + self.conn + .add_match(rule, move |(node,): (u32,), _, _| sender.send(node).is_ok())?; + proxy.method_call::<(), _, _, _>(SESSION_INTERFACE, "Start", ())?; + let started = Instant::now(); + while !stopped(stop) { + match receiver.try_recv() { + Ok(node) => return Ok(Some(node)), + Err(TryRecvError::Disconnected) => bail!("Mutter cursor node subscription closed"), + Err(TryRecvError::Empty) => {} + } + if started.elapsed() >= START_TIMEOUT { + bail!("Timed out waiting for the Mutter cursor node"); + } + self.conn.process(POLL_INTERVAL)?; + } + Ok(None) + } +} + +impl Drop for Session { + fn drop(&mut self) { + let result: Result<(), _> = self + .conn + .with_proxy(BUS, self.path.clone(), DBUS_TIMEOUT) + .method_call(SESSION_INTERFACE, "Stop", ()); + if let Err(error) = result { + log::error!("drm: could not stop the Mutter cursor session: {error}"); + } + } +} diff --git a/src/server/drm_capturer/cursor/ffi.rs b/src/server/drm_capturer/cursor/ffi.rs new file mode 100644 index 000000000..7019069c7 --- /dev/null +++ b/src/server/drm_capturer/cursor/ffi.rs @@ -0,0 +1,232 @@ +// The stable PipeWire 0.3/SPA C ABI; load it at runtime like the DRM capture library. +use hbb_common::{anyhow::anyhow, libloading::Library, ResultType}; +use std::{ + ffi::{c_char, c_int, c_void}, + mem::size_of, + sync::OnceLock, +}; + +pub type Handle = *mut c_void; +pub const META_CURSOR: u32 = 5; +pub const PARAM_FORMAT: u32 = 4; +pub const STREAM_ERROR: c_int = -1; +pub const STREAM_UNCONNECTED: c_int = 0; +pub const DIRECTION_INPUT: c_int = 0; +pub const AUTOCONNECT: u32 = 1; +pub const DONT_RECONNECT: u32 = 1 << 7; +const PARAM_ENUM_FORMAT: u32 = 3; +const PARAM_META: u32 = 6; +const TYPE_ID: u32 = 3; +const TYPE_INT: u32 = 4; +const TYPE_OBJECT: u32 = 15; +const TYPE_CHOICE: u32 = 19; +const CHOICE_RANGE: u32 = 1; +const OBJECT_FORMAT: u32 = 0x40003; +const OBJECT_META: u32 = 0x40005; +const FORMAT_MEDIA_TYPE: u32 = 1; +const FORMAT_MEDIA_SUBTYPE: u32 = 2; +const FORMAT_VIDEO: u32 = 0x20001; +const MEDIA_VIDEO: u32 = 2; +const MEDIA_RAW: u32 = 1; +const VIDEO_BGRA: u32 = 12; +const META_TYPE: u32 = 1; +const META_SIZE: u32 = 2; + +macro_rules! api { + ($($name:ident: $signature:ty),* $(,)?) => { + pub struct Api { + _library: Library, + $(pub $name: $signature,)* + } + + impl Api { + pub fn get() -> ResultType<&'static Self> { + static API: OnceLock> = OnceLock::new(); + API.get_or_init(|| unsafe { Self::load() }.map_err(|e| e.to_string())) + .as_ref().map_err(|e| anyhow!("PipeWire cursor library: {e}")) + } + + unsafe fn load() -> ResultType { + let library = Library::new("libpipewire-0.3.so.0")?; + let api = Self { + $($name: *library.get(concat!(stringify!($name), "\0").as_bytes())?,)* + _library: library, + }; + (api.pw_init)(std::ptr::null_mut(), std::ptr::null_mut()); + Ok(api) + } + } + }; +} + +api! { + pw_init: unsafe extern "C" fn(*mut c_int, *mut *mut *mut c_char), + pw_thread_loop_new: unsafe extern "C" fn(*const c_char, Handle) -> Handle, + pw_thread_loop_get_loop: unsafe extern "C" fn(Handle) -> Handle, + pw_thread_loop_start: unsafe extern "C" fn(Handle) -> c_int, + pw_thread_loop_stop: unsafe extern "C" fn(Handle), + pw_thread_loop_destroy: unsafe extern "C" fn(Handle), + pw_thread_loop_lock: unsafe extern "C" fn(Handle), + pw_thread_loop_unlock: unsafe extern "C" fn(Handle), + pw_properties_new_string: unsafe extern "C" fn(*const c_char) -> Handle, + pw_stream_new_simple: unsafe extern "C" fn(Handle, *const c_char, Handle, *const Events, Handle) -> Handle, + pw_stream_connect: unsafe extern "C" fn(Handle, c_int, u32, u32, *const *const Pod, u32) -> c_int, + pw_stream_update_params: unsafe extern "C" fn(Handle, *const *const Pod, u32) -> c_int, + pw_stream_dequeue_buffer: unsafe extern "C" fn(Handle) -> *mut PwBuffer, + pw_stream_queue_buffer: unsafe extern "C" fn(Handle, *mut PwBuffer) -> c_int, + pw_stream_destroy: unsafe extern "C" fn(Handle), +} + +#[repr(C)] +pub struct Events { + pub version: u32, + pub destroy: Option, + pub state_changed: Option, + pub control_info: Option, + pub io_changed: Option, + pub param_changed: Option, + pub add_buffer: Option, + pub remove_buffer: Option, + pub process: Option, + pub drained: Option, + pub command: Option, + pub trigger_done: Option, +} + +#[repr(C)] +pub struct PwBuffer { + pub buffer: *const Buffer, +} + +#[repr(C)] +pub struct Buffer { + pub n_metas: u32, + pub n_datas: u32, + pub metas: *const Meta, + pub datas: Handle, +} + +#[repr(C)] +pub struct Meta { + pub kind: u32, + pub size: u32, + pub data: Handle, +} + +#[repr(C)] +pub struct Pod { + size: u32, + kind: u32, +} + +#[repr(C)] +struct Property { + key: u32, + flags: u32, + pod: Pod, + value: u32, + padding: u32, +} + +impl Property { + fn new(key: u32, kind: u32, value: u32) -> Self { + Self { + key, + flags: 0, + pod: Pod { + size: size_of::() as u32, + kind, + }, + value, + padding: 0, + } + } +} + +#[repr(C, align(8))] +pub struct Object { + pub pod: Pod, + kind: u32, + id: u32, + properties: [Property; N], +} + +impl Object { + fn new(kind: u32, id: u32, properties: [Property; N]) -> Self { + Self { + pod: Pod { + size: (size_of::() - size_of::()) as u32, + kind: TYPE_OBJECT, + }, + kind, + id, + properties, + } + } +} + +pub fn video_format() -> Object<3> { + Object::new( + OBJECT_FORMAT, + PARAM_ENUM_FORMAT, + [ + Property::new(FORMAT_MEDIA_TYPE, TYPE_ID, MEDIA_VIDEO), + Property::new(FORMAT_MEDIA_SUBTYPE, TYPE_ID, MEDIA_RAW), + Property::new(FORMAT_VIDEO, TYPE_ID, VIDEO_BGRA), + ], + ) +} + +#[repr(C)] +struct SizeRange { + key: u32, + flags: u32, + pod: Pod, + choice: u32, + choice_flags: u32, + child: Pod, + values: [u32; 3], + padding: u32, +} + +#[repr(C, align(8))] +pub struct CursorMeta { + pub pod: Pod, + kind: u32, + id: u32, + meta_type: Property, + size: SizeRange, +} + +pub fn cursor_meta() -> CursorMeta { + CursorMeta { + pod: Pod { + size: (size_of::() - size_of::()) as u32, + kind: TYPE_OBJECT, + }, + kind: OBJECT_META, + id: PARAM_META, + meta_type: Property::new(META_TYPE, TYPE_ID, META_CURSOR), + size: SizeRange { + key: META_SIZE, + flags: 0, + pod: Pod { + size: (size_of::<[u32; 2]>() + size_of::() + size_of::<[u32; 3]>()) as u32, + kind: TYPE_CHOICE, + }, + choice: CHOICE_RANGE, + choice_flags: 0, + child: Pod { + size: size_of::() as u32, + kind: TYPE_INT, + }, + // Older Mutter allocates 64x64, newer versions 384x384. Let the producer choose. + values: [ + super::metadata::META_BYTES, + super::metadata::META_HEADER_BYTES, + i32::MAX as u32, + ], + padding: 0, + }, + } +} diff --git a/src/server/drm_capturer/cursor/metadata.rs b/src/server/drm_capturer/cursor/metadata.rs new file mode 100644 index 000000000..237de8efe --- /dev/null +++ b/src/server/drm_capturer/cursor/metadata.rs @@ -0,0 +1,255 @@ +use super::DrmCursorData; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + io, + mem::size_of, +}; + +const RGBA: u32 = 11; +const PIXEL_BYTES: usize = 4; +const CURSOR_WORDS: usize = 7; +const BITMAP_WORDS: usize = 5; +const CURSOR_BYTES: usize = CURSOR_WORDS * size_of::(); +const BITMAP_BYTES: usize = BITMAP_WORDS * size_of::(); +pub const META_HEADER_BYTES: u32 = (CURSOR_BYTES + BITMAP_BYTES) as u32; +// Preferred allocation; negotiation accepts the compositor's metadata size. +const CURSOR_META_SIDE: usize = 384; +pub const META_BYTES: u32 = + META_HEADER_BYTES + (CURSOR_META_SIDE * CURSOR_META_SIDE * PIXEL_BYTES) as u32; + +#[derive(Default)] +pub struct CursorState { + image: Option, + published: Option, +} + +impl CursorState { + pub fn update(&mut self, data: &[u8]) -> io::Result> { + // Mutter uses id 0 outside this monitor or while the pointer is hidden. Reentry may + // contain only a position, so retain the sprite while publishing the hidden sentinel. + if words::(data)?[0] == 0 { + if self.published == Some(scrap::drm_reader::HIDDEN_CURSOR_ID) { + return Ok(None); + } + self.published = Some(scrap::drm_reader::HIDDEN_CURSOR_ID); + return Ok(Some(hidden_cursor())); + } + if let Some(cursor) = decode(data)? { + self.image = Some(cursor); + } + if let Some(cursor) = self.image.as_ref() { + if self.published != Some(cursor.id) { + self.published = Some(cursor.id); + return Ok(Some(cursor.clone())); + } + } + Ok(None) + } +} + +fn hidden_cursor() -> DrmCursorData { + DrmCursorData { + id: scrap::drm_reader::HIDDEN_CURSOR_ID, + width: 1, + height: 1, + hotx: 0, + hoty: 0, + colors: vec![0; PIXEL_BYTES], + } +} + +fn invalid(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +fn words(data: &[u8]) -> io::Result<[u32; N]> { + let bytes = data + .get(..N * size_of::()) + .ok_or_else(|| invalid("Truncated PipeWire cursor metadata"))?; + let mut values = [0; N]; + for (value, bytes) in values.iter_mut().zip(bytes.chunks_exact(size_of::())) { + *value = u32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + } + Ok(values) +} + +fn decode(data: &[u8]) -> io::Result> { + let [id, _flags, _x, _y, hotx, hoty, offset] = words::(data)?; + // Position-only updates have no valid hotspot or bitmap. Keep the previous shape. + if id == 0 || offset == 0 { + return Ok(None); + } + if (offset as usize) < CURSOR_BYTES { + return Err(invalid("PipeWire cursor bitmap overlaps its header")); + } + let bitmap = data + .get(offset as usize..) + .ok_or_else(|| invalid("PipeWire cursor bitmap offset exceeds metadata"))?; + let [format, width, height, stride, offset] = words::(bitmap)?; + if offset == 0 { + return Ok(Some(hidden_cursor())); + } + if format == 0 { + return Ok(None); + } + let mut cursor = decode_bitmap(bitmap, [format, width, height, stride, offset])?; + cursor.hotx = hotx as i32; + cursor.hoty = hoty as i32; + let mut hash = DefaultHasher::new(); + (cursor.width, cursor.height, cursor.hotx, cursor.hoty).hash(&mut hash); + cursor.colors.hash(&mut hash); + // Mutter reuses id 1 for every sprite, including different hotspots. + cursor.id = hash.finish(); + Ok(Some(cursor)) +} + +fn decode_bitmap(data: &[u8], header: [u32; BITMAP_WORDS]) -> io::Result { + let [format, width, height, stride, offset] = header; + if format != RGBA { + return Err(invalid("Unsupported PipeWire cursor pixel format")); + } + if width == 0 || height == 0 || width > i32::MAX as u32 || height > i32::MAX as u32 { + return Err(invalid("Invalid PipeWire cursor dimensions")); + } + let row = (width as usize) + .checked_mul(PIXEL_BYTES) + .ok_or_else(|| invalid("PipeWire cursor row overflows"))?; + if (stride as i32) <= 0 || (stride as usize) < row || (offset as usize) < BITMAP_BYTES { + return Err(invalid("Invalid PipeWire cursor stride or pixel offset")); + } + let length = (stride as usize) + .checked_mul(height as usize - 1) + .and_then(|n| n.checked_add(row)) + .ok_or_else(|| invalid("PipeWire cursor bitmap size overflows"))?; + let pixels = data + .get(offset as usize..) + .and_then(|bytes| bytes.get(..length)) + .ok_or_else(|| invalid("Truncated PipeWire cursor pixels"))?; + let mut colors = Vec::with_capacity(row * height as usize); + for bytes in pixels.chunks(stride as usize) { + colors.extend_from_slice(&bytes[..row]); + } + Ok(DrmCursorData { + id: 0, + width: width as i32, + height: height as i32, + hotx: 0, + hoty: 0, + colors, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn packet(hotspot: [u32; 2]) -> Vec { + let mut bytes: Vec<_> = [ + 1, + 0, + 100, + 200, + hotspot[0], + hotspot[1], + CURSOR_BYTES as u32, + RGBA, + 96, + 96, + 96 * PIXEL_BYTES as u32, + BITMAP_BYTES as u32, + ] + .into_iter() + .flat_map(u32::to_ne_bytes) + .collect(); + bytes.resize(CURSOR_BYTES + BITMAP_BYTES + 96 * 96 * PIXEL_BYTES, 0); + let artwork = CURSOR_BYTES + BITMAP_BYTES + (4 * 96 + 4) * PIXEL_BYTES; + bytes[artwork..artwork + PIXEL_BYTES].copy_from_slice(&[128, 64, 32, 128]); + bytes + } + + #[test] + fn compositor_hotspot_is_independent_of_visible_bounds_and_id() { + let bytes = packet([42, 42]); + let cross = decode(&bytes).unwrap().unwrap(); + assert_eq!( + (cross.width, cross.height, cross.hotx, cross.hoty), + (96, 96, 42, 42) + ); + assert_eq!(&cross.colors, &bytes[CURSOR_BYTES + BITMAP_BYTES..]); + let origin = decode(&packet([0, 0])).unwrap().unwrap(); + assert_eq!((origin.hotx, origin.hoty), (0, 0)); + assert_ne!(cross.id, origin.id); + let mut changed = bytes.clone(); + *changed.last_mut().unwrap() = 255; + assert_ne!(cross.id, decode(&changed).unwrap().unwrap().id); + } + + #[test] + fn movement_keeps_the_shape_and_empty_bitmap_hides_it() { + let mut bytes = packet([42, 45]); + bytes[CURSOR_BYTES - size_of::()..CURSOR_BYTES].fill(0); + assert!(decode(&bytes).unwrap().is_none()); + bytes[..size_of::()].fill(0); + assert!(decode(&bytes).unwrap().is_none()); + let mut hidden = packet([42, 45]); + hidden[CURSOR_BYTES..CURSOR_BYTES + BITMAP_BYTES].fill(0); + let hidden = decode(&hidden).unwrap().unwrap(); + assert_eq!(hidden.id, scrap::drm_reader::HIDDEN_CURSOR_ID); + assert_eq!(hidden.colors, [0; PIXEL_BYTES]); + } + + #[test] + fn leaving_and_returning_to_a_monitor_restores_the_cached_sprite() { + let mut state = CursorState::default(); + let mut bytes = packet([42, 42]); + let initial = state.update(&bytes).unwrap().unwrap(); + bytes[..size_of::()].fill(0); + let hidden = state + .update(&bytes) + .unwrap() + .expect("Mutter id 0 hides the cursor"); + assert_eq!(hidden.id, scrap::drm_reader::HIDDEN_CURSOR_ID); + assert!(state.update(&bytes).unwrap().is_none()); + bytes[..size_of::()].copy_from_slice(&1u32.to_ne_bytes()); + bytes[CURSOR_BYTES - size_of::()..CURSOR_BYTES].fill(0); + let restored = state + .update(&bytes) + .unwrap() + .expect("Position-only reentry restores the sprite"); + assert_eq!( + (restored.id, restored.hotx, restored.hoty), + (initial.id, 42, 42) + ); + assert_eq!(restored.colors, initial.colors); + assert!(state.update(&bytes).unwrap().is_none()); + } + + #[test] + fn malformed_metadata_is_an_error() { + let bytes = packet([12, 3]); + for length in [ + 0, + CURSOR_BYTES - 1, + CURSOR_BYTES + BITMAP_BYTES - 1, + bytes.len() - 1, + ] { + assert!(decode(&bytes[..length]).is_err()); + } + for (word, value) in [ + (6, 1), + (6, u32::MAX), + (7, 99), + (8, 0), + (9, u32::MAX), + (10, 1), + (10, u32::MAX), + (11, 1), + ] { + let mut invalid = bytes.clone(); + invalid[word * size_of::()..(word + 1) * size_of::()] + .copy_from_slice(&value.to_ne_bytes()); + assert!(decode(&invalid).is_err(), "word {word}, value {value}"); + } + } +} diff --git a/src/server/drm_capturer/cursor/pipewire.rs b/src/server/drm_capturer/cursor/pipewire.rs new file mode 100644 index 000000000..75e8d4760 --- /dev/null +++ b/src/server/drm_capturer/cursor/pipewire.rs @@ -0,0 +1,218 @@ +use super::{ffi, metadata, DrmCursorData}; +use hbb_common::{anyhow::anyhow, bail, ResultType}; +use std::{ + cell::UnsafeCell, + ffi::{c_char, c_int, CStr}, + ptr, slice, +}; + +struct State { + api: &'static ffi::Api, + stream: ffi::Handle, + publish: Box, + error: Option, + received: bool, + cursor: metadata::CursorState, +} + +pub struct Stream { + api: &'static ffi::Api, + thread: ffi::Handle, + // PipeWire callbacks own this state while holding the thread-loop lock. + state: Box>, + started: bool, +} + +impl Stream { + pub fn new(node: u32, publish: impl FnMut(DrmCursorData) + Send + 'static) -> ResultType { + let api = ffi::Api::get()?; + let thread = unsafe { + (api.pw_thread_loop_new)(b"rustdesk-cursor\0".as_ptr().cast(), ptr::null_mut()) + }; + if thread.is_null() { + bail!("Could not create the PipeWire cursor loop"); + } + let mut stream = Self { + api, + thread, + started: false, + state: Box::new(UnsafeCell::new(State { + api, + stream: ptr::null_mut(), + publish: Box::new(publish), + error: None, + received: false, + cursor: metadata::CursorState::default(), + })), + }; + stream.connect(node)?; + Ok(stream) + } + + fn connect(&mut self, node: u32) -> ResultType<()> { + // The loop has not started; callbacks during setup run synchronously on this thread. + unsafe { + let properties = (self.api.pw_properties_new_string)( + b"media.type=Video media.category=Capture media.role=Screen\0" + .as_ptr() + .cast(), + ); + if properties.is_null() { + bail!("Could not create PipeWire cursor properties"); + } + let stream = (self.api.pw_stream_new_simple)( + (self.api.pw_thread_loop_get_loop)(self.thread), + b"RustDesk cursor\0".as_ptr().cast(), + properties, + &EVENTS, + self.state.get().cast(), + ); + (*self.state.get()).stream = stream; + if stream.is_null() { + bail!("Could not create the PipeWire cursor stream"); + } + let format = ffi::video_format(); + let params = [&format.pod as *const _]; + check((self.api.pw_stream_connect)( + stream, + ffi::DIRECTION_INPUT, + node, + ffi::AUTOCONNECT | ffi::DONT_RECONNECT, + params.as_ptr(), + params.len() as u32, + ))?; + check((self.api.pw_thread_loop_start)(self.thread))?; + self.started = true; + } + Ok(()) + } + + pub fn received(&self) -> ResultType { + unsafe { + (self.api.pw_thread_loop_lock)(self.thread); + let state = &*self.state.get(); + let result = match &state.error { + Some(error) => Err(anyhow!("PipeWire cursor: {error}")), + None => Ok(state.received), + }; + (self.api.pw_thread_loop_unlock)(self.thread); + result + } + } +} + +impl Drop for Stream { + fn drop(&mut self) { + unsafe { + if self.started { + (self.api.pw_thread_loop_stop)(self.thread); + } + let stream = (*self.state.get()).stream; + if !stream.is_null() { + (self.api.pw_stream_destroy)(stream); + } + (self.api.pw_thread_loop_destroy)(self.thread); + } + } +} + +fn check(result: c_int) -> ResultType<()> { + if result < 0 { + bail!("{}", std::io::Error::from_raw_os_error(-result)); + } + Ok(()) +} + +unsafe extern "C" fn state_changed( + data: ffi::Handle, + old: c_int, + state: c_int, + error: *const c_char, +) { + let context = &mut *data.cast::(); + if state == ffi::STREAM_ERROR || (state == ffi::STREAM_UNCONNECTED && old != state) { + context.error = Some(if error.is_null() { + "Cursor stream disconnected".to_owned() + } else { + CStr::from_ptr(error).to_string_lossy().into_owned() + }); + } +} + +unsafe extern "C" fn param_changed(data: ffi::Handle, id: u32, param: *const ffi::Pod) { + if id != ffi::PARAM_FORMAT || param.is_null() { + return; + } + let context = data.cast::(); + let meta = ffi::cursor_meta(); + let params = [&meta.pod as *const _]; + if let Err(error) = check(((*context).api.pw_stream_update_params)( + (*context).stream, + params.as_ptr(), + params.len() as u32, + )) { + (*context).error = Some(error.to_string()); + } +} + +unsafe fn cursor( + decoder: &mut metadata::CursorState, + buffer: *const ffi::Buffer, +) -> ResultType> { + let buffer = buffer + .as_ref() + .ok_or_else(|| anyhow!("Missing PipeWire buffer"))?; + if buffer.metas.is_null() { + bail!("PipeWire did not negotiate cursor metadata"); + } + let metas = slice::from_raw_parts(buffer.metas, buffer.n_metas as usize); + let meta = metas + .iter() + .find(|meta| meta.kind == ffi::META_CURSOR) + .ok_or_else(|| anyhow!("PipeWire did not negotiate cursor metadata"))?; + if meta.data.is_null() { + bail!("Missing PipeWire cursor metadata payload"); + } + Ok(decoder.update(slice::from_raw_parts(meta.data.cast(), meta.size as usize))?) +} + +unsafe extern "C" fn process(data: ffi::Handle) { + let state = data.cast::(); + let buffer = ((*state).api.pw_stream_dequeue_buffer)((*state).stream); + if buffer.is_null() { + return; + } + let result = cursor(&mut (*state).cursor, (*buffer).buffer); + let queued = check(((*state).api.pw_stream_queue_buffer)( + (*state).stream, + buffer, + )); + let context = &mut *state; + if context.error.is_some() { + return; + } + match result.and_then(|cursor| queued.map(|_| cursor)) { + Ok(cursor) => { + context.received = true; + if let Some(cursor) = cursor { + (context.publish)(cursor); + } + } + Err(error) => context.error = Some(error.to_string()), + } +} + +static EVENTS: ffi::Events = ffi::Events { + version: 2, + destroy: None, + state_changed: Some(state_changed), + control_info: None, + io_changed: None, + param_changed: Some(param_changed), + add_buffer: None, + remove_buffer: None, + process: Some(process), + drained: None, + command: None, + trigger_done: None, +};