From bbf86259e9e223940b1f01ed9a192c053d132c9e Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 12 Sep 2026 20:10:57 +0800 Subject: [PATCH] fix(cursor): encode macOS cursor alpha correctly in Sciter --- src/ui/remote.rs | 13 +++++-- src/ui/remote/cursor.rs | 79 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 src/ui/remote/cursor.rs diff --git a/src/ui/remote.rs b/src/ui/remote.rs index 2f36f5d81..a90a74b92 100644 --- a/src/ui/remote.rs +++ b/src/ui/remote.rs @@ -1,7 +1,10 @@ use std::{ collections::HashMap, ops::{Deref, DerefMut}, - sync::{atomic::AtomicUsize, Arc, Mutex, RwLock}, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, RwLock, + }, }; use sciter::{ @@ -28,6 +31,8 @@ use crate::{ type Video = AssetPtr; +mod cursor; + lazy_static::lazy_static! { static ref VIDEO: Arc>> = Default::default(); } @@ -39,6 +44,8 @@ lazy_static::lazy_static! { pub struct SciterHandler { element: Arc>>, close_state: HashMap, + // The I/O session clones its UI handler, so share the peer's pixel format. + peer_is_macos: Arc, } impl SciterHandler { @@ -122,7 +129,7 @@ impl SciterHandler { impl InvokeUiSession for SciterHandler { fn set_cursor_data(&self, cd: CursorData) { - let mut colors = hbb_common::compress::decompress(&cd.colors); + let mut colors = cursor::png_colors(&cd, self.peer_is_macos.load(Ordering::Relaxed)); if colors.iter().filter(|x| **x != 0).next().is_none() { log::info!("Fix transparent"); // somehow all 0 images shows black rect, here is a workaround @@ -301,6 +308,8 @@ impl InvokeUiSession for SciterHandler { } fn set_peer_info(&self, pi: &PeerInfo) { + self.peer_is_macos + .store(pi.platform == "Mac OS", Ordering::Relaxed); let mut pi_sciter = Value::map(); pi_sciter.set_item("username", pi.username.clone()); pi_sciter.set_item("hostname", pi.hostname.clone()); diff --git a/src/ui/remote/cursor.rs b/src/ui/remote/cursor.rs new file mode 100644 index 000000000..69e5d5187 --- /dev/null +++ b/src/ui/remote/cursor.rs @@ -0,0 +1,79 @@ +use base::message_proto::CursorData; + +const CHANNELS: usize = 4; +const ALPHA: usize = CHANNELS - 1; +const MAX_CHANNEL: u16 = u8::MAX as u16; + +pub(super) fn png_colors(cursor: &CursorData, is_macos: bool) -> Vec { + let mut colors = hbb_common::compress::decompress(&cursor.colors); + // New macOS packets are premultiplied for Flutter, but PNG stores straight + // colors. Legacy macOS packets (scale zero) already contain straight RGBA. + if !(is_macos && cursor.scale > 0.0) { + return colors; + } + for pixel in colors.chunks_exact_mut(CHANNELS) { + let alpha = u16::from(pixel[ALPHA]); + if alpha == 0 { + continue; + } + for channel in &mut pixel[..ALPHA] { + // Capture can round RGB and alpha differently by one channel value. + *channel = + ((u16::from(*channel) * MAX_CHANNEL + alpha / 2) / alpha).min(MAX_CHANNEL) as u8; + } + } + colors +} + +#[cfg(test)] +mod tests { + use super::*; + + const STRAIGHT: [u8; 16] = [ + 255, 255, 255, 128, 128, 64, 32, 128, 240, 100, 20, 255, 0, 0, 0, 0, + ]; + const PREMULTIPLIED: [u8; 16] = [ + 128, 128, 128, 128, 64, 32, 16, 128, 240, 100, 20, 255, 0, 0, 0, 0, + ]; + + fn encode_packet(pixels: &[u8], scale: f64, is_macos: bool) -> Vec { + let cursor = CursorData { + width: 4, + height: 1, + colors: hbb_common::compress::compress(pixels).into(), + scale, + ..Default::default() + }; + let colors = png_colors(&cursor, is_macos); + let mut png = Vec::new(); + repng::encode(&mut png, cursor.width as _, cursor.height as _, &colors).unwrap(); + assert_eq!(hbb_common::compress::decompress(&cursor.colors), pixels); + image::load_from_memory_with_format(&png, image::ImageFormat::Png) + .unwrap() + .to_rgba8() + .into_raw() + } + + #[test] + fn macos_density_packets_encode_straight_png_colors() { + for scale in [1.0, 2.0] { + let actual = encode_packet(&PREMULTIPLIED, scale, true); + for (got, expected) in actual + .chunks_exact(CHANNELS) + .zip(STRAIGHT.chunks_exact(CHANNELS)) + { + assert_eq!(got[ALPHA], expected[ALPHA]); + for channel in 0..ALPHA { + assert!(got[channel].abs_diff(expected[channel]) <= 1, "{actual:?}"); + } + } + } + } + + #[test] + fn legacy_and_non_macos_packets_keep_their_colors() { + for (scale, is_macos) in [(0.0, true), (0.0, false), (2.0, false)] { + assert_eq!(encode_packet(&STRAIGHT, scale, is_macos), STRAIGHT); + } + } +}