Compare commits

...

71 Commits

Author SHA1 Message Date
fufesou
c02f17212a Reject unusable cursor density and oversized raster requests 2026-09-13 23:17:40 +08:00
fufesou
fbfa37c8fd Preserve legacy macOS cursor dimensions with optional Retina artwork 2026-09-13 23:17:21 +08:00
fufesou
819535f1db Bump cursor plugin to preserve same-name recreation 2026-09-13 18:18:36 +08:00
fufesou
110ddb9a6c Report Linux cursor density independently of the host UI 2026-09-13 17:48:03 +08:00
fufesou
9a1c1a1a36 Preserve DRM cursor density during display cache contention 2026-09-13 17:25:11 +08:00
fufesou
3629db2aa0 Keep one macOS cursor density snapshot and retry failed captures 2026-09-13 17:15:28 +08:00
fufesou
8869a440da fix(cursor): comments
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-09-13 16:52:29 +08:00
fufesou
777407e72e Bump cursor plugin to invalidate activation after pointer removal 2026-09-13 16:45:28 +08:00
fufesou
20ede1a85f Activate native cursors after successful registration retries 2026-09-13 15:52:24 +08:00
fufesou
a87d9c1082 Use key-stable native cursor sessions during local movement 2026-09-13 15:05:31 +08:00
fufesou
bb06f54070 Keep rounded Retina cursor hotspots inside the bitmap 2026-09-13 15:01:18 +08:00
fufesou
16088cfc80 Synchronize custom-scrollbar cursor geometry after relayout 2026-09-13 14:53:47 +08:00
fufesou
b630f14483 Synchronize edge-scroll cursor geometry after relayout
Refresh edge-scroll fractions from laid-out scroll positions and repaint when
they differ from the values used by the frame. Preserve the repaint when a
deferred settings refresh has already changed the model.

Cover DPR changes, viewport clamping, detached scroll axes and restoration
with the actual video and cursor widgets in Original and Custom modes.

Verified 87 native Flutter cursor tests on each of macOS and Linux, plus
17 Chrome Web cursor tests.
2026-09-13 13:27:46 +08:00
fufesou
d2d7c83602 Keep edge-scroll fractions valid after clamped jumps 2026-09-13 12:04:05 +08:00
fufesou
d2561f7f6b Align remote cursor overlays with edge-scrolled video 2026-09-13 11:04:55 +08:00
fufesou
70c50402a2 Keep resized Web cursors within a proportional minimum 2026-09-13 10:58:34 +08:00
fufesou
bbc8ee3bdb Align painted cursors with Linux texture origins
Use the truncated Linux texture origin while preserving fractional cursor positions and hotspots. Compare painted cursor geometry with the actual video widget in the existing regression tests.
2026-09-13 10:04:12 +08:00
fufesou
32e9895294 Fix predefined cursor density and scrollbar centering 2026-09-13 00:18:06 +08:00
fufesou
feff308f11 test: reduce duplicate cursor fixtures and geometry checks 2026-09-12 23:32:26 +08:00
fufesou
5a89aa8c58 Resolve DRM cursor density from the complete display mapping 2026-09-12 23:02:12 +08:00
fufesou
6e2d838bf1 Keep density-aware cursor IDs compatible with legacy Web clients 2026-09-12 22:56:13 +08:00
fufesou
1fabdd6a9c Preserve Windows cursor alpha during native image decoding 2026-09-12 22:52:36 +08:00
fufesou
afdd1d5c9d Preserve the macOS cursor wire alpha format for older controllers 2026-09-12 21:47:08 +08:00
fufesou
371e5e8f2d Fix native cursor display selection during local movement 2026-09-12 21:46:28 +08:00
fufesou
4d064a8445 docs(cursor): explain outline alignment and Linux density scope 2026-09-12 20:51:59 +08:00
fufesou
02e4f02815 docs(cursor): explain image decoding and Retina sampling 2026-09-12 20:51:58 +08:00
fufesou
bbf86259e9 fix(cursor): encode macOS cursor alpha correctly in Sciter 2026-09-12 20:10:57 +08:00
fufesou
8dfff101f8 fix(cursor): align painted and native minimum sizes 2026-09-12 20:01:26 +08:00
fufesou
6934b3cb14 fix(cursor): follow Linux video pixel scale 2026-09-12 19:56:55 +08:00
fufesou
8f3beba2d1 fix(cursor): decode legacy macOS alpha for native cursors 2026-09-12 18:40:27 +08:00
fufesou
be1f4e0a33 fix(cursor): isolate optional X11 density failures 2026-09-12 18:30:27 +08:00
fufesou
cb068ba5df fix(cursor): decode predefined PNG images with correct alpha
Use Flutter's PNG codec before native rasterization and retain the straight-alpha image cache. Cover both bundled assets through PNG and Windows BGRA registration.
2026-09-12 17:46:57 +08:00
fufesou
1d98d1a647 fix(cursor): keep DRM density lookup off the polling path
Read optional Wayland metadata from the cache without starting discovery or waiting on a refresh. Keep delivering cursor shapes with unknown density when metadata is unavailable, and cover lock contention and repeated empty-cache reads.
2026-09-12 16:56:48 +08:00
fufesou
944f93ba7b fix(cursor): use plugin with nonzero raster dimensions 2026-09-12 15:54:38 +08:00
fufesou
aea8e2a4a6 fix(cursor): preserve macOS alpha through Web decode and resize 2026-09-12 15:51:22 +08:00
fufesou
d65faca21f fix(cursor): preserve unzoomed Web size across browser DPRs 2026-09-12 15:42:47 +08:00
fufesou
85e7c72ccd test(cursor): clarify scale constraints and isolate DPR checks 2026-09-12 14:32:15 +08:00
fufesou
a3bfe075d5 test(cursor): reuse painting and web fixtures 2026-09-12 14:28:41 +08:00
fufesou
7e2e9b68da test(cursor): consolidate native DPI and bridge coverage 2026-09-12 14:26:00 +08:00
fufesou
6871ae130e docs(cursor): clarify optional capture metadata and rendering scope 2026-09-12 10:46:05 +08:00
fufesou
54881092b7 refactor(cursor): separate native capture from rendering fixes 2026-09-12 10:40:23 +08:00
fufesou
34ceb16b63 fix(cursor): align the remote cursor with scrollbar offsets 2026-09-12 06:57:15 +08:00
fufesou
d95c3f2ec9 fix(cursor): preserve full cursor IDs in Web clients 2026-09-12 03:43:32 +08:00
fufesou
daafa09318 fix(cursor): keep the Windows minimum in logical pixels 2026-09-12 03:33:38 +08:00
fufesou
96cd5cd1e2 fix(cursor): align Web hotspots with rounded cursor images 2026-09-12 03:29:27 +08:00
fufesou
d876ea0ce4 fix(cursor): report X11 and Wayland cursor density 2026-09-12 03:27:53 +08:00
fufesou
2a61a0955a fix(cursor): capture the physical Windows cursor across DPI changes 2026-09-12 03:26:52 +08:00
fufesou
db7c48e1a7 fix(cursor): normalize native cursor density in scaled views 2026-09-12 02:57:37 +08:00
fufesou
e72e7fb8ba fix(cursor): capture complete macOS Retina cursor images 2026-09-12 02:47:37 +08:00
fufesou
4f0f784532 feat(cursor): carry the remote bitmap pixel density 2026-09-12 02:47:37 +08:00
fufesou
844bf40703 fix(cursor): align the painted cursor with the remote image 2026-09-12 02:24:31 +08:00
fufesou
f8bfc4eb56 chore(cursor): pin plugin with legacy platform routing preserved 2026-09-12 02:06:41 +08:00
fufesou
1061670368 fix(cursor): delegate native image scaling to cursor plugin
Use the original Flutter image and source hotspot with explicit DPR, retaining the existing view and minimum-size policies. Pin the plugin implementation from rustdesk-org/flutter_custom_cursor#1.
2026-09-12 01:40:26 +08:00
fufesou
8da629c57d revert(cursor): return to f5b98b32 before splitting plugin changes 2026-09-12 00:32:58 +08:00
fufesou
650a6e21cc fix(web): preserve full precision in cursor identifiers 2026-09-12 00:20:47 +08:00
fufesou
5f4bb00007 fix(web): align cursor bitmap and hotspot rounding 2026-09-12 00:20:28 +08:00
fufesou
a7f1eb4c25 fix(cursor): address sizing and capture review findings 2026-09-12 00:19:10 +08:00
fufesou
be5fb304d4 fix(flutter): align painted cursor with the software image origin 2026-09-11 17:54:56 +08:00
fufesou
fdd67a875b test(flutter): cover local cursor sizing and mode switches 2026-09-11 16:02:52 +08:00
fufesou
39940b717a fix(flutter): size unzoomed remote cursors to the local system 2026-09-11 16:02:52 +08:00
fufesou
67b94c7906 feat(windows): measure the native system cursor size 2026-09-11 16:02:52 +08:00
fufesou
eeff9eb121 feat(linux): measure the active system cursor size 2026-09-11 16:02:52 +08:00
fufesou
c983d00437 feat(macos): expose the local system cursor size 2026-09-11 16:02:52 +08:00
fufesou
7bb3fe6b5a fix(flutter): preserve cursor minimum sizes and native hotspots 2026-09-11 13:51:24 +08:00
fufesou
d202a2fba4 fix(flutter): scale the remote cursor with its image 2026-09-11 13:51:24 +08:00
fufesou
72ea38ca4f fix(cursor): preserve native pixels and hotspots across display scales
Capture actual Mutter and DXGI cursor metadata, preserve Retina artwork, and invalidate cursor identities when physical pixels or DPI change. Keep straight-alpha colors during native cursor resizing and correct the Windows XOR outline offset.

Verified all six Mac/Linux/Windows directions with real pointer movement, arrow/I-beam/crosshair transitions, adaptive zoom off/on/off, Original view, and live DPI changes. Requested debug builds and focused native/Flutter regressions pass.
2026-09-11 11:49:04 +08:00
fufesou
f96d00d9d1 fix(flutter): correct cursor scaling and Linux cursor clipping
Keep Linux/macOS bitmap sizing and painted cursor DPI
conversion consistent, and derive hotspots from the actual scaled bitmap.

Pad tall Linux native cursor buffers with transparency to
preserve the lower half in the hardware cursor plane.
Cover bitmap sizing, hotspots, artwork preservation,
and painting with focused regression tests.
2026-09-10 21:56:25 +08:00
rustdesk
f5b98b32f2 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
2026-09-10 14:25:51 +08:00
rustdesk
8aee2a442e 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
2026-09-10 14:09:39 +08:00
rustdesk
12eaf2cc75 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
2026-09-10 14:09:39 +08:00
rustdesk
7194743a30 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
2026-09-10 14:08:42 +08:00
32 changed files with 3414 additions and 118 deletions

View File

