revert(cursor): return to f5b98b32 before splitting plugin changes

This commit is contained in:
fufesou
2026-09-12 00:32:58 +08:00
parent 650a6e21cc
commit 8da629c57d
34 changed files with 68 additions and 3177 deletions

View File

@@ -19,7 +19,6 @@ import '../../models/input_model.dart';
import '../../models/platform_model.dart';
import '../../common/shared_state.dart';
import '../../utils/image.dart';
import '../../utils/cursor_size.dart';
import '../widgets/remote_toolbar.dart';
import '../widgets/kb_layout_type_chooser.dart';
import '../widgets/tabbar_widget.dart';
@@ -1089,21 +1088,6 @@ class ImagePaint extends StatefulWidget {
class _ImagePaintState extends State<ImagePaint> {
bool _lastRemoteCursorMoved = false;
final _localCursorSize = LocalCursorSize();
@override
void initState() {
super.initState();
_localCursorSize.addListener(_cursorSizeChanged);
}
void _cursorSizeChanged() => setState(() {});
@override
void dispose() {
_localCursorSize.dispose();
super.dispose();
}
String get id => widget.id;
RxBool get zoomCursor => widget.zoomCursor;
@@ -1121,29 +1105,22 @@ class _ImagePaintState extends State<ImagePaint> {
// changes, so read it live to follow the window across monitors.
final dpr = MediaQuery.devicePixelRatioOf(context);
bool isViewScaled() =>
c.viewStyle.style == kRemoteViewStyleAdaptive ||
c.viewStyle.style == kRemoteViewStyleCustom;
bool isViewAdaptive() => c.viewStyle.style == kRemoteViewStyleAdaptive;
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
mouseRegion({child}) => Obx(() {
final useLocalSize = !isWeb &&
(isLinux || isMacOS || isWindows) &&
!zoomCursor.value &&
isViewScaled();
if (useLocalSize) _localCursorSize.ensureLoaded(dpr);
double getCursorScale() {
var c = Provider.of<CanvasModel>(context);
var cursorScale = 1.0;
if (isWindows) {
// debug win10
if (zoomCursor.value && isViewScaled()) {
if (zoomCursor.value && isViewAdaptive()) {
cursorScale = s * c.devicePixelRatio;
}
} else {
if (zoomCursor.value || isViewOriginal()) {
cursorScale = s;
} else if (isLinux || isMacOS) {
} else {
// 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
@@ -1172,16 +1149,11 @@ class _ImagePaintState extends State<ImagePaint> {
_firstEnterImage.value = true;
}
return _buildCustomCursor(
context, getCursorScale(),
useLocalSize: useLocalSize);
context, getCursorScale());
}
}())
: _buildDisabledCursor(context, getCursorScale(),
useLocalSize: useLocalSize)
: _buildDisabledCursor(context, getCursorScale())
: MouseCursor.defer,
onEnter: (_) {
if (useLocalSize) _localCursorSize.refresh();
},
onHover: (evt) {},
child: child);
});
@@ -1294,19 +1266,15 @@ class _ImagePaintState extends State<ImagePaint> {
);
}
MouseCursor _buildCustomCursor(BuildContext context, double scale,
{bool useLocalSize = false}) {
MouseCursor _buildCustomCursor(BuildContext context, double scale) {
final cursor = Provider.of<CursorModel>(context);
final cache = cursor.cache ?? preDefaultCursor.cache;
cache?.localSize = useLocalSize ? _localCursorSize.value : null;
return buildCursorOfCache(cursor, scale, cache);
}
MouseCursor _buildDisabledCursor(BuildContext context, double scale,
{bool useLocalSize = false}) {
MouseCursor _buildDisabledCursor(BuildContext context, double scale) {
final cursor = Provider.of<CursorModel>(context);
final cache = preForbiddenCursor.cache;
cache?.localSize = useLocalSize ? _localCursorSize.value : null;
return buildCursorOfCache(cursor, scale, cache);
}
@@ -1425,9 +1393,8 @@ class CursorPaint extends StatelessWidget {
}
}
final imageOffset = _softwareImageOffset(c);
double cx = imageOffset?.dx ?? c.x;
double cy = imageOffset?.dy ?? c.y;
double cx = c.x;
double cy = c.y;
if (c.viewStyle.style == kRemoteViewStyleOriginal &&
c.scrollStyle == ScrollStyle.scrollbar) {
final rect = c.parent.target!.ffiModel.rect;
@@ -1446,44 +1413,38 @@ class CursorPaint extends StatelessWidget {
}
}
final image = m.image ?? preDefaultCursor.image;
final nativePixels = isWindows ? MediaQuery.devicePixelRatioOf(context) : 1.0;
double scale = c.scale;
if (image != null && scale * nativePixels != 1.0) {
final sx = kMinCursorSize / (image.width * nativePixels);
final sy = kMinCursorSize / (image.height * nativePixels);
final minimumScale = sx > sy ? sx : sy;
if (scale < minimumScale) scale = minimumScale;
double x = m.x * c.scale + cx - hotx;
double y = m.y * c.scale + cy - hoty;
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;
} else if (!isWindows) {
// Keep the painted cursor the same physical size as the native one
// built by getCursorScale() above, including its min-size clamp.
scale = 1.0 / MediaQuery.devicePixelRatioOf(context);
final image = m.image ?? preDefaultCursor.image;
if (scale != 1.0 &&
image != null &&
((image.width * scale).toInt() < kMinCursorSize ||
(image.height * scale).toInt() < kMinCursorSize)) {
final sw = kMinCursorSize / image.width;
final sh = kMinCursorSize / image.height;
scale = sw < sh ? sh : sw;
}
x = (m.x * c.scale + cx) / scale - hotx;
y = (m.y * c.scale + cy) / scale - hoty;
}
final x = (m.x * c.scale + cx) / scale - hotx;
final y = (m.y * c.scale + cy) / scale - hoty;
return CustomPaint(
painter: ImagePainter(
image: image,
image: m.image ?? preDefaultCursor.image,
x: x,
y: y,
scale: scale,
useIntegerPosition: false,
),
);
}
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) {
return 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);
}
}

View File

