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.
This commit is contained in:
fufesou
2026-09-11 11:49:04 +08:00
parent f96d00d9d1
commit 72ea38ca4f
16 changed files with 1644 additions and 12 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

@@ -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,

View File

@@ -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<FFI>(_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 {

View File

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

View File

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

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

@@ -35,12 +35,14 @@ use std::{
sync::Mutex,
};
mod cursor;
// macOS boolean_t is defined as `int` in <mach/boolean.h>
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<Option<u64>> {
fn unsafe_get_cursor() -> ResultType<Option<u64>> {
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<Option<u64>> {
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<CursorData> {
// https://github.com/stweil/OSXvnc/blob/master/OSXvnc-server/mousecursor.c
fn unsafe_get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
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<CursorData> {
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 _);
}
}

View File

@@ -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<f64> {
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<StrongPtr> {
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<CursorData> {
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));
}
}

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,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<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,
..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,
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)
);
}
}

View File

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

View File

@@ -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<Sender<()>>,
thread: Option<JoinHandle<()>>,
error: Arc<Mutex<Option<String>>>,
}
impl Capture {
pub async fn start(display: i32, epoch: u64, connector: String) -> ResultType<Option<Self>> {
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<String> {
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<bool> {
// 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<Self> {
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<Option<u32>> {
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}");
}
}
}

View File

@@ -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<Result<Api, String>> = 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<Self> {
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<unsafe extern "C" fn()>,
pub state_changed: Option<unsafe extern "C" fn(Handle, c_int, c_int, *const c_char)>,
pub control_info: Option<unsafe extern "C" fn()>,
pub io_changed: Option<unsafe extern "C" fn()>,
pub param_changed: Option<unsafe extern "C" fn(Handle, u32, *const Pod)>,
pub add_buffer: Option<unsafe extern "C" fn()>,
pub remove_buffer: Option<unsafe extern "C" fn()>,
pub process: Option<unsafe extern "C" fn(Handle)>,
pub drained: Option<unsafe extern "C" fn()>,
pub command: Option<unsafe extern "C" fn()>,
pub trigger_done: Option<unsafe extern "C" fn()>,
}
#[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::<u32>() as u32,
kind,
},
value,
padding: 0,
}
}
}
#[repr(C, align(8))]
pub struct Object<const N: usize> {
pub pod: Pod,
kind: u32,
id: u32,
properties: [Property; N],
}
impl<const N: usize> Object<N> {
fn new(kind: u32, id: u32, properties: [Property; N]) -> Self {
Self {
pod: Pod {
size: (size_of::<Self>() - size_of::<Pod>()) 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::<CursorMeta>() - size_of::<Pod>()) 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::<Pod>() + size_of::<[u32; 3]>()) as u32,
kind: TYPE_CHOICE,
},
choice: CHOICE_RANGE,
choice_flags: 0,
child: Pod {
size: size_of::<u32>() 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,
},
}
}

View File

@@ -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::<u32>();
const BITMAP_BYTES: usize = BITMAP_WORDS * size_of::<u32>();
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<DrmCursorData>,
published: Option<u64>,
}
impl CursorState {
pub fn update(&mut self, data: &[u8]) -> io::Result<Option<DrmCursorData>> {
// 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::<CURSOR_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<const N: usize>(data: &[u8]) -> io::Result<[u32; N]> {
let bytes = data
.get(..N * size_of::<u32>())
.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::<u32>())) {
*value = u32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
}
Ok(values)
}
fn decode(data: &[u8]) -> io::Result<Option<DrmCursorData>> {
let [id, _flags, _x, _y, hotx, hoty, offset] = words::<CURSOR_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_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<DrmCursorData> {
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<u8> {
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::<u32>()..CURSOR_BYTES].fill(0);
assert!(decode(&bytes).unwrap().is_none());
bytes[..size_of::<u32>()].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::<u32>()].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::<u32>()].copy_from_slice(&1u32.to_ne_bytes());
bytes[CURSOR_BYTES - size_of::<u32>()..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::<u32>()..(word + 1) * size_of::<u32>()]
.copy_from_slice(&value.to_ne_bytes());
assert!(decode(&invalid).is_err(), "word {word}, value {value}");
}
}
}

View File

@@ -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<dyn FnMut(DrmCursorData) + Send>,
error: Option<String>,
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<UnsafeCell<State>>,
started: bool,
}
impl Stream {
pub fn new(node: u32, publish: impl FnMut(DrmCursorData) + Send + 'static) -> ResultType<Self> {
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<bool> {
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::<State>();
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::<State>();
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<Option<DrmCursorData>> {
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::<State>();
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,
};