@@ -40,7 +40,6 @@ drm-wake = ["drm"]
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
unix-file-copy-paste = [
"dep:x11-clipboard",
"dep:x11rb",
"dep:percent-encoding",
"dep:once_cell",
"clipboard/unix-file-copy-paste",
@@ -191,7 +190,7 @@ evdev = { git="https://github.com/rustdesk-org/evdev" }
dbus = "0.9"
dbus-crossroads = "0.5"
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
x11rb = {version = "0.12", features = ["all-extensions"]}
percent-encoding = {version = "2.3", optional = true}
once_cell = {version = "1.18", optional = true}
nix = { version = "0.29", features = ["term", "process"]}

View File

@@ -1101,22 +1101,54 @@ class _ImagePaintState extends State<ImagePaint> {
final m = Provider.of<ImageModel>(context);
var c = Provider.of<CanvasModel>(context);
final s = c.scale;
// CanvasModel caches the DPR and only refreshes it when the view style
// changes, so read it live to follow the window across monitors.
final dpr = MediaQuery.devicePixelRatioOf(context);
bool isViewAdaptive() => c.viewStyle.style == kRemoteViewStyleAdaptive;
bool isViewScaled() =>
c.viewStyle.style == kRemoteViewStyleAdaptive ||
c.viewStyle.style == kRemoteViewStyleCustom;
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
mouseRegion({child}) => Obx(() {
double getCursorScale() {
var c = Provider.of<CanvasModel>(context);
final cursor = Provider.of<CursorModel>(context);
// Predefined artwork must not inherit the cached remote bitmap's DPI.
final cache = keyboardEnabled.isTrue
? cursor.cache ?? preDefaultCursor.cache
: preForbiddenCursor.cache;
final peerDpr = cache?.pixelRatio ?? 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.
// Windows expects a physical-pixel scale at this call boundary, hence dpr;
// buildCursorOfCache() converts it back to logical scale for the plugin.
return (isWindows ? dpr : 1.0) / peerDpr;
}
// Density metadata is optional. Keep the legacy path for hosts that
// omit it so capture-backend upgrades are not a client prerequisite.
final imageScale = isViewScaled() && zoomCursor.isTrue
? _cursorImageScale(widget.ffi, cursor, useLocalPointer: true)
: s;
var cursorScale = 1.0;
if (isWindows) {
// debug win10
if (zoomCursor.value && isViewAdaptive()) {
cursorScale = s * c.devicePixelRatio;
if (zoomCursor.value && isViewScaled()) {
cursorScale = imageScale * dpr;
}
} else {
if (zoomCursor.value || isViewOriginal()) {
cursorScale = s;
cursorScale = imageScale;
} else if (!isWeb) {
// NSCursor and GdkCursor treat the bitmap size as logical
// pixels, so an unzoomed cursor must be shrunk by the DPR to
// keep 1 remote px == 1 physical px, the size Original view
// already renders it at.
cursorScale = 1.0 / dpr;
}
}
return cursorScale;
@@ -1149,6 +1181,7 @@ class _ImagePaintState extends State<ImagePaint> {
child: child);
});
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
_syncScrollAfterLayout(c);
final paintWidth = c.getDisplayWidth() * s;
final paintHeight = c.getDisplayHeight() * s;
final paintSize = Size(paintWidth, paintHeight);
@@ -1193,6 +1226,25 @@ class _ImagePaintState extends State<ImagePaint> {
}
}
/// Schedules a refresh after layout to keep the painted cursor aligned with video.
///
/// Viewport, view scale or DPR changes can clamp or detach scroll positions
/// without a ScrollNotification, so read the resulting metrics after layout.
/// Save this build's fractions: a delayed refresh may update the model without
/// notifying, leaving the cursor painted from older values. CanvasModel compares
/// the refreshed fractions with this snapshot and notifies only if they differ,
/// repairing the cursor position without creating a rebuild loop.
void _syncScrollAfterLayout(CanvasModel canvas) {
// Custom scrollbars also paint from scroll fractions; preserve Original's path.
if (canvas.scrollStyle != ScrollStyle.scrolledge &&
canvas.viewStyle.style != kRemoteViewStyleCustom) return;
final renderedScroll = (canvas.scrollX, canvas.scrollY);
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
canvas.updateScrollAfterLayout(renderedScroll);
});
}
Widget _buildScrollbarNonTextureRender(
ImageModel m, Size imageSize, double s) {
return CustomPaint(
@@ -1384,43 +1436,165 @@ class CursorPaint extends StatelessWidget {
}
}
double cx = c.x;
double cy = c.y;
if (c.viewStyle.style == kRemoteViewStyleOriginal &&
c.scrollStyle == ScrollStyle.scrollbar) {
final imageOffset = _softwareImageOffset(c);
double cx = imageOffset?.dx ?? c.x;
double cy = imageOffset?.dy ?? c.y;
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
final rect = c.parent.target!.ffiModel.rect;
if (rect == null) {
// unreachable!
debugPrint('unreachable! The displays rect is null.');
return Container();
}
if (cx < 0) {
final imageWidth = rect.width * c.scale;
cx = -imageWidth * c.scrollX;
}
if (cy < 0) {
final imageHeight = rect.height * c.scale;
cy = -imageHeight * c.scrollY;
}
// Scrollbar and edge scrolling share a layout that ignores canvas pan offsets.
final imageWidth = rect.width * c.scale;
final imageHeight = rect.height * c.scale;
// Match the integer centering in _buildCrossScrollbarFromLayout.
cx = (c.size.width > imageWidth ? (c.size.width - imageWidth) ~/ 2 : 0) -
imageWidth * c.scrollX;
cy = (c.size.height > imageHeight ? (c.size.height - imageHeight) ~/ 2 : 0) -
imageHeight * c.scrollY;
}
double x = (m.x - hotx) * c.scale + cx;
double y = (m.y - hoty) * c.scale + cy;
double scale = 1.0;
final isViewOriginal = c.viewStyle.style == kRemoteViewStyleOriginal;
if (zoomCursor.value || isViewOriginal) {
x = m.x - hotx + cx / c.scale;
y = m.y - hoty + cy / c.scale;
scale = c.scale;
final image = m.image ?? preDefaultCursor.image;
// Match native registration's logical minimum for density-aware scaled views.
final logicalMinimum = (m.cache?.pixelRatio ?? 0) > 0 &&
c.viewStyle.style != kRemoteViewStyleOriginal;
final nativePixels = isWindows && !logicalMinimum
? MediaQuery.devicePixelRatioOf(context) : 1.0;
// Show remote cursor follows the image scale, independently of Zoom cursor.
double scale = _cursorImageScale(c.parent.target!, m);
if (image != null && (logicalMinimum || scale * nativePixels != 1.0)) {
final sx = kMinCursorSize / (image.width * nativePixels);
final sy = kMinCursorSize / (image.height * nativePixels);
// Preserve Original's short-edge minimum; scaled views use the long edge.
final minimumScale = c.viewStyle.style == kRemoteViewStyleOriginal
? (sx > sy ? sx : sy)
: (sx < sy ? sx : sy);
if (scale < minimumScale) scale = minimumScale;
}
// Drawing origin = (remote cursor position * canvas scale + video origin)
// / cursor image scale - source hotspot.
// ImagePainter scales both the drawing origin and the artwork, so this
// keeps the hotspot at the displayed remote position even if the cursor
// scale differs from the canvas scale, e.g. because of the minimum size.
final x = (m.x * c.scale + cx) / scale - hotx;
final y = (m.y * c.scale + cy) / scale - hoty;
return CustomPaint(
painter: ImagePainter(
image: m.image ?? preDefaultCursor.image,
image: image,
x: x,
y: y,
scale: scale,
useIntegerPosition: false,
),
);
}
/// Returns the renderer-adjusted video origin in local logical pixels,
/// keeping the painted cursor aligned with the video.
///
/// Software rendering truncates the origin in image coordinates before
/// scaling it back. Linux texture rendering truncates it directly in logical
/// pixels. Match that rounding without rounding the cursor or its hotspot.
///
/// Returns null for overflowing scrollbar/edge-scroll layouts, whose origin
/// the caller computes separately, and for other texture renderers, which
/// use canvas.x/y unchanged.
///
/// Example: canvas.x = 10.75 and video scale = 0.5 produce a software-rendered
/// origin of (10.75 / 0.5).toInt() * 0.5 = 10.5. Using that same origin for
/// the cursor avoids a 0.25-logical-pixel alignment error.
Offset? _softwareImageOffset(CanvasModel canvas) {
if (canvas.imageOverflow.isTrue &&
canvas.scrollStyle != ScrollStyle.scrollauto) {
return null;
}
final ffi = canvas.parent.target!;
final peer = ffi.ffiModel;
if (ffi.imageModel.useTextureRender || peer.pi.forceTextureRender) {
// Match Linux's texture origin without rounding the cursor or hotspot.
return isLinux
? Offset(canvas.x.toInt().toDouble(), canvas.y.toInt().toDouble())
: null;
}
var scale = canvas.scale;
final displays = peer.pi.getCurDisplays();
if (peer.isPeerLinux && displays.isNotEmpty) scale /= displays[0].scale;
// Match the origin used by _buildScrollAutoNonTextureRender's ImagePainter.
return Offset(
(canvas.x / scale).toInt() * scale, (canvas.y / scale).toInt() * scale);
}
}
/// Returns the base cursor artwork scale needed to match the remote video.
///
/// The result is Flutter logical pixels per source cursor bitmap pixel.
/// Callers handle minimum-size clamping, native-platform DPR conversion,
/// rasterization, and hotspot positioning separately. This function does not
/// draw the cursor or transform its remote position.
///
/// Callers decide whether the cursor should follow the video:
/// - [CursorPaint] uses this scale regardless of the Zoom cursor setting.
/// - The MouseRegion cursor uses it in Adaptive/Custom view only when Zoom
/// cursor is enabled. With Zoom cursor disabled, a separate sizing policy
/// keeps the native cursor independent of video zoom.
/// Selecting Adaptive/Custom view alone does not imply cursor zoom.
///
/// For Linux hosts, desktop coordinates and captured bitmap pixels can use
/// different scales. The video renderer normally converts bitmap pixels to
/// view coordinates using `canvas.scale / display.scale`; cursor artwork
/// must use the same conversion. The non-texture scrollbar/edge-scroll
/// renderer is an exception: it paints directly at `canvas.scale`.
/// Non-Linux hosts also use `canvas.scale` directly.
///
/// With multiple viewed Linux displays, [useLocalPointer] selects which
/// position determines the relevant display. When true, prefer the remote
/// coordinates mapped from local mouse input, so native cursor sizing updates
/// on display crossings without waiting for a host-position echo. When false,
/// use [cursor]'s host-reported position, as required for the painted overlay.
/// Before the first mapped local position, both paths use [cursor].
/// If no display can be selected, fall back to `canvas.scale`.
///
/// Do not substitute cursor-bitmap density (`CursorData.pixelRatio`) for
/// `display.scale`: this helper follows video geometry, not unzoomed cursor
/// DPI normalization. A high-DPI capture path can still use physical desktop
/// coordinates, so cursor density alone does not determine the video scale.
///
/// For example, with Linux `display.scale == 2` and `canvas.scale == 0.5`,
/// the usual rendering path returns 0.25: a 64-pixel cursor is drawn 16 logical
/// pixels wide, before any minimum-size adjustment.
double _cursorImageScale(FFI ffi, CursorModel cursor,
{bool useLocalPointer = false}) {
final canvas = ffi.canvasModel;
final peer = ffi.ffiModel;
if (!peer.isPeerLinux) return canvas.scale;
if (canvas.imageOverflow.isTrue &&
canvas.scrollStyle != ScrollStyle.scrollauto &&
!ffi.imageModel.useTextureRender &&
!peer.pi.forceTextureRender) {
return canvas.scale; // The nontexture scrollbar also paints physical pixels.
}
final displays = peer.pi.getCurDisplays();
if (displays.length == 1) return canvas.scale / displays.first.scale;
final rect = peer.rect;
if (rect != null) {
// Mapped local input is already in host desktop coordinates. CursorModel's
// position is relative to rect, so restore its origin for display lookup.
// Before the first local movement, both paths use the host position.
final position =
(useLocalPointer ? ffi.inputModel.remotePointerPosition.value : null) ??
Offset(cursor.x + rect.left, cursor.y + rect.top);
for (final display in displays) {
// Display origins are desktop coordinates; dimensions are physical pixels.
// Convert the dimensions so these bounds use the same units as position.
if (Rect.fromLTWH(display.x, display.y, display.width / display.scale,
display.height / display.scale).contains(position)) {
return canvas.scale / display.scale;
}
}
}
return canvas.scale;
}

View File

