mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-14 00:11:01 +03:00
fix(cursor): decode legacy macOS alpha for native cursors
This commit is contained in:
@@ -3463,8 +3463,12 @@ class CursorModel with ChangeNotifier {
|
||||
pixelRatio > 0 &&
|
||||
parent.target?.ffiModel.pi.platform == kPeerPlatformMacOS) {
|
||||
// macOS cursors with density metadata use premultiplied alpha; older
|
||||
// hosts send straight alpha. Keep native decoding and old hosts unchanged.
|
||||
// hosts send straight alpha. Web needs a straight-alpha resize source.
|
||||
(rgba, image) = await _decodeWebMacCursor(rgba, width, height);
|
||||
} else if (!isWeb &&
|
||||
pixelRatio == 0 &&
|
||||
parent.target?.ffiModel.pi.platform == kPeerPlatformMacOS) {
|
||||
image = await _decodeLegacyMacCursor(rgba, width, height);
|
||||
} else {
|
||||
image = await img.decodeImageFromPixels(
|
||||
rgba, width, height, ui.PixelFormat.rgba8888);
|
||||
@@ -3485,6 +3489,22 @@ class CursorModel with ChangeNotifier {
|
||||
_updateCurData();
|
||||
}
|
||||
|
||||
Future<ui.Image?> _decodeLegacyMacCursor(
|
||||
Uint8List rgba, int width, int height) async {
|
||||
// Old macOS packets are straight alpha. Premultiply a copy for native
|
||||
// ui.Image, retaining the original colors for the separate byte cache.
|
||||
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<(Uint8List, ui.Image)> _decodeWebMacCursor(
|
||||
Uint8List rgba, int width, int height) async {
|
||||
final source = img2.Image.fromBytes(
|
||||
|
||||
154
flutter/test/cursor_native_alpha_test.dart
Normal file
154
flutter/test/cursor_native_alpha_test.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
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', [32, 16, 8, 128]),
|
||||
(kPeerPlatformMacOS, '2', [32, 16, 8, 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));
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user