@@ -39,7 +39,6 @@ import 'package:vector_math/vector_math.dart' show Vector2;
import '../common.dart';
import '../utils/image.dart' as img;
import '../utils/cursor_size.dart';
import '../common/widgets/dialog.dart';
import 'input_model.dart';
import 'platform_model.dart';
@@ -2864,8 +2863,6 @@ class CursorData {
double hoty;
final int width;
final int height;
double? localSize;
late final int _visibleSize = cursorVisibleSize(image);
CursorData({
required this.peerId,
@@ -2882,34 +2879,9 @@ class CursorData {
int _doubleToInt(double v) => (v * 10e6).round().toInt();
bool get _usesLogicalCursorPixels => isLinux || isMacOS || isWeb;
int get scaledWidth => _scaledDimension(width, scale);
int get scaledHeight => _scaledDimension(height, scale);
int _scaledDimension(int dimension, double scale) {
const minBitmapSize = 1;
final pixels = dimension * scale;
return _usesLogicalCursorPixels || localSize != null
? max(minBitmapSize, pixels.round())
: pixels.toInt();
}
double _checkUpdateScale(double scale) {
double oldScale = this.scale;
if (localSize != null) {
scale = _visibleSize == 0
? 1.0
: max(localSize!, kMinCursorSize) / _visibleSize;
// Sparse peer artwork must not amplify the native bitmap allocation.
const maxNativeCursorBitmapSize = 512;
final maxScale = maxNativeCursorBitmapSize / max(width, height);
if (scale > maxScale) {
if (oldScale != maxScale) {
debugPrint('Cursor $id exceeds the native bitmap limit; reducing scale');
}
scale = maxScale;
}
} else if (scale != 1.0) {
if (scale != 1.0) {
// Update data if scale changed.
final tgtWidth = (width * scale).toInt();
final tgtHeight = (height * scale).toInt();
@@ -2920,17 +2892,13 @@ class CursorData {
}
}
const bytesPerPixel = 4;
final byteLength = _scaledDimension(width, scale) *
_scaledDimension(height, scale) * bytesPerPixel;
if (_doubleToInt(oldScale) != _doubleToInt(scale) ||
(isWindows && data != null && data!.length != byteLength)) {
if (_doubleToInt(oldScale) != _doubleToInt(scale)) {
if (isWindows) {
data = img2
.copyResize(
image,
width: _scaledDimension(width, scale),
height: _scaledDimension(height, scale),
width: (width * scale).toInt(),
height: (height * scale).toInt(),
interpolation: img2.Interpolation.average,
)
.getBytes(order: img2.ChannelOrder.bgra);
@@ -2939,8 +2907,8 @@ class CursorData {
img2.encodePng(
img2.copyResize(
image,
width: _scaledDimension(width, scale),
height: _scaledDimension(height, scale),
width: (width * scale).toInt(),
height: (height * scale).toInt(),
interpolation: img2.Interpolation.average,
),
),
@@ -2949,14 +2917,14 @@ class CursorData {
}
this.scale = scale;
hotx = hotxOrigin * scaledWidth / width;
hoty = hotyOrigin * scaledHeight / height;
hotx = hotxOrigin * scale;
hoty = hotyOrigin * scale;
return scale;
}
String updateGetKey(double scale) {
scale = _checkUpdateScale(scale);
return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}${localSize == null ? '' : '_local'}';
return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}';
}
}
@@ -3470,8 +3438,6 @@ class CursorModel with ChangeNotifier {
if (await _updateCache(rgba, image, id, hotx, hoty, width, height)) {
_images[id]?.item1.dispose();
_images[id] = Tuple3(image, hotx, hoty);
} else {
image.dispose();
}
// Update last cursor data.
@@ -3492,32 +3458,14 @@ class CursorModel with ChangeNotifier {
img2.Image imgOrigin = img2.Image.fromBytes(
width: w, height: h, bytes: rgba.buffer, order: img2.ChannelOrder.rgba);
if (isWindows) {
final pixels =
await image.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
if (pixels == null) {
debugPrint('Could not read straight-alpha cursor pixels: $id');
return false;
}
imgOrigin = img2.Image.fromBytes(
width: w, height: h, bytes: pixels.buffer, order: img2.ChannelOrder.rgba);
data = imgOrigin.getBytes(order: img2.ChannelOrder.bgra);
} else {
ByteData? imgBytes =
await image.toByteData(format: ui.ImageByteFormat.png);
if (imgBytes == null) {
debugPrint('Could not encode cursor PNG: $id');
return false;
}
data = imgBytes.buffer.asUint8List();
if (isLinux || isMacOS) {
// Preserve the PNG's straight-alpha colors when resizing native cursors.
final decoded = img2.decodePng(data);
if (decoded == null) {
debugPrint('Invalid native cursor PNG: $id');
return false;
}
imgOrigin = decoded;
}
}
final cache = CursorData(
peerId: peerId,

View File

@@ -1,39 +1,16 @@
import 'dart: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:image/image.dart' as img;
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/model.dart';
deleteCustomCursor(String key) =>
custom_cursor_manager.CursorManager.instance.deleteCursor(key);
resetSystemCursor() {}
double _nativeHotspot(double hotspot, int bitmapSize) {
if (!isLinux && !isWindows) return hotspot;
// GDK and Win32 take integer hotspots inside the bitmap; use the nearest pixel.
return min(hotspot.round(), bitmapSize - 1).toDouble();
}
Uint8List _padCursorWidth(Uint8List data, int width) {
final bitmap = img.decodePng(data);
if (bitmap == null) {
throw const FormatException('Invalid native cursor PNG');
}
final padded = img.copyExpandCanvas(bitmap,
newWidth: width,
newHeight: bitmap.height,
position: img.ExpandCanvasPosition.topLeft,
toImage: img.Image(width: width, height: bitmap.height, numChannels: 4));
return Uint8List.fromList(img.encodePng(padded));
}
MouseCursor buildCursorOfCache(
CursorModel cursor, double scale, CursorData? cache) {
if (cache == null) {
@@ -46,12 +23,6 @@ MouseCursor buildCursorOfCache(
if (data == null) {
return MouseCursor.defer;
}
// Pad tall Linux buffers to prevent clipping in the hardware cursor plane.
final width = isLinux
? max(cache.scaledWidth, cache.scaledHeight)
: cache.scaledWidth;
final buffer =
width == cache.scaledWidth ? data : _padCursorWidth(data, width);
debugPrint(
"Register custom cursor with key $key (${cache.hotx},${cache.hoty})");
// [Safety]
@@ -61,11 +32,11 @@ MouseCursor buildCursorOfCache(
custom_cursor_manager.CursorManager.instance
.registerCursor(custom_cursor_manager.CursorData()
..name = key
..buffer = buffer
..width = width
..height = cache.scaledHeight
..hotX = _nativeHotspot(cache.hotx, cache.scaledWidth)
..hotY = _nativeHotspot(cache.hoty, cache.scaledHeight));
..buffer = data
..width = (cache.width * cache.scale).toInt()
..height = (cache.height * cache.scale).toInt()
..hotX = cache.hotx
..hotY = cache.hoty);
cursor.addKey(key);
}
return FlutterCustomMemoryImageCursor(key: key);

View File

@@ -1,55 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:image/image.dart' as img;
int cursorVisibleSize(img.Image image) {
var left = image.width;
var top = image.height;
var right = -1;
var bottom = -1;
for (final pixel in image) {
if (pixel.a == 0) continue;
if (pixel.x < left) left = pixel.x;
if (pixel.x > right) right = pixel.x;
if (pixel.y < top) top = pixel.y;
if (pixel.y > bottom) bottom = pixel.y;
}
if (right < left) return 0;
final width = right - left + 1;
final height = bottom - top + 1;
return width > height ? width : height;
}
class LocalCursorSize extends ValueNotifier<double?> {
LocalCursorSize() : super(null);
static const _channel = MethodChannel('org.rustdesk.rustdesk/cursor');
double? _devicePixelRatio;
int _generation = 0;
void ensureLoaded(double devicePixelRatio) {
if (_devicePixelRatio == devicePixelRatio) return;
_devicePixelRatio = devicePixelRatio;
refresh();
}
Future<void> refresh() async {
final generation = ++_generation;
try {
final size = await _channel.invokeMethod<double>('getSystemCursorSize');
if (size == null || !size.isFinite || size <= 0) {
throw const FormatException('Invalid local system cursor size');
}
if (generation == _generation) value = size;
} catch (error, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: error, stack: stack, library: 'local cursor size'));
}
}
@override
void dispose() {
++_generation;
super.dispose();
}
}

View File

@@ -96,14 +96,12 @@ 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) {
@@ -124,10 +122,8 @@ class ImagePainter extends CustomPainter {
if (isWeb) {
paint.filterQuality = FilterQuality.high;
}
final position = useIntegerPosition
? Offset(x.toInt().toDouble(), y.toInt().toDouble())
: Offset(x, y);
canvas.drawImage(image!, position, paint);
canvas.drawImage(
image!, Offset(x.toInt().toDouble(), y.toInt().toDouble()), paint);
}
@override

View File

@@ -52,8 +52,8 @@ class CursorManager {
'cursor',
jsonEncode({
'url': cursorData.url,
'hotx': cursorData.hotX.round(),
'hoty': cursorData.hotY.round(),
'hotx': cursorData.hotX.toInt(),
'hoty': cursorData.hotY.toInt(),
})
]);
}
@@ -116,8 +116,8 @@ MouseCursor buildCursorOfCache(
CursorManager.instance.registerCursor(CursorData(
key: key,
url: 'data:image/rgba;base64,${base64Encode(data)}',
width: cache.scaledWidth,
height: cache.scaledHeight,
width: (cache.width * cache.scale).toInt(),
height: (cache.height * cache.scale).toInt(),
hotX: cache.hotx,
hotY: cache.hoty));
cursor.addKey(key);

View File

@@ -53,7 +53,6 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(XCURSOR REQUIRED IMPORTED_TARGET xcursor)
# Wayland protocol for keyboard shortcuts inhibit
pkg_check_modules(WAYLAND_CLIENT IMPORTED_TARGET wayland-client)
@@ -127,7 +126,6 @@ apply_standard_settings(${BINARY_NAME})
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::XCURSOR)
target_link_libraries(${BINARY_NAME} PRIVATE ${CMAKE_DL_LIBS})
# target_link_libraries(${BINARY_NAME} PRIVATE librustdesk)

View File

@@ -1,109 +0,0 @@
#ifndef RUSTDESK_CURSOR_SIZE_H_
#define RUSTDESK_CURSOR_SIZE_H_
#include <flutter_linux/flutter_linux.h>
#include <X11/Xcursor/Xcursor.h>
#ifdef GDK_WINDOWING_WAYLAND
#include <gdk/gdkwayland.h>
#endif
#include <algorithm>
#include <memory>
#include <stdexcept>
namespace cursor_size {
template <typename IsVisible>
double VisibleSize(int width, int height, IsVisible visible) {
int left = width, top = height, right = -1, bottom = -1;
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
if (!visible(x, y)) continue;
left = std::min(left, x);
right = std::max(right, x);
top = std::min(top, y);
bottom = std::max(bottom, y);
}
}
if (right < left) throw std::runtime_error("System cursor has no visible pixels");
return std::max(right - left + 1, bottom - top + 1);
}
inline double WaylandSize(GtkWidget* view) {
g_autofree gchar* theme = nullptr;
gint size = 0;
g_object_get(gtk_widget_get_settings(view), "gtk-cursor-theme-name", &theme,
"gtk-cursor-theme-size", &size, nullptr);
// GTK uses 24 when the theme size is unset, and loads at the window scale.
constexpr int kDefaultThemeSize = 24;
if (size == 0) size = kDefaultThemeSize;
const int scale = gtk_widget_get_scale_factor(view);
if (size < 0 || scale <= 0 || size > G_MAXINT / scale) {
throw std::runtime_error("Invalid GTK cursor theme size or window scale");
}
auto* loaded = XcursorLibraryLoadImage("default", theme, size * scale);
// Match GTK's CSS default -> traditional left_ptr name mapping.
if (!loaded) loaded = XcursorLibraryLoadImage("left_ptr", theme, size * scale);
std::unique_ptr<XcursorImage, decltype(&XcursorImageDestroy)> image(
loaded, XcursorImageDestroy);
if (!image) throw std::runtime_error("Could not load the GTK system cursor");
// GTK reduces the buffer scale until both theme dimensions are divisible.
// For example, a 64px theme at 3x is displayed using a buffer scale of 2.
int cursor_scale = scale;
while (image->width % cursor_scale != 0 || image->height % cursor_scale != 0) {
--cursor_scale;
}
constexpr XcursorPixel kAlphaMask = 0xff000000;
return VisibleSize(image->width, image->height, [&](int x, int y) {
return (image->pixels[y * image->width + x] & kAlphaMask) != 0;
}) / cursor_scale;
}
inline double SystemSize(GtkWidget* view) {
GdkDisplay* display = gtk_widget_get_display(view);
#ifdef GDK_WINDOWING_WAYLAND
if (GDK_IS_WAYLAND_DISPLAY(display)) return WaylandSize(view);
#endif
g_autoptr(GdkCursor) cursor = gdk_cursor_new_from_name(display, "default");
if (!cursor) throw std::runtime_error("Could not load the GDK system cursor");
g_autoptr(GdkPixbuf) image = gdk_cursor_get_image(cursor);
if (!image) throw std::runtime_error("Could not read the GDK system cursor");
const auto* pixels = gdk_pixbuf_read_pixels(image);
const int stride = gdk_pixbuf_get_rowstride(image);
const int channels = gdk_pixbuf_get_n_channels(image);
return VisibleSize(gdk_pixbuf_get_width(image), gdk_pixbuf_get_height(image),
[&](int x, int y) {
return !gdk_pixbuf_get_has_alpha(image) ||
pixels[y * stride + x * channels + channels - 1] != 0;
});
}
inline void Register(FlView* view) {
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
FlMethodChannel* channel = fl_method_channel_new(
fl_engine_get_binary_messenger(fl_view_get_engine(view)),
"org.rustdesk.rustdesk/cursor", FL_METHOD_CODEC(codec));
fl_method_channel_set_method_call_handler(channel,
[](FlMethodChannel*, FlMethodCall* call, gpointer data) {
g_autoptr(GError) error = nullptr;
if (g_strcmp0(fl_method_call_get_name(call), "getSystemCursorSize") != 0) {
fl_method_call_respond_not_implemented(call, &error);
} else {
try {
g_autoptr(FlValue) value = fl_value_new_float(SystemSize(GTK_WIDGET(data)));
fl_method_call_respond_success(call, value, &error);
} catch (const std::exception& failure) {
fl_method_call_respond_error(call, "cursor_size", failure.what(), nullptr, &error);
}
}
if (error) g_warning("Cursor size response failed: %s", error->message);
}, view, nullptr);
g_object_set_data_full(G_OBJECT(view), "cursor-size-channel", channel, [](gpointer data) {
fl_method_channel_set_method_call_handler(FL_METHOD_CHANNEL(data), nullptr, nullptr, nullptr);
g_object_unref(data);
});
}
} // namespace cursor_size
#endif // RUSTDESK_CURSOR_SIZE_H_

View File

@@ -1,7 +1,6 @@
#include "my_application.h"
#include "bump_mouse.h"
#include "cursor_size.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
@@ -95,7 +94,6 @@ static void on_subwindow_created(FlPluginRegistry* registry) {
// Set up side button forwarding for sub-windows.
if (registry == NULL || !FL_IS_VIEW(registry)) return;
FlView* view = FL_VIEW(registry);
cursor_size::Register(view);
GtkWidget* toplevel = gtk_widget_get_toplevel(GTK_WIDGET(view));
if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) {
FlMethodChannel* channel = side_buttons_create_channel(fl_view_get_engine(view));
@@ -180,7 +178,6 @@ static void my_application_activate(GApplication* application) {
(WindowCreatedCallback)on_subwindow_created);
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
cursor_size::Register(view);
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
self->host_channel = fl_method_channel_new(

View File

@@ -198,46 +198,7 @@ class MainFlutterWindow: NSWindow {
}
}
private func registerCursorSizeChannel(registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/cursor", binaryMessenger: registrar.messenger)
channel.setMethodCallHandler { call, result in
guard call.method == "getSystemCursorSize" else {
result(FlutterMethodNotImplemented)
return
}
guard let size = self.systemCursorSize() else {
result(FlutterError(code: "cursor_size", message: "Could not measure the local system cursor", details: nil))
return
}
result(size)
}
}
private func systemCursorSize() -> Double? {
let image = NSCursor.arrow.image
guard let data = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: data),
bitmap.pixelsWide > 0, bitmap.pixelsHigh > 0 else { return nil }
var left = bitmap.pixelsWide, top = bitmap.pixelsHigh
var right = -1, bottom = -1
for y in 0..<bitmap.pixelsHigh {
for x in 0..<bitmap.pixelsWide {
guard let color = bitmap.colorAt(x: x, y: y) else { return nil }
if color.alphaComponent == 0 { continue }
left = min(left, x)
right = max(right, x)
top = min(top, y)
bottom = max(bottom, y)
}
}
guard right >= left else { return nil }
let width = Double(right - left + 1) * image.size.width / Double(bitmap.pixelsWide)
let height = Double(bottom - top + 1) * image.size.height / Double(bitmap.pixelsHigh)
return max(width, height)
}
public func setMethodHandler(registrar: FlutterPluginRegistrar) {
registerCursorSizeChannel(registrar: registrar)
let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/host", binaryMessenger: registrar.messenger)
channel.setMethodCallHandler({
(call, result) -> Void in

View File

@@ -1,196 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.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;
// Source size, source hotspot, requested/effective scale, artwork size, integer hotspot.
final _cases = [
((17, 23), (4.0, 4.0), 1 / 3, 12 / 17, (12, 16), (3.0, 3.0)),
((32, 16), (16.0, 8.0), 0.5, 0.75, (24, 12), (12.0, 6.0)),
((34, 46), (8.0, 8.0), 0.5, 0.5, (17, 23), (4.0, 4.0)),
((24, 24), (11.0, 11.0), 1 / 3, 0.5, (12, 12), (6.0, 6.0)),
((1, 48), (0.0, 24.0), 1.0, 1.0, (1, 48), (0.0, 24.0)),
((9, 18), (4.0, 9.0), 1.0, 1.0, (9, 18), (4.0, 9.0)),
((24, 24), (4.0, 4.0), 0.1, 0.5, (12, 12), (2.0, 2.0)),
(
(19, 27),
(6.0, 13.0),
1.37,
1.37,
Platform.isWindows ? (26, 36) : (26, 37),
Platform.isWindows ? (8.0, 17.0) : (8.0, 18.0)
),
(
(18, 36),
(8.0, 18.0),
0.75,
0.75,
Platform.isWindows ? (13, 27) : (14, 27),
(6.0, 14.0)
),
];
const _hotspotTolerance = 1e-9;
class _CursorModel implements CursorModel {
@override
final Set<String> cachedKeys = {};
@override
void addKey(String key) => cachedKeys.add(key);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _CursorFFI implements FFI {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
CursorData _cursorData((int, int) size, (double, double) hotspot) {
final image = img.Image(width: size.$1, height: size.$2, numChannels: 4);
for (var y = 0; y < image.height; y++) {
image.setPixelRgba(image.width ~/ 2, y, 255, 255, 255, 255);
}
for (var x = 0; x < image.width; x++) {
image.setPixelRgba(x, 0, 255, 0, 0, 255);
image.setPixelRgba(x, image.height - 1, 0, 0, 255, 255);
}
return CursorData(
peerId: 'cursor-test',
id: 'native',
image: image,
scale: 1,
data: Platform.isWindows
? image.getBytes(order: img.ChannelOrder.bgra)
: Uint8List.fromList(img.encodePng(image)),
hotxOrigin: hotspot.$1,
hotyOrigin: hotspot.$2,
width: size.$1,
height: size.$2,
);
}
Future<Map<dynamic, dynamic>> _register(
WidgetTester tester, CursorData data, double scale) async {
final channel = Platform.isWindows
? SystemChannels.mouseCursor
: const MethodChannel('flutter_custom_cursor');
Map<dynamic, dynamic>? registered;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
(call) async {
expect(
call.method,
Platform.isWindows
? 'createCustomCursor/windows'
: 'createCustomCursor');
registered = call.arguments as Map<dynamic, dynamic>;
return registered!['name'];
});
addTearDown(() => tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null));
buildCursorOfCache(_CursorModel(), scale, data);
await tester.pump();
expect(registered, isNotNull);
return registered!;
}
void _expectArtwork(img.Image native, img.Image artwork) {
final content = img.copyCrop(native,
x: 0, y: 0, width: artwork.width, height: artwork.height);
expect(content.getBytes(), artwork.getBytes());
for (final pixel in native) {
if (pixel.x >= artwork.width) {
expect(pixel.a, 0, reason: 'Cursor padding must be transparent');
}
}
}
void main() {
testWidgets('received cursor keeps edge colors across scale changes',
(tester) async {
const side = 4;
for (final (rgba, expected) in [
([128, 64, 32, 128], [255, 128, 64, 128]),
([0, 0, 0, 0], [0, 0, 0, 0]),
([32, 64, 128, 255], [32, 64, 128, 255]),
]) {
final cursor = CursorModel(WeakReference<FFI>(_CursorFFI()))
..id = 'edge-colors';
addTearDown(cursor.disposeImages);
addTearDown(cursor.dispose);
await tester.runAsync(() => cursor.updateCursorData({
'id': 'edge-colors',
'hotx': '1',
'hoty': '1',
'width': '$side',
'height': '$side',
'colors': jsonEncode(List.generate(side * side, (_) => rgba)
.expand((pixel) => pixel)
.toList()),
}));
final data = cursor.cache!;
for (final scale in [1.0, 0.5, 1.0]) {
data.updateGetKey(scale);
final image = Platform.isWindows
? img.Image.fromBytes(
width: data.scaledWidth,
height: data.scaledHeight,
bytes: data.data!.buffer,
order: img.ChannelOrder.bgra)
: img.decodePng(data.data!)!;
for (final pixel in image) {
expect([pixel.r, pixel.g, pixel.b, pixel.a], expected,
reason: 'Edge color must survive scale $scale');
}
}
}
});
for (final (source, hotspot, scale, effectiveScale, size, integerHotspot)
in _cases) {
testWidgets('${source.$1}x${source.$2} cursor at scale $scale',
(tester) async {
final data = _cursorData(source, hotspot);
final cursor = await _register(tester, data, scale);
final artwork = Platform.isWindows
? img.Image.fromBytes(
width: data.scaledWidth,
height: data.scaledHeight,
bytes: data.data!.buffer,
order: img.ChannelOrder.bgra)
: img.decodePng(data.data!)!;
final native = Platform.isWindows
? img.Image.fromBytes(
width: cursor['width'],
height: cursor['height'],
bytes: (cursor['buffer'] as Uint8List).buffer,
bytesOffset: (cursor['buffer'] as Uint8List).offsetInBytes,
order: img.ChannelOrder.bgra)
: img.decodePng(cursor['buffer'] as Uint8List)!;
final width = Platform.isLinux && size.$2 > size.$1 ? size.$2 : size.$1;
expect(data.scale, effectiveScale);
expect((artwork.width, artwork.height), size);
expect(data.hotx / size.$1,
closeTo(hotspot.$1 / source.$1, _hotspotTolerance));
expect(data.hoty / size.$2,
closeTo(hotspot.$2 / source.$2, _hotspotTolerance));
expect((native.width, native.height), (width, size.$2));
expect((cursor['width'], cursor['height']), (width, size.$2));
expect(
(cursor['hotX'], cursor['hotY']),
Platform.isLinux || Platform.isWindows
? integerHotspot
: (data.hotx, data.hoty));
_expectArtwork(native, artwork);
if (width == artwork.width) {
expect(cursor['buffer'], data.data);
}
});
}
}

View File

@@ -1,292 +0,0 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
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';
import 'package:flutter_hbb/utils/cursor_size.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
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 ChangeNotifier implements CursorModel {
_Cursor(this.cache);
@override
CursorData cache;
@override
ui.Image? get image => null;
@override
double get hotx => cache.hotxOrigin;
@override
double get hoty => cache.hotyOrigin;
@override
final Set<String> cachedKeys = {};
@override
void addKey(String key) => cachedKeys.add(key);
@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 get isPeerLinux => false;
}
class _FFI extends Fake implements FFI {
@override
final inputModel = _Input();
@override
final ffiModel = _Peer();
}
CursorData _cursor(img.Image image,
{String id = 'sprite', double hotspot = 0}) {
return CursorData(
peerId: 'local-size-test',
id: id,
image: image,
scale: 1,
data: Platform.isWindows
? image.getBytes(order: img.ChannelOrder.bgra)
: Uint8List.fromList(img.encodePng(image)),
hotxOrigin: hotspot,
hotyOrigin: hotspot,
width: image.width,
height: image.height,
);
}
CursorData _arrow(int density) {
final image =
img.Image(width: 17 * density, height: 23 * density, numChannels: 4);
img.fill(image, color: img.ColorRgba8(255, 255, 255, 255));
return _cursor(image, id: 'arrow-$density', hotspot: 4.0 * density);
}
void main() {
test('local size preserves remote pixels, padding and asymmetric hotspot',
() {
final artwork = img.Image(width: 40, height: 48, numChannels: 4);
img.fillRect(artwork,
x1: 8,
y1: 12,
x2: 22,
y2: 35,
color: img.ColorRgba8(180, 90, 45, 128),
alphaBlend: false);
for (final density in [1, 2, 3]) {
final source = img.copyResize(artwork,
width: artwork.width * density,
height: artwork.height * density,
interpolation: img.Interpolation.nearest);
expect(cursorVisibleSize(source), 24 * density);
final cursor = CursorData(
peerId: 'local-size-test',
id: 'padded-$density',
image: source,
scale: 1,
data: Platform.isWindows
? source.getBytes(order: img.ChannelOrder.bgra)
: Uint8List.fromList(img.encodePng(source)),
hotxOrigin: 10.0 * density,
hotyOrigin: 17.0 * density,
width: source.width,
height: source.height,
)..localSize = 24;
cursor.updateGetKey(0.01);
final output = Platform.isWindows
? img.Image.fromBytes(
width: cursor.scaledWidth,
height: cursor.scaledHeight,
bytes: cursor.data!.buffer,
order: img.ChannelOrder.bgra)
: img.decodePng(cursor.data!)!;
expect(cursorVisibleSize(output), 24);
expect(output.getBytes(), artwork.getBytes());
expect((cursor.hotx, cursor.hoty), (10, 17));
}
});
test('local cursor minimum keeps a thin remote cursor visible', () {
final thin = img.Image(width: 1, height: 48, numChannels: 4);
img.fill(thin, color: img.ColorRgba8(255, 255, 255, 255));
final cursor = _cursor(thin)..localSize = 2;
cursor.updateGetKey(1);
expect(cursor.scaledWidth, 1);
expect(cursor.scaledHeight, kMinCursorSize);
final blank = _cursor(img.Image(width: 17, height: 23, numChannels: 4))
..localSize = 24;
blank.updateGetKey(1);
final output = Platform.isWindows
? img.Image.fromBytes(
width: 17,
height: 23,
bytes: blank.data!.buffer,
order: img.ChannelOrder.bgra)
: img.decodePng(blank.data!)!;
expect(cursorVisibleSize(output), 0);
expect(blank.scale, 1);
});
test('switching local sizing keeps bitmap data and cache geometry consistent',
() {
final pending = _arrow(1)..data = null;
pending.updateGetKey(1);
expect(pending.data, isNull);
final cursor = _arrow(1);
const scale = 1.5;
final originalKey = cursor.updateGetKey(scale);
cursor.localSize = 23 * scale;
final localKey = cursor.updateGetKey(scale);
expect(cursor.scaledWidth, 26);
if (Platform.isWindows) {
expect(localKey, isNot(originalKey));
expect(cursor.data!.length, 26 * 35 * 4);
}
cursor.localSize = null;
expect(cursor.updateGetKey(scale), originalKey);
if (Platform.isWindows) expect(cursor.data!.length, 25 * 34 * 4);
});
for (final (dpr, style, scale) in [
for (final dpr in [1.0, 2.0, 3.0])
for (final (style, scale) in [
(kRemoteViewStyleAdaptive, 0.5),
(kRemoteViewStyleCustom, 0.25),
(kRemoteViewStyleCustom, 2.0),
]) (dpr, style, scale),
]) {
testWidgets('$style scale=$scale DPR=$dpr preserves local size and hotspot',
(tester) async {
final channel = Platform.isWindows
? SystemChannels.mouseCursor
: const MethodChannel('flutter_custom_cursor');
final registered = <Map<dynamic, dynamic>>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
(call) async {
if (call.method.startsWith('createCustomCursor')) {
final arguments = call.arguments as Map<dynamic, dynamic>;
registered.add(arguments);
return arguments['name'];
}
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null));
const sizeChannel = MethodChannel('org.rustdesk.rustdesk/cursor');
tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(sizeChannel, (call) async {
expect(call.method, 'getSystemCursorSize');
return Platform.isWindows ? 23.0 * dpr : 23.0;
});
addTearDown(() => tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(sizeChannel, null));
final cursor = _Cursor(_arrow(1));
final zoom = false.obs;
await tester.pumpWidget(MediaQuery(
data: MediaQueryData(devicePixelRatio: dpr),
child: MultiProvider(
providers: [
ChangeNotifierProvider<ImageModel>(create: (_) => _Image()),
ChangeNotifierProvider<CanvasModel>(
create: (_) => _Canvas(dpr, style: style, scale: scale)),
ChangeNotifierProvider<CursorModel>.value(value: cursor),
],
child: ImagePaint(
ffi: _FFI(),
id: 'local-size-test',
zoomCursor: zoom,
cursorOverImage: true.obs,
keyboardEnabled: true.obs,
remoteCursorMoved: false.obs,
),
),
));
await tester.pump();
expect(registered, isNotEmpty);
expect(cursor.cache.localSize, Platform.isWindows ? 23.0 * dpr : 23.0);
final original = registered.last;
final beforeDensityChange = registered.length;
cursor.cache = _arrow(2);
cursor.notifyListeners();
await tester.pump();
expect(registered, hasLength(beforeDensityChange + 1));
final retina = registered.last;
expect(
[retina['width'], retina['height'], retina['hotX'], retina['hotY']],
[
original['width'],
original['height'],
original['hotX'],
original['hotY']
],
reason: 'Only the remote raster density changed; the local cursor must '
'retain its size and hotspot.',
);
zoom.value = true;
await tester.pump();
expect(cursor.cache.localSize, isNull);
final zoomScale = scale * (Platform.isWindows ? dpr : 1);
expect(cursor.cache.scale,
zoomScale < kMinCursorSize / 34 ? kMinCursorSize / 34 : zoomScale);
expect(cursor.cache.hotx / cursor.cache.scaledWidth, closeTo(4 / 17, 1e-9));
expect(cursor.cache.hoty / cursor.cache.scaledHeight, closeTo(4 / 23, 1e-9));
await tester.pumpWidget(const SizedBox.shrink());
cursor.dispose();
});
}
}

View File

@@ -1,181 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
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';
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
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 ChangeNotifier implements CursorModel {
_Cursor(this.cache);
@override
CursorData cache;
@override
ui.Image? get image => null;
@override
double get hotx => cache.hotxOrigin;
@override
double get hoty => cache.hotyOrigin;
@override
final Set<String> cachedKeys = {};
@override
void addKey(String key) => cachedKeys.add(key);
@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 get isPeerLinux => false;
}
class _FFI extends Fake implements FFI {
@override
final inputModel = _Input();
@override
final ffiModel = _Peer();
}
CursorData _cursor(img.Image image,
{String id = 'sprite', double hotspot = 0}) {
return CursorData(
peerId: 'local-size-test',
id: id,
image: image,
scale: 1,
data: Platform.isWindows
? image.getBytes(order: img.ChannelOrder.bgra)
: Uint8List.fromList(img.encodePng(image)),
hotxOrigin: hotspot,
hotyOrigin: hotspot,
width: image.width,
height: image.height,
);
}
CursorData _arrow(int density) {
final image =
img.Image(width: 17 * density, height: 23 * density, numChannels: 4);
img.fill(image, color: img.ColorRgba8(255, 255, 255, 255));
return _cursor(image, id: 'arrow-$density', hotspot: 4.0 * density);
}
void main() {
testWidgets('pending or failed size measurement preserves the remote cursor',
(tester) async {
final channel = Platform.isWindows
? SystemChannels.mouseCursor
: const MethodChannel('flutter_custom_cursor');
final registered = <Map<dynamic, dynamic>>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
(call) async {
if (call.method.startsWith('createCustomCursor')) {
final arguments = call.arguments as Map<dynamic, dynamic>;
registered.add(arguments);
return arguments['name'];
}
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null));
const sizeChannel = MethodChannel('org.rustdesk.rustdesk/cursor');
final measurement = Completer<double>();
tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(sizeChannel, (_) => measurement.future);
addTearDown(() => tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(sizeChannel, null));
final cursor = _Cursor(_arrow(2));
await tester.pumpWidget(_view(cursor));
expect(registered, isNotEmpty);
measurement.completeError(PlatformException(code: 'cursor_size'));
await tester.pump();
expect(tester.takeException(), isA<PlatformException>());
expect(cursor.cache.localSize, isNull);
final scale = Platform.isWindows ? 1.0 : 0.5;
expect(cursor.cache.scaledWidth, 34 * scale);
expect(cursor.cache.scaledHeight, 46 * scale);
expect(cursor.cache.hotx, 8 * scale);
expect(cursor.cache.hoty, 8 * scale);
expect(registered.last['hotX'], 8 * scale);
expect(registered.last['hotY'], 8 * scale);
await tester.pumpWidget(const SizedBox.shrink());
cursor.dispose();
});
}
Widget _view(_Cursor cursor) => MediaQuery(
data: const MediaQueryData(devicePixelRatio: 2),
child: MultiProvider(
providers: [
ChangeNotifierProvider<ImageModel>(create: (_) => _Image()),
ChangeNotifierProvider<CanvasModel>(create: (_) => _Canvas(2,
style: kRemoteViewStyleAdaptive, scale: 0.25)),
ChangeNotifierProvider<CursorModel>.value(value: cursor),
],
child: ImagePaint(
ffi: _FFI(),
id: 'measurement-failure-test',
zoomCursor: false.obs,
cursorOverImage: true.obs,
keyboardEnabled: true.obs,
remoteCursorMoved: false.obs,
),
),
);

View File

@@ -1,187 +0,0 @@
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/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);
@override
final ui.Image image;
@override
double get hotx => _hotspot.dx;
@override
double get hoty => _hotspot.dy;
@override
double get x => _remotePosition.dx;
@override
double get y => _remotePosition.dy;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _ImageModel extends Fake implements ImageModel {
_ImageModel(this.useTextureRender);
@override
final bool useTextureRender;
}
class _Peer extends Fake implements FfiModel {
@override
final pi = PeerInfo();
@override
bool get isPeerLinux => false;
}
class _FFI extends Fake implements FFI {
_FFI(bool useTexture) : imageModel = _ImageModel(useTexture);
@override
final ImageModel imageModel;
@override
final ffiModel = _Peer();
}
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(),
);
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
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
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;
}
}
void main() {
for (final (style, zoom, dpr, source, canvasScale, scale, texture) in [
(kRemoteViewStyleAdaptive, false, 2.0, (48, 64), 0.375, 0.375, true),
(kRemoteViewStyleAdaptive, false, 3.0, (48, 64), 0.25, 0.25, true),
(kRemoteViewStyleAdaptive, true, 3.0, (48, 64), 0.375, 0.375, true),
(kRemoteViewStyleOriginal, false, 2.0, (48, 64), 0.5, 0.5, true),
(kRemoteViewStyleOriginal, true, 2.0, (48, 64), 0.5, 0.5, true),
for (final zoom in [false, true])
for (final scale in [0.25, 2.0])
for (final texture in [false, true])
(kRemoteViewStyleCustom, zoom, 2.0, (48, 64), scale, scale, texture),
(
kRemoteViewStyleAdaptive,
false,
2.0,
(9, 18),
0.1,
Platform.isWindows ? 2 / 3 : 4 / 3,
true
),
(
kRemoteViewStyleAdaptive,
true,
2.0,
(9, 18),
0.1,
Platform.isWindows ? 2 / 3 : 4 / 3,
true
),
(kRemoteViewStyleAdaptive, false, 2.25, (48, 48), 0.375, 0.375, false),
(
kRemoteViewStyleAdaptive,
true,
2.0,
(9, 18),
0.1,
Platform.isWindows ? 2 / 3 : 4 / 3,
false
),
]) {
testWidgets(
'$style zoom=$zoom dpr=$dpr source=$source texture=$texture keeps remote geometry',
(tester) async {
final image = (await tester.runAsync(
() => createTestImage(width: source.$1, height: source.$2)))!;
addTearDown(image.dispose);
await tester.pumpWidget(MediaQuery(
data: MediaQueryData(devicePixelRatio: dpr),
child: MultiProvider(
providers: [
ChangeNotifierProvider<CursorModel>(
create: (_) => _CursorModel(image)),
ChangeNotifierProvider<CanvasModel>(
create: (_) => _CanvasModel(style, canvasScale, texture)),
],
child: CursorPaint(id: 'cursor-test', zoomCursor: zoom.obs),
),
));
final painter = tester
.widget<CustomPaint>(find.byType(CustomPaint))
.painter! as ImagePainter;
expect(painter.image, same(image));
expect(painter.scale, scale);
var imageOrigin = _canvasOffset;
if (!texture) {
final background = _Canvas();
ImagePainter(
image: image,
x: _canvasOffset.dx / canvasScale,
y: _canvasOffset.dy / canvasScale,
scale: canvasScale,
).paint(background, _viewport);
imageOrigin = background.position!;
}
final target = _remotePosition * canvasScale + imageOrigin;
expect((Offset(painter.x, painter.y) + _hotspot) * scale, target);
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));
});
}
}