@@ -449,6 +449,11 @@ class InputModel {
final isPhysicalMouse = false.obs;
int _lastButtons = 0;
Offset lastMousePos = Offset.zero;
// Latest local pointer position mapped to absolute remote desktop coordinates.
// Used to select the native cursor's display scale in Linux All Displays mode,
// without waiting for a host position update. Keep it separate from the
// host-reported position used to paint the remote cursor.
final remotePointerPosition = Rxn<Offset>();
int _lastWheelTsUs = 0;
// Wheel acceleration thresholds.
@@ -1954,7 +1959,7 @@ class InputModel {
canvasModel.updateLocalCursor(x, y);
}
return _handlePointerDevicePos(
final point = _handlePointerDevicePos(
kind,
x,
y,
@@ -1965,6 +1970,20 @@ class InputModel {
onExit: onExit,
buttons: buttons,
);
_rememberRemotePointer(point, isMove);
return point;
}
void _rememberRemotePointer(Point? point, bool isMove) {
final peer = parent.target!.ffiModel;
if (point == null ||
!isMove ||
!(isDesktop || isWebDesktop) ||
peer.pi.currentDisplay != kAllDisplayValue ||
!peer.isPeerLinux) {
return;
}
remotePointerPosition.value = Offset(point.x.toDouble(), point.y.toDouble());
}
bool _isInCurrentWindow(double x, double y) {

View File

@@ -2696,10 +2696,20 @@ class CanvasModel with ChangeNotifier {
setScrollPercent(scrollPixelPercent.x, scrollPixelPercent.y);
pushScrollPositionToUI(scrollPixel.x, scrollPixel.y);
// A no-op jump emits no notification to refresh the actual scroll fractions.
updateScrollPercent();
notifyListeners();
}
void updateScrollAfterLayout((double, double) renderedScroll) {
updateScrollPercent();
// A delayed refresh may already have changed the model without repainting.
if (renderedScroll != (_scrollX, _scrollY)) {
notifyListeners();
}
}
panX(double dx) {
_x += dx;
if (isMobile) {
@@ -2850,11 +2860,27 @@ class CanvasModel with ChangeNotifier {
}
}
// data for cursor
// Bound integer cache keys and raster allocation: 4096 squared RGBA is 64 MiB.
const _maxCursorRasterSize = 4096;
bool _validCursorRasterSize(double width, double height,
{double rasterScale = 1}) =>
width.isFinite && height.isFinite && rasterScale.isFinite &&
width > 0 && height > 0 && rasterScale > 0 &&
width.ceilToDouble() * rasterScale <= _maxCursorRasterSize &&
height.ceilToDouble() * rasterScale <= _maxCursorRasterSize;
// Scale the host's bitmap and hotspot together. Incorrect source geometry
// must be fixed in host capture, independently of the client's sizing policy.
class CursorData {
final String peerId;
final String id;
final img2.Image image;
// Borrowed from CursorModel/PredefinedCursor, which own its lifetime.
// The plugin clones the handle before starting asynchronous encoding.
final ui.Image nativeImage;
// Zero preserves legacy sizing for capture backends without density metadata.
final double pixelRatio;
double scale;
Uint8List? data;
final double hotxOrigin;
@@ -2868,6 +2894,8 @@ class CursorData {
required this.peerId,
required this.id,
required this.image,
required this.nativeImage,
this.pixelRatio = 0,
required this.scale,
required this.data,
required this.hotxOrigin,
@@ -2879,12 +2907,21 @@ class CursorData {
int _doubleToInt(double v) => (v * 10e6).round().toInt();
double _checkUpdateScale(double scale) {
double oldScale = this.scale;
if (scale != 1.0) {
// Keep the minimum-size policy here. Native callers let the plugin rasterize
// the original ui.Image; Web keeps the encoded-image resizing path.
double? _validatedScale(double scale,
{required bool useLegacyMinimum, required double rasterScale}) {
if (!scale.isFinite || scale <= 0) {
debugPrint('Rejected cursor $id: invalid scale $scale');
return null;
}
if (!useLegacyMinimum) {
scale = max(scale, kMinCursorSize / max(width, height));
}
if (useLegacyMinimum && scale != 1.0) {
// Update data if scale changed.
final tgtWidth = (width * scale).toInt();
final tgtHeight = (width * scale).toInt();
final tgtWidth = width * scale;
final tgtHeight = height * scale;
if (tgtWidth < kMinCursorSize || tgtHeight < kMinCursorSize) {
double sw = kMinCursorSize.toDouble() / width;
double sh = kMinCursorSize.toDouble() / height;
@@ -2892,7 +2929,21 @@ class CursorData {
}
}
if (_doubleToInt(oldScale) != _doubleToInt(scale)) {
if (!_validCursorRasterSize(width * scale, height * scale,
rasterScale: rasterScale)) {
debugPrint('Rejected cursor $id: raster ${width * scale}x${height * scale} '
'at pixel ratio $rasterScale exceeds $_maxCursorRasterSize');
return null;
}
return scale;
}
double _checkUpdateScale(double scale, {required bool resizeImage}) {
double oldScale = this.scale;
// Web's long-edge minimum can round a thin axis below one raster pixel.
final webWidth = max(1, (width * scale).round());
final webHeight = max(1, (height * scale).round());
if (resizeImage && _doubleToInt(oldScale) != _doubleToInt(scale)) {
if (isWindows) {
data = img2
.copyResize(
@@ -2907,8 +2958,8 @@ class CursorData {
img2.encodePng(
img2.copyResize(
image,
width: (width * scale).toInt(),
height: (height * scale).toInt(),
width: isWeb ? webWidth : (width * scale).toInt(),
height: isWeb ? webHeight : (height * scale).toInt(),
interpolation: img2.Interpolation.average,
),
),
@@ -2919,12 +2970,22 @@ class CursorData {
this.scale = scale;
hotx = hotxOrigin * scale;
hoty = hotyOrigin * scale;
if (isWeb) {
// CSS hotspots must follow the actual rounded PNG dimensions.
hotx = hotxOrigin * webWidth / width;
hoty = hotyOrigin * webHeight / height;
}
return scale;
}
String updateGetKey(double scale) {
scale = _checkUpdateScale(scale);
return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}';
String? updateGetKey(double scale,
{bool resizeImage = true, bool useLegacyMinimum = true,
double rasterScale = 1}) {
final effectiveScale = _validatedScale(scale,
useLegacyMinimum: useLegacyMinimum, rasterScale: rasterScale);
if (effectiveScale == null) return null;
_checkUpdateScale(effectiveScale, resizeImage: resizeImage);
return '${peerId}_${id}_${_doubleToInt(width * effectiveScale)}_${_doubleToInt(height * effectiveScale)}';
}
}
@@ -2964,7 +3025,8 @@ class PredefinedCursor {
CursorData? get cache => _cache;
init() {
_image2 = img2.decodePng(base64Decode(png));
final pngBytes = base64Decode(png);
_image2 = img2.decodePng(pngBytes);
if (_image2 != null) {
// The png type of forbidden cursor image is `PngColorType.indexed`.
if (id == kPreForbiddenCursorId) {
@@ -2972,17 +3034,20 @@ class PredefinedCursor {
}
() async {
final defaultImg = _image2!;
// This function is called only one time, no need to care about the performance.
Uint8List data = defaultImg.getBytes(order: img2.ChannelOrder.rgba);
_image?.dispose();
_image = await img.decodeImageFromPixels(
data, defaultImg.width, defaultImg.height, ui.PixelFormat.rgba8888);
if (_image == null) {
print("decodeImageFromPixels failed, pre-defined cursor $id");
return;
// Native registration uses this ui.Image. The RGBA bytes from img2 are
// straight alpha, but PixelFormat.rgba8888 requires premultiplied alpha.
// Decode the PNG directly to preserve translucent cursor colors.
final codec = await ui.instantiateImageCodec(pngBytes);
final ui.Image nativeImage;
try {
nativeImage = (await codec.getNextFrame()).image;
} finally {
codec.dispose();
}
_image = nativeImage;
double scale = 1.0;
final Uint8List data;
if (isWindows) {
data = _image2!.getBytes(order: img2.ChannelOrder.bgra);
} else {
@@ -2993,6 +3058,7 @@ class PredefinedCursor {
peerId: '',
id: id,
image: _image2!.clone(),
nativeImage: nativeImage,
scale: scale,
data: data,
hotxOrigin:
@@ -3428,16 +3494,33 @@ class CursorModel with ChangeNotifier {
final hoty = double.parse(evt['hoty']);
final width = int.parse(evt['width']);
final height = int.parse(evt['height']);
final pixelRatio = double.tryParse(evt['scale'] ?? '0');
if (pixelRatio == null || !pixelRatio.isFinite || pixelRatio < 0 ||
(pixelRatio > 0 &&
!_validCursorRasterSize(width / pixelRatio, height / pixelRatio))) {
debugPrint('Rejected cursor $id: invalid pixel ratio ${evt['scale']}');
return;
}
List<dynamic> colors = json.decode(evt['colors']);
final rgba = Uint8List.fromList(colors.map((s) => s as int).toList());
final image = await img.decodeImageFromPixels(
rgba, width, height, ui.PixelFormat.rgba8888);
final ui.Image? image;
final platform = parent.target?.ffiModel.pi.platform;
if (!isWeb &&
(platform == kPeerPlatformMacOS || platform == kPeerPlatformWindows)) {
image = await _decodeStraightAlphaCursor(rgba, width, height);
} else {
image = await img.decodeImageFromPixels(
rgba, width, height, ui.PixelFormat.rgba8888);
}
if (image == null) {
return;
}
if (await _updateCache(rgba, image, id, hotx, hoty, width, height)) {
if (await _updateCache(rgba, image, id, hotx, hoty, width, height,
pixelRatio: pixelRatio)) {
_images[id]?.item1.dispose();
_images[id] = Tuple3(image, hotx, hoty);
} else {
image.dispose();
}
// Update last cursor data.
@@ -3445,6 +3528,22 @@ class CursorModel with ChangeNotifier {
_updateCurData();
}
Future<ui.Image?> _decodeStraightAlphaCursor(
Uint8List rgba, int width, int height) async {
// macOS and Win32 capture send straight alpha; XFixes/DRM are premultiplied.
// Convert a copy for native ui.Image, preserving the wire and PNG cache colors.
final source = img2.Image.fromBytes(
width: width, height: height, bytes: rgba.buffer, order: img2.ChannelOrder.rgba);
for (final pixel in source) {
final opacity = pixel.a / pixel.maxChannelValue;
pixel.r = (pixel.r * opacity).round();
pixel.g = (pixel.g * opacity).round();
pixel.b = (pixel.b * opacity).round();
}
return img.decodeImageFromPixels(
source.getBytes(), width, height, ui.PixelFormat.rgba8888);
}
Future<bool> _updateCache(
Uint8List rgba,
ui.Image image,
@@ -3452,8 +3551,9 @@ class CursorModel with ChangeNotifier {
double hotx,
double hoty,
int w,
int h,
) async {
int h, {
required double pixelRatio,
}) async {
Uint8List? data;
img2.Image imgOrigin = img2.Image.fromBytes(
width: w, height: h, bytes: rgba.buffer, order: img2.ChannelOrder.rgba);
@@ -3463,6 +3563,7 @@ class CursorModel with ChangeNotifier {
ByteData? imgBytes =
await image.toByteData(format: ui.ImageByteFormat.png);
if (imgBytes == null) {
debugPrint('Unable to encode cursor $id as PNG');
return false;
}
data = imgBytes.buffer.asUint8List();
@@ -3471,6 +3572,8 @@ class CursorModel with ChangeNotifier {
peerId: peerId,
id: id,
image: imgOrigin,
nativeImage: image,
pixelRatio: pixelRatio,
scale: 1.0,
data: data,
hotxOrigin: hotx,

View File

@@ -1,10 +1,16 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter_custom_cursor/cursor_manager.dart'
as custom_cursor_manager;
import 'package:flutter_custom_cursor/flutter_custom_cursor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show WidgetsBinding;
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/model.dart';
deleteCustomCursor(String key) =>
@@ -16,29 +22,49 @@ MouseCursor buildCursorOfCache(
if (cache == null) {
return MouseCursor.defer;
} else {
final key = cache.updateGetKey(scale);
// Include the live DPR so moving between monitors rebuilds the native
// bitmap even when the remote view scale has not changed.
final dpr = WidgetsBinding
.instance.platformDispatcher.views.single.devicePixelRatio;
// Keep Original and older peers unchanged. A long-edge minimum preserves
// the proportions of thin remote cursors when normalizing their DPI.
final legacyMinimum = cache.pixelRatio == 0 ||
cursor.parent.target?.canvasModel.viewStyle.style == kRemoteViewStyleOriginal;
// The minimum is logical, while Windows callers pass a physical scale.
final effectiveScale = !legacyMinimum && isWindows
? math.max(scale, kMinCursorSize * dpr / math.max(cache.width, cache.height))
: scale;
final cacheKey = cache.updateGetKey(effectiveScale, resizeImage: false,
useLegacyMinimum: legacyMinimum,
rasterScale: isWindows ? 1 : (isLinux ? dpr.ceilToDouble() : dpr));
if (cacheKey == null) return MouseCursor.defer;
final key = '${cacheKey}_$dpr';
if (!cursor.cachedKeys.contains(key)) {
// data should be checked here, because it may be changed after `updateGetKey()`
final data = cache.data;
if (data == null) {
return MouseCursor.defer;
}
debugPrint(
"Register custom cursor with key $key (${cache.hotx},${cache.hoty})");
// [Safety]
// It's ok to call async registerCursor in current synchronous context,
// because activating the cursor is also an async call and will always
// be executed after this.
custom_cursor_manager.CursorManager.instance
.registerCursor(custom_cursor_manager.CursorData()
..name = key
..buffer = data
..width = (cache.width * cache.scale).toInt()
..height = (cache.height * cache.scale).toInt()
..hotX = cache.hotx
..hotY = cache.hoty);
unawaited(custom_cursor_manager.CursorManager.instance
.registerCursorImage(
name: key,
image: cache.nativeImage,
hotSpot: Offset(cache.hotxOrigin, cache.hotyOrigin),
// Windows callers already express scale in physical pixels.
// The plugin takes logical scale and applies DPR during rasterization.
scale: isWindows ? cache.scale / dpr : cache.scale,
devicePixelRatio: dpr,
)
.then<void>((_) {}, onError: (Object error, StackTrace stack) {
cursor.cachedKeys.remove(key);
FlutterError.reportError(FlutterErrorDetails(
exception: error,
stack: stack,
library: 'native cursor',
context: ErrorDescription('registering cursor $key')));
}));
cursor.addKey(key);
}
return FlutterCustomMemoryImageCursor(key: key);
return FlutterCustomMemoryImageCursor(
key: key,
registrationToken: custom_cursor_manager.CursorManager.instance
.registrationTokenFor(key));
}
}

View File

@@ -96,12 +96,14 @@ class ImagePainter extends CustomPainter {
required this.x,
required this.y,
required this.scale,
this.useIntegerPosition = true,
});
ui.Image? image;
double x;
double y;
double scale;
final bool useIntegerPosition;
@override
void paint(Canvas canvas, Size size) {
@@ -122,8 +124,10 @@ class ImagePainter extends CustomPainter {
if (isWeb) {
paint.filterQuality = FilterQuality.high;
}
canvas.drawImage(
image!, Offset(x.toInt().toDouble(), y.toInt().toDouble()), paint);
final position = useIntegerPosition
? Offset(x.toInt().toDouble(), y.toInt().toDouble())
: Offset(x, y);
canvas.drawImage(image!, position, paint);
}
@override

View File

@@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:js' as js;
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -52,8 +53,9 @@ class CursorManager {
'cursor',
jsonEncode({
'url': cursorData.url,
'hotx': cursorData.hotX.toInt(),
'hoty': cursorData.hotY.toInt(),
// Rounding must keep the hotspot inside even a one-pixel raster.
'hotx': cursorData.hotX.round().clamp(0, cursorData.width - 1),
'hoty': cursorData.hotY.round().clamp(0, cursorData.height - 1),
})
]);
}
@@ -104,7 +106,10 @@ MouseCursor buildCursorOfCache(
if (cache == null) {
return MouseCursor.defer;
} else {
final key = cache.updateGetKey(scale);
// A short-edge minimum can enlarge thin artwork beyond CSS cursor limits.
// Keep unzoomed images unchanged and use the long edge when resizing.
final key = cache.updateGetKey(scale, useLegacyMinimum: scale == 1.0);
if (key == null) return MouseCursor.defer;
if (!cursor.cachedKeys.contains(key)) {
// data should be checked here, because it may be changed after `updateGetKey()`
final data = cache.data;
@@ -116,8 +121,8 @@ MouseCursor buildCursorOfCache(
CursorManager.instance.registerCursor(CursorData(
key: key,
url: 'data:image/rgba;base64,${base64Encode(data)}',
width: (cache.width * cache.scale).toInt(),
height: (cache.height * cache.scale).toInt(),
width: max(1, (cache.width * cache.scale).round()),
height: max(1, (cache.height * cache.scale).round()),
hotX: cache.hotx,
hotY: cache.hoty));
cursor.addKey(key);

View File

@@ -522,9 +522,9 @@ packages:
dependency: "direct main"
description:
path: "."
ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
resolved-ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
url: "https://github.com/rustdesk-org/flutter_custom_cursor"
ref: "d2844a7b150fc9f72b17870b0e3e6c3000d4d848"
resolved-ref: "d2844a7b150fc9f72b17870b0e3e6c3000d4d848"
url: "https://github.com/fufesou/flutter_custom_cursor"
source: git
version: "0.0.3"
flutter_gpu_texture_renderer:

View File

@@ -62,8 +62,8 @@ dependencies:
freezed_annotation: ^2.0.3
flutter_custom_cursor:
git:
url: https://github.com/rustdesk-org/flutter_custom_cursor
ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
url: https://github.com/fufesou/flutter_custom_cursor
ref: d2844a7b150fc9f72b17870b0e3e6c3000d4d848
window_size:
git:
url: https://github.com/21pages/flutter-desktop-embedding.git

View File

@@ -0,0 +1,207 @@
import 'dart:convert';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_custom_cursor/cursor_manager.dart' show CursorManager;
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/common.dart' as common;
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/native/custom_cursor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:image/image.dart' as img;
import 'package:provider/provider.dart';
const _viewport = Size(200, 160);
class _Image extends ChangeNotifier implements ImageModel {
@override
bool get useTextureRender => false;
@override
ui.Image? get image => null;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Canvas extends ChangeNotifier implements CanvasModel {
_Canvas(this.devicePixelRatio, {required this.style, required this.scale});
final String style;
@override
final double devicePixelRatio;
@override
final imageOverflow = false.obs;
@override
late final viewStyle = ViewStyle(
style: style,
width: _viewport.width,
height: _viewport.height,
displayWidth: 400,
displayHeight: 320,
);
@override
bool get cursorEmbedded => false;
@override
ScrollStyle get scrollStyle => ScrollStyle.scrollauto;
@override
Size get size => _viewport;
@override
final double scale;
@override
double get x => 0;
@override
double get y => 0;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Input extends Fake implements InputModel {
@override
final relativeMouseMode = false.obs;
}
class _Peer extends Fake implements FfiModel {
@override
final pi = PeerInfo();
@override
bool isPeerLinux = false;
}
class _FFI extends Fake implements FFI {
_FFI(this.canvasModel);
@override
final CanvasModel canvasModel;
@override
final inputModel = _Input();
@override
final _Peer ffiModel = _Peer();
}
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
_rasterBoundsTests();
final channel = common.isWindows
? SystemChannels.mouseCursor
: const MethodChannel('flutter_custom_cursor');
final registrations = <Map<dynamic, dynamic>>[];
setUp(() {
registrations.clear();
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
(call) async {
if (!call.method.startsWith('createCustomCursor')) return null;
final args = call.arguments as Map<dynamic, dynamic>;
registrations.add(args);
return args['name'];
});
});
tearDown(() =>
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
for (final density in ['0', '1', '2', '1e-300', '0.001']) {
testWidgets(
'density boundary $style density=$density',
(tester) => tester.runAsync(
() => checkDensity(tester, (style, density), registrations)));
}
}
}
void _rasterBoundsTests() {
for (final (width, height, scale, legacy, rasterScale) in [
(32, 32, 1e300, false, 1.0),
(32, 32, 129.0, false, 1.0),
(32, 32, 100.0, false, 2.0),
(512, 1, 0.1, true, 1.0),
]) {
test('rejects raster ${width}x$height scale=$scale before key creation',
() async {
final image = await createTestImage(width: width, height: height);
addTearDown(image.dispose);
final data = CursorData(
peerId: 'bounds',
id: 'bounds',
image: img.Image(width: width, height: height, numChannels: 4),
nativeImage: image,
scale: 1,
data: null,
hotxOrigin: 0,
hotyOrigin: 0,
width: width,
height: height);
final initial = data.updateGetKey(1, resizeImage: false);
expect(
data.updateGetKey(scale,
resizeImage: false,
useLegacyMinimum: legacy,
rasterScale: rasterScale),
isNull);
expect(data.scale, 1);
expect(data.updateGetKey(1, resizeImage: false), initial);
});
}
}
Future<void> checkDensity(WidgetTester tester, (String, String) input,
List<Map<dynamic, dynamic>> registrations) async {
final (style, density) = input;
final id = '$style-$density';
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetDevicePixelRatio);
final canvas = _Canvas(1, style: style, scale: 0.5);
final ffi = _FFI(canvas)..ffiModel.pi.platform = kPeerPlatformMacOS;
final cursor = CursorModel(WeakReference(ffi))..id = id;
addTearDown(() async {
expect(cursor.parent.target, same(ffi));
for (final key in cursor.cachedKeys) {
await deleteCustomCursor(key);
}
cursor.disposeImages();
cursor.dispose();
canvas.dispose();
});
await cursor.updateCursorData(_cursorEvent(id, density));
final rejected = density == '1e-300' || density == '0.001';
if (rejected) {
expect(cursor.cache, isNull,
reason: 'Reject density before publishing a cursor');
await cursor.updateCursorData(_cursorEvent(id, '2'));
}
expect(cursor.cache!.pixelRatio, rejected ? 2 : double.parse(density));
await tester.pumpWidget(MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1),
child: MultiProvider(
providers: [
ChangeNotifierProvider<ImageModel>(create: (_) => _Image()),
ChangeNotifierProvider<CanvasModel>.value(value: canvas),
ChangeNotifierProvider<CursorModel>.value(value: cursor),
],
child: ImagePaint(
ffi: ffi,
id: 'density-review',
zoomCursor: false.obs,
cursorOverImage: true.obs,
keyboardEnabled: true.obs,
remoteCursorMoved: false.obs)),
));
expect(tester.takeException(), isNull);
for (final key in cursor.cachedKeys) {
await CursorManager.instance.ensureCursorRegistered(key);
}
expect(registrations, hasLength(1));
await tester.pumpWidget(const SizedBox.shrink());
}
Map<String, String> _cursorEvent(String id, String density) => {
'id': id,
'width': '32',
'height': '32',
'hotx': '8',
'hoty': '12',
'scale': density,
'colors': jsonEncode(List<int>.filled(32 * 32 * 4, 255)),
};

View File

@@ -0,0 +1,307 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_custom_cursor/cursor_manager.dart' show CursorManager;
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/native/custom_cursor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:image/image.dart' as img;
import 'package:provider/provider.dart';
const _viewport = Size(200, 160);
class _Image extends ChangeNotifier implements ImageModel {
@override
bool get useTextureRender => false;
@override
ui.Image? get image => null;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Canvas extends ChangeNotifier implements CanvasModel {
_Canvas(this.devicePixelRatio, {required this.style, required this.scale});
final String style;
@override
final double devicePixelRatio;
@override
final imageOverflow = false.obs;
@override
late final viewStyle = ViewStyle(
style: style,
width: _viewport.width,
height: _viewport.height,
displayWidth: 400,
displayHeight: 320,
);
@override
bool get cursorEmbedded => false;
@override
ScrollStyle get scrollStyle => ScrollStyle.scrollauto;
@override
Size get size => _viewport;
@override
final double scale;
@override
double get x => 0;
@override
double get y => 0;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Cursor extends CursorModel {
_Cursor(this.cache, this._ffi) : super(WeakReference(_ffi));
final FFI _ffi;
@override
WeakReference<FFI> get parent => WeakReference(_ffi);
@override
CursorData cache;
@override
double get hotx => cache.hotxOrigin;
@override
double get hoty => cache.hotyOrigin;
}
class _Input extends Fake implements InputModel {
@override
final relativeMouseMode = false.obs;
}
class _Peer extends Fake implements FfiModel {
@override
final pi = PeerInfo();
@override
bool isPeerLinux = false;
}
class _LinuxDisplay extends Display {
@override
double get scale => 2;
}
class _FFI extends Fake implements FFI {
_FFI(this.canvasModel);
@override
final CanvasModel canvasModel;
@override
final inputModel = _Input();
@override
final _Peer ffiModel = _Peer();
}
Future<CursorData> _data(int density, String id) async {
final bitmapDensity = density == 0 ? 1 : density;
final image = await createTestImage(
width: 9 * bitmapDensity, height: 18 * bitmapDensity);
return CursorData(
peerId: 'dpi-policy',
id: id,
image: img.Image(width: image.width, height: image.height, numChannels: 4),
nativeImage: image,
scale: 1,
data: Uint8List.fromList([1, 2]),
hotxOrigin: 4.0 * bitmapDensity,
hotyOrigin: 9.0 * bitmapDensity,
width: image.width,
height: image.height,
pixelRatio: density.toDouble(),
);
}
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
final view = binding.platformDispatcher.views.single;
final channel = Platform.isWindows
? SystemChannels.mouseCursor
: const MethodChannel('flutter_custom_cursor');
final windows = Platform.isWindows;
final registrations = <Map<dynamic, dynamic>>[];
setUp(() {
registrations.clear();
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
(call) async {
if (!call.method.startsWith('createCustomCursor')) return null;
final args = call.arguments as Map<dynamic, dynamic>;
registrations.add(args);
return args['name'];
});
});
tearDown(() {
view.resetDevicePixelRatio();
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
});
for (final forbidden in [false, true]) {
test('predefined cursor forbidden=$forbidden preserves native RGBA', () {
view.devicePixelRatio = 1;
return _checkPredefinedCursor(
forbidden ? preForbiddenCursor : preDefaultCursor, registrations);
});
}
for (final testCase in [
(kRemoteViewStyleAdaptive, false, 0, 0.25, windows ? 1.0 : 4 / 3),
(kRemoteViewStyleCustom, false, 0, 0.25, windows ? 1.0 : 4 / 3),
(kRemoteViewStyleAdaptive, false, 1, 0.25, windows ? 2.0 : 1.0),
(kRemoteViewStyleAdaptive, false, 2, 0.25, windows ? 1.0 : 0.5),
(kRemoteViewStyleCustom, false, 2, 0.25, windows ? 1.0 : 0.5),
(kRemoteViewStyleAdaptive, true, 2, 0.25, windows ? 2 / 3 : 1 / 3),
(kRemoteViewStyleCustom, true, 2, 0.25, windows ? 2 / 3 : 1 / 3),
(kRemoteViewStyleOriginal, false, 2, 0.5, windows ? 1.0 : 2 / 3),
]) {
testWidgets(
'${testCase.$1} zoom=${testCase.$2} peerDPR=${testCase.$3}',
(tester) => tester
.runAsync(() => _checkPolicy(tester, testCase, registrations)));
}
test('live DPR changes invalidate a cached native cursor',
() => _checkDprChange(view, registrations));
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
testWidgets('$style forbidden cursor ignores remote DPR', (tester) =>
tester.runAsync(() => _checkPolicy(tester,
(style, false, 2, 0.25, 0.5), registrations, dpr: 1)));
testWidgets('Linux $style zoom follows video pixels', (tester) => tester.runAsync(
() => _checkPolicy(tester, (style, true, 2, 1.0, windows ? 1.0 : 0.5),
registrations, linux: true)));
}
}
Future<void> _checkPolicy(
WidgetTester tester,
(String, bool, int, double, double) testCase,
List<Map<dynamic, dynamic>> registrations,
{bool linux = false, double dpr = 2}) async {
final (style, zoom, density, viewScale, expectedScale) = testCase;
tester.view.devicePixelRatio = dpr;
final data = await _data(density, '$style-$zoom-$density');
final originalBytes = data.data;
// A stale cached DPR must not affect the cursor when the window moves.
final canvas = _Canvas(1, style: style, scale: viewScale);
final ffi = _FFI(canvas);
ffi.ffiModel.isPeerLinux = linux;
if (linux) ffi.ffiModel.pi.displays.add(_LinuxDisplay());
final cursor = _Cursor(data, ffi);
final keyboardEnabled = true.obs;
await tester.pumpWidget(MediaQuery(
data: MediaQueryData(devicePixelRatio: dpr),
child: MultiProvider(
providers: [
ChangeNotifierProvider<ImageModel>(create: (_) => _Image()),
ChangeNotifierProvider<CanvasModel>.value(value: canvas),
ChangeNotifierProvider<CursorModel>.value(value: cursor),
],
child: ImagePaint(
ffi: ffi,
id: 'dpi-policy',
zoomCursor: zoom.obs,
cursorOverImage: true.obs,
keyboardEnabled: keyboardEnabled,
remoteCursorMoved: false.obs,
)),
));
final revoke = !zoom && style != kRemoteViewStyleOriginal;
if (revoke) {
await Future.wait(cursor.cachedKeys
.map(CursorManager.instance.ensureCursorRegistered));
keyboardEnabled.value = false;
await tester.pump();
}
await tester.pumpWidget(const SizedBox.shrink());
for (final key in cursor.cachedKeys) {
await deleteCustomCursor(key);
}
data.nativeImage.dispose();
cursor.dispose();
canvas.dispose();
expect(data.scale, closeTo(expectedScale, 1e-9));
expect(data.hotx, closeTo(data.hotxOrigin * expectedScale, 1e-9));
expect(data.hoty, closeTo(data.hotyOrigin * expectedScale, 1e-9));
expect(data.data, same(originalBytes));
if (revoke) {
final args = registrations.last;
expect(args['name'], contains('_${kPreForbiddenCursorId}_'));
expect((args['width'], args['height']), (32, 32));
expect((args['hotX'], args['hotY']), (0.0, 0.0));
}
if (style == kRemoteViewStyleAdaptive && !zoom && density > 0) {
final args = registrations.first;
expect((args['width'], args['height']), ((Platform.isLinux ? 18 : 9) * dpr, 18 * dpr));
expect((args['hotX'], args['hotY']), (4 * dpr, 9 * dpr));
expect(args['imagePixelRatio'], dpr);
}
}
Future<void> _checkDprChange(
TestFlutterView view, List<Map<dynamic, dynamic>> registrations) async {
// Keep the scale above the minimum so only DPR invalidates the cache.
final data = await _data(2, 'dpr-cache');
final canvas = _Canvas(1, style: kRemoteViewStyleAdaptive, scale: 1);
final cursor = _Cursor(data, _FFI(canvas));
for (final dpr in [2.0, 1.0]) {
view.devicePixelRatio = dpr;
buildCursorOfCache(cursor, 1, data);
await deleteCustomCursor(cursor.cachedKeys.last);
}
expect(registrations, hasLength(2));
expect(registrations[0]['name'], isNot(registrations[1]['name']));
data.nativeImage.dispose();
cursor.dispose();
canvas.dispose();
}
Future<void> _checkPredefinedCursor(PredefinedCursor predefined,
List<Map<dynamic, dynamic>> registrations) async {
await Future.doWhile(() async {
await Future<void>.delayed(const Duration(milliseconds: 10));
return predefined.cache == null;
}).timeout(const Duration(seconds: 5));
final cache = predefined.cache!;
final original = img
.decodePng(base64Decode(predefined.png))!
.convert(format: img.Format.uint8, numChannels: 4);
final canvas = _Canvas(1, style: kRemoteViewStyleAdaptive, scale: 1);
final cursor = _Cursor(cache, _FFI(canvas));
buildCursorOfCache(cursor, 1, cache);
await deleteCustomCursor(cursor.cachedKeys.single);
cursor.dispose();
canvas.dispose();
final args = registrations.single;
final bytes = args['buffer'] as Uint8List;
final decoded = Platform.isWindows
? img.Image.fromBytes(
width: args['width'] as int,
height: args['height'] as int,
bytes: bytes.buffer,
bytesOffset: bytes.offsetInBytes,
order: img.ChannelOrder.bgra)
: img.decodePng(bytes)!;
expect((decoded.width, decoded.height), (original.width, original.height));
expect(args['imagePixelRatio'], 1.0);
expect((args['hotX'], args['hotY']), (cache.hotxOrigin, cache.hotyOrigin));
expect(cache.image.getBytes(), original.getBytes());
// Actual bundled pixels cover transparent, opaque and translucent colors.
for (final (x, y) in [(0, 0), (1, 8), (13, 22), (16, 16)]) {
final expected = original.getPixel(x, y);
final actual = decoded.getPixel(x, y);
expect(actual.a, expected.a);
for (final (got, want) in [
(actual.r, expected.r),
(actual.g, expected.g),
(actual.b, expected.b)
]) {
expect(got, closeTo(want, 1), reason: '${predefined.id} at ($x, $y)');
}
}
}

View File

@@ -0,0 +1,281 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_custom_cursor/cursor_manager.dart' as cursor_manager;
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
import 'package:flutter_hbb/models/desktop_render_texture.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/native/custom_cursor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
const _viewport = Size(1920, 540);
const _id = 'local-display-review';
const _side = 64;
const _hotspot = Offset(8, 12);
class _Display extends Display {
_Display(this.density, double left, int w, int h) {
x = left;
width = w;
height = h;
}
final double density;
@override
double get scale => density;
}
class _Peer extends Fake implements FfiModel {
_Peer() {
pi.platform = kPeerPlatformLinux;
pi.currentDisplay = kAllDisplayValue;
pi.displays.assignAll([
_Display(2, 0, 3840, 2160),
_Display(1, 1920, 1920, 1080),
]);
}
@override
final pi = PeerInfo();
@override
bool get isPeerLinux => true;
@override
bool get keyboard => true;
@override
bool get viewOnly => false;
@override
Rect get rect => const Rect.fromLTWH(0, 0, 3840, 1080);
}
class _Image extends ChangeNotifier implements ImageModel {
@override
bool get useTextureRender => true;
@override
ui.Image? get image => null;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Texture extends Fake implements TextureModel {
final ids = <int, RxInt>{};
@override
RxInt getTextureId(int display) =>
ids.putIfAbsent(display, () => display.obs);
}
class _Canvas extends CanvasModel {
_Canvas(FFI ffi, String style)
: viewStyle = ViewStyle(
style: style,
width: 1920,
height: 540,
displayWidth: 3840,
displayHeight: 1080),
super(WeakReference(ffi)) {
id = _id;
}
@override
double get scale => 0.5;
@override
Size get size => _viewport;
@override
bool get cursorEmbedded => false;
@override
final ViewStyle viewStyle;
}
class _FFI extends Fake implements FFI {
_FFI(String style) {
canvasModel = _Canvas(this, style);
cursorModel = CursorModel(WeakReference(this));
inputModel = InputModel(WeakReference(this));
}
@override
final sessionId = Uuid().v4obj();
@override
String get id => _id;
@override
final ffiModel = _Peer();
@override
final imageModel = _Image();
@override
final textureModel = _Texture();
@override
late final CanvasModel canvasModel;
@override
late final CursorModel cursorModel;
@override
late final InputModel inputModel;
Offset? mappedPosition;
}
Future<void> _shape(_FFI ffi, String id) async {
ffi.cursorModel.id = id;
await ffi.cursorModel.updateCursorData({
'id': id,
'width': '$_side',
'height': '$_side',
'scale': '2',
'hotx': '${_hotspot.dx}',
'hoty': '${_hotspot.dy}',
'colors': jsonEncode(List.generate(_side * _side * 4, (_) => 255)),
});
}
Widget _widget(_FFI ffi, double dpr) => Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData(devicePixelRatio: dpr),
child: MultiProvider(
providers: [
ChangeNotifierProvider<ImageModel>.value(value: ffi.imageModel),
ChangeNotifierProvider<CanvasModel>.value(value: ffi.canvasModel),
ChangeNotifierProvider<CursorModel>.value(value: ffi.cursorModel),
],
child: ImagePaint(
ffi: ffi,
id: _id,
zoomCursor: true.obs,
cursorOverImage: true.obs,
keyboardEnabled: true.obs,
remoteCursorMoved: RemoteCursorMovedState.find(_id),
listenerBuilder: (child) => Listener(
child: child,
onPointerHover: (e) {
final point = ffi.inputModel.handlePointerDevicePos(
kPointerEventKindMouse,
e.position.dx,
e.position.dy,
true,
kMouseEventTypeDefault,
moveCanvas: false);
if (point != null) {
ffi.mappedPosition =
Offset(point.x.toDouble(), point.y.toDouble());
}
})))));
Future<void> _settle(WidgetTester tester, _FFI ffi) async {
await tester.pump();
for (final key in ffi.cursorModel.cachedKeys) {
await cursor_manager.CursorManager.instance.ensureCursorRegistered(key);
}
await tester.pump();
}
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
final registrations = <Map<dynamic, dynamic>>[];
final activations = <String>[];
final channel = Platform.isWindows
? SystemChannels.mouseCursor
: const MethodChannel('flutter_custom_cursor');
setUp(() {
registrations.clear();
activations.clear();
RemoteCursorMovedState.init(_id);
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
(call) async {
if (call.method.startsWith('setCustomCursor')) {
activations.add(call.arguments['name'] as String);
}
if (!call.method.startsWith('createCustomCursor')) return null;
final args = call.arguments as Map<dynamic, dynamic>;
registrations.add(args);
return args['name'];
});
});
tearDown(() {
RemoteCursorMovedState.delete(_id);
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
});
for (final (style, dpr) in [
(kRemoteViewStyleAdaptive, 1.0),
(kRemoteViewStyleCustom, 2.0),
]) {
for (final mode in ['movement', 'shape refresh', 'host position control']) {
testWidgets(
'mixed displays: $style DPR=$dpr $mode',
(tester) => tester.runAsync(
() => _checkMovement(tester, (style, dpr, mode), (registrations, activations))));
}
}
}
Future<void> _checkMovement(
WidgetTester tester,
(String, double, String) testCase,
(List<Map<dynamic, dynamic>>, List<String>) calls) async {
final (style, dpr, mode) = testCase;
final (registrations, activations) = calls;
tester.view.devicePixelRatio = dpr;
tester.view.physicalSize = _viewport * dpr;
addTearDown(tester.view.reset);
final ffi = _FFI(style);
addTearDown(() => _dispose(ffi));
await _shape(ffi, '$style-$dpr-$mode-1');
await ffi.cursorModel.updateCursorPosition({'x': '100', 'y': '100'}, _id);
ffi.canvasModel.updateLocalCursor(50, 50);
await tester.pumpWidget(_widget(ffi, dpr));
await _settle(tester, ffi);
expect(registrations.last['width'], 16 * dpr);
await _moveToB(tester, ffi, (mode, activations));
final args = registrations.last;
expect((args['width'], args['height']), (32 * dpr, 32 * dpr));
expect((args['hotX'], args['hotY']), (4 * dpr, 6 * dpr));
}
Future<void> _moveToB(WidgetTester tester, _FFI ffi, (String, List<String>) testCase) async {
final (mode, activations) = testCase;
final mouse = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
await mouse.addPointer(location: const Offset(50, 50));
await _settle(tester, ffi);
final before = activations.length;
for (final x in [60.0, 70.0, 80.0]) {
await mouse.moveTo(Offset(x, 50));
await _settle(tester, ffi);
expect(activations.length, before);
}
await mouse.moveTo(
Offset(1100 + CanvasModel.leftToEdge, 50 + CanvasModel.topToEdge));
await _settle(tester, ffi);
expect(activations.length, before + 1);
expect(ffi.mappedPosition, const Offset(2200, 100));
expect(ffi.cursorModel.offset, const Offset(100, 100));
if (mode == 'shape refresh') {
await _shape(ffi, '$mode-2');
} else if (mode == 'host position control') {
await ffi.cursorModel.updateCursorPosition({'x': '2200', 'y': '100'}, _id);
ffi.canvasModel.updateLocalCursor(1100, 50);
}
await _settle(tester, ffi);
final after = activations.length;
expect(after, before + (mode == 'shape refresh' ? 2 : 1));
for (final x in [1110.0, 1120.0, 1130.0]) {
await mouse.moveTo(Offset(x, 50));
await _settle(tester, ffi);
expect(activations.length, after);
}
final displayB = tester.widgetList<Positioned>(find.byType(Positioned)).last;
expect(displayB.width! / 1920, 0.5);
await mouse.removePointer();
}
Future<void> _dispose(_FFI ffi) async {
for (final key in ffi.cursorModel.cachedKeys) {
await deleteCustomCursor(key);
}
ffi.cursorModel.disposeImages();
ffi.cursorModel.dispose();
ffi.canvasModel.dispose();
ffi.imageModel.dispose();
// Relative mode is never started and this fixture has no Rust session.
ffi.inputModel.disposeSideButtonTracking();
}

View File

@@ -0,0 +1,158 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/native/custom_cursor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
const _side = 32;
const _hotspot = Offset(7, 9);
const _colors = [
[64, 32, 16, 128],
[240, 100, 20, 255],
[0, 0, 0, 0],
[0, 0, 0, 128],
];
final _stripeWidth = _side ~/ _colors.length;
class _Canvas extends Fake implements CanvasModel {
@override
final viewStyle = ViewStyle(
style: kRemoteViewStyleAdaptive,
width: 200,
height: 160,
displayWidth: 200,
displayHeight: 160);
}
class _Peer extends Fake implements FfiModel {
_Peer(String platform) : pi = (PeerInfo()..platform = platform);
@override
final PeerInfo pi;
}
class _FFI extends Fake implements FFI {
_FFI(String platform) : ffiModel = _Peer(platform);
@override
final canvasModel = _Canvas();
@override
final _Peer ffiModel;
}
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
final view = binding.platformDispatcher.views.single;
final channel = Platform.isWindows
? SystemChannels.mouseCursor
: const MethodChannel('flutter_custom_cursor');
final registrations = <Map<dynamic, dynamic>>[];
setUp(() {
registrations.clear();
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
(call) async {
if (!call.method.startsWith('createCustomCursor')) return null;
final args = call.arguments as Map<dynamic, dynamic>;
registrations.add(args);
return args['name'];
});
});
tearDown(() {
view.resetDevicePixelRatio();
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
});
for (final testCase in <(String, String?, List<int>)>[
(kPeerPlatformMacOS, null, [64, 32, 16, 128]),
(kPeerPlatformMacOS, '0', [64, 32, 16, 128]),
(kPeerPlatformMacOS, '1', [64, 32, 16, 128]),
(kPeerPlatformMacOS, '2', [64, 32, 16, 128]),
(kPeerPlatformWindows, null, [64, 32, 16, 128]),
(kPeerPlatformWindows, '0', [64, 32, 16, 128]),
(kPeerPlatformLinux, '0', [32, 16, 8, 128]),
]) {
for (final dpr in [1.0, 2.0]) {
test(
'native ${testCase.$1} scale=${testCase.$2} DPR=$dpr preserves alpha',
() {
view.devicePixelRatio = dpr;
return _checkCursor(testCase, dpr, registrations);
});
}
}
}
Future<void> _checkCursor((String, String?, List<int>) testCase, double dpr,
List<Map<dynamic, dynamic>> registrations) async {
final (platform, density, color) = testCase;
final ffi = _FFI(platform);
final cursor = CursorModel(WeakReference(ffi))..id = '$testCase-$dpr';
addTearDown(() {
cursor.disposeImages();
cursor.dispose();
});
final palette = [color, ..._colors.skip(1)];
final rgba = [
for (var y = 0; y < _side; y++)
for (var x = 0; x < _side; x++) ...palette[x ~/ _stripeWidth]
];
await cursor.updateCursorData({
'id': '$testCase-$dpr',
'width': '$_side',
'height': '$_side',
'hotx': '${_hotspot.dx}',
'hoty': '${_hotspot.dy}',
if (density != null) 'scale': density,
'colors': jsonEncode(rgba),
});
final cache = cursor.cache!;
buildCursorOfCache(cursor, Platform.isWindows ? dpr : 1, cache);
await deleteCustomCursor(cursor.cachedKeys.single);
expect(cache.image.getBytes(), rgba); // Keep the original byte-cache source.
_checkRegistration(registrations.single, dpr);
// The same ui.Image also supplies the painted remote cursor.
final straight = await cache.nativeImage
.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
_checkColors(img.Image.fromBytes(
width: _side,
height: _side,
bytes: straight!.buffer,
bytesOffset: straight.offsetInBytes,
order: img.ChannelOrder.rgba));
// Real sessions own FFI strongly throughout asynchronous cursor decoding.
expect(cursor.parent.target, same(ffi));
}
void _checkRegistration(Map<dynamic, dynamic> args, double dpr) {
final bytes = args['buffer'] as Uint8List;
final decoded = Platform.isWindows
? img.Image.fromBytes(
width: args['width'] as int,
height: args['height'] as int,
bytes: bytes.buffer,
bytesOffset: bytes.offsetInBytes,
order: img.ChannelOrder.bgra)
: img.decodePng(bytes)!;
expect((decoded.width, decoded.height), (_side * dpr, _side * dpr));
expect((args['hotX'], args['hotY']), (_hotspot.dx * dpr, _hotspot.dy * dpr));
_checkColors(decoded);
}
void _checkColors(img.Image bitmap) {
for (var stripe = 0; stripe < _colors.length; stripe++) {
final pixel = bitmap.getPixel(
((stripe + 0.5) * bitmap.width / _colors.length).floor(),
bitmap.height ~/ 2);
final expected = _colors[stripe];
expect(pixel.a, expected.last);
for (final (actual, wanted) in [
(pixel.r, expected[0]),
(pixel.g, expected[1]),
(pixel.b, expected[2])
]) {
expect(actual, closeTo(wanted, 1), reason: 'RGBA stripe $stripe');
}
}
}

View File

@@ -0,0 +1,297 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
import 'package:flutter_hbb/models/desktop_render_texture.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/utils/image.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
const _hotspot = Offset(4, 9);
const _remotePosition = Offset(100.25, 80.75);
const _canvasOffset = Offset(15.125, -10.25);
const _viewport = Size(200, 160);
class _CursorModel extends ChangeNotifier implements CursorModel {
_CursorModel(this.image, this.position, this.density);
final Offset position;
final double density;
@override
CursorData get cache => _Density(density);
@override
final ui.Image image;
@override
double get hotx => _hotspot.dx;
@override
double get hoty => _hotspot.dy;
@override
double get x => position.dx;
@override
double get y => position.dy;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Density extends Fake implements CursorData {
_Density(this.pixelRatio);
@override
final double pixelRatio;
}
class _ImageModel extends ChangeNotifier implements ImageModel {
_ImageModel(this.useTextureRender);
@override
final bool useTextureRender;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _TextureModel extends Fake implements TextureModel {
@override
RxInt getTextureId(int display) => 0.obs;
}
class _Display extends Display {
_Display(this.scale, double left) {
x = left;
width = 3840;
height = 2160;
}
@override
final double scale;
}
class _Peer extends Fake implements FfiModel {
@override
final pi = PeerInfo()
..displays.add(Display()
..width = _viewport.width.toInt()
..height = _viewport.height.toInt());
@override
bool isPeerLinux = false;
@override
Rect rect = Offset.zero & _viewport;
}
class _FFI extends Fake implements FFI {
_FFI(bool useTexture) : imageModel = _ImageModel(useTexture);
@override
final ImageModel imageModel;
@override
final _Peer ffiModel = _Peer();
@override
final textureModel = _TextureModel();
@override
late CanvasModel canvasModel;
}
class _CanvasModel extends ChangeNotifier implements CanvasModel {
_CanvasModel(String style, this.scale, bool useTexture)
: _ffi = _FFI(useTexture),
viewStyle = ViewStyle(
style: style,
width: _viewport.width,
height: _viewport.height,
displayWidth: _viewport.width.toInt(),
displayHeight: _viewport.height.toInt(),
) {
_ffi.canvasModel = this;
}
final _FFI _ffi;
@override
WeakReference<FFI> get parent => WeakReference(_ffi);
@override
final imageOverflow = false.obs;
@override
final ViewStyle viewStyle;
@override
double get x => _canvasOffset.dx;
@override
double get y => _canvasOffset.dy;
@override
final double scale;
@override
ScrollStyle get scrollStyle => ScrollStyle.scrollauto;
@override
Size get size => _viewport;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _ScrollbarCanvasModel extends _CanvasModel {
_ScrollbarCanvasModel(String style, {Size? frame, double scale = 2})
: super(style, scale, true) {
imageOverflow.value = true;
if (frame != null) _ffi.ffiModel.rect = Offset.zero & frame;
}
@override
ScrollStyle get scrollStyle => ScrollStyle.scrollbar;
@override
double get scrollX => _ffi.ffiModel.rect.width * scale > size.width ? 0.1 : 0;
@override
double get scrollY => _ffi.ffiModel.rect.height * scale > size.height ? 0.2 : 0;
}
class _Canvas extends Fake implements Canvas {
double factor = 1;
Offset? position;
@override
void scale(double sx, [double? sy]) => factor *= sx;
@override
void drawImage(ui.Image image, Offset offset, Paint paint) {
position = offset * factor;
}
}
Future<ImagePainter> _paintCursor(WidgetTester tester, CanvasModel canvas,
{double dpr = 2, bool zoom = true, (int, int) source = (48, 64),
Offset position = _remotePosition, double density = 0}) async {
final image = (await tester
.runAsync(() => createTestImage(width: source.$1, height: source.$2)))!;
addTearDown(image.dispose);
tester.view.devicePixelRatio = dpr;
tester.view.physicalSize = _viewport * dpr;
addTearDown(tester.view.reset);
await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData(devicePixelRatio: dpr),
child: MultiProvider(
providers: [
ChangeNotifierProvider<ImageModel>.value(value: canvas.parent.target!.imageModel),
ChangeNotifierProvider<CursorModel>(create: (_) => _CursorModel(image, position, density)),
ChangeNotifierProvider<CanvasModel>(create: (_) => canvas),
],
child: Stack(fit: StackFit.expand, children: [
// Measure the video's origin instead of copying its rounding policy.
if (canvas.parent.target!.imageModel.useTextureRender &&
canvas.scrollStyle == ScrollStyle.scrollauto)
ImagePaint(ffi: canvas.parent.target!, id: 'cursor-test',
zoomCursor: zoom.obs, cursorOverImage: false.obs,
keyboardEnabled: true.obs, remoteCursorMoved: false.obs),
CursorPaint(id: 'cursor-test', zoomCursor: zoom.obs),
]),
),
)));
final painter = tester.widget<CustomPaint>(find.byType(CustomPaint)).painter!
as ImagePainter;
expect(painter.image, same(image));
return painter;
}
void _linuxDisplayTests() {
for (final (texture, displayScale, allDisplays) in [
(false, 2.0, false), (true, 2.0, false), (true, 2.0, true),
(false, 1.0, false), (true, 1.0, false),
]) {
testWidgets('Linux scale=$displayScale texture=$texture all=$allDisplays',
(tester) async {
final canvas = _CanvasModel(kRemoteViewStyleCustom, 2, texture);
final peer = canvas._ffi.ffiModel;
peer.isPeerLinux = true;
// The first output's physical extent overlaps the second in logical
// coordinates. Selection must use logical extents and the union origin.
peer.pi.displays.assignAll([
if (displayScale > 1) _Display(4, -960), _Display(displayScale, 0),
]);
peer.pi.currentDisplay = allDisplays ? kAllDisplayValue
: peer.pi.displays.length - 1;
peer.rect = Rect.fromLTWH(allDisplays ? -960 : 0, 0, 3840, 2160);
final painter = await _paintCursor(tester, canvas,
dpr: 1, zoom: false, source: (64, 64), density: 2,
position: allDisplays ? _remotePosition + const Offset(960, 0)
: _remotePosition);
final pixelScale = 2 / displayScale;
expect(painter.scale, pixelScale);
final origin = texture ? tester.getTopLeft(find.byType(Texture).first)
: Offset((_canvasOffset.dx / pixelScale).toInt() * pixelScale,
(_canvasOffset.dy / pixelScale).toInt() * pixelScale);
final position = allDisplays
? _remotePosition + const Offset(960, 0) : _remotePosition;
expect((Offset(painter.x, painter.y) + _hotspot) * painter.scale,
position * 2 + origin);
});
}
}
void main() {
_linuxDisplayTests();
final minimumScale = Platform.isWindows ? 1 / 3 : 2 / 3;
for (final (style, zoom, dpr, source, canvasScale, scale, texture, density) in [
(kRemoteViewStyleAdaptive, false, 2.0, (48, 64), 0.375, 0.375, true, 0.0),
(kRemoteViewStyleOriginal, false, 2.0, (48, 64), 0.5, 0.5, true, 0.0),
(kRemoteViewStyleCustom, false, 2.0, (48, 64), 0.25, 0.25, false, 0.0),
(kRemoteViewStyleCustom, true, 2.0, (48, 64), 2.0, 2.0, false, 0.0),
(kRemoteViewStyleAdaptive, false, 2.25, (48, 48), 0.375, 0.375, false, 0.0),
(kRemoteViewStyleAdaptive, true, 2.0, (9, 18), 0.1, minimumScale, true, 0.0),
(kRemoteViewStyleAdaptive, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0),
(kRemoteViewStyleAdaptive, true, 2.0, (48, 64), 0.05, 0.1875, true, 2.0),
(kRemoteViewStyleCustom, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0),
(kRemoteViewStyleCustom, true, 2.0, (48, 64), 0.05, 0.1875, true, 2.0),
(kRemoteViewStyleCustom, true, 2.0, (8, 10), 1.0, 1.2, true, 2.0),
(kRemoteViewStyleOriginal, true, 1.0, (48, 64), 0.05, 0.25, true, 2.0),
(kRemoteViewStyleOriginal, true, 2.0, (48, 64), 0.05,
Platform.isWindows ? 0.125 : 0.25, true, 2.0),
]) {
testWidgets(
'$style zoom=$zoom dpr=$dpr source=$source texture=$texture density=$density keeps remote geometry',
(tester) async {
final painter = await _paintCursor(
tester, _CanvasModel(style, canvasScale, texture),
dpr: dpr, zoom: zoom, source: source, density: density);
expect(painter.scale, scale);
var imageOrigin = texture
? tester.getTopLeft(find.byType(Texture).first) : _canvasOffset;
if (!texture) {
final background = _Canvas();
ImagePainter(
image: painter.image,
x: _canvasOffset.dx / canvasScale,
y: _canvasOffset.dy / canvasScale,
scale: canvasScale,
).paint(background, _viewport);
imageOrigin = background.position!;
}
final target = _remotePosition * canvasScale + imageOrigin;
final hotspot = (Offset(painter.x, painter.y) + _hotspot) * scale;
expect(hotspot.dx, closeTo(target.dx, 1e-9));
expect(hotspot.dy, closeTo(target.dy, 1e-9));
final canvas = _Canvas();
painter.paint(canvas, _viewport);
final position = canvas.position! + _hotspot * canvas.factor;
expect(position.dx, closeTo(target.dx, 1e-9));
expect(position.dy, closeTo(target.dy, 1e-9));
});
}
for (final style in [kRemoteViewStyleOriginal, kRemoteViewStyleCustom]) {
for (final (frame, scale, offset) in [
(_viewport, 2.0, const Offset(-40, -64)),
(const Size(199, 320), 1.0, const Offset(0, -64)),
(const Size(198, 320), 1.0, const Offset(1, -64)),
(const Size(400, 159), 1.0, const Offset(-40, 0)),
(const Size(400, 158), 1.0, const Offset(-40, 1)),
]) {
testWidgets('$style frame=$frame painted cursor follows scrollbar layout',
(tester) async {
final painter = await _paintCursor(
tester, _ScrollbarCanvasModel(style, frame: frame, scale: scale));
final target = _remotePosition * scale + offset;
expect((Offset(painter.x, painter.y) + _hotspot) * painter.scale, target);
});
}
}
}

View File

@@ -0,0 +1,299 @@
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
import 'package:flutter_hbb/models/desktop_render_texture.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/utils/image.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import 'package:vector_math/vector_math.dart' show Vector2;
const _viewport = Size(200, 160);
const _hotspot = Offset(4, 9);
const _scrollDeltas = [80.0, -20.0, 1000.0, 1000.0, -1000.0, -1000.0, 80.0, 0.0];
class _Peer extends Fake implements FfiModel {
_Peer(ui.Image frame)
: rect = Rect.fromLTWH(
0, 0, frame.width.toDouble(), frame.height.toDouble()) {
pi.displays.add(Display()
..width = frame.width
..height = frame.height);
}
@override
final pi = PeerInfo();
@override
final Rect rect;
@override
bool get isPeerLinux => false;
}
class _Image extends ImageModel {
_Image(FFI ffi, this.image, this.useTextureRender)
: super(WeakReference(ffi));
@override
final ui.Image image;
@override
final bool useTextureRender;
}
class _Texture extends Fake implements TextureModel {
@override
RxInt getTextureId(int display) => 0.obs;
}
class _ScrollCanvas extends CanvasModel {
_ScrollCanvas(FFI ffi, String style, this.scrollStyle)
: viewStyle = ViewStyle(
style: style,
width: _viewport.width,
height: _viewport.height,
displayWidth: ffi.ffiModel.rect!.width.toInt(),
displayHeight: ffi.ffiModel.rect!.height.toInt()),
super(WeakReference(ffi)) {
resetOffset();
imageOverflow.value = true;
}
Size viewport = _viewport;
double imageScale = 1;
@override
Size get size => viewport;
@override
double get scale => imageScale;
@override
double get x => (size.width - getDisplayWidth() * scale) / 2;
@override
double get y => (size.height - getDisplayHeight() * scale) / 2;
@override
final ScrollStyle scrollStyle;
@override
final ViewStyle viewStyle;
void relayout(Size viewport, double dpr) {
this.viewport = viewport;
imageScale = 1 / dpr;
imageOverflow.value = viewport.width < getDisplayWidth() * scale ||
viewport.height < getDisplayHeight() * scale;
setScrollPercent(0, 0);
notifyListeners();
}
}
class _Cursor extends CursorModel {
_Cursor(FFI ffi, this.image, this.position) : super(WeakReference(ffi));
final Offset position;
@override
final ui.Image image;
@override
double get hotx => _hotspot.dx;
@override
double get hoty => _hotspot.dy;
@override
double get x => position.dx;
@override
double get y => position.dy;
}
class _FFI extends Fake implements FFI {
_FFI(ui.Image frame, ui.Image cursor,
{required bool texture, required (String, ScrollStyle) style, required Offset position})
: ffiModel = _Peer(frame) {
imageModel = _Image(this, frame, texture);
canvasModel = _ScrollCanvas(this, style.$1, style.$2);
cursorModel = _Cursor(this, cursor, position);
}
@override
final sessionId = Uuid().v4obj();
@override
final _Peer ffiModel;
@override
late final ImageModel imageModel;
@override
late final CanvasModel canvasModel;
@override
late final CursorModel cursorModel;
@override
final textureModel = _Texture();
}
class _Draw extends Fake implements Canvas {
double factor = 1;
Offset? position;
@override
void scale(double sx, [double? sy]) => factor *= sx;
@override
void drawImage(ui.Image image, Offset offset, Paint paint) =>
position = offset * factor;
}
void main() {
for (final style in [(kRemoteViewStyleOriginal, ScrollStyle.scrolledge),
(kRemoteViewStyleCustom, ScrollStyle.scrolledge),
(kRemoteViewStyleCustom, ScrollStyle.scrollbar)]) {
for (final texture in [false, true]) {
for (final (frame, dpr) in [
(const Size(199, 320), 1.0),
(const Size(198, 320), 2.0),
(const Size(400, 159), 2.0),
(const Size(400, 158), 1.0),
]) {
testWidgets(
'Scroll $style texture=$texture frame=$frame DPR=$dpr',
(tester) => tester
.runAsync(() => _check(tester, (style, texture, frame, dpr))));
}
for (final refreshBeforeLayout in [true, false]) {
testWidgets(
'Scroll relayout $style texture=$texture early=$refreshBeforeLayout',
(tester) => tester.runAsync(() =>
_checkRelayout(tester, (style, texture, refreshBeforeLayout))));
}
}
}
}
Future<void> _check(
WidgetTester tester, ((String, ScrollStyle), bool, Size, double) testCase) async {
final (style, texture, frame, dpr) = testCase;
tester.view.devicePixelRatio = dpr;
tester.view.physicalSize = _viewport * dpr;
addTearDown(tester.view.reset);
final vertical = frame.height > _viewport.height;
final pointer =
vertical ? const Offset(50.25, 200.75) : const Offset(240.25, 50.75);
final video = await createTestImage(
width: frame.width.toInt(), height: frame.height.toInt());
final cursor = await createTestImage(width: 48, height: 64);
final ffi =
_FFI(video, cursor, texture: texture, style: style, position: pointer);
addTearDown(() {
ffi.canvasModel.scrollHorizontal.dispose();
ffi.canvasModel.scrollVertical.dispose();
ffi.canvasModel.dispose();
ffi.imageModel.dispose();
ffi.cursorModel.dispose();
video.dispose();
cursor.dispose();
});
await _mount(tester, ffi, dpr);
final videoWidget = texture ? find.byType(Texture) : _paintOf(video);
final scrolling = vertical
? ffi.canvasModel.scrollVertical
: ffi.canvasModel.scrollHorizontal;
for (final distance in _scrollDeltas) {
// Drive the real scroll controllers; canvas pan offsets are not the video origin.
ffi.canvasModel.performEdgeScroll(
vertical ? Vector2(0, distance) : Vector2(distance, 0));
expect(vertical ? ffi.canvasModel.scrollY : ffi.canvasModel.scrollX,
closeTo(scrolling.offset / (vertical ? frame.height : frame.width), 1e-9));
await tester.pump();
_expectAlignment(tester, ffi, videoWidget);
}
await tester.pumpWidget(const SizedBox.shrink());
}
Future<void> _checkRelayout(
WidgetTester tester, ((String, ScrollStyle), bool, bool) testCase) async {
final (style, texture, refreshBeforeLayout) = testCase;
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = _viewport;
addTearDown(tester.view.reset);
final video = await createTestImage(width: 400, height: 320);
final cursor = await createTestImage(width: 48, height: 64);
final ffi = _FFI(video, cursor,
texture: texture, style: style, position: const Offset(150.25, 120.75));
final canvas = ffi.canvasModel as _ScrollCanvas;
addTearDown(() {
canvas.scrollHorizontal.dispose();
canvas.scrollVertical.dispose();
canvas.dispose();
ffi.imageModel.dispose();
ffi.cursorModel.dispose();
video.dispose();
cursor.dispose();
});
await _mount(tester, ffi, 1);
canvas.performEdgeScroll(Vector2(20, 20));
await tester.pump();
final videoWidget = texture ? find.byType(Texture) : _paintOf(video);
_expectAlignment(tester, ffi, videoWidget);
for (final (viewport, dpr) in [
(const Size(160, 120), 2.0),
(const Size(195, 155), 2.0), // Clamp both existing scroll positions.
(const Size(240, 155), 2.0), // Detach the horizontal scroll controller.
(const Size(240, 200), 2.0), // No scrolling remains.
(_viewport, 1.0),
]) {
tester.view.devicePixelRatio = dpr;
tester.view.physicalSize = viewport * dpr;
canvas.relayout(viewport, dpr);
// A settings refresh may run before layout or after the first cursor build.
if (refreshBeforeLayout) {
canvas.updateScrollPercent();
} else {
tester.binding.addPostFrameCallback((_) => canvas.updateScrollPercent());
}
await _mount(tester, ffi, dpr);
await tester.pumpAndSettle(const Duration(milliseconds: 16),
EnginePhase.sendSemanticsUpdate, const Duration(seconds: 1));
_expectAlignment(tester, ffi, videoWidget);
}
await tester.pumpWidget(const SizedBox.shrink());
}
void _expectAlignment(WidgetTester tester, FFI ffi, Finder videoWidget) {
final cursor = ffi.cursorModel;
final cursorWidget = _paintOf(cursor.image!);
final painter =
tester.widget<CustomPaint>(cursorWidget).painter! as ImagePainter;
final output = _Draw();
painter.paint(output, ffi.canvasModel.size);
final hotspot = tester.getTopLeft(cursorWidget) +
output.position! +
_hotspot * output.factor;
var videoOrigin = tester.getTopLeft(videoWidget);
final video = tester.widget(videoWidget);
if (video is CustomPaint) {
final drawnVideo = _Draw();
video.painter!.paint(drawnVideo, ffi.canvasModel.size);
videoOrigin += drawnVideo.position!;
}
final target =
videoOrigin + Offset(cursor.x, cursor.y) * ffi.canvasModel.scale;
expect(hotspot.dx, closeTo(target.dx, 1e-9));
expect(hotspot.dy, closeTo(target.dy, 1e-9));
}
Finder _paintOf(ui.Image image) => find.byWidgetPredicate((widget) =>
widget is CustomPaint &&
widget.painter is ImagePainter &&
identical((widget.painter as ImagePainter).image, image));
Future<void> _mount(WidgetTester tester, FFI ffi, double dpr) =>
tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData(devicePixelRatio: dpr),
child: MultiProvider(
providers: [
ChangeNotifierProvider<ImageModel>.value(
value: ffi.imageModel),
ChangeNotifierProvider<CanvasModel>.value(
value: ffi.canvasModel),
ChangeNotifierProvider<CursorModel>.value(
value: ffi.cursorModel),
],
child: Stack(fit: StackFit.expand, children: [
ImagePaint(
ffi: ffi,
id: 'scroll-edge-test',
zoomCursor: false.obs,
cursorOverImage: false.obs,
keyboardEnabled: true.obs,
remoteCursorMoved: false.obs),
CursorPaint(id: 'scroll-edge-test', zoomCursor: false.obs),
])))));

View File

@@ -0,0 +1,290 @@
// The test drives the cursor lifecycle normally owned by MouseTracker.
// ignore_for_file: invalid_use_of_protected_member
@TestOn('browser')
library;
import 'dart:convert';
import 'dart:js' as js;
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart' as model;
import 'package:flutter_hbb/web/custom_cursor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:image/image.dart' as img;
import 'package:provider/provider.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
_alphaTests();
_thinCursorTests();
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
for (final dpr in [1.0, 2.0]) {
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 {
final registered = _captureCursor();
final canvas = _Canvas(kRemoteViewStyleAdaptive);
addTearDown(canvas.dispose);
final ffi = _FFI(canvas);
for (final (hotspot, scale, side, expected) in [
((7.0, 7.0), 633 / 1600, 19, (3, 3)),
((21.0, 23.0), 633 / 1600, 19, (8, 9)),
((22.0, 22.0), 633 / 1600, 19, (9, 9)),
((21.0, 23.0), 0.05, 12, (5, 6)),
((21.0, 23.0), 0.5, 24, (11, 12)),
((7.0, 7.0), 1.0, 48, (7, 7)),
]) {
final cursor = await _loadCursor(ffi, '$hotspot-$scale',
hotspot: hotspot, pixelRatio: null);
final session =
buildCursorOfCache(cursor, scale, cursor.cache).createSession(1);
await session.activate();
final uri = Uri.parse(registered['url'] as String);
final bitmap = img.decodePng(uri.data!.contentAsBytes())!;
expect((bitmap.width, bitmap.height), (side, side));
expect((registered['hotx'], registered['hoty']), expected);
session.dispose();
}
});
}
void _thinCursorTests() {
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
for (final zoom in [false, true]) {
testWidgets('ImagePaint Web $style thin cursor zoom=$zoom', (tester) =>
tester.runAsync(() => _checkPolicy(tester, style, 1,
zoom: zoom, source: (64, 4), hotspot: (32, 2),
expectedSize: zoom ? (32, 2) : (64, 4),
expectedHotspot: zoom ? (16, 1) : (32, 2))));
}
}
test('Web thin cursors keep a raster pixel and an in-bounds hotspot', () async {
final registered = _captureCursor();
final canvas = _Canvas(kRemoteViewStyleAdaptive);
addTearDown(canvas.dispose);
final ffi = _FFI(canvas);
for (final (source, hotspot, scale, size, expected) in [
((4, 64), (2.0, 32.0), 0.5, (2, 32), (1, 16)),
((2, 128), (1.0, 64.0), 0.01, (1, 12), (0, 6)),
((128, 2), (64.0, 1.0), 0.01, (12, 1), (6, 0)),
]) {
final cursor = await _loadCursor(ffi, '$source',
source: source, hotspot: hotspot);
for (final factor in [1.0, scale, 1.0]) {
final session =
buildCursorOfCache(cursor, factor, cursor.cache).createSession(1);
await session.activate();
session.dispose();
final png = img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!;
expect((png.width, png.height), factor == 1 ? source : size);
expect((registered['hotx'], registered['hoty']),
factor == 1 ? (hotspot.$1, hotspot.$2) : expected);
}
}
});
}
class _Image extends ChangeNotifier implements model.ImageModel {
@override
bool get useTextureRender => false;
@override
ui.Image? get image => null;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Canvas extends ChangeNotifier implements model.CanvasModel {
_Canvas(String style)
: viewStyle = model.ViewStyle(
style: style,
width: 200,
height: 160,
displayWidth: 400,
displayHeight: 320);
@override
final model.ViewStyle viewStyle;
@override
final imageOverflow = false.obs;
@override
bool get cursorEmbedded => false;
@override
Size get size => const Size(200, 160);
@override
double get scale => 0.5;
@override
double get x => 0;
@override
double get y => 0;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Input extends Fake implements InputModel {
@override
final relativeMouseMode = false.obs;
}
class _Peer extends Fake implements model.FfiModel {
@override
final pi = model.PeerInfo();
@override
bool get isPeerLinux => false;
}
class _FFI extends Fake implements model.FFI {
_FFI(this.canvasModel);
@override
final model.CanvasModel canvasModel;
@override
final inputModel = _Input();
@override
final ffiModel = _Peer();
}
Future<model.CursorModel> _loadCursor(model.FFI ffi, String id,
{List<int> pixel = const [255, 255, 255, 255],
(int, int) source = (48, 48),
(double, double) hotspot = (7, 9),
double? pixelRatio = 1}) async {
final cursor = model.CursorModel(WeakReference(ffi))..id = id;
await cursor.updateCursorData({
'id': id,
'width': '${source.$1}',
'height': '${source.$2}',
'hotx': '${hotspot.$1}',
'hoty': '${hotspot.$2}',
if (pixelRatio != null) 'scale': '$pixelRatio',
'colors': jsonEncode([for (var i = 0; i < source.$1 * source.$2; i++) ...pixel]),
});
addTearDown(() async {
// Keep the session owner alive across asynchronous image decoding.
expect(cursor.parent.target, same(ffi));
for (final key in cursor.cachedKeys) {
await deleteCustomCursor(key);
}
cursor.disposeImages();
cursor.dispose();
});
return cursor;
}
Map<String, dynamic> _captureCursor() {
final originals = {
for (final key in ['isMobile', 'getByName', 'setByName'])
key: js.context[key]
};
js.context['isMobile'] = js.allowInterop(() => false);
js.context['getByName'] = js.allowInterop((String name, String value) => '');
final registered = <String, dynamic>{};
js.context['setByName'] = js.allowInterop((String name, String value) {
if (name == 'cursor') {
registered
..clear()
..addAll(jsonDecode(value));
}
});
addTearDown(() {
for (final entry in originals.entries) {
js.context[entry.key] = entry.value;
}
});
return registered;
}
Future<void> _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), double? pixelRatio = 1,
String platform = kPeerPlatformMacOS}) async {
final registered = _captureCursor();
final canvas = _Canvas(style);
addTearDown(canvas.dispose);
final ffi = _FFI(canvas);
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(
providers: [
ChangeNotifierProvider<model.ImageModel>(create: (_) => _Image()),
ChangeNotifierProvider<model.CanvasModel>.value(value: canvas),
ChangeNotifierProvider<model.CursorModel>.value(value: cursor),
],
child: ImagePaint(
ffi: ffi,
id: 'web-cursor-test',
zoomCursor: zoom.obs,
cursorOverImage: true.obs,
keyboardEnabled: true.obs,
remoteCursorMoved: false.obs)),
));
final session = tester
.widget<MouseRegion>(find.byType(MouseRegion))
.cursor
.createSession(1);
await session.activate();
session.dispose();
await tester.pumpWidget(const SizedBox.shrink());
final png =
img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!;
expect((png.width, png.height), expectedSize);
expect((registered['hotx'], registered['hoty']), expectedHotspot);
}
void _alphaTests() {
for (final (density, pixel) in [
(1.0, [255, 255, 255, 128]),
(2.0, [255, 128, 64, 128]),
(1.0, [255, 128, 64, 112]),
(1.0, [0, 0, 0, 0]),
(1.0, [255, 255, 255, 255]),
// Old macOS hosts send straight alpha without density metadata.
(0.0, [255, 128, 64, 128]),
(null, [80, 40, 20, 128]),
]) {
test('Web macOS cursor density $density preserves RGBA $pixel',
() => _checkAlpha(density, pixel));
}
}
Future<void> _checkAlpha(double? density, List<int> pixel) async {
final registered = _captureCursor();
final canvas = _Canvas(kRemoteViewStyleAdaptive);
addTearDown(canvas.dispose);
final ffi = _FFI(canvas)..ffiModel.pi.platform = kPeerPlatformMacOS;
final cursor = await _loadCursor(ffi, 'alpha-$density-$pixel',
pixel: pixel, pixelRatio: density);
// Cover both the painted remote cursor and the initial CSS cursor PNG.
final straight = await cursor.image!
.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
expect(straight!.buffer.asUint8List(0, 4), pixel);
for (final scale in [1.0, 0.5, 1.0]) {
final session =
buildCursorOfCache(cursor, scale, cursor.cache).createSession(1);
await session.activate();
session.dispose();
final png =
img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!;
final color = png.getPixel(png.width ~/ 2, png.height ~/ 2);
expect([color.r, color.g, color.b, color.a], pixel);
}
}

View File

@@ -310,12 +310,16 @@ message KeyEvent {
}
message CursorData {
uint64 id = 1;
uint64 id = 1 [jstype = JS_STRING];
sint32 hotx = 2;
sint32 hoty = 3;
int32 width = 4;
int32 height = 5;
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 {
@@ -999,7 +1003,7 @@ message Message {
AudioFrame audio_frame = 11;
CursorData cursor_data = 12;
CursorPosition cursor_position = 13;
uint64 cursor_id = 14;
uint64 cursor_id = 14 [jstype = JS_STRING];
KeyEvent key_event = 15;
Clipboard clipboard = 16;
FileAction file_action = 17;

View File

@@ -248,6 +248,25 @@ pub fn wayland_failure_stamped() -> bool {
LAST_FAILED_LOOKUP.lock().unwrap().is_some()
}
#[cfg(feature = "drm")]
pub enum CachedDisplays {
Busy,
Ready(Option<Arc<Displays>>),
}
/// Cursor polls must neither wait for discovery nor mistake contention for missing metadata.
#[cfg(feature = "drm")]
pub fn get_cached_displays() -> CachedDisplays {
match DISPLAYS.try_lock() {
Ok(cache) => CachedDisplays::Ready(cache.clone()),
Err(std::sync::TryLockError::WouldBlock) => CachedDisplays::Busy,
Err(err) => {
warn!("Failed to read cached Wayland displays: {}", err);
CachedDisplays::Ready(None)
}
}
}
pub fn get_displays() -> Arc<Displays> {
let mut lock = DISPLAYS.lock().unwrap();
match lock.as_ref() {
@@ -516,6 +535,27 @@ fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_e
mod tests {
use super::*;
#[cfg(feature = "drm")]
#[test]
fn cursor_metadata_does_not_wait_for_display_discovery() {
let cache = DISPLAYS.lock().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
tx.send(matches!(get_cached_displays(), CachedDisplays::Busy))
.unwrap();
});
// Discovery owns this lock until it completes or times out.
let result = rx.recv_timeout(Duration::from_secs(1));
drop(cache);
reader.join().unwrap();
assert!(result.unwrap());
clear_wayland_displays_cache();
for _ in 0..100 {
assert!(matches!(get_cached_displays(), CachedDisplays::Ready(None)));
}
}
#[test]
fn test_lookup_backoff_boundaries() {
// Future `now`s sidestep Instant subtraction, which can panic near boot.

View File

@@ -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",
@@ -651,6 +652,7 @@ impl InvokeUiSession for FlutterHandler {
("hoty", &cd.hoty.to_string()),
("width", &cd.width.to_string()),
("height", &cd.height.to_string()),
("scale", &cd.scale.to_string()),
(
"colors",
&serde_json::ser::to_string(&colors).unwrap_or("".to_owned()),

View File

@@ -35,6 +35,9 @@ use std::{
use terminfo::{capability as cap, Database};
use wallpaper;
// Cursor density is capture metadata, independent of either endpoint's UI.
mod cursor;
pub const PA_SAMPLE_RATE: u32 = 48000;
static mut UNMODIFIED: bool = true;
@@ -570,7 +573,9 @@ pub fn get_cursor() -> ResultType<Option<u64>> {
// polled there is a live session, which is the case the latch reads correctly.
#[cfg(feature = "drm")]
if !is_x11() {
if let Some(id) = crate::server::drm_capturer::drm_cursor_id() {
let cursor = cursor::drm_snapshot(|c| c.id)?
.map(|(id, scale)| cursor::cache_id(id, scale));
if let Some(id) = cursor {
// In a mixed DRM + PipeWire session the DRM streams only cover the DRM-backed displays;
// when the pointer sits on a PipeWire-served display every DRM stream reports the hidden
// sentinel. Returning that sentinel here would hide the cursor globally, including on the
@@ -598,6 +603,7 @@ pub fn get_cursor() -> ResultType<Option<u64>> {
}
}
});
let res = res.map(cursor::x11_cursor_id);
Ok(res)
}
@@ -610,7 +616,8 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
// agree anyway, since a caller that took the DRM branch there has to take it here.
#[cfg(feature = "drm")]
if !is_x11() {
if let Some(c) = crate::server::drm_capturer::drm_cursor() {
let cursor = cursor::drm_snapshot(Clone::clone)?;
if let Some((c, scale)) = cursor {
// See get_cursor(): a hidden DRM sentinel is authoritative only in a pure-DRM session. In
// a mixed DRM + PipeWire session fall through so the PipeWire display's cursor is served
// by the normal path instead of being hidden everywhere.
@@ -618,7 +625,8 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|| !crate::server::display_service::has_non_drm_backed_display()
{
let mut cd: CursorData = Default::default();
cd.id = c.id;
cd.id = cursor::cache_id(c.id, scale);
cd.scale = scale;
cd.width = c.width;
cd.height = c.height;
cd.hotx = c.hotx;
@@ -628,20 +636,23 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
}
}
}
let scale = cursor::x11_cursor_scale();
let matches = |id| cursor::cache_id(id, scale) == hcursor;
let mut res = None;
DISPLAY.with(|conn| {
if let Ok(ref mut d) = conn.try_borrow_mut() {
if !d.is_null() {
unsafe {
let img = XFixesGetCursorImage(**d);
if !img.is_null() && hcursor == (*img).cursor_serial as u64 {
if !img.is_null() && matches((*img).cursor_serial as u64) {
let mut cd: CursorData = Default::default();
cd.hotx = (*img).xhot as _;
cd.hoty = (*img).yhot as _;
cd.width = (*img).width as _;
cd.height = (*img).height as _;
// to-do: how about if it is 0
cd.id = (*img).cursor_serial as _;
cd.id = hcursor;
cd.scale = scale;
let pixels =
std::slice::from_raw_parts((*img).pixels, (cd.width * cd.height) as _);
// cd.colors.resize(pixels.len() * 4, 0);

View File

@@ -0,0 +1,165 @@
use hbb_common::{anyhow::Context, bail, log, ResultType};
use std::{
cell::{Cell, RefCell},
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
};
use x11rb::{protocol::xproto::ConnectionExt, rust_connection::RustConnection, NONE};
mod xsettings;
#[cfg(test)]
mod x11_tests;
thread_local! {
static SETTINGS: RefCell<Option<(RustConnection, usize)>> = const { RefCell::new(None) };
static X11_SCALE: Cell<Option<f64>> = const { Cell::new(Some(0.0)) };
}
pub(super) fn cache_id(id: u64, scale: f64) -> u64 {
// Legacy Web decoders require JS-safe integers; zero is the service's initial ID.
const MAX_CURSOR_ID: u64 = (1 << 53) - 1;
if scale == 0.0 {
return id;
}
let mut hash = DefaultHasher::new();
(id, scale.to_bits()).hash(&mut hash);
hash.finish() % MAX_CURSOR_ID + 1
}
pub(super) fn x11_cursor_id(id: u64) -> u64 {
let scale = X11_SCALE.with(|last| match x11_scale() {
Ok(scale) => {
last.set(Some(scale));
scale
}
Err(err) => {
// XSETTINGS is optional; warn once per failure streak without
// turning valid XFixes cursor updates into service errors.
if last.replace(None).is_some() {
log::warn!("Failed to read XSETTINGS cursor density; using unknown density: {err}");
}
0.0
}
});
cache_id(id, scale)
}
pub(super) fn x11_cursor_scale() -> f64 {
// The cursor service reads the ID and bitmap on the same thread. Reuse
// that poll's density even if the settings manager changes between them.
X11_SCALE.with(|last| last.get().unwrap_or(0.0))
}
pub(super) fn x11_scale() -> ResultType<f64> {
if !super::is_x11() {
return Ok(0.0);
}
SETTINGS.with(|settings| {
let mut state = settings.try_borrow_mut()?;
if state.is_none() {
*state = Some(x11rb::connect(None)?);
}
let (connection, screen) = state.as_ref().context("Missing XSETTINGS connection")?;
let result = read_settings(connection, *screen);
if result.is_err() {
*state = None;
}
result
})
}
fn read_settings(connection: &RustConnection, screen: usize) -> ResultType<f64> {
let selection = connection
.intern_atom(true, format!("_XSETTINGS_S{screen}").as_bytes())?
.reply()?
.atom;
if selection == NONE {
return Ok(0.0);
}
let owner = connection.get_selection_owner(selection)?.reply()?.owner;
if owner == NONE {
return Ok(0.0);
}
let property = connection
.intern_atom(true, b"_XSETTINGS_SETTINGS")?
.reply()?
.atom;
let reply = connection
.get_property(false, owner, property, property, 0, u32::MAX)?
.reply()?;
if reply.format != 8 || reply.bytes_after != 0 {
bail!("Incomplete XSETTINGS property");
}
// Xft/DPI includes text scaling; it is not the cursor's pixel density.
// Zero explicitly keeps the existing policy on desktops without a window scale.
Ok(xsettings::scale(&reply.value)?.unwrap_or(0.0))
}
#[cfg(feature = "drm")]
pub(super) fn drm_snapshot<T>(
f: impl Fn(&crate::server::drm_capturer::DrmCursorData) -> T,
) -> ResultType<Option<(T, f64)>> {
crate::server::drm_capturer::drm_cursor_snapshot(f)
.map(|(cursor, display)| {
// A hidden cursor or an unavailable display probe has no density metadata.
let scale = display
.as_ref()
.map(wayland_scale)
.transpose()?
.unwrap_or(0.0);
Ok((cursor, scale))
})
.transpose()
}
#[cfg(feature = "drm")]
fn wayland_scale(display: &base::platform::linux::WaylandDisplayInfo) -> ResultType<f64> {
// Missing logical geometry means unknown density, as with older senders.
let Some((logical_width, logical_height)) = display.logical_size else {
return Ok(0.0);
};
if logical_width <= 0 || logical_height <= 0 || display.width <= 0 || display.height <= 0 {
bail!("Invalid Wayland cursor display dimensions");
}
// Logical geometry is already rotated; the physical mode dimensions are not.
let width = if matches!(display.transform, 90 | 270) {
display.height
} else {
display.width
};
Ok(f64::from(width) / f64::from(logical_width))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_cache_ids_fit_legacy_web_numbers() {
for cursor in [1, 123, u64::MAX] {
assert_eq!(cache_id(cursor, 0.0), cursor);
for scale in [1.0, 1.25, 2.0] {
assert!((1..=9_007_199_254_740_991).contains(&cache_id(cursor, scale)));
}
}
}
#[cfg(feature = "drm")]
#[test]
fn cursor_density_tracks_fractional_rotation_and_cache_identity() {
let display = base::platform::linux::WaylandDisplayInfo {
name: "test".into(),
x: 0,
y: 0,
width: 1280,
height: 800,
logical_size: Some((600, 960)),
refresh_rate: 60000,
transform: 90,
};
assert_eq!(wayland_scale(&display).unwrap(), 4.0 / 3.0);
assert_ne!(cache_id(1, 1.0), cache_id(1, 2.0));
assert_eq!(cache_id(1, 0.0), 1);
}
}

View File

@@ -0,0 +1,171 @@
use super::super::{get_cursor, get_cursor_data};
use hbb_common::ResultType;
use x11rb::{
connection::Connection, protocol::xproto::*, rust_connection::RustConnection,
wrapper::ConnectionExt as _, CURRENT_TIME,
};
struct Settings {
connection: RustConnection,
owner: Window,
property: Atom,
}
impl Settings {
fn new() -> ResultType<Self> {
// This test replaces the settings manager; never run on a real desktop.
assert_eq!(
std::env::var("RUSTDESK_X11_CURSOR_TEST").as_deref(),
Ok("1")
);
let (connection, screen) = x11rb::connect(None)?;
let selection = connection
.intern_atom(false, format!("_XSETTINGS_S{screen}").as_bytes())?
.reply()?
.atom;
let property = connection
.intern_atom(false, b"_XSETTINGS_SETTINGS")?
.reply()?
.atom;
let owner = connection.generate_id()?;
connection
.create_window(
0,
owner,
connection.setup().roots[screen].root,
0,
0,
1,
1,
0,
WindowClass::INPUT_OUTPUT,
0,
&CreateWindowAux::new(),
)?
.check()?;
connection
.set_selection_owner(owner, selection, CURRENT_TIME)?
.check()?;
Ok(Self {
connection,
owner,
property,
})
}
fn property(&self, bytes: &[u8]) {
self.connection
.change_property8(
PropMode::REPLACE,
self.owner,
self.property,
self.property,
bytes,
)
.unwrap()
.check()
.unwrap();
}
fn scale(&self, value: i32) {
const ALIGNMENT: usize = 4;
let name = b"Gdk/WindowScalingFactor";
let mut bytes = vec![0, 0, 0, 0];
bytes.extend(1_u32.to_le_bytes()); // Serial.
bytes.extend(1_u32.to_le_bytes()); // One integer setting.
bytes.extend([0, 0]);
bytes.extend((name.len() as u16).to_le_bytes());
bytes.extend(name);
bytes.resize(bytes.len().div_ceil(ALIGNMENT) * ALIGNMENT, 0);
bytes.extend(1_u32.to_le_bytes());
bytes.extend(value.to_le_bytes());
self.property(&bytes);
}
fn arrow(&self) {
const LEFT_PTR: u16 = 68;
const WHITE: u16 = u16::MAX;
let font = self.connection.generate_id().unwrap();
let cursor = self.connection.generate_id().unwrap();
self.connection
.open_font(font, b"cursor")
.unwrap()
.check()
.unwrap();
self.connection
.create_glyph_cursor(
cursor,
font,
font,
LEFT_PTR,
LEFT_PTR + 1,
0,
0,
0,
WHITE,
WHITE,
WHITE,
)
.unwrap()
.check()
.unwrap();
self.connection
.change_window_attributes(
self.connection.setup().roots[0].root,
&ChangeWindowAttributesAux::new().cursor(cursor),
)
.unwrap()
.check()
.unwrap();
}
}
fn cursor(scale: f64) -> u64 {
let id = get_cursor().unwrap().expect("Xvfb must have a cursor");
let data = get_cursor_data(id).unwrap();
assert_eq!((data.id, data.scale), (id, scale));
assert!(data.width > 0 && data.height > 0 && !data.colors.is_empty());
id
}
#[test]
#[ignore = "requires isolated Xvfb and RUSTDESK_FORCED_DISPLAY_SERVER=x11"]
fn x11_metadata_errors_preserve_cursor_delivery_and_recover() {
let settings = Settings::new().unwrap();
let unknown = cursor(0.0); // Selection owner exists but has no property.
settings.scale(2);
let known = cursor(2.0);
assert_ne!(known, unknown);
settings.property(&[0]); // Truncated header.
assert_eq!(cursor(0.0), unknown);
settings.scale(0); // Invalid density.
assert_eq!(cursor(0.0), unknown);
settings.scale(2);
assert_eq!(cursor(2.0), known);
settings.property(&[0]);
settings.arrow();
assert_ne!(cursor(0.0), unknown); // New shapes still arrive during failure.
}
#[test]
#[ignore = "requires isolated Xvfb and RUSTDESK_FORCED_DISPLAY_SERVER=x11"]
fn x11_metadata_change_between_id_and_bitmap_keeps_snapshot() {
let settings = Settings::new().unwrap();
settings.scale(2);
let id = get_cursor().unwrap().unwrap();
settings
.connection
.destroy_window(settings.owner)
.unwrap()
.check()
.unwrap();
let data = get_cursor_data(id).unwrap();
assert_eq!((data.id, data.scale), (id, 2.0));
let unknown = get_cursor().unwrap().unwrap();
assert_ne!(unknown, id);
let recovered = Settings::new().unwrap();
recovered.scale(2);
let data = get_cursor_data(unknown).unwrap();
assert_eq!((data.id, data.scale), (unknown, 0.0));
assert_eq!(cursor(2.0), id);
}

View File

@@ -0,0 +1,135 @@
use std::{convert::TryInto, io};
const HEADER_SIZE: usize = 12;
const ALIGNMENT: usize = 4;
const INTEGER: u8 = 0;
const STRING: u8 = 1;
const COLOR: u8 = 2;
const WINDOW_SCALE: &[u8] = b"Gdk/WindowScalingFactor";
struct Reader<'a> {
bytes: &'a [u8],
offset: usize,
little_endian: bool,
}
impl<'a> Reader<'a> {
fn take(&mut self, length: usize) -> io::Result<&'a [u8]> {
let end = self.offset.checked_add(length).ok_or_else(invalid)?;
let bytes = self.bytes.get(self.offset..end).ok_or_else(invalid)?;
self.offset = end;
Ok(bytes)
}
fn number(&mut self, length: usize) -> io::Result<u32> {
let bytes = self.take(length)?;
Ok(if self.little_endian {
bytes
.iter()
.rev()
.fold(0, |n, byte| (n << 8) | *byte as u32)
} else {
bytes.iter().fold(0, |n, byte| (n << 8) | *byte as u32)
})
}
fn string(&mut self, length: usize) -> io::Result<&'a [u8]> {
let bytes = self.take(length)?;
self.take((ALIGNMENT - length % ALIGNMENT) % ALIGNMENT)?;
Ok(bytes)
}
}
fn invalid() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
"Invalid XSETTINGS cursor density",
)
}
pub(super) fn scale(bytes: &[u8]) -> io::Result<Option<f64>> {
let little_endian = match bytes.first() {
Some(0) => true,
Some(1) => false,
_ => return Err(invalid()),
};
let mut reader = Reader {
bytes,
offset: 0,
little_endian,
};
reader.take(HEADER_SIZE - ALIGNMENT)?;
let count = reader.number(ALIGNMENT)?;
let mut scale = None;
for _ in 0..count {
let kind = reader.number(1)? as u8;
reader.take(1)?;
let length = reader.number(2)? as usize;
let name = reader.string(length)?;
reader.take(ALIGNMENT)?; // Last-change serial.
match kind {
INTEGER => {
let value = reader.number(ALIGNMENT)? as i32;
if name == WINDOW_SCALE {
if value <= 0 {
return Err(invalid());
}
scale = Some(f64::from(value));
}
}
STRING => {
let length = reader
.number(ALIGNMENT)?
.try_into()
.map_err(|_| invalid())?;
reader.string(length)?;
}
COLOR => {
reader.take(ALIGNMENT * 2)?;
}
_ => return Err(invalid()),
}
}
Ok(scale)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_scale_ignores_text_dpi_and_checks_the_entire_property() {
for little in [false, true] {
let mut bytes = vec![u8::from(!little), 0, 0, 0];
let number = |n: u32| {
if little {
n.to_le_bytes()
} else {
n.to_be_bytes()
}
};
bytes.extend(number(1));
bytes.extend(number(2));
for (name, value) in [(b"Xft/DPI".as_slice(), 196608), (WINDOW_SCALE, 2)] {
bytes.extend([INTEGER, 0]);
let length = name.len() as u16;
bytes.extend(if little {
length.to_le_bytes()
} else {
length.to_be_bytes()
});
bytes.extend(name);
bytes.resize(bytes.len().div_ceil(ALIGNMENT) * ALIGNMENT, 0);
bytes.extend(number(1));
bytes.extend(number(value));
}
assert_eq!(scale(&bytes).unwrap(), Some(2.0));
for length in 0..bytes.len() {
assert!(scale(&bytes[..length]).is_err());
}
let end = bytes.len();
bytes[end - ALIGNMENT..].copy_from_slice(&number(0));
assert!(scale(&bytes).is_err());
}
}
}

View File

@@ -35,12 +35,17 @@ use std::{
sync::Mutex,
};
mod cursor;
#[cfg(test)]
mod cursor_poll_tests;
// 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,23 +566,23 @@ 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);
}
let c = get_cursor_id(seed.1)?;
LATEST_SEED = seed;
Ok(Some(c.1))
}
let c = get_cursor_id()?;
Ok(Some(c.1))
}
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(scale: f64) -> ResultType<(id, u64)> {
unsafe {
let c: id = msg_send![class!(NSCursor), currentSystemCursor];
if c == nil {
@@ -620,21 +625,34 @@ fn get_cursor_id() -> ResultType<(id, u64)> {
hcursor += (r + g + b + a) * (255 << i) as f64;
}
}
Ok((c, hcursor as _))
Ok((c, cursor::cache_id(hcursor as _, scale)))
}
}
pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
autoreleasepool(|| unsafe_get_cursor_data(hcursor))
let result = autoreleasepool(|| unsafe_get_cursor_data(hcursor));
if result.is_err() {
// A failed capture must be retried even if the seed and density stay unchanged.
reset_input_cache();
}
result
}
// 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()?;
// Keep the poll's density if the pointer crosses displays before capture.
let scale = LATEST_SEED.1;
let (c, hcursor2) = get_cursor_id(scale)?;
if hcursor != hcursor2 {
bail!("cursor changed");
}
// NSImage.size is in points; using it as bitmap dimensions can crop Retina
// artwork. Render the full image and convert its hotspot to pixels;
// keep the existing 1x sampling path below.
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,6 +686,7 @@ 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];
// Keep straight RGBA on the wire for older Web/Sciter receivers.
colors.push((r * 255.) as _);
colors.push((g * 255.) as _);
colors.push((b * 255.) as _);
@@ -681,6 +700,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,290 @@
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 {
// Legacy Web decoders require JS-safe integers; zero is the service's initial ID.
const MAX_CURSOR_ID: u64 = (1 << 53) - 1;
let mut hash = DefaultHasher::new();
(cursor, scale.to_bits()).hash(&mut hash);
hash.finish() % MAX_CURSOR_ID + 1
}
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> {
// 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<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: straight_rgba(slice::from_raw_parts(pixels, length)).into(),
// A valid fractional hotspot near an edge can round past the last pixel.
hotx: (hotspot.x * size.width / logical.width)
.round()
.min(size.width - 1.0) as _,
hoty: (hotspot.y * size.height / logical.height)
.round()
.min(size.height - 1.0) as _,
width: size.width as _,
height: size.height as _,
scale,
..Default::default()
})
}
fn straight_rgba(pixels: &[u8]) -> Vec<u8> {
// AppKit renders premultiplied pixels, but macOS cursor packets have always
// used straight alpha. Density metadata does not negotiate a new format.
const MAX_CHANNEL: u16 = u8::MAX as u16;
let mut colors = pixels.to_vec();
for pixel in colors.chunks_exact_mut(CHANNELS) {
let alpha = u16::from(pixel[CHANNELS - 1]);
if alpha == 0 {
continue;
}
for channel in &mut pixel[..CHANNELS - 1] {
*channel =
((u16::from(*channel) * MAX_CHANNEL + alpha / 2) / alpha).min(MAX_CHANNEL) as u8;
}
}
colors
}
#[cfg(test)]
mod compat_tests;
#[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 = physical_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());
assert_retina_hotspots(*image, &expected);
});
}
unsafe fn assert_retina_hotspots(image: id, expected: &[u8]) {
for (point, pixels) in [
((8.8, 17.8), (17, 35)),
((8.8, 9.0), (17, 18)),
((4.0, 17.8), (8, 35)),
((0.0, 0.0), (0, 0)),
] {
let c: id = msg_send![class!(NSCursor), alloc];
let c = StrongPtr::new(msg_send![c,
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 = physical_data(*c, 1, 2.0).unwrap();
assert_eq!((result.hotx, result.hoty), pixels);
assert_eq!(result.colors.as_ref(), expected);
}
}
#[test]
fn retina_cursor_keeps_straight_alpha_for_legacy_receivers() {
const SIDE: usize = 4;
const SCALE: f64 = 2.0;
const PREMULTIPLIED: [[u8; CHANNELS]; SIDE] = [
[128, 128, 128, 128],
[64, 32, 16, 128],
[240, 100, 20, 255],
[0, 0, 0, 0],
];
const STRAIGHT: [[u8; CHANNELS]; SIDE] = [
[255, 255, 255, 128],
[128, 64, 32, 128],
[240, 100, 20, 255],
[0, 0, 0, 0],
];
autoreleasepool(|| unsafe {
let logical = NSSize::new(SIDE as f64 / SCALE, SIDE as f64 / SCALE);
let image: id = msg_send![class!(NSImage), alloc];
let image = StrongPtr::new(msg_send![image, initWithSize: logical]);
let rep = bitmap(NSSize::new(SIDE as f64, SIDE as f64)).unwrap();
let pixels: Vec<u8> = (0..SIDE * SIDE)
.flat_map(|index| PREMULTIPLIED[index % SIDE])
.collect();
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];
let cursor: id = msg_send![class!(NSCursor), alloc];
let cursor = StrongPtr::new(
msg_send![cursor, initWithImage: *image hotSpot: NSPoint::new(1.0, 1.0)],
);
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(
&mut png,
result.width as _,
result.height as _,
&result.colors,
)
.unwrap();
let decoded = image::load_from_memory(&png).unwrap().to_rgba8();
for (index, pixel) in decoded.pixels().enumerate() {
assert_eq!(pixel.0, STRAIGHT[index % SIDE]);
}
});
}
#[test]
fn cursor_cache_changes_with_display_scale() {
assert_ne!(cache_id(123, 1.0), cache_id(123, 2.0));
}
#[test]
fn cursor_cache_ids_fit_legacy_web_numbers() {
for cursor in [1, 123, u64::MAX] {
for scale in [1.0, 1.25, 2.0] {
assert!((1..=9_007_199_254_740_991).contains(&cache_id(cursor, scale)));
}
}
}
}

View File

@@ -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());
});
}

View File

@@ -0,0 +1,17 @@
use super::*;
#[test]
fn capture_failure_invalidates_the_density_poll() {
let previous = unsafe { LATEST_SEED };
unsafe {
LATEST_SEED = (1, 2.0);
}
// Zero cannot be a macOS cursor ID, even if AppKit has no current cursor.
let result = get_cursor_data(0);
let after_failure = unsafe { LATEST_SEED };
unsafe {
LATEST_SEED = previous;
}
assert!(result.is_err());
assert_eq!(after_failure, (0, 0.0));
}

View File

@@ -413,7 +413,9 @@ extern "C"
{
auto in = in0;
auto out0_end = out0 + out0_size;
auto offset = width * 4 + 4;
// The output adds a pixel on each side; place the source at (1, 1)
// using the padded stride to match the caller's hotspot +1 adjustment.
auto offset = (width + 2) * 4 + 4;
auto out = out0 + offset;
for (int y = 0; y < height; y++)
{

View File

@@ -4750,6 +4750,44 @@ pub(super) fn get_pids_with_first_arg_by_wmic<S1: AsRef<str>, S2: AsRef<str>>(
mod tests {
use super::*;
#[test]
fn cursor_outline_preserves_pixels_at_hotspot_offset() {
const CHANNELS: usize = 4;
const BORDER: usize = 1;
const WIDTH: usize = 3;
const HEIGHT: usize = 9;
const INK: [u8; CHANNELS] = [32, 64, 96, 255];
let mut source = vec![0; WIDTH * HEIGHT * CHANNELS];
for y in 0..HEIGHT {
for x in 0..WIDTH {
if x == WIDTH / 2 || y == 0 || y == HEIGHT - 1 {
let offset = (y * WIDTH + x) * CHANNELS;
source[offset..offset + CHANNELS].copy_from_slice(&INK);
}
}
}
let stride = WIDTH + BORDER * 2;
let mut outlined = vec![0; stride * (HEIGHT + BORDER * 2) * CHANNELS];
unsafe {
drawOutline(
outlined.as_mut_ptr(),
source.as_ptr(),
WIDTH as _,
HEIGHT as _,
outlined.len() as _,
);
}
for y in 0..HEIGHT {
for x in 0..WIDTH {
let input = (y * WIDTH + x) * CHANNELS;
if source[input + CHANNELS - 1] != 0 {
let output = ((y + BORDER) * stride + x + BORDER) * CHANNELS;
assert_eq!(&outlined[output..output + CHANNELS], &INK, "({x}, {y})");
}
}
}
}
// Test-only reusable Win32 HANDLE RAII helper.
// If a future non-test path needs the same pattern, move it out of this test module.
//

View File

@@ -14,6 +14,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
mod cursor_metadata;
const HANDSHAKE_TIMEOUT_MS: u64 = 3000;
const DRM_CONNECT_TIMEOUT_MS: u64 = 1000;
/// The service may hold the list back while it wakes sleeping displays: ~3.6s (DRM_WAKE_*).
@@ -955,23 +957,63 @@ fn fold_cursor_id(id: u64, t: i32) -> u64 {
}
}
fn with_drm_cursor<T>(f: impl Fn(&DrmCursorData) -> T) -> Option<T> {
let map = DRM_CURSOR.lock().unwrap();
map.values()
.map(|(_, c)| c)
.find(|c| c.id != scrap::drm_reader::HIDDEN_CURSOR_ID)
.or_else(|| map.values().map(|(_, c)| c).next())
.map(f)
/// Snapshot of the DRM hardware cursor and optional display metadata. Pixels retain the
/// premultiplied format used by the XFixes path.
pub fn drm_cursor_snapshot<T>(
f: impl Fn(&DrmCursorData) -> T,
) -> Option<(T, Option<base::platform::linux::WaylandDisplayInfo>)> {
use scrap::wayland::display::{get_cached_displays, wayland_snapshot_generation};
// Keep cursor identity and output together, then release the map before DRM_STATE.
let (value, display, epoch, hidden) = {
let map = DRM_CURSOR.lock().unwrap();
let (display, (epoch, cursor)) = map
.iter()
.find(|(_, (_, cursor))| cursor.id != scrap::drm_reader::HIDDEN_CURSOR_ID)
.or_else(|| map.iter().next())?;
(
f(cursor),
*display,
*epoch,
cursor.id == scrap::drm_reader::HIDDEN_CURSOR_ID,
)
};
let monitor = if hidden {
None
} else {
cursor_metadata::monitor(
cursor_metadata::Context {
display,
epoch,
layout_generation: wayland_snapshot_generation(),
},
get_cached_displays(),
|wayland| {
cursor_monitor(
display.max(0) as usize,
&DRM_STATE.lock().unwrap(),
&wayland.displays,
)
},
)
};
Some((value, monitor))
}
pub fn drm_cursor_id() -> Option<u64> {
with_drm_cursor(|c| c.id)
}
/// Snapshot of the DRM hardware cursor, or None. The pixels are premultiplied ARGB and are passed
/// through as-is, like the XFixes path, so the client sees one cursor format from either backend.
pub fn drm_cursor() -> Option<DrmCursorData> {
with_drm_cursor(|c| c.clone())
fn cursor_monitor(
display: usize,
state: &ProbeState,
monitors: &[base::platform::linux::WaylandDisplayInfo],
) -> Option<base::platform::linux::WaylandDisplayInfo> {
let ProbeState::Available(_, displays) = state else {
return None;
};
// Reserve every connector's name match before guessing this cursor's density.
let index = identity_matches(displays, monitors)
.get(display)
.copied()
.flatten()?;
monitors.get(index).cloned()
}
enum ProbeState {
@@ -2270,6 +2312,35 @@ mod drm_capturer_tests {
assert!(m2[0].is_none() && m2[1].is_none());
}
#[test]
fn cursor_monitor_reserves_other_displays_before_guessing_density() {
let state = ProbeState::Available(
Instant::now(),
vec![
drm_display("DSI-1", 1920, 1080),
drm_display("HDMI-A-1", 1920, 1080),
],
);
for other_width in [2560, 1920] {
let mut monitors = vec![
wl_display("HDMI-1", 0, 0, 1920, 1080),
wl_display("Unknown-9", 960, 0, other_width, 1080),
];
monitors[0].logical_size = Some((960, 540));
let selected = cursor_monitor(0, &state, &monitors);
if other_width == 2560 {
assert!(selected.is_none(), "DSI must not inherit HDMI's 2x density");
} else {
let selected = selected.unwrap();
assert_eq!(selected.name, "Unknown-9");
assert_eq!(selected.logical_size, Some((1920, 1080)));
}
let hdmi = cursor_monitor(1, &state, &monitors).unwrap();
assert_eq!(hdmi.name, "HDMI-1");
assert_eq!(hdmi.logical_size, Some((960, 540)));
}
}
#[test]
fn outputs_are_matched_by_name_across_the_drm_naming_difference() {
let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)];

View File

@@ -0,0 +1,123 @@
use base::platform::linux::WaylandDisplayInfo;
use scrap::wayland::display::{CachedDisplays, Displays};
use std::cell::Cell;
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) struct Context {
pub display: i32,
pub epoch: u64,
pub layout_generation: u64,
}
thread_local! {
static LAST_MONITOR: Cell<Option<(Context, WaylandDisplayInfo)>> = const { Cell::new(None) };
}
pub(super) fn monitor(
context: Context,
snapshot: CachedDisplays,
resolve: impl FnOnce(&Displays) -> Option<WaylandDisplayInfo>,
) -> Option<WaylandDisplayInfo> {
// ID polling and bitmap retrieval run on the same cursor-service thread.
// Only contention may reuse metadata, and only for this output, stream and layout.
LAST_MONITOR.with(|last| match snapshot {
CachedDisplays::Busy => {
let cached = last.take();
let monitor = cached
.as_ref()
.filter(|(key, _)| *key == context)
.map(|(_, monitor)| monitor.clone());
last.set(cached);
monitor
}
CachedDisplays::Ready(displays) => {
let monitor = displays.as_deref().and_then(resolve);
// An accessible cache with no matching metadata invalidates the old density.
last.set(monitor.clone().map(|monitor| (context, monitor)));
monitor
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
const CONTEXT: Context = Context {
display: 0,
epoch: 7,
layout_generation: 11,
};
fn displays(logical_width: i32) -> CachedDisplays {
CachedDisplays::Ready(Some(Arc::new(Displays {
primary: 0,
displays: vec![WaylandDisplayInfo {
name: "DP-1".into(),
x: 0,
y: 0,
width: 1280,
height: 800,
logical_size: Some((logical_width, logical_width * 800 / 1280)),
refresh_rate: 60000,
transform: 0,
}],
})))
}
fn read(context: Context, snapshot: CachedDisplays) -> Option<WaylandDisplayInfo> {
monitor(context, snapshot, |displays| {
displays.displays.first().cloned()
})
}
#[test]
fn busy_cache_preserves_metadata_until_a_completed_read() {
for width in [640, 1280] {
let expected = read(CONTEXT, displays(width)).unwrap().logical_size;
for _ in 0..5 {
let retained = monitor(CONTEXT, CachedDisplays::Busy, |_| {
panic!("a busy cursor lookup must not resolve displays")
});
assert_eq!(retained.unwrap().logical_size, expected);
}
}
}
#[test]
fn busy_cache_does_not_borrow_another_output_stream_or_layout() {
read(CONTEXT, displays(640));
for changed in [
Context {
display: 1,
..CONTEXT
},
Context {
epoch: 8,
..CONTEXT
},
Context {
layout_generation: 12,
..CONTEXT
},
] {
assert!(read(changed, CachedDisplays::Busy).is_none());
}
assert_eq!(
read(CONTEXT, CachedDisplays::Busy).unwrap().logical_size,
Some((640, 400))
);
}
#[test]
fn missing_or_unmatched_metadata_invalidates_retained_density() {
read(CONTEXT, displays(640));
assert!(read(CONTEXT, CachedDisplays::Ready(None)).is_none());
assert!(read(CONTEXT, CachedDisplays::Busy).is_none());
read(CONTEXT, displays(640));
assert!(monitor(CONTEXT, displays(640), |_| None).is_none());
assert!(read(CONTEXT, CachedDisplays::Busy).is_none());
}
}

View File

@@ -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);