From fbfa37c8fd058416319de286a4d4d0dfe8bc7c8b Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 13 Sep 2026 23:17:21 +0800 Subject: [PATCH] Preserve legacy macOS cursor dimensions with optional Retina artwork --- flutter/lib/desktop/pages/remote_page.dart | 5 +++- flutter/test/cursor_web_test.dart | 22 ++++++++++---- libs/base/protos/message.proto | 2 ++ src/flutter.rs | 1 + src/platform/macos/cursor.rs | 19 ++++++++++-- src/platform/macos/cursor/compat_tests.rs | 35 ++++++++++++++++++++++ src/server/input_service.rs | 3 ++ 7 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 src/platform/macos/cursor/compat_tests.rs diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 4d5b15ea4..45b0e74da 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -1118,7 +1118,10 @@ class _ImagePaintState extends State { ? cursor.cache ?? preDefaultCursor.cache : preForbiddenCursor.cache; final peerDpr = cache?.pixelRatio ?? 0; - if (!isWeb && isViewScaled() && zoomCursor.isFalse && peerDpr > 0) { + if (isViewScaled() && zoomCursor.isFalse && peerDpr > 0 && + (!isWeb || widget.ffi.ffiModel.pi.platform == kPeerPlatformMacOS)) { + // Retina export is physical-sized; Web must undo that change too. + // Other Web host bitmaps retain their existing sizing policy. // Adaptive/Custom scales the video, but Zoom cursor is off: preserve the // cursor's logical size instead of multiplying it by the canvas scale. // Divide by the source bitmap density to obtain logical cursor pixels. diff --git a/flutter/test/cursor_web_test.dart b/flutter/test/cursor_web_test.dart index ad6ae3686..84e3320e7 100644 --- a/flutter/test/cursor_web_test.dart +++ b/flutter/test/cursor_web_test.dart @@ -25,8 +25,15 @@ void main() { _thinCursorTests(); for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) { for (final dpr in [1.0, 2.0]) { - testWidgets('ImagePaint Web $style zoom off DPR $dpr keeps source size', - (tester) => tester.runAsync(() => _checkPolicy(tester, style, dpr))); + for (final (platform, density) in [ + (kPeerPlatformMacOS, null), (kPeerPlatformMacOS, 0.0), + (kPeerPlatformMacOS, 1.0), (kPeerPlatformMacOS, 2.0), + (kPeerPlatformLinux, 2.0), (kPeerPlatformWindows, 2.0), + ]) { + testWidgets('Web $style zoom off DPR $dpr $platform density=$density', + (tester) => tester.runAsync(() => _checkPolicy(tester, style, dpr, + pixelRatio: density, platform: platform))); + } } } test('Web cursor aligns CSS hotspots with rounded PNG dimensions', () async { @@ -202,13 +209,18 @@ Map _captureCursor() { Future _checkPolicy(WidgetTester tester, String style, double dpr, {bool zoom = false, (int, int) source = (48, 48), (double, double) hotspot = (7, 9), (int, int) expectedSize = (48, 48), - (int, int) expectedHotspot = (7, 9)}) async { + (int, int) expectedHotspot = (7, 9), double? pixelRatio = 1, + String platform = kPeerPlatformMacOS}) async { final registered = _captureCursor(); final canvas = _Canvas(style); addTearDown(canvas.dispose); final ffi = _FFI(canvas); - final cursor = await _loadCursor(ffi, '$style-$dpr-$zoom-$source', - source: source, hotspot: hotspot); + ffi.ffiModel.pi.platform = platform; + // Retina export changes the bitmap too; varying metadata alone misses this boundary. + final density = platform == kPeerPlatformMacOS && pixelRatio == 2 ? 2 : 1; + final cursor = await _loadCursor(ffi, '$style-$dpr-$zoom-$source-$platform-$pixelRatio', + source: (source.$1 * density, source.$2 * density), pixelRatio: pixelRatio, + hotspot: (hotspot.$1 * density, hotspot.$2 * density)); await tester.pumpWidget(MediaQuery( data: MediaQueryData(devicePixelRatio: dpr), child: MultiProvider( diff --git a/libs/base/protos/message.proto b/libs/base/protos/message.proto index 1d8eccc0c..e9f1b4394 100644 --- a/libs/base/protos/message.proto +++ b/libs/base/protos/message.proto @@ -318,6 +318,8 @@ message CursorData { bytes colors = 6; // Physical bitmap pixels per remote logical pixel; 0 means unknown density. double scale = 7; + // Optional physical image. Legacy receivers keep using the logical-sized fields above. + CursorData high_resolution = 8; } message CursorPosition { diff --git a/src/flutter.rs b/src/flutter.rs index f745dbe91..ace0119b3 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -642,6 +642,7 @@ impl FlutterHandler { impl InvokeUiSession for FlutterHandler { fn set_cursor_data(&self, cd: CursorData) { + let cd = cd.high_resolution.as_ref().unwrap_or(&cd); let colors = hbb_common::compress::decompress(&cd.colors); self.push_event( "cursor_data", diff --git a/src/platform/macos/cursor.rs b/src/platform/macos/cursor.rs index 520d6bb28..8dc12ebdf 100644 --- a/src/platform/macos/cursor.rs +++ b/src/platform/macos/cursor.rs @@ -81,6 +81,16 @@ unsafe fn render(image: id, bitmap: id, size: NSSize) -> ResultType<()> { } pub(super) unsafe fn data(cursor: id, id: u64, scale: f64) -> ResultType { + // Older receivers ignore density. Keep their image logical-sized while + // retaining the complete physical artwork for density-aware controllers. + let mut legacy = physical_data(cursor, id, 1.0)?; + if scale > 1.0 { + legacy.high_resolution = Some(physical_data(cursor, id, scale)?).into(); + } + Ok(legacy) +} + +unsafe fn physical_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( @@ -142,6 +152,9 @@ fn straight_rgba(pixels: &[u8]) -> Vec { colors } +#[cfg(test)] +mod compat_tests; + #[cfg(test)] mod tests { use super::*; @@ -184,7 +197,7 @@ mod tests { let cursor = StrongPtr::new( msg_send![cursor, initWithImage: *image hotSpot: NSPoint::new(4.0, 9.0)], ); - let result = data(*cursor, 1, 2.0).unwrap(); + let result = physical_data(*cursor, 1, 2.0).unwrap(); assert_eq!( (result.width, result.height, result.hotx, result.hoty), (18, 36, 8, 18) @@ -206,7 +219,7 @@ mod tests { initWithImage: image hotSpot: NSPoint::new(point.0, point.1)]); let actual: NSPoint = msg_send![*c, hotSpot]; assert_eq!((actual.x, actual.y), point); - let result = data(*c, 1, 2.0).unwrap(); + let result = physical_data(*c, 1, 2.0).unwrap(); assert_eq!((result.hotx, result.hoty), pixels); assert_eq!(result.colors.as_ref(), expected); } @@ -244,7 +257,7 @@ mod tests { let cursor = StrongPtr::new( msg_send![cursor, initWithImage: *image hotSpot: NSPoint::new(1.0, 1.0)], ); - let result = data(*cursor, 1, SCALE).unwrap(); + let result = physical_data(*cursor, 1, SCALE).unwrap(); // Older Sciter receivers encode the received bytes directly as PNG. let mut png = Vec::new(); repng::encode( diff --git a/src/platform/macos/cursor/compat_tests.rs b/src/platform/macos/cursor/compat_tests.rs new file mode 100644 index 000000000..01ec7fc1e --- /dev/null +++ b/src/platform/macos/cursor/compat_tests.rs @@ -0,0 +1,35 @@ +use super::*; +use hbb_common::protobuf::Message; +use objc::rc::autoreleasepool; + +#[test] +fn retina_export_preserves_legacy_dimensions_and_hotspot() { + autoreleasepool(|| unsafe { + let logical = NSSize::new(32.0, 32.0); + let image: id = msg_send![class!(NSImage), alloc]; + let image = StrongPtr::new(msg_send![image, initWithSize: logical]); + let rep = bitmap(NSSize::new(64.0, 64.0)).unwrap(); + let buffer: *mut u8 = msg_send![*rep, bitmapData]; + ptr::write_bytes(buffer, 255, 64 * 64 * CHANNELS); + let (): () = msg_send![*rep, setSize: logical]; + let (): () = msg_send![*image, addRepresentation: *rep]; + let cursor: id = msg_send![class!(NSCursor), alloc]; + let cursor = StrongPtr::new(msg_send![cursor, + initWithImage: *image hotSpot: NSPoint::new(8.0, 12.0)]); + + let exported = data(*cursor, 123, 2.0).unwrap(); + let legacy = CursorData::parse_from_bytes(&exported.write_to_bytes().unwrap()).unwrap(); + assert_eq!((legacy.width, legacy.height), (32, 32)); + assert_eq!((legacy.hotx, legacy.hoty), (8, 12)); + assert_eq!(legacy.colors.len(), 32 * 32 * CHANNELS); + assert_eq!(legacy.scale, 1.0); + assert!(legacy.colors.iter().all(|channel| *channel == 255)); + let physical = legacy.high_resolution.as_ref().unwrap(); + assert_eq!((physical.width, physical.height), (64, 64)); + assert_eq!((physical.hotx, physical.hoty), (16, 24)); + assert_eq!(physical.colors.len(), 64 * 64 * CHANNELS); + assert_eq!((physical.id, physical.scale), (legacy.id, 2.0)); + assert!(physical.colors.iter().all(|channel| *channel == 255)); + assert!(data(*cursor, 123, 1.0).unwrap().high_resolution.is_none()); + }); +} diff --git a/src/server/input_service.rs b/src/server/input_service.rs index 4eb6c7b76..9bc05a307 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -422,6 +422,9 @@ fn run_cursor(sp: MouseCursorService, state: &mut StateCursor) -> ResultType<()> #[cfg(not(all(target_os = "linux", feature = "drm")))] let cache_key = hcursor; data.colors = hbb_common::compress::compress(&data.colors[..]).into(); + if let Some(physical) = data.high_resolution.as_mut() { + physical.colors = hbb_common::compress::compress(&physical.colors).into(); + } let mut tmp = Message::new(); tmp.set_cursor_data(data); msg = Arc::new(tmp);