View File

@@ -1,43 +0,0 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:image/image.dart' as img;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('sparse remote artwork cannot amplify the native bitmap without limit',
() {
const sourceSize = 64;
const bitmapLimit = 512;
final image = img.Image(width: sourceSize, height: sourceSize, numChannels: 4)
..setPixelRgba(0, 0, 255, 255, 255, 255);
final cache = CursorData(
peerId: 'sparse-cursor',
id: '1',
image: image,
scale: 1,
data: Uint8List.fromList(img.encodePng(image)),
hotxOrigin: 32,
hotyOrigin: 48,
width: sourceSize,
height: sourceSize,
)..localSize = sourceSize.toDouble();
final messages = <String>[];
final previous = debugPrint;
debugPrint = (String? message, {int? wrapWidth}) {
messages.add(message ?? '');
};
addTearDown(() => debugPrint = previous);
cache.updateGetKey(1);
expect(max(cache.scaledWidth, cache.scaledHeight), bitmapLimit);
expect(cache.hotx, 32 * cache.scaledWidth / sourceSize);
expect(cache.hoty, 48 * cache.scaledHeight / sourceSize);
expect(messages.single, contains('bitmap limit'));
});
}

View File

@@ -1,64 +0,0 @@
@TestOn('browser')
library;
import 'dart:convert';
import 'dart:js' as js;
import 'dart:typed_data';
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:image/image.dart' as img;
class _CursorModel extends Fake implements model.CursorModel {
@override
final Set<String> cachedKeys = {};
@override
void addKey(String key) => cachedKeys.add(key);
}
void main() {
test('Web cursor rounds its bitmap and hotspot together', () async {
Map<String, dynamic>? registered;
final original = js.context['setByName'];
js.context['setByName'] = js.allowInterop((String name, String value) {
expect(name, 'cursor');
registered = jsonDecode(value) as Map<String, dynamic>;
});
addTearDown(() => js.context['setByName'] = original);
const sourceSide = 48;
final image = img.Image(width: sourceSide, height: sourceSide, numChannels: 4);
img.fill(image, color: img.ColorRgba8(255, 255, 255, 255));
for (final (sourceHotspot, scale, outputSide, expectedHotspot) 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 cache = model.CursorData(
peerId: 'web-cursor-test',
id: '$sourceHotspot',
image: image,
scale: 1,
data: Uint8List.fromList(img.encodePng(image)),
hotxOrigin: sourceHotspot.$1,
hotyOrigin: sourceHotspot.$2,
width: sourceSide,
height: sourceSide,
);
final cursor = buildCursorOfCache(_CursorModel(), scale, cache);
final session = cursor.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), (outputSide, outputSide));
expect((registered!['hotx'], registered!['hoty']), expectedHotspot);
session.dispose();
await deleteCustomCursor(cache.updateGetKey(scale));
}
});
}

