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:
@@ -1005,8 +1005,8 @@ class _RemotePageState extends State<RemotePage>
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
/// A widget that tracks the view size and updates CanvasModel.updateViewStyle()
|
||||
/// and InputModel.updateImageWidgetSize() only when size actually changes.
|
||||
/// Tracks view size and DPR to update CanvasModel.updateViewStyle()
|
||||
/// and InputModel.updateImageWidgetSize() only when either changes.
|
||||
/// This avoids scheduling post-frame callbacks on every LayoutBuilder rebuild.
|
||||
class _ViewStyleUpdater extends StatefulWidget {
|
||||
final CanvasModel canvasModel;
|
||||
@@ -1026,10 +1026,12 @@ class _ViewStyleUpdater extends StatefulWidget {
|
||||
|
||||
class _ViewStyleUpdaterState extends State<_ViewStyleUpdater> {
|
||||
Size? _lastSize;
|
||||
double? _lastDevicePixelRatio;
|
||||
bool _callbackScheduled = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
@@ -1039,8 +1041,9 @@ class _ViewStyleUpdaterState extends State<_ViewStyleUpdater> {
|
||||
return widget.child;
|
||||
}
|
||||
final newSize = Size(maxWidth, maxHeight);
|
||||
if (_lastSize != newSize) {
|
||||
if (_lastSize != newSize || _lastDevicePixelRatio != devicePixelRatio) {
|
||||
_lastSize = newSize;
|
||||
_lastDevicePixelRatio = devicePixelRatio;
|
||||
// Schedule the update for after the current frame to avoid setState during build.
|
||||
// Use _callbackScheduled flag to prevent accumulating multiple callbacks
|
||||
// when size changes rapidly before any callback executes.
|
||||
@@ -1088,6 +1091,7 @@ class ImagePaint extends StatefulWidget {
|
||||
|
||||
class _ImagePaintState extends State<ImagePaint> {
|
||||
bool _lastRemoteCursorMoved = false;
|
||||
final _cursorDisplayScale = 1.0.obs;
|
||||
|
||||
String get id => widget.id;
|
||||
RxBool get zoomCursor => widget.zoomCursor;
|
||||
@@ -1101,25 +1105,19 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
final m = Provider.of<ImageModel>(context);
|
||||
var c = Provider.of<CanvasModel>(context);
|
||||
final s = c.scale;
|
||||
// Read the live DPR while the canvas's post-frame update is pending.
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
|
||||
bool isViewAdaptive() => c.viewStyle.style == kRemoteViewStyleAdaptive;
|
||||
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
|
||||
mouseRegion({child}) => Obx(() {
|
||||
double getCursorScale() {
|
||||
var c = Provider.of<CanvasModel>(context);
|
||||
var cursorScale = 1.0;
|
||||
if (isWindows) {
|
||||
// debug win10
|
||||
if (zoomCursor.value && isViewAdaptive()) {
|
||||
cursorScale = s * c.devicePixelRatio;
|
||||
}
|
||||
if (isDesktop) {
|
||||
return _getDesktopCursorScale(c, dpr);
|
||||
} else {
|
||||
if (zoomCursor.value || isViewOriginal()) {
|
||||
cursorScale = s;
|
||||
}
|
||||
return zoomCursor.value || isViewOriginal() ? s : 1.0;
|
||||
}
|
||||
return cursorScale;
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
@@ -1193,11 +1191,70 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Matches desktop cursors to the rendered image and native pixel units.
|
||||
///
|
||||
/// Windows cursor pixels are physical; NSCursor/GdkCursor use logical pixels.
|
||||
/// Unzoomed Adaptive/Custom views preserve macOS cursors' point dimensions.
|
||||
double _getDesktopCursorScale(CanvasModel c, double dpr) {
|
||||
final peer = widget.ffi.ffiModel;
|
||||
if (!zoomCursor.value &&
|
||||
peer.pi.platform == kPeerPlatformMacOS &&
|
||||
(c.viewStyle.style == kRemoteViewStyleAdaptive ||
|
||||
c.viewStyle.style == kRemoteViewStyleCustom)) {
|
||||
return isWindows ? dpr : 1.0;
|
||||
}
|
||||
if (peer.isPeerLinux && peer.pi.currentDisplay == kAllDisplayValue) {
|
||||
if (!zoomCursor.value || c.viewStyle.style == kRemoteViewStyleOriginal) {
|
||||
// Remove the host output's density without applying canvas zoom.
|
||||
final scale = 1.0 / _cursorDisplayScale.value;
|
||||
return isWindows ? scale : scale / dpr;
|
||||
}
|
||||
final scale = c.scale / _cursorDisplayScale.value;
|
||||
return isWindows ? scale * dpr : scale;
|
||||
}
|
||||
if (!zoomCursor.value || c.viewStyle.style == kRemoteViewStyleOriginal) {
|
||||
// Original and unzoomed views do not apply canvas zoom.
|
||||
final scale = _getCursorScaleForDisplay(1.0);
|
||||
return isWindows ? scale : scale / dpr;
|
||||
}
|
||||
final scale = _getCursorScaleForDisplay(c.scale);
|
||||
return isWindows ? scale * dpr : scale;
|
||||
}
|
||||
|
||||
double _getCursorScaleForDisplay(double scale) {
|
||||
final peer = widget.ffi.ffiModel;
|
||||
// All Displays can mix densities; no single display scale applies.
|
||||
if (peer.pi.currentDisplay == kAllDisplayValue) return scale;
|
||||
final displays = peer.pi.getCurDisplays();
|
||||
if (displays.isEmpty) return scale;
|
||||
if (peer.pi.platform == kPeerPlatformMacOS) {
|
||||
// macOS sends NSImage.size in points (src/platform/macos.rs:640,682),
|
||||
// while HiDPI screen frames use backing pixels (libs/scrap/src/quartz/display.rs:37).
|
||||
return scale * displays.first.scale;
|
||||
}
|
||||
if (!peer.isPeerLinux) return scale;
|
||||
// Match rendering when a Wayland host
|
||||
// with multiple outputs reports a display scale > 1.
|
||||
// A single-output host keeps scale at 1.0 to preserve physical uinput
|
||||
// coordinates, even with OS scaling enabled. This counts host outputs,
|
||||
// not the selected displays.length.
|
||||
// See src/server/display_service.rs:650 (update_sync_displays) and
|
||||
// src/server/drm_capturer.rs:1523 (DRM's matching convention).
|
||||
return scale / displays.first.scale;
|
||||
}
|
||||
|
||||
Widget _buildScrollbarNonTextureRender(
|
||||
ImageModel m, Size imageSize, double s) {
|
||||
double sizeScale = s;
|
||||
if (widget.ffi.ffiModel.isPeerLinux) {
|
||||
final displays = widget.ffi.ffiModel.pi.getCurDisplays();
|
||||
if (displays.isNotEmpty) {
|
||||
sizeScale = s / displays[0].scale;
|
||||
}
|
||||
}
|
||||
return CustomPaint(
|
||||
size: imageSize,
|
||||
painter: ImagePainter(image: m.image, x: 0, y: 0, scale: s),
|
||||
painter: ImagePainter(image: m.image, x: 0, y: 0, scale: sizeScale),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1242,11 +1299,14 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
top: (displays[i].y - rect.top) * s + offset.dy,
|
||||
width: displays[i].width * sizeScale,
|
||||
height: displays[i].height * sizeScale,
|
||||
child: Obx(() => Texture(
|
||||
textureId: textureId.value,
|
||||
filterQuality:
|
||||
isViewOriginal ? FilterQuality.none : FilterQuality.low,
|
||||
)),
|
||||
child: _trackCursorDisplay(
|
||||
Obx(() => Texture(
|
||||
textureId: textureId.value,
|
||||
filterQuality:
|
||||
isViewOriginal ? FilterQuality.none : FilterQuality.low,
|
||||
)),
|
||||
displays[i],
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1257,6 +1317,20 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _trackCursorDisplay(Widget child, Display display) {
|
||||
final peer = widget.ffi.ffiModel;
|
||||
if (!isDesktop ||
|
||||
!peer.isPeerLinux ||
|
||||
peer.pi.currentDisplay != kAllDisplayValue) {
|
||||
return child;
|
||||
}
|
||||
return MouseRegion(
|
||||
onEnter: (_) => _cursorDisplayScale.value = display.scale,
|
||||
onHover: (_) => _cursorDisplayScale.value = display.scale,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
MouseCursor _buildCustomCursor(BuildContext context, double scale) {
|
||||
final cursor = Provider.of<CursorModel>(context);
|
||||
final cache = cursor.cache ?? preDefaultCursor.cache;
|
||||
|
||||
Reference in New Issue
Block a user