mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-20 19:31:00 +03:00
fix(cursor): correct native cursor sizing and validate received images (#16213)
* fix(flutter): shrink the unzoomed remote cursor by DPR on macOS and Linux With "Zoom cursor" off in Adaptive or Custom view, the remote cursor bitmap was registered at scale 1.0. NSCursor and GdkCursor treat the bitmap size as logical pixels, so on a HiDPI controller the cursor was drawn DPR times larger than in Original view (which already passes 1/DPR) and than on Windows (whose cursor path is in physical pixels). A HiDPI remote such as KDE Wayland sends a 48-64 px bitmap, which then showed up 3-4x too big on a Retina Mac. Scale the bitmap by 1/DPR in that case, and scale the Flutter-painted cursor used while the peer moves the mouse the same way so its size does not jump. The new branch is an identity at DPR 1 and the Windows paths are untouched. Fixes https://github.com/rustdesk/rustdesk/discussions/15363 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K8HSHTEHo27mDcJXVyVfSP * fix(flutter): check the cursor height against the min cursor size `_checkUpdateScale` computed the scaled height from `width`, so the min-size clamp never looked at the height. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K8HSHTEHo27mDcJXVyVfSP * fix(flutter): keep the painted cursor hotspot in place when zoom cursor is off `CursorPaint` subtracted the hotspot in remote pixels and then scaled it by the canvas scale, but drew the image at scale 1.0, so the hotspot landed hotx * (1 - scale) logical pixels away from the remote cursor position. Cursors with a centered hotspot (I-beam, crosshair) were off by up to half their size in Adaptive view. Subtract the hotspot after scaling the position instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K8HSHTEHo27mDcJXVyVfSP * fix(flutter): read the live DPR and clamp the painted cursor like the native one CanvasModel caches devicePixelRatio and only refreshes it when the view style changes, so after the window moves to a monitor with a different DPR the unzoomed cursor kept the previous monitor's scale. Read it from MediaQuery instead, which also rebuilds the cursor when it changes. The native path clamps the scaled bitmap to kMinCursorSize; apply the same clamp to the painted cursor so a small cursor does not change size when the peer moves the mouse. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K8HSHTEHo27mDcJXVyVfSP * build(cursor): declare existing Zstd dependency for bounded decoding * fix(cursor): validate and bound received cursor images * fix(cursor): preserve thin cursor sizes when scaling * fix(cursor): limit view changes to native cursor sizing * fix(cursor): apply long-edge minimum to Web cursor sizing * fix: preserve Linux cursor alpha and match Windows Custom scale * fix(cursor): keep scaled buffers and raster dimensions in sync * fix(cursor): preserve Windows peer alpha when resizing * fix(cursor): preserve mixed alpha during downsampling * comments Signed-off-by: fufesou <linlong1266@gmail.com> * fix(cursor): match desktop cursor size to peer display density * refactor(cursor): clarify desktop and web scale branches * fix(cursor): correct adaptive display scaling and fixed cursor size * fix(cursor): apply all-display adaptive density on every desktop * fix(cursor): normalize unzoomed all-display cursor density * fix(cursor): synchronize original view on DPR changes * fix(cursor): match original zoom to the active renderer * fix: align Wayland software scrolling with input coordinates * fix: preserve scroll offsets when switching scrolling modes * fix: correct cursor size and pointer mapping with custom scale Apply the hovered Linux display density to Custom cursor zoom in All Displays. Refresh scroll fractions after layout so changing the Custom percentage uses current scrollbar extents and detached controllers. Validated with macOS and Windows component tests, formatting, and static analysis. * fix(linux): pad tall native cursors to prevent clipping * fix(cursor): preserve macOS point size in unzoomed views * fix(linux): pad rectangular cursors to square canvases * fix(cursor): divide dpr on Linux -> macOS Signed-off-by: fufesou <linlong1266@gmail.com> * fix: cursor size test Signed-off-by: fufesou <linlong1266@gmail.com> * fix(cursor): cursor size of controlled side macOS Signed-off-by: fufesou <linlong1266@gmail.com> * fix(cursor): limit received cursor allocations * test(cursor): retain reverse raster transition coverage * fix(cursor): bound compressed cursor input * fix(cursor): restrict the scaled size of the cursor Signed-off-by: fufesou <linlong1266@gmail.com> * fix(cursor): align hotspots with resized raster dimensions Calculate each hotspot axis from the actual raster-to-source ratio. Update existing boundary tests and run cursor tests in Flutter CI. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(cursor): align Sciter hotspots with raster dimensions Calculate native and overlay hotspots from the final raster size. Preserve input coordinate scaling and cursor refresh order. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(cursor): use `1.0` instead of `1.0/dpr` on Linux -> macOS, Zoom off, Scale adaptive The mouse cursor currently appears somewhat large. However, this is difficult to adjust because the cursor size is fixed while the window size varies; its relative size depends on the specific desktop environment. We can modify it if users actually provide feedback. Ideally, we should check the "Zoom cursor" option. Further adjustments may also be needed later based on cursor density. Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: fufesou <linlong1266@gmail.com> Co-authored-by: rustdesk <info@rustdesk.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1511,7 +1511,11 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
_ => {}
|
||||
},
|
||||
Some(message::Union::CursorData(cd)) => {
|
||||
self.handler.set_cursor_data(cd);
|
||||
let id = cd.id;
|
||||
match decode_cursor_data(cd) {
|
||||
Ok(cd) => self.handler.set_cursor_data(cd),
|
||||
Err(err) => log::warn!("Rejected cursor {id}: {err}"),
|
||||
}
|
||||
}
|
||||
Some(message::Union::CursorId(id)) => {
|
||||
self.handler.set_cursor_id(id.to_string());
|
||||
@@ -2545,6 +2549,47 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// Both UI handlers receive validated, uncompressed RGBA from the receive loop.
|
||||
fn decode_cursor_data(data: CursorData) -> hbb_common::ResultType<CursorData> {
|
||||
use hbb_common::{anyhow::anyhow, bail};
|
||||
|
||||
// Limit decoded cursor data to 1 MiB before JSON serialization.
|
||||
const MAX_CURSOR_SIZE: i32 = 512;
|
||||
const RGBA_CHANNELS: usize = 4;
|
||||
|
||||
let mut cd = data;
|
||||
if !(1..=MAX_CURSOR_SIZE).contains(&cd.width) || !(1..=MAX_CURSOR_SIZE).contains(&cd.height) {
|
||||
bail!("invalid source size {}x{}", cd.width, cd.height);
|
||||
}
|
||||
if !(0..cd.width).contains(&cd.hotx) || !(0..cd.height).contains(&cd.hoty) {
|
||||
bail!(
|
||||
"hotspot ({},{}) is outside the cursor image",
|
||||
cd.hotx,
|
||||
cd.hoty
|
||||
);
|
||||
}
|
||||
let expected = (cd.width as usize)
|
||||
.checked_mul(cd.height as usize)
|
||||
.and_then(|pixels| pixels.checked_mul(RGBA_CHANNELS))
|
||||
.ok_or_else(|| anyhow!("cursor RGBA size overflow"))?;
|
||||
let max_compressed_size = zstd::zstd_safe::compress_bound(expected);
|
||||
if cd.colors.len() > max_compressed_size {
|
||||
bail!(
|
||||
"compressed cursor data too large: {} bytes (limit {max_compressed_size})",
|
||||
cd.colors.len()
|
||||
);
|
||||
}
|
||||
let colors = zstd::bulk::decompress(&cd.colors, expected)?;
|
||||
if colors.len() != expected {
|
||||
bail!(
|
||||
"invalid RGBA length: expected {expected}, got {}",
|
||||
colors.len()
|
||||
);
|
||||
}
|
||||
cd.colors = colors.into();
|
||||
Ok(cd)
|
||||
}
|
||||
|
||||
struct RemoveJob {
|
||||
files: Vec<FileEntry>,
|
||||
path: String,
|
||||
|
||||
@@ -642,7 +642,7 @@ impl FlutterHandler {
|
||||
|
||||
impl InvokeUiSession for FlutterHandler {
|
||||
fn set_cursor_data(&self, cd: CursorData) {
|
||||
let colors = hbb_common::compress::decompress(&cd.colors);
|
||||
let colors = &cd.colors;
|
||||
self.push_event(
|
||||
"cursor_data",
|
||||
&[
|
||||
|
||||
@@ -122,7 +122,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: Vec<u8> = cd.colors.into();
|
||||
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
|
||||
|
||||
@@ -386,14 +386,35 @@ var cur_local_x = 0;
|
||||
var cur_local_y = 0;
|
||||
var cursors = {};
|
||||
var image_binded;
|
||||
const MAX_CURSOR_RASTER_SIZE = 1024;
|
||||
var cursor_scale_limited = false;
|
||||
|
||||
function limitCursorImageScale(img, factor) {
|
||||
// Subtraction rejects NaN and Infinity without converting them to integers.
|
||||
var valid = factor > 0 && factor - factor == 0;
|
||||
var side = img.width > img.height ? img.width : img.height;
|
||||
var max_factor = MAX_CURSOR_RASTER_SIZE.toFloat() / side;
|
||||
// Invalid factors use the validated source image at its original size.
|
||||
var limited_factor = valid ? (factor > max_factor ? max_factor : factor) : 1.;
|
||||
var limited = !valid || limited_factor != factor;
|
||||
if (limited && !cursor_scale_limited) {
|
||||
stdout.println("Cursor image scale " + factor + " limited to " + limited_factor
|
||||
+ " (maximum raster side " + MAX_CURSOR_RASTER_SIZE + ")");
|
||||
}
|
||||
cursor_scale_limited = limited;
|
||||
return limited_factor;
|
||||
}
|
||||
|
||||
function scaleCursorImage(img) {
|
||||
var factor = cursor_scale;
|
||||
if (cursor_img.style#display != 'none') {
|
||||
factor /= scaleFactor;
|
||||
}
|
||||
factor = limitCursorImageScale(img, factor);
|
||||
var w = (img.width * factor).toInteger();
|
||||
var h = (img.height * factor).toInteger();
|
||||
if (w < 1) w = 1;
|
||||
if (h < 1) h = 1;
|
||||
cursor_img.style.set {
|
||||
width: w + "px",
|
||||
height: h + "px",
|
||||
@@ -413,7 +434,10 @@ function updateCursor(system=false) {
|
||||
if (system) {
|
||||
handler.style#cursor = undefined;
|
||||
} else if (cur_img) {
|
||||
handler.style.cursor(cur_img, (cur_hotx * cursor_scale).toInteger(), (cur_hoty * cursor_scale).toInteger());
|
||||
var img = cursors[cur_id][0];
|
||||
handler.style.cursor(cur_img,
|
||||
(cur_hotx * cur_img.width.toFloat() / img.width).toInteger(),
|
||||
(cur_hoty * cur_img.height.toFloat() / img.height).toInteger());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +492,16 @@ handler.setCursorPosition = function(x, y) {
|
||||
var y = cur_y - cur_hoty;
|
||||
x *= cursor_scale / scaleFactor;
|
||||
y *= cursor_scale / scaleFactor;
|
||||
// The overlay may still be hidden, so compute its raster before refreshCursor().
|
||||
// Keep screen coordinates on the video scale and only adjust the hotspot.
|
||||
var img = cursors[cur_id][0];
|
||||
var hotspot_scale = limitCursorImageScale(img, cursor_scale / scaleFactor);
|
||||
var w = (img.width * hotspot_scale).toInteger();
|
||||
var h = (img.height * hotspot_scale).toInteger();
|
||||
if (w < 1) w = 1;
|
||||
if (h < 1) h = 1;
|
||||
x += cur_hotx * (cursor_scale / scaleFactor - w.toFloat() / img.width);
|
||||
y += cur_hoty * (cursor_scale / scaleFactor - h.toFloat() / img.height);
|
||||
cursor_img.style.set {
|
||||
left: x + "px",
|
||||
top: y + "px",
|
||||
|
||||
Reference in New Issue
Block a user