View File

@@ -1,146 +0,0 @@
#ifndef RUSTDESK_CURSOR_SIZE_H_
#define RUSTDESK_CURSOR_SIZE_H_
#include <flutter/method_channel.h>
#include <flutter/standard_method_codec.h>
#include <windows.h>
#include <algorithm>
#include <cstdint>
#include <stdexcept>
namespace cursor_size {
struct IconBitmaps {
ICONINFO info{};
~IconBitmaps() {
if (info.hbmColor) DeleteObject(info.hbmColor);
if (info.hbmMask) DeleteObject(info.hbmMask);
}
};
struct Surface {
HDC dc = CreateCompatibleDC(nullptr);
HBITMAP bitmap = nullptr;
HGDIOBJ previous = nullptr;
uint32_t* pixels = nullptr;
int width = 0, height = 0;
~Surface() {
if (previous) SelectObject(dc, previous);
if (bitmap) DeleteObject(bitmap);
if (dc) DeleteDC(dc);
}
void Init(const BITMAP& source, bool monochrome) {
width = source.bmWidth;
height = monochrome ? source.bmHeight / 2 : source.bmHeight;
if (!dc || width <= 0 || height <= 0) {
throw std::runtime_error("Could not create the cursor measurement surface");
}
constexpr WORD kColorBits = 32;
BITMAPINFO format{};
format.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
format.bmiHeader.biWidth = width;
format.bmiHeader.biHeight = -height;
format.bmiHeader.biPlanes = 1;
format.bmiHeader.biBitCount = kColorBits;
format.bmiHeader.biCompression = BI_RGB;
bitmap = CreateDIBSection(dc, &format, DIB_RGB_COLORS,
reinterpret_cast<void**>(&pixels), nullptr, 0);
if (!bitmap) throw std::runtime_error("Could not allocate the cursor bitmap");
previous = SelectObject(dc, bitmap);
if (!previous || previous == HGDI_ERROR) {
previous = nullptr;
throw std::runtime_error("Could not select the cursor bitmap");
}
}
};
inline void IncludeVisiblePixels(const Surface& surface, uint32_t background,
RECT& bounds) {
constexpr uint32_t kRgbMask = 0x00ffffff;
for (int y = 0; y < surface.height; ++y) {
for (int x = 0; x < surface.width; ++x) {
if ((surface.pixels[y * surface.width + x] & kRgbMask) == background) continue;
bounds.left = (std::min)(bounds.left, static_cast<LONG>(x));
bounds.right = (std::max)(bounds.right, static_cast<LONG>(x + 1));
bounds.top = (std::min)(bounds.top, static_cast<LONG>(y));
bounds.bottom = (std::max)(bounds.bottom, static_cast<LONG>(y + 1));
}
}
}
inline double WindowScale(HWND window) {
const auto user32 = GetModuleHandleW(L"user32.dll");
const auto window_dpi = reinterpret_cast<UINT(WINAPI*)(HWND)>(
GetProcAddress(user32, "GetDpiForWindow"));
const auto system_dpi = reinterpret_cast<UINT(WINAPI*)()>(
GetProcAddress(user32, "GetDpiForSystem"));
const auto metrics = reinterpret_cast<int(WINAPI*)(int, UINT)>(
GetProcAddress(user32, "GetSystemMetricsForDpi"));
// Preserve the system-DPI path on Windows versions without per-window metrics.
if (!window_dpi || !system_dpi || !metrics) return 1.0;
const UINT target_dpi = window_dpi(window);
const UINT source_dpi = system_dpi();
if (!target_dpi || !source_dpi) {
throw std::runtime_error("Could not read the cursor window DPI");
}
const int target_size = metrics(SM_CXCURSOR, target_dpi);
const int source_size = metrics(SM_CXCURSOR, source_dpi);
if (target_size <= 0 || source_size <= 0) {
throw std::runtime_error("Could not read the DPI-specific cursor metrics");
}
return static_cast<double>(target_size) / source_size;
}
inline double SystemSize(HWND window) {
HCURSOR cursor = LoadCursorW(nullptr, IDC_ARROW);
IconBitmaps bitmaps;
if (!cursor || !GetIconInfo(cursor, &bitmaps.info)) {
throw std::runtime_error("Could not read the Windows system cursor");
}
BITMAP source{};
const auto bitmap = bitmaps.info.hbmColor ? bitmaps.info.hbmColor : bitmaps.info.hbmMask;
if (!GetObject(bitmap, sizeof(source), &source)) {
throw std::runtime_error("Could not read the system cursor bitmap size");
}
Surface surface;
surface.Init(source, !bitmaps.info.hbmColor);
RECT bounds{surface.width, surface.height, 0, 0};
// Drawing on both backgrounds measures alpha and legacy AND/XOR cursors.
constexpr uint32_t kBlack = 0x00000000, kWhite = 0x00ffffff;
for (const auto background : {kBlack, kWhite}) {
std::fill_n(surface.pixels, surface.width * surface.height, background);
if (!DrawIconEx(surface.dc, 0, 0, cursor, 0, 0, 0, nullptr, DI_NORMAL) || !GdiFlush()) {
throw std::runtime_error("Could not draw the Windows system cursor");
}
IncludeVisiblePixels(surface, background, bounds);
}
if (bounds.right <= bounds.left) {
throw std::runtime_error("System cursor has no visible pixels");
}
return (std::max)(bounds.right - bounds.left, bounds.bottom - bounds.top) *
WindowScale(window);
}
inline void Register(flutter::BinaryMessenger* messenger, HWND window) {
flutter::MethodChannel<> channel(messenger, "org.rustdesk.rustdesk/cursor",
&flutter::StandardMethodCodec::GetInstance());
channel.SetMethodCallHandler([window](const flutter::MethodCall<>& call,
std::unique_ptr<flutter::MethodResult<>> result) {
if (call.method_name() != "getSystemCursorSize") {
result->NotImplemented();
return;
}
try {
result->Success(flutter::EncodableValue(SystemSize(window)));
} catch (const std::exception& error) {
result->Error("cursor_size", error.what());
}
});
}
} // namespace cursor_size
#endif // RUSTDESK_CURSOR_SIZE_H_

View File

@@ -18,7 +18,6 @@
#include <memory>
#include "win32_desktop.h"
#include "cursor_size.h"
namespace {
@@ -101,8 +100,6 @@ bool FlutterWindow::OnCreate() {
return false;
}
RegisterPlugins(flutter_controller_->engine());
cursor_size::Register(flutter_controller_->engine()->messenger(),
flutter_controller_->view()->GetNativeWindow());
flutter::MethodChannel<> channel(
flutter_controller_->engine()->messenger(),
@@ -149,8 +146,6 @@ bool FlutterWindow::OnCreate() {
auto *flutter_view_controller =
reinterpret_cast<flutter::FlutterViewController *>(controller);
auto *registry = flutter_view_controller->engine();
cursor_size::Register(registry->messenger(),
flutter_view_controller->view()->GetNativeWindow());
TextureRgbaRendererPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("TextureRgbaRendererPlugin"));
FlutterGpuTextureRendererPluginCApiRegisterWithRegistrar(