fix(cursor): capture complete macOS Retina cursor images

This commit is contained in:
fufesou
2026-09-12 02:47:37 +08:00
parent 4f0f784532
commit e72e7fb8ba
2 changed files with 191 additions and 9 deletions

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 _);
}
}
@@ -681,6 +687,7 @@ fn unsafe_get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
hoty: hotspot.y as _,
width: size.width as _,
height: size.height as _,
scale: 1.0,
..Default::default()
})
}

View File

@@ -0,0 +1,175 @@
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 _,
scale,
..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));
}
}