mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-11 23:11:01 +03:00
revert(cursor): return to f5b98b32 before splitting plugin changes
This commit is contained in:
@@ -25,7 +25,7 @@ inline = []
|
||||
use_samplerate = ["samplerate"]
|
||||
use_rubato = ["rubato"]
|
||||
use_dasp = ["dasp"]
|
||||
flutter = ["flutter_rust_bridge", "scrap/cursor"]
|
||||
flutter = ["flutter_rust_bridge"]
|
||||
default = ["use_dasp"]
|
||||
hwcodec = ["scrap/hwcodec"]
|
||||
vram = ["scrap/vram"]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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_
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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_
|
||||
@@ -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(
|
||||
|
||||
@@ -310,7 +310,7 @@ message KeyEvent {
|
||||
}
|
||||
|
||||
message CursorData {
|
||||
uint64 id = 1 [jstype = JS_STRING];
|
||||
uint64 id = 1;
|
||||
sint32 hotx = 2;
|
||||
sint32 hoty = 3;
|
||||
int32 width = 4;
|
||||
@@ -999,7 +999,7 @@ message Message {
|
||||
AudioFrame audio_frame = 11;
|
||||
CursorData cursor_data = 12;
|
||||
CursorPosition cursor_position = 13;
|
||||
uint64 cursor_id = 14 [jstype = JS_STRING];
|
||||
uint64 cursor_id = 14;
|
||||
KeyEvent key_event = 15;
|
||||
Clipboard clipboard = 16;
|
||||
FileAction file_action = 17;
|
||||
|
||||
@@ -10,7 +10,6 @@ authors = ["Ram <quadrupleslap@gmail.com>"]
|
||||
edition = "2018"
|
||||
|
||||
[features]
|
||||
cursor = []
|
||||
wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"]
|
||||
# `drm` is a pure runtime-dlopen backend: rustdesk loads `libdrmtap.so.0` at runtime (`drmtap_dl.rs`)
|
||||
# and NEVER link-time links it, so the graceful PipeWire fallback when the .so or EGL is absent is
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
use super::wrap_hresult;
|
||||
use std::{
|
||||
collections::{hash_map::DefaultHasher, HashMap},
|
||||
hash::{Hash, Hasher},
|
||||
io,
|
||||
sync::{Arc, Mutex, Weak},
|
||||
time::Instant,
|
||||
};
|
||||
use winapi::shared::dxgi1_2::{
|
||||
IDXGIOutputDuplication, DXGI_OUTDUPL_FRAME_INFO, DXGI_OUTDUPL_POINTER_SHAPE_INFO,
|
||||
DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR, DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR,
|
||||
DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME,
|
||||
};
|
||||
|
||||
pub const CURSOR_ID_FLAG: u64 = 1 << 63;
|
||||
const CHANNELS: u32 = 4;
|
||||
const BITS_PER_BYTE: u32 = 8;
|
||||
|
||||
pub struct Shape {
|
||||
pub id: u64,
|
||||
pub kind: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pitch: u32,
|
||||
pub hotspot: (i32, i32),
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
fn new(info: DXGI_OUTDUPL_POINTER_SHAPE_INFO, pixels: Vec<u8>) -> io::Result<Self> {
|
||||
let (height, minimum_pitch) = match info.Type {
|
||||
DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME if info.Height % 2 == 0 => {
|
||||
(info.Height / 2, info.Width.div_ceil(BITS_PER_BYTE))
|
||||
}
|
||||
DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR
|
||||
| DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR => (
|
||||
info.Height,
|
||||
info.Width.checked_mul(CHANNELS).ok_or_else(invalid_shape)?,
|
||||
),
|
||||
_ => return Err(invalid_shape()),
|
||||
};
|
||||
let length = (info.Pitch as usize).checked_mul(info.Height as usize);
|
||||
if info.Width == 0
|
||||
|| height == 0
|
||||
|| info.Width > i32::MAX as u32
|
||||
|| info.Height > i32::MAX as u32
|
||||
|| info.Pitch > i32::MAX as u32
|
||||
|| info.Pitch < minimum_pitch
|
||||
|| length != Some(pixels.len())
|
||||
|| info.HotSpot.x < 0
|
||||
|| info.HotSpot.x as u32 >= info.Width
|
||||
|| info.HotSpot.y < 0
|
||||
|| info.HotSpot.y as u32 >= height
|
||||
{
|
||||
return Err(invalid_shape());
|
||||
}
|
||||
let hotspot = (info.HotSpot.x, info.HotSpot.y);
|
||||
let mut hash = DefaultHasher::new();
|
||||
(info.Type, info.Width, height, info.Pitch, hotspot, &pixels).hash(&mut hash);
|
||||
Ok(Self {
|
||||
id: hash.finish() | CURSOR_ID_FLAG,
|
||||
kind: info.Type,
|
||||
width: info.Width,
|
||||
height,
|
||||
pitch: info.Pitch,
|
||||
hotspot,
|
||||
pixels,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_shape() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "Invalid DXGI cursor shape")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Snapshot {
|
||||
Unavailable,
|
||||
Pending,
|
||||
Ready(Arc<Shape>),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
struct State {
|
||||
updated: Instant,
|
||||
snapshot: Snapshot,
|
||||
}
|
||||
|
||||
type SharedState = Arc<Mutex<State>>;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CAPTURES: Mutex<HashMap<usize, Vec<Weak<Mutex<State>>>>> =
|
||||
Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
pub fn snapshot(monitor: usize) -> Snapshot {
|
||||
let captures = CAPTURES.lock().unwrap();
|
||||
captures
|
||||
.get(&monitor)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Weak::upgrade)
|
||||
.map(|state| {
|
||||
let state = state.lock().unwrap();
|
||||
(state.updated, state.snapshot.clone())
|
||||
})
|
||||
.max_by_key(|(updated, _)| *updated)
|
||||
.map(|(_, snapshot)| snapshot)
|
||||
.unwrap_or(Snapshot::Unavailable)
|
||||
}
|
||||
|
||||
pub fn shape(id: u64) -> Option<Arc<Shape>> {
|
||||
CAPTURES
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.flatten()
|
||||
.filter_map(Weak::upgrade)
|
||||
.find_map(|state| match &state.lock().unwrap().snapshot {
|
||||
Snapshot::Ready(shape) if shape.id == id => Some(shape.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) struct Capture {
|
||||
monitor: usize,
|
||||
state: SharedState,
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
pub fn new(monitor: usize) -> Self {
|
||||
let state = Arc::new(Mutex::new(State {
|
||||
updated: Instant::now(),
|
||||
snapshot: Snapshot::Pending,
|
||||
}));
|
||||
let capture = Self { monitor, state };
|
||||
capture.activate();
|
||||
capture
|
||||
}
|
||||
|
||||
pub fn activate(&self) {
|
||||
let mut captures = CAPTURES.lock().unwrap();
|
||||
let states = captures.entry(self.monitor).or_default();
|
||||
let own = Arc::downgrade(&self.state);
|
||||
if !states.iter().any(|state| state.ptr_eq(&own)) {
|
||||
states.push(own);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deactivate(&self) {
|
||||
let mut captures = CAPTURES.lock().unwrap();
|
||||
if let Some(states) = captures.get_mut(&self.monitor) {
|
||||
let own = Arc::downgrade(&self.state);
|
||||
states.retain(|state| !state.ptr_eq(&own));
|
||||
if states.is_empty() {
|
||||
captures.remove(&self.monitor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub unsafe fn update(
|
||||
&self,
|
||||
duplication: *mut IDXGIOutputDuplication,
|
||||
frame: &DXGI_OUTDUPL_FRAME_INFO,
|
||||
) {
|
||||
if frame.PointerShapeBufferSize == 0 {
|
||||
return;
|
||||
}
|
||||
let snapshot = match read(duplication, frame.PointerShapeBufferSize) {
|
||||
Ok(shape) => Snapshot::Ready(Arc::new(shape)),
|
||||
Err(error) => {
|
||||
hbb_common::log::error!("DXGI cursor capture failed: {error}");
|
||||
Snapshot::Failed(error.to_string())
|
||||
}
|
||||
};
|
||||
*self.state.lock().unwrap() = State {
|
||||
updated: Instant::now(),
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn read(duplication: *mut IDXGIOutputDuplication, size: u32) -> io::Result<Shape> {
|
||||
let mut pixels = vec![0; size as usize];
|
||||
let mut required = 0;
|
||||
let mut info = std::mem::zeroed();
|
||||
wrap_hresult((*duplication).GetFramePointerShape(
|
||||
size,
|
||||
pixels.as_mut_ptr().cast(),
|
||||
&mut required,
|
||||
&mut info,
|
||||
))?;
|
||||
if required > size {
|
||||
return Err(invalid_shape());
|
||||
}
|
||||
pixels.truncate(required as usize);
|
||||
Shape::new(info, pixels)
|
||||
}
|
||||
|
||||
impl Drop for Capture {
|
||||
fn drop(&mut self) {
|
||||
self.deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use winapi::shared::windef::POINT;
|
||||
|
||||
#[test]
|
||||
fn cursor_state_follows_capture_lifetime_and_gdi_switches() {
|
||||
const MONITOR: usize = usize::MAX;
|
||||
let first = Capture::new(MONITOR);
|
||||
let second = Capture::new(MONITOR);
|
||||
first.deactivate();
|
||||
assert!(matches!(snapshot(MONITOR), Snapshot::Pending));
|
||||
drop(second);
|
||||
assert!(matches!(snapshot(MONITOR), Snapshot::Unavailable));
|
||||
first.activate();
|
||||
first.activate();
|
||||
assert!(matches!(snapshot(MONITOR), Snapshot::Pending));
|
||||
drop(first);
|
||||
assert!(matches!(snapshot(MONITOR), Snapshot::Unavailable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_keeps_physical_hotspot_and_both_monochrome_planes() {
|
||||
let info = DXGI_OUTDUPL_POINTER_SHAPE_INFO {
|
||||
Type: DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME,
|
||||
Width: 64,
|
||||
Height: 128,
|
||||
Pitch: 8,
|
||||
HotSpot: POINT { x: 31, y: 29 },
|
||||
};
|
||||
let pixels = vec![0xa5; 1024];
|
||||
let shape = Shape::new(info, pixels.clone()).unwrap();
|
||||
assert_eq!(
|
||||
(shape.width, shape.height, shape.hotspot),
|
||||
(64, 64, (31, 29))
|
||||
);
|
||||
assert_eq!(shape.pixels, pixels);
|
||||
assert!(Shape::new(info, vec![0; 512]).is_err());
|
||||
let mut changed = info;
|
||||
changed.HotSpot.y += 1;
|
||||
assert_ne!(shape.id, Shape::new(changed, pixels).unwrap().id);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ use std::{io, mem, ptr, slice};
|
||||
pub mod gdi;
|
||||
pub use gdi::CapturerGDI;
|
||||
pub mod mag;
|
||||
#[cfg(feature = "cursor")]
|
||||
pub mod cursor;
|
||||
|
||||
use winapi::{
|
||||
shared::{
|
||||
@@ -44,8 +42,6 @@ impl<T> Drop for ComPtr<T> {
|
||||
}
|
||||
|
||||
pub struct Capturer {
|
||||
#[cfg(feature = "cursor")]
|
||||
cursor: Option<cursor::Capture>,
|
||||
device: ComPtr<ID3D11Device>,
|
||||
display: Display,
|
||||
context: ComPtr<ID3D11DeviceContext>,
|
||||
@@ -162,9 +158,6 @@ impl Capturer {
|
||||
let rotate = Self::create_rotations(device.0, context.0, &display);
|
||||
|
||||
Ok(Capturer {
|
||||
#[cfg(feature = "cursor")]
|
||||
cursor: (!duplication.is_null())
|
||||
.then(|| cursor::Capture::new(display.hmonitor() as usize)),
|
||||
device,
|
||||
context,
|
||||
duplication: ComPtr(duplication),
|
||||
@@ -323,25 +316,12 @@ impl Capturer {
|
||||
|
||||
pub fn set_gdi(&mut self) -> bool {
|
||||
self.gdi_capturer = self.display.create_gdi();
|
||||
#[cfg(feature = "cursor")]
|
||||
if self.is_gdi() {
|
||||
if let Some(cursor) = &self.cursor {
|
||||
cursor.deactivate();
|
||||
}
|
||||
}
|
||||
self.is_gdi()
|
||||
}
|
||||
|
||||
pub fn cancel_gdi(&mut self) {
|
||||
self.gdi_buffer = Vec::new();
|
||||
self.gdi_capturer.take();
|
||||
#[cfg(feature = "cursor")]
|
||||
if !self.duplication.is_null() {
|
||||
let monitor = self.display.hmonitor() as usize;
|
||||
self.cursor
|
||||
.get_or_insert_with(|| cursor::Capture::new(monitor))
|
||||
.activate();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "vram")]
|
||||
@@ -356,10 +336,6 @@ impl Capturer {
|
||||
|
||||
wrap_hresult((*self.duplication.0).AcquireNextFrame(timeout, &mut info, &mut frame))?;
|
||||
let frame = ComPtr(frame);
|
||||
#[cfg(feature = "cursor")]
|
||||
if let Some(cursor) = &self.cursor {
|
||||
cursor.update(self.duplication.0, &info);
|
||||
}
|
||||
|
||||
if *info.LastPresentTime.QuadPart() == 0 {
|
||||
return Err(std::io::ErrorKind::WouldBlock.into());
|
||||
@@ -503,10 +479,6 @@ impl Capturer {
|
||||
|
||||
wrap_hresult((*self.duplication.0).AcquireNextFrame(timeout, &mut info, &mut frame))?;
|
||||
let frame = ComPtr(frame);
|
||||
#[cfg(feature = "cursor")]
|
||||
if let Some(cursor) = &self.cursor {
|
||||
cursor.update(self.duplication.0, &info);
|
||||
}
|
||||
|
||||
if info.AccumulatedFrames == 0 || *info.LastPresentTime.QuadPart() == 0 {
|
||||
return Err(std::io::ErrorKind::WouldBlock.into());
|
||||
|
||||
@@ -35,14 +35,12 @@ use std::{
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
mod cursor;
|
||||
|
||||
// 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, f64) = (0, 0.0);
|
||||
static mut LATEST_SEED: i32 = 0;
|
||||
|
||||
#[inline]
|
||||
fn get_update_temp_dir() -> PathBuf {
|
||||
@@ -563,7 +561,7 @@ pub fn get_cursor() -> ResultType<Option<u64>> {
|
||||
|
||||
fn unsafe_get_cursor() -> ResultType<Option<u64>> {
|
||||
unsafe {
|
||||
let seed = (CGSCurrentCursorSeed(), cursor::scale()?);
|
||||
let seed = CGSCurrentCursorSeed();
|
||||
if seed == LATEST_SEED {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -575,11 +573,11 @@ fn unsafe_get_cursor() -> ResultType<Option<u64>> {
|
||||
|
||||
pub fn reset_input_cache() {
|
||||
unsafe {
|
||||
LATEST_SEED = (0, 0.0);
|
||||
LATEST_SEED = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn get_cursor_id() -> ResultType<(id, u64, f64)> {
|
||||
fn get_cursor_id() -> ResultType<(id, u64)> {
|
||||
unsafe {
|
||||
let c: id = msg_send![class!(NSCursor), currentSystemCursor];
|
||||
if c == nil {
|
||||
@@ -622,8 +620,7 @@ fn get_cursor_id() -> ResultType<(id, u64, f64)> {
|
||||
hcursor += (r + g + b + a) * (255 << i) as f64;
|
||||
}
|
||||
}
|
||||
let scale = cursor::scale()?;
|
||||
Ok((c, cursor::cache_id(hcursor as _, scale), scale))
|
||||
Ok((c, hcursor as _))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,13 +631,10 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
||||
// https://github.com/stweil/OSXvnc/blob/master/OSXvnc-server/mousecursor.c
|
||||
fn unsafe_get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
||||
unsafe {
|
||||
let (c, hcursor2, scale) = get_cursor_id()?;
|
||||
let (c, hcursor2) = get_cursor_id()?;
|
||||
if hcursor != hcursor2 {
|
||||
bail!("cursor changed");
|
||||
}
|
||||
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];
|
||||
@@ -674,9 +668,9 @@ 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];
|
||||
colors.push((r * a * 255.).round() as _);
|
||||
colors.push((g * a * 255.).round() as _);
|
||||
colors.push((b * a * 255.).round() as _);
|
||||
colors.push((r * 255.) as _);
|
||||
colors.push((g * 255.) as _);
|
||||
colors.push((b * 255.) as _);
|
||||
colors.push((a * 255.) as _);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
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 {
|
||||
let mut hash = DefaultHasher::new();
|
||||
(cursor, scale.to_bits()).hash(&mut hash);
|
||||
hash.finish()
|
||||
}
|
||||
|
||||
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> {
|
||||
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: slice::from_raw_parts(pixels, length).to_vec().into(),
|
||||
hotx: (hotspot.x * size.width / logical.width).round() as _,
|
||||
hoty: (hotspot.y * size.height / logical.height).round() as _,
|
||||
width: size.width as _,
|
||||
height: size.height as _,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[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 = 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());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_cache_changes_with_display_scale() {
|
||||
assert_ne!(cache_id(123, 1.0), cache_id(123, 2.0));
|
||||
}
|
||||
}
|
||||
@@ -413,7 +413,7 @@ extern "C"
|
||||
{
|
||||
auto in = in0;
|
||||
auto out0_end = out0 + out0_size;
|
||||
auto offset = (width + 2) * 4 + 4;
|
||||
auto offset = width * 4 + 4;
|
||||
auto out = out0 + offset;
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
|
||||
@@ -97,8 +97,6 @@ use windows_service::{
|
||||
use winreg::{enums::*, RegKey};
|
||||
|
||||
mod acl;
|
||||
#[cfg(feature = "flutter")]
|
||||
mod cursor;
|
||||
mod installer_handoff;
|
||||
mod installer_shell;
|
||||
mod msi_registry;
|
||||
@@ -217,14 +215,7 @@ pub fn get_cursor() -> ResultType<Option<u64>> {
|
||||
if ci.flags & CURSOR_SHOWING == 0 {
|
||||
Ok(None)
|
||||
} else {
|
||||
#[cfg(feature = "flutter")]
|
||||
{
|
||||
cursor::current(&ci)
|
||||
}
|
||||
#[cfg(not(feature = "flutter"))]
|
||||
{
|
||||
Ok(Some(ci.hCursor as _))
|
||||
}
|
||||
Ok(Some(ci.hCursor as _))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,10 +260,6 @@ impl Drop for IconInfo {
|
||||
// https://github.com/TurboVNC/tightvnc/blob/a235bae328c12fd1c3aed6f3f034a37a6ffbbd22/vnc_winsrc/winvnc/vncEncoder.cpp
|
||||
// https://github.com/TigerVNC/tigervnc/blob/master/win/rfb_win32/DeviceFrameBuffer.cxx
|
||||
pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
||||
#[cfg(feature = "flutter")]
|
||||
if let Some(data) = cursor::data(hcursor)? {
|
||||
return Ok(data);
|
||||
}
|
||||
unsafe {
|
||||
let mut ii = IconInfo::new(hcursor as _)?;
|
||||
let bm_mask = get_bitmap(ii.0.hbmMask)?;
|
||||
@@ -4763,44 +4750,6 @@ 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.
|
||||
//
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
use super::{drawOutline, handleMask, CursorData};
|
||||
use hbb_common::{anyhow::Context, bail, ResultType};
|
||||
use scrap::dxgi::cursor::{self, Shape, Snapshot, CURSOR_ID_FLAG};
|
||||
use winapi::{
|
||||
shared::dxgi1_2::{
|
||||
DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR, DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME,
|
||||
},
|
||||
um::winuser::{MonitorFromPoint, CURSORINFO, MONITOR_DEFAULTTONULL},
|
||||
};
|
||||
|
||||
const CHANNELS: usize = 4;
|
||||
const BORDER: i32 = 1;
|
||||
|
||||
pub(super) fn current(info: &CURSORINFO) -> ResultType<Option<u64>> {
|
||||
let monitor = unsafe { MonitorFromPoint(info.ptScreenPos, MONITOR_DEFAULTTONULL) };
|
||||
match cursor::snapshot(monitor as usize) {
|
||||
Snapshot::Unavailable => Ok(Some(info.hCursor as usize as u32 as u64)),
|
||||
Snapshot::Pending => Ok(None),
|
||||
Snapshot::Ready(shape) => Ok(Some(shape.id)),
|
||||
Snapshot::Failed(error) => bail!("DXGI cursor capture: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn data(id: u64) -> ResultType<Option<CursorData>> {
|
||||
if id & CURSOR_ID_FLAG == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let shape = cursor::shape(id).context("DXGI cursor changed before export")?;
|
||||
let (colors, outline) = colors(&shape)?;
|
||||
let data = CursorData {
|
||||
id,
|
||||
colors: colors.into(),
|
||||
width: shape.width as _,
|
||||
height: shape.height as _,
|
||||
hotx: shape.hotspot.0,
|
||||
hoty: shape.hotspot.1,
|
||||
..Default::default()
|
||||
};
|
||||
Ok(Some(if outline { outlined(data)? } else { data }))
|
||||
}
|
||||
|
||||
fn colors(shape: &Shape) -> ResultType<(Vec<u8>, bool)> {
|
||||
let length = (shape.width as usize)
|
||||
.checked_mul(shape.height as usize)
|
||||
.and_then(|pixels| pixels.checked_mul(CHANNELS))
|
||||
.context("Cursor size overflow")?;
|
||||
if shape.kind == DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME {
|
||||
let mut colors = vec![0; length];
|
||||
let outline = unsafe {
|
||||
handleMask(
|
||||
colors.as_mut_ptr(),
|
||||
shape.pixels.as_ptr(),
|
||||
shape.width as _,
|
||||
shape.height as _,
|
||||
shape.pitch as _,
|
||||
(shape.height * 2) as _,
|
||||
)
|
||||
} > 0;
|
||||
return Ok((colors, outline));
|
||||
}
|
||||
let mut colors = Vec::with_capacity(length);
|
||||
let mut outline = false;
|
||||
for row in shape.pixels.chunks_exact(shape.pitch as usize) {
|
||||
for pixel in row[..shape.width as usize * CHANNELS].chunks_exact(CHANNELS) {
|
||||
let (rgba, xor) = rgba(pixel, shape.kind);
|
||||
outline |= xor;
|
||||
colors.extend_from_slice(&rgba);
|
||||
}
|
||||
}
|
||||
Ok((colors, outline))
|
||||
}
|
||||
|
||||
fn rgba(pixel: &[u8], kind: u32) -> ([u8; CHANNELS], bool) {
|
||||
if kind != DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR {
|
||||
return ([pixel[2], pixel[1], pixel[0], pixel[3]], false);
|
||||
}
|
||||
if pixel[3] == 0 {
|
||||
return ([pixel[2], pixel[1], pixel[0], 255], false);
|
||||
}
|
||||
// Match the Win32 exporter's outlined replacement for background-dependent XOR.
|
||||
if pixel[..3].iter().any(|value| *value != 0) {
|
||||
([0, 0, 0, 255], true)
|
||||
} else {
|
||||
([0; CHANNELS], false)
|
||||
}
|
||||
}
|
||||
|
||||
fn outlined(data: CursorData) -> ResultType<CursorData> {
|
||||
let width = data
|
||||
.width
|
||||
.checked_add(BORDER * 2)
|
||||
.context("Cursor width overflow")?;
|
||||
let height = data
|
||||
.height
|
||||
.checked_add(BORDER * 2)
|
||||
.context("Cursor height overflow")?;
|
||||
let length = (width as usize)
|
||||
.checked_mul(height as usize)
|
||||
.and_then(|pixels| pixels.checked_mul(CHANNELS))
|
||||
.context("Cursor size overflow")?;
|
||||
let length_i32 =
|
||||
i32::try_from(length).context("Cursor outline exceeds the native buffer size")?;
|
||||
let mut colors = vec![0; length];
|
||||
unsafe {
|
||||
drawOutline(
|
||||
colors.as_mut_ptr(),
|
||||
data.colors.as_ptr(),
|
||||
data.width,
|
||||
data.height,
|
||||
length_i32,
|
||||
);
|
||||
}
|
||||
Ok(CursorData {
|
||||
colors: colors.into(),
|
||||
width,
|
||||
height,
|
||||
hotx: data.hotx + BORDER,
|
||||
hoty: data.hoty + BORDER,
|
||||
..data
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use winapi::shared::dxgi1_2::DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR;
|
||||
|
||||
#[test]
|
||||
fn physical_cursor_preserves_alpha_and_ignores_row_padding() {
|
||||
let shape = Shape {
|
||||
id: CURSOR_ID_FLAG,
|
||||
kind: DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR,
|
||||
width: 1,
|
||||
height: 2,
|
||||
pitch: 8,
|
||||
hotspot: (0, 1),
|
||||
pixels: vec![
|
||||
32, 64, 128, 128, 255, 255, 255, 255, 1, 2, 3, 255, 255, 255, 255, 255,
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
colors(&shape).unwrap(),
|
||||
(vec![128, 64, 32, 128, 3, 2, 1, 255], false)
|
||||
);
|
||||
let masked = Shape {
|
||||
kind: DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR,
|
||||
pixels: vec![0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 0, 0, 0, 0],
|
||||
..shape
|
||||
};
|
||||
assert_eq!(
|
||||
colors(&masked).unwrap(),
|
||||
(vec![0, 0, 0, 255, 0, 0, 0, 255], true)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod cursor;
|
||||
|
||||
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_*).
|
||||
@@ -632,19 +630,6 @@ async fn recv_thread(
|
||||
let _ = tx.send(Err(err));
|
||||
return;
|
||||
}
|
||||
let mut mutter_cursor = match cursor::Capture::start(
|
||||
display,
|
||||
cursor_epoch,
|
||||
displays[wire_idx].name.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => cursor,
|
||||
Err(error) => {
|
||||
log::error!("drm: could not start Mutter cursor capture; using wire cursor: {error:#}");
|
||||
None
|
||||
}
|
||||
};
|
||||
let _ = tx.send(Ok((displays, wire_idx)));
|
||||
|
||||
// A cursor that arrived before new() stored the session transform, held for replay. Only the
|
||||
@@ -654,21 +639,9 @@ async fn recv_thread(
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break "stopped".to_owned();
|
||||
}
|
||||
if let Some(error) = mutter_cursor.as_ref().and_then(cursor::Capture::error) {
|
||||
log::error!("drm: {error}; resuming wire cursor capture");
|
||||
if let Some(mut cursor) = mutter_cursor.take() {
|
||||
if let Some(wire) = cursor.wire_cursor.take() {
|
||||
pending_cursor = Some(wire);
|
||||
}
|
||||
if let Err(error) = cursor.stop().await {
|
||||
log::error!("drm: could not join the Mutter cursor worker: {error:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if pending_cursor.is_some() {
|
||||
let ready = mutter_cursor.as_ref().map(cursor::Capture::ready);
|
||||
let t = shared.transform.load(std::sync::atomic::Ordering::Acquire);
|
||||
if t != TRANSFORM_PENDING && ready.as_deref() != Some(&true) {
|
||||
if t != TRANSFORM_PENDING {
|
||||
if let Some((id, width, height, hotx, hoty, raw)) = pending_cursor.take() {
|
||||
deliver_drm_cursor(display, cursor_epoch, id, width, height, hotx, hoty, raw, t);
|
||||
}
|
||||
@@ -790,15 +763,6 @@ async fn recv_thread(
|
||||
raw.len()
|
||||
);
|
||||
}
|
||||
// Retain the latest wire shape for a failed Mutter worker, even when
|
||||
// that shape arrived before the first Mutter sprite.
|
||||
if let Some(cursor) = mutter_cursor.as_mut() {
|
||||
cursor.wire_cursor = Some((id, width, height, hotx, hoty, raw.clone()));
|
||||
}
|
||||
let ready = mutter_cursor.as_ref().map(cursor::Capture::ready);
|
||||
if ready.as_deref() == Some(&true) {
|
||||
continue;
|
||||
}
|
||||
let t = shared.transform.load(std::sync::atomic::Ordering::Acquire);
|
||||
if t == TRANSFORM_PENDING {
|
||||
pending_cursor = Some((id, width, height, hotx, hoty, raw));
|
||||
@@ -894,11 +858,6 @@ async fn recv_thread(
|
||||
// Drop the render context on THIS thread: its EGL state + cached imports are thread-local and
|
||||
// a cross-thread close strands them. Never in `Drop`, which runs on the encoder thread.
|
||||
drop(converter);
|
||||
if let Some(cursor) = mutter_cursor {
|
||||
if let Err(error) = cursor.stop().await {
|
||||
log::error!("drm: could not join the Mutter cursor worker: {error:#}");
|
||||
}
|
||||
}
|
||||
remove_drm_cursor(display, cursor_epoch);
|
||||
let mut slot = shared.slot.lock().unwrap();
|
||||
slot.ended = Some(format!("drm stream ended ({end_reason})"));
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
use super::DrmCursorData;
|
||||
use dbus::{
|
||||
arg::{PropMap, Variant},
|
||||
blocking::Connection,
|
||||
message::MatchRule,
|
||||
Path,
|
||||
};
|
||||
use hbb_common::{anyhow::anyhow, bail, log, tokio, ResultType};
|
||||
use std::{
|
||||
sync::{
|
||||
mpsc::{self, Receiver, Sender, TryRecvError},
|
||||
Arc, Mutex, MutexGuard,
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
mod ffi;
|
||||
mod metadata;
|
||||
mod pipewire;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
const BUS: &str = "org.gnome.Mutter.ScreenCast";
|
||||
const SESSION_INTERFACE: &str = "org.gnome.Mutter.ScreenCast.Session";
|
||||
const STREAM_INTERFACE: &str = "org.gnome.Mutter.ScreenCast.Stream";
|
||||
const CURSOR_METADATA_MODE: u32 = 2;
|
||||
const DBUS_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const START_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(20);
|
||||
|
||||
pub(super) type WireCursor = (u64, u32, u32, i32, i32, Vec<u8>);
|
||||
|
||||
pub struct Capture {
|
||||
stop: Option<Sender<()>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
ready: Arc<Mutex<bool>>,
|
||||
pub(super) wire_cursor: Option<WireCursor>,
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
pub async fn start(display: i32, epoch: u64, connector: String) -> ResultType<Option<Self>> {
|
||||
if !tokio::task::spawn_blocking(mutter_available).await?? {
|
||||
return Ok(None);
|
||||
}
|
||||
let (stop, receiver) = mpsc::channel();
|
||||
let error = Arc::new(Mutex::new(None));
|
||||
let worker_error = error.clone();
|
||||
let ready = Arc::new(Mutex::new(false));
|
||||
let worker_ready = ready.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("drm-cursor".into())
|
||||
.spawn(move || {
|
||||
let result = run(connector, receiver, move |cursor| {
|
||||
publish((display, epoch), &worker_ready, cursor);
|
||||
});
|
||||
if let Err(error) = result {
|
||||
log::error!("drm: Mutter cursor capture failed: {error:#}");
|
||||
*worker_error.lock().unwrap() =
|
||||
Some(format!("Mutter cursor capture: {error:#}"));
|
||||
}
|
||||
})?;
|
||||
Ok(Some(Self {
|
||||
stop: Some(stop),
|
||||
thread: Some(thread),
|
||||
error,
|
||||
ready,
|
||||
wire_cursor: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<String> {
|
||||
self.error.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn ready(&self) -> MutexGuard<'_, bool> {
|
||||
self.ready.lock().unwrap()
|
||||
}
|
||||
|
||||
pub async fn stop(mut self) -> ResultType<()> {
|
||||
drop(self.stop.take());
|
||||
if let Some(thread) = self.thread.take() {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
thread
|
||||
.join()
|
||||
.map_err(|_| anyhow!("Mutter cursor worker panicked"))
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn mutter_available() -> ResultType<bool> {
|
||||
// Service-spawned servers have a session bus, but no XDG_CURRENT_DESKTOP.
|
||||
let conn = Connection::new_session()?;
|
||||
let (available,): (bool,) = conn
|
||||
.with_proxy(
|
||||
"org.freedesktop.DBus",
|
||||
"/org/freedesktop/DBus",
|
||||
DBUS_TIMEOUT,
|
||||
)
|
||||
.method_call("org.freedesktop.DBus", "NameHasOwner", (BUS,))?;
|
||||
Ok(available)
|
||||
}
|
||||
|
||||
fn stopped(receiver: &Receiver<()>) -> bool {
|
||||
!matches!(receiver.try_recv(), Err(TryRecvError::Empty))
|
||||
}
|
||||
|
||||
fn publish(target: (i32, u64), ready: &Mutex<bool>, cursor: DrmCursorData) {
|
||||
// Serialize source switching with wire publication, including pending replay.
|
||||
let mut ready = ready.lock().unwrap();
|
||||
if !*ready {
|
||||
log::info!("drm: using Mutter cursor metadata for display {}", target.0);
|
||||
}
|
||||
*ready = true;
|
||||
// These sprites already have the monitor's upright orientation and physical scale.
|
||||
super::set_drm_cursor(target.0, target.1, cursor);
|
||||
}
|
||||
|
||||
fn run(
|
||||
connector: String,
|
||||
stop: Receiver<()>,
|
||||
publish: impl FnMut(DrmCursorData) + Send + 'static,
|
||||
) -> ResultType<()> {
|
||||
let session = Session::new()?;
|
||||
let Some(node) = session.start(&super::normalize_connector(&connector), &stop)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let stream = pipewire::Stream::new(node, publish)?;
|
||||
let started = Instant::now();
|
||||
while !stopped(&stop) {
|
||||
if !stream.received()? && started.elapsed() >= START_TIMEOUT {
|
||||
bail!("Timed out waiting for PipeWire cursor metadata");
|
||||
}
|
||||
session.conn.process(POLL_INTERVAL)?;
|
||||
}
|
||||
// Drop order closes PipeWire before stopping its Mutter session.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Session {
|
||||
conn: Connection,
|
||||
path: Path<'static>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn new() -> ResultType<Self> {
|
||||
let conn = Connection::new_session()?;
|
||||
let (path,): (Path<'static>,) = conn
|
||||
.with_proxy(BUS, "/org/gnome/Mutter/ScreenCast", DBUS_TIMEOUT)
|
||||
.method_call(BUS, "CreateSession", (PropMap::new(),))?;
|
||||
Ok(Self { conn, path })
|
||||
}
|
||||
|
||||
fn start(&self, connector: &str, stop: &Receiver<()>) -> ResultType<Option<u32>> {
|
||||
let mut options = PropMap::new();
|
||||
options.insert(
|
||||
"cursor-mode".into(),
|
||||
Variant(Box::new(CURSOR_METADATA_MODE)),
|
||||
);
|
||||
let proxy = self.conn.with_proxy(BUS, self.path.clone(), DBUS_TIMEOUT);
|
||||
let (stream,): (Path<'static>,) =
|
||||
proxy.method_call(SESSION_INTERFACE, "RecordMonitor", (connector, options))?;
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let rule = MatchRule::new_signal(STREAM_INTERFACE, "PipeWireStreamAdded")
|
||||
.with_sender(BUS)
|
||||
.with_path(stream);
|
||||
self.conn
|
||||
.add_match(rule, move |(node,): (u32,), _, _| sender.send(node).is_ok())?;
|
||||
proxy.method_call::<(), _, _, _>(SESSION_INTERFACE, "Start", ())?;
|
||||
let started = Instant::now();
|
||||
while !stopped(stop) {
|
||||
match receiver.try_recv() {
|
||||
Ok(node) => return Ok(Some(node)),
|
||||
Err(TryRecvError::Disconnected) => bail!("Mutter cursor node subscription closed"),
|
||||
Err(TryRecvError::Empty) => {}
|
||||
}
|
||||
if started.elapsed() >= START_TIMEOUT {
|
||||
bail!("Timed out waiting for the Mutter cursor node");
|
||||
}
|
||||
self.conn.process(POLL_INTERVAL)?;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
let result: Result<(), _> = self
|
||||
.conn
|
||||
.with_proxy(BUS, self.path.clone(), DBUS_TIMEOUT)
|
||||
.method_call(SESSION_INTERFACE, "Stop", ());
|
||||
if let Err(error) = result {
|
||||
log::error!("drm: could not stop the Mutter cursor session: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
// The stable PipeWire 0.3/SPA C ABI; load it at runtime like the DRM capture library.
|
||||
use hbb_common::{anyhow::anyhow, libloading::Library, ResultType};
|
||||
use std::{
|
||||
ffi::{c_char, c_int, c_void},
|
||||
mem::size_of,
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
pub type Handle = *mut c_void;
|
||||
pub const META_CURSOR: u32 = 5;
|
||||
pub const PARAM_FORMAT: u32 = 4;
|
||||
pub const STREAM_ERROR: c_int = -1;
|
||||
pub const STREAM_UNCONNECTED: c_int = 0;
|
||||
pub const DIRECTION_INPUT: c_int = 0;
|
||||
pub const AUTOCONNECT: u32 = 1;
|
||||
pub const DONT_RECONNECT: u32 = 1 << 7;
|
||||
const PARAM_ENUM_FORMAT: u32 = 3;
|
||||
const PARAM_META: u32 = 6;
|
||||
const TYPE_ID: u32 = 3;
|
||||
const TYPE_INT: u32 = 4;
|
||||
const TYPE_OBJECT: u32 = 15;
|
||||
const TYPE_CHOICE: u32 = 19;
|
||||
const CHOICE_RANGE: u32 = 1;
|
||||
const OBJECT_FORMAT: u32 = 0x40003;
|
||||
const OBJECT_META: u32 = 0x40005;
|
||||
const FORMAT_MEDIA_TYPE: u32 = 1;
|
||||
const FORMAT_MEDIA_SUBTYPE: u32 = 2;
|
||||
const FORMAT_VIDEO: u32 = 0x20001;
|
||||
const MEDIA_VIDEO: u32 = 2;
|
||||
const MEDIA_RAW: u32 = 1;
|
||||
const VIDEO_BGRA: u32 = 12;
|
||||
const META_TYPE: u32 = 1;
|
||||
const META_SIZE: u32 = 2;
|
||||
|
||||
macro_rules! api {
|
||||
($($name:ident: $signature:ty),* $(,)?) => {
|
||||
pub struct Api {
|
||||
_library: Library,
|
||||
$(pub $name: $signature,)*
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub fn get() -> ResultType<&'static Self> {
|
||||
static API: OnceLock<Result<Api, String>> = OnceLock::new();
|
||||
API.get_or_init(|| unsafe { Self::load() }.map_err(|e| e.to_string()))
|
||||
.as_ref().map_err(|e| anyhow!("PipeWire cursor library: {e}"))
|
||||
}
|
||||
|
||||
unsafe fn load() -> ResultType<Self> {
|
||||
let library = Library::new("libpipewire-0.3.so.0")?;
|
||||
let api = Self {
|
||||
$($name: *library.get(concat!(stringify!($name), "\0").as_bytes())?,)*
|
||||
_library: library,
|
||||
};
|
||||
(api.pw_init)(std::ptr::null_mut(), std::ptr::null_mut());
|
||||
Ok(api)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
api! {
|
||||
pw_init: unsafe extern "C" fn(*mut c_int, *mut *mut *mut c_char),
|
||||
pw_thread_loop_new: unsafe extern "C" fn(*const c_char, Handle) -> Handle,
|
||||
pw_thread_loop_get_loop: unsafe extern "C" fn(Handle) -> Handle,
|
||||
pw_thread_loop_start: unsafe extern "C" fn(Handle) -> c_int,
|
||||
pw_thread_loop_stop: unsafe extern "C" fn(Handle),
|
||||
pw_thread_loop_destroy: unsafe extern "C" fn(Handle),
|
||||
pw_thread_loop_lock: unsafe extern "C" fn(Handle),
|
||||
pw_thread_loop_unlock: unsafe extern "C" fn(Handle),
|
||||
pw_properties_new_string: unsafe extern "C" fn(*const c_char) -> Handle,
|
||||
pw_stream_new_simple: unsafe extern "C" fn(Handle, *const c_char, Handle, *const Events, Handle) -> Handle,
|
||||
pw_stream_connect: unsafe extern "C" fn(Handle, c_int, u32, u32, *const *const Pod, u32) -> c_int,
|
||||
pw_stream_update_params: unsafe extern "C" fn(Handle, *const *const Pod, u32) -> c_int,
|
||||
pw_stream_dequeue_buffer: unsafe extern "C" fn(Handle) -> *mut PwBuffer,
|
||||
pw_stream_queue_buffer: unsafe extern "C" fn(Handle, *mut PwBuffer) -> c_int,
|
||||
pw_stream_destroy: unsafe extern "C" fn(Handle),
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct Events {
|
||||
pub version: u32,
|
||||
pub destroy: Option<unsafe extern "C" fn()>,
|
||||
pub state_changed: Option<unsafe extern "C" fn(Handle, c_int, c_int, *const c_char)>,
|
||||
pub control_info: Option<unsafe extern "C" fn()>,
|
||||
pub io_changed: Option<unsafe extern "C" fn()>,
|
||||
pub param_changed: Option<unsafe extern "C" fn(Handle, u32, *const Pod)>,
|
||||
pub add_buffer: Option<unsafe extern "C" fn()>,
|
||||
pub remove_buffer: Option<unsafe extern "C" fn()>,
|
||||
pub process: Option<unsafe extern "C" fn(Handle)>,
|
||||
pub drained: Option<unsafe extern "C" fn()>,
|
||||
pub command: Option<unsafe extern "C" fn()>,
|
||||
pub trigger_done: Option<unsafe extern "C" fn()>,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct PwBuffer {
|
||||
pub buffer: *const Buffer,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct Buffer {
|
||||
pub n_metas: u32,
|
||||
pub n_datas: u32,
|
||||
pub metas: *const Meta,
|
||||
pub datas: Handle,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct Meta {
|
||||
pub kind: u32,
|
||||
pub size: u32,
|
||||
pub data: Handle,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct Pod {
|
||||
size: u32,
|
||||
kind: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct Property {
|
||||
key: u32,
|
||||
flags: u32,
|
||||
pod: Pod,
|
||||
value: u32,
|
||||
padding: u32,
|
||||
}
|
||||
|
||||
impl Property {
|
||||
fn new(key: u32, kind: u32, value: u32) -> Self {
|
||||
Self {
|
||||
key,
|
||||
flags: 0,
|
||||
pod: Pod {
|
||||
size: size_of::<u32>() as u32,
|
||||
kind,
|
||||
},
|
||||
value,
|
||||
padding: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C, align(8))]
|
||||
pub struct Object<const N: usize> {
|
||||
pub pod: Pod,
|
||||
kind: u32,
|
||||
id: u32,
|
||||
properties: [Property; N],
|
||||
}
|
||||
|
||||
impl<const N: usize> Object<N> {
|
||||
fn new(kind: u32, id: u32, properties: [Property; N]) -> Self {
|
||||
Self {
|
||||
pod: Pod {
|
||||
size: (size_of::<Self>() - size_of::<Pod>()) as u32,
|
||||
kind: TYPE_OBJECT,
|
||||
},
|
||||
kind,
|
||||
id,
|
||||
properties,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn video_format() -> Object<3> {
|
||||
Object::new(
|
||||
OBJECT_FORMAT,
|
||||
PARAM_ENUM_FORMAT,
|
||||
[
|
||||
Property::new(FORMAT_MEDIA_TYPE, TYPE_ID, MEDIA_VIDEO),
|
||||
Property::new(FORMAT_MEDIA_SUBTYPE, TYPE_ID, MEDIA_RAW),
|
||||
Property::new(FORMAT_VIDEO, TYPE_ID, VIDEO_BGRA),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct SizeRange {
|
||||
key: u32,
|
||||
flags: u32,
|
||||
pod: Pod,
|
||||
choice: u32,
|
||||
choice_flags: u32,
|
||||
child: Pod,
|
||||
values: [u32; 3],
|
||||
padding: u32,
|
||||
}
|
||||
|
||||
#[repr(C, align(8))]
|
||||
pub struct CursorMeta {
|
||||
pub pod: Pod,
|
||||
kind: u32,
|
||||
id: u32,
|
||||
meta_type: Property,
|
||||
size: SizeRange,
|
||||
}
|
||||
|
||||
pub fn cursor_meta() -> CursorMeta {
|
||||
CursorMeta {
|
||||
pod: Pod {
|
||||
size: (size_of::<CursorMeta>() - size_of::<Pod>()) as u32,
|
||||
kind: TYPE_OBJECT,
|
||||
},
|
||||
kind: OBJECT_META,
|
||||
id: PARAM_META,
|
||||
meta_type: Property::new(META_TYPE, TYPE_ID, META_CURSOR),
|
||||
size: SizeRange {
|
||||
key: META_SIZE,
|
||||
flags: 0,
|
||||
pod: Pod {
|
||||
size: (size_of::<[u32; 2]>() + size_of::<Pod>() + size_of::<[u32; 3]>()) as u32,
|
||||
kind: TYPE_CHOICE,
|
||||
},
|
||||
choice: CHOICE_RANGE,
|
||||
choice_flags: 0,
|
||||
child: Pod {
|
||||
size: size_of::<u32>() as u32,
|
||||
kind: TYPE_INT,
|
||||
},
|
||||
// Older Mutter allocates 64x64, newer versions 384x384. Let the producer choose.
|
||||
values: [
|
||||
super::metadata::META_BYTES,
|
||||
super::metadata::META_HEADER_BYTES,
|
||||
i32::MAX as u32,
|
||||
],
|
||||
padding: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
use super::DrmCursorData;
|
||||
use std::{
|
||||
collections::hash_map::DefaultHasher,
|
||||
hash::{Hash, Hasher},
|
||||
io,
|
||||
mem::size_of,
|
||||
};
|
||||
|
||||
const RGBA: u32 = 11;
|
||||
const PIXEL_BYTES: usize = 4;
|
||||
const CURSOR_WORDS: usize = 7;
|
||||
const BITMAP_WORDS: usize = 5;
|
||||
const CURSOR_BYTES: usize = CURSOR_WORDS * size_of::<u32>();
|
||||
const BITMAP_BYTES: usize = BITMAP_WORDS * size_of::<u32>();
|
||||
pub const META_HEADER_BYTES: u32 = (CURSOR_BYTES + BITMAP_BYTES) as u32;
|
||||
// Preferred allocation; negotiation accepts the compositor's metadata size.
|
||||
const CURSOR_META_SIDE: usize = 384;
|
||||
pub const META_BYTES: u32 =
|
||||
META_HEADER_BYTES + (CURSOR_META_SIDE * CURSOR_META_SIDE * PIXEL_BYTES) as u32;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CursorState {
|
||||
image: Option<DrmCursorData>,
|
||||
published: Option<u64>,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
pub fn update(&mut self, data: &[u8]) -> io::Result<Option<DrmCursorData>> {
|
||||
// Mutter uses id 0 outside this monitor or while the pointer is hidden. Reentry may
|
||||
// contain only a position, so retain the sprite while publishing the hidden sentinel.
|
||||
if words::<CURSOR_WORDS>(data)?[0] == 0 {
|
||||
if self.published == Some(scrap::drm_reader::HIDDEN_CURSOR_ID) {
|
||||
return Ok(None);
|
||||
}
|
||||
self.published = Some(scrap::drm_reader::HIDDEN_CURSOR_ID);
|
||||
return Ok(Some(hidden_cursor()));
|
||||
}
|
||||
if let Some(cursor) = decode(data)? {
|
||||
self.image = Some(cursor);
|
||||
}
|
||||
if let Some(cursor) = self.image.as_ref() {
|
||||
if self.published != Some(cursor.id) {
|
||||
self.published = Some(cursor.id);
|
||||
return Ok(Some(cursor.clone()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn hidden_cursor() -> DrmCursorData {
|
||||
DrmCursorData {
|
||||
id: scrap::drm_reader::HIDDEN_CURSOR_ID,
|
||||
width: 1,
|
||||
height: 1,
|
||||
hotx: 0,
|
||||
hoty: 0,
|
||||
colors: vec![0; PIXEL_BYTES],
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
|
||||
fn words<const N: usize>(data: &[u8]) -> io::Result<[u32; N]> {
|
||||
let bytes = data
|
||||
.get(..N * size_of::<u32>())
|
||||
.ok_or_else(|| invalid("Truncated PipeWire cursor metadata"))?;
|
||||
let mut values = [0; N];
|
||||
for (value, bytes) in values.iter_mut().zip(bytes.chunks_exact(size_of::<u32>())) {
|
||||
*value = u32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn decode(data: &[u8]) -> io::Result<Option<DrmCursorData>> {
|
||||
let [id, _flags, _x, _y, hotx, hoty, offset] = words::<CURSOR_WORDS>(data)?;
|
||||
// Position-only updates have no valid hotspot or bitmap. Keep the previous shape.
|
||||
if id == 0 || offset == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if (offset as usize) < CURSOR_BYTES {
|
||||
return Err(invalid("PipeWire cursor bitmap overlaps its header"));
|
||||
}
|
||||
let bitmap = data
|
||||
.get(offset as usize..)
|
||||
.ok_or_else(|| invalid("PipeWire cursor bitmap offset exceeds metadata"))?;
|
||||
let [format, width, height, stride, offset] = words::<BITMAP_WORDS>(bitmap)?;
|
||||
if offset == 0 {
|
||||
return Ok(Some(hidden_cursor()));
|
||||
}
|
||||
if format == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut cursor = decode_bitmap(bitmap, [format, width, height, stride, offset])?;
|
||||
cursor.hotx = hotx as i32;
|
||||
cursor.hoty = hoty as i32;
|
||||
let mut hash = DefaultHasher::new();
|
||||
(cursor.width, cursor.height, cursor.hotx, cursor.hoty).hash(&mut hash);
|
||||
cursor.colors.hash(&mut hash);
|
||||
// Mutter reuses id 1 for every sprite, including different hotspots.
|
||||
cursor.id = hash.finish();
|
||||
Ok(Some(cursor))
|
||||
}
|
||||
|
||||
fn decode_bitmap(data: &[u8], header: [u32; BITMAP_WORDS]) -> io::Result<DrmCursorData> {
|
||||
let [format, width, height, stride, offset] = header;
|
||||
if format != RGBA {
|
||||
return Err(invalid("Unsupported PipeWire cursor pixel format"));
|
||||
}
|
||||
if width == 0 || height == 0 || width > i32::MAX as u32 || height > i32::MAX as u32 {
|
||||
return Err(invalid("Invalid PipeWire cursor dimensions"));
|
||||
}
|
||||
let row = (width as usize)
|
||||
.checked_mul(PIXEL_BYTES)
|
||||
.ok_or_else(|| invalid("PipeWire cursor row overflows"))?;
|
||||
if (stride as i32) <= 0 || (stride as usize) < row || (offset as usize) < BITMAP_BYTES {
|
||||
return Err(invalid("Invalid PipeWire cursor stride or pixel offset"));
|
||||
}
|
||||
let length = (stride as usize)
|
||||
.checked_mul(height as usize - 1)
|
||||
.and_then(|n| n.checked_add(row))
|
||||
.ok_or_else(|| invalid("PipeWire cursor bitmap size overflows"))?;
|
||||
let pixels = data
|
||||
.get(offset as usize..)
|
||||
.and_then(|bytes| bytes.get(..length))
|
||||
.ok_or_else(|| invalid("Truncated PipeWire cursor pixels"))?;
|
||||
let mut colors = Vec::with_capacity(row * height as usize);
|
||||
for bytes in pixels.chunks(stride as usize) {
|
||||
colors.extend_from_slice(&bytes[..row]);
|
||||
}
|
||||
Ok(DrmCursorData {
|
||||
id: 0,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
hotx: 0,
|
||||
hoty: 0,
|
||||
colors,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn packet(hotspot: [u32; 2]) -> Vec<u8> {
|
||||
let mut bytes: Vec<_> = [
|
||||
1,
|
||||
0,
|
||||
100,
|
||||
200,
|
||||
hotspot[0],
|
||||
hotspot[1],
|
||||
CURSOR_BYTES as u32,
|
||||
RGBA,
|
||||
96,
|
||||
96,
|
||||
96 * PIXEL_BYTES as u32,
|
||||
BITMAP_BYTES as u32,
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(u32::to_ne_bytes)
|
||||
.collect();
|
||||
bytes.resize(CURSOR_BYTES + BITMAP_BYTES + 96 * 96 * PIXEL_BYTES, 0);
|
||||
let artwork = CURSOR_BYTES + BITMAP_BYTES + (4 * 96 + 4) * PIXEL_BYTES;
|
||||
bytes[artwork..artwork + PIXEL_BYTES].copy_from_slice(&[128, 64, 32, 128]);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compositor_hotspot_is_independent_of_visible_bounds_and_id() {
|
||||
let bytes = packet([42, 42]);
|
||||
let cross = decode(&bytes).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
(cross.width, cross.height, cross.hotx, cross.hoty),
|
||||
(96, 96, 42, 42)
|
||||
);
|
||||
assert_eq!(&cross.colors, &bytes[CURSOR_BYTES + BITMAP_BYTES..]);
|
||||
let origin = decode(&packet([0, 0])).unwrap().unwrap();
|
||||
assert_eq!((origin.hotx, origin.hoty), (0, 0));
|
||||
assert_ne!(cross.id, origin.id);
|
||||
let mut changed = bytes.clone();
|
||||
*changed.last_mut().unwrap() = 255;
|
||||
assert_ne!(cross.id, decode(&changed).unwrap().unwrap().id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_keeps_the_shape_and_empty_bitmap_hides_it() {
|
||||
let mut bytes = packet([42, 45]);
|
||||
bytes[CURSOR_BYTES - size_of::<u32>()..CURSOR_BYTES].fill(0);
|
||||
assert!(decode(&bytes).unwrap().is_none());
|
||||
bytes[..size_of::<u32>()].fill(0);
|
||||
assert!(decode(&bytes).unwrap().is_none());
|
||||
let mut hidden = packet([42, 45]);
|
||||
hidden[CURSOR_BYTES..CURSOR_BYTES + BITMAP_BYTES].fill(0);
|
||||
let hidden = decode(&hidden).unwrap().unwrap();
|
||||
assert_eq!(hidden.id, scrap::drm_reader::HIDDEN_CURSOR_ID);
|
||||
assert_eq!(hidden.colors, [0; PIXEL_BYTES]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_and_returning_to_a_monitor_restores_the_cached_sprite() {
|
||||
let mut state = CursorState::default();
|
||||
let mut bytes = packet([42, 42]);
|
||||
let initial = state.update(&bytes).unwrap().unwrap();
|
||||
bytes[..size_of::<u32>()].fill(0);
|
||||
let hidden = state
|
||||
.update(&bytes)
|
||||
.unwrap()
|
||||
.expect("Mutter id 0 hides the cursor");
|
||||
assert_eq!(hidden.id, scrap::drm_reader::HIDDEN_CURSOR_ID);
|
||||
assert!(state.update(&bytes).unwrap().is_none());
|
||||
bytes[..size_of::<u32>()].copy_from_slice(&1u32.to_ne_bytes());
|
||||
bytes[CURSOR_BYTES - size_of::<u32>()..CURSOR_BYTES].fill(0);
|
||||
let restored = state
|
||||
.update(&bytes)
|
||||
.unwrap()
|
||||
.expect("Position-only reentry restores the sprite");
|
||||
assert_eq!(
|
||||
(restored.id, restored.hotx, restored.hoty),
|
||||
(initial.id, 42, 42)
|
||||
);
|
||||
assert_eq!(restored.colors, initial.colors);
|
||||
assert!(state.update(&bytes).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_metadata_is_an_error() {
|
||||
let bytes = packet([12, 3]);
|
||||
for length in [
|
||||
0,
|
||||
CURSOR_BYTES - 1,
|
||||
CURSOR_BYTES + BITMAP_BYTES - 1,
|
||||
bytes.len() - 1,
|
||||
] {
|
||||
assert!(decode(&bytes[..length]).is_err());
|
||||
}
|
||||
for (word, value) in [
|
||||
(6, 1),
|
||||
(6, u32::MAX),
|
||||
(7, 99),
|
||||
(8, 0),
|
||||
(9, u32::MAX),
|
||||
(10, 1),
|
||||
(10, u32::MAX),
|
||||
(11, 1),
|
||||
] {
|
||||
let mut invalid = bytes.clone();
|
||||
invalid[word * size_of::<u32>()..(word + 1) * size_of::<u32>()]
|
||||
.copy_from_slice(&value.to_ne_bytes());
|
||||
assert!(decode(&invalid).is_err(), "word {word}, value {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
use super::{ffi, metadata, DrmCursorData};
|
||||
use hbb_common::{anyhow::anyhow, bail, ResultType};
|
||||
use std::{
|
||||
cell::UnsafeCell,
|
||||
ffi::{c_char, c_int, CStr},
|
||||
ptr, slice,
|
||||
};
|
||||
|
||||
struct State {
|
||||
api: &'static ffi::Api,
|
||||
stream: ffi::Handle,
|
||||
publish: Box<dyn FnMut(DrmCursorData) + Send>,
|
||||
error: Option<String>,
|
||||
received: bool,
|
||||
cursor: metadata::CursorState,
|
||||
}
|
||||
|
||||
pub struct Stream {
|
||||
api: &'static ffi::Api,
|
||||
thread: ffi::Handle,
|
||||
// PipeWire callbacks own this state while holding the thread-loop lock.
|
||||
state: Box<UnsafeCell<State>>,
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
pub fn new(node: u32, publish: impl FnMut(DrmCursorData) + Send + 'static) -> ResultType<Self> {
|
||||
let api = ffi::Api::get()?;
|
||||
let thread = unsafe {
|
||||
(api.pw_thread_loop_new)(b"rustdesk-cursor\0".as_ptr().cast(), ptr::null_mut())
|
||||
};
|
||||
if thread.is_null() {
|
||||
bail!("Could not create the PipeWire cursor loop");
|
||||
}
|
||||
let mut stream = Self {
|
||||
api,
|
||||
thread,
|
||||
started: false,
|
||||
state: Box::new(UnsafeCell::new(State {
|
||||
api,
|
||||
stream: ptr::null_mut(),
|
||||
publish: Box::new(publish),
|
||||
error: None,
|
||||
received: false,
|
||||
cursor: metadata::CursorState::default(),
|
||||
})),
|
||||
};
|
||||
stream.connect(node)?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn connect(&mut self, node: u32) -> ResultType<()> {
|
||||
// The loop has not started; callbacks during setup run synchronously on this thread.
|
||||
unsafe {
|
||||
let properties = (self.api.pw_properties_new_string)(
|
||||
b"media.type=Video media.category=Capture media.role=Screen\0"
|
||||
.as_ptr()
|
||||
.cast(),
|
||||
);
|
||||
if properties.is_null() {
|
||||
bail!("Could not create PipeWire cursor properties");
|
||||
}
|
||||
let stream = (self.api.pw_stream_new_simple)(
|
||||
(self.api.pw_thread_loop_get_loop)(self.thread),
|
||||
b"RustDesk cursor\0".as_ptr().cast(),
|
||||
properties,
|
||||
&EVENTS,
|
||||
self.state.get().cast(),
|
||||
);
|
||||
(*self.state.get()).stream = stream;
|
||||
if stream.is_null() {
|
||||
bail!("Could not create the PipeWire cursor stream");
|
||||
}
|
||||
let format = ffi::video_format();
|
||||
let params = [&format.pod as *const _];
|
||||
check((self.api.pw_stream_connect)(
|
||||
stream,
|
||||
ffi::DIRECTION_INPUT,
|
||||
node,
|
||||
ffi::AUTOCONNECT | ffi::DONT_RECONNECT,
|
||||
params.as_ptr(),
|
||||
params.len() as u32,
|
||||
))?;
|
||||
check((self.api.pw_thread_loop_start)(self.thread))?;
|
||||
self.started = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn received(&self) -> ResultType<bool> {
|
||||
unsafe {
|
||||
(self.api.pw_thread_loop_lock)(self.thread);
|
||||
let state = &*self.state.get();
|
||||
let result = match &state.error {
|
||||
Some(error) => Err(anyhow!("PipeWire cursor: {error}")),
|
||||
None => Ok(state.received),
|
||||
};
|
||||
(self.api.pw_thread_loop_unlock)(self.thread);
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Stream {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
if self.started {
|
||||
(self.api.pw_thread_loop_stop)(self.thread);
|
||||
}
|
||||
let stream = (*self.state.get()).stream;
|
||||
if !stream.is_null() {
|
||||
(self.api.pw_stream_destroy)(stream);
|
||||
}
|
||||
(self.api.pw_thread_loop_destroy)(self.thread);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check(result: c_int) -> ResultType<()> {
|
||||
if result < 0 {
|
||||
bail!("{}", std::io::Error::from_raw_os_error(-result));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe extern "C" fn state_changed(
|
||||
data: ffi::Handle,
|
||||
old: c_int,
|
||||
state: c_int,
|
||||
error: *const c_char,
|
||||
) {
|
||||
let context = &mut *data.cast::<State>();
|
||||
if state == ffi::STREAM_ERROR || (state == ffi::STREAM_UNCONNECTED && old != state) {
|
||||
context.error = Some(if error.is_null() {
|
||||
"Cursor stream disconnected".to_owned()
|
||||
} else {
|
||||
CStr::from_ptr(error).to_string_lossy().into_owned()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn param_changed(data: ffi::Handle, id: u32, param: *const ffi::Pod) {
|
||||
if id != ffi::PARAM_FORMAT || param.is_null() {
|
||||
return;
|
||||
}
|
||||
let context = data.cast::<State>();
|
||||
let meta = ffi::cursor_meta();
|
||||
let params = [&meta.pod as *const _];
|
||||
if let Err(error) = check(((*context).api.pw_stream_update_params)(
|
||||
(*context).stream,
|
||||
params.as_ptr(),
|
||||
params.len() as u32,
|
||||
)) {
|
||||
(*context).error = Some(error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn cursor(
|
||||
decoder: &mut metadata::CursorState,
|
||||
buffer: *const ffi::Buffer,
|
||||
) -> ResultType<Option<DrmCursorData>> {
|
||||
let buffer = buffer
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Missing PipeWire buffer"))?;
|
||||
if buffer.metas.is_null() {
|
||||
bail!("PipeWire did not negotiate cursor metadata");
|
||||
}
|
||||
let metas = slice::from_raw_parts(buffer.metas, buffer.n_metas as usize);
|
||||
let meta = metas
|
||||
.iter()
|
||||
.find(|meta| meta.kind == ffi::META_CURSOR)
|
||||
.ok_or_else(|| anyhow!("PipeWire did not negotiate cursor metadata"))?;
|
||||
if meta.data.is_null() {
|
||||
bail!("Missing PipeWire cursor metadata payload");
|
||||
}
|
||||
Ok(decoder.update(slice::from_raw_parts(meta.data.cast(), meta.size as usize))?)
|
||||
}
|
||||
|
||||
unsafe extern "C" fn process(data: ffi::Handle) {
|
||||
let state = data.cast::<State>();
|
||||
let buffer = ((*state).api.pw_stream_dequeue_buffer)((*state).stream);
|
||||
if buffer.is_null() {
|
||||
return;
|
||||
}
|
||||
let result = cursor(&mut (*state).cursor, (*buffer).buffer);
|
||||
let queued = check(((*state).api.pw_stream_queue_buffer)(
|
||||
(*state).stream,
|
||||
buffer,
|
||||
));
|
||||
let context = &mut *state;
|
||||
if context.error.is_some() {
|
||||
return;
|
||||
}
|
||||
match result.and_then(|cursor| queued.map(|_| cursor)) {
|
||||
Ok(cursor) => {
|
||||
context.received = true;
|
||||
if let Some(cursor) = cursor {
|
||||
(context.publish)(cursor);
|
||||
}
|
||||
}
|
||||
Err(error) => context.error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
static EVENTS: ffi::Events = ffi::Events {
|
||||
version: 2,
|
||||
destroy: None,
|
||||
state_changed: Some(state_changed),
|
||||
control_info: None,
|
||||
io_changed: None,
|
||||
param_changed: Some(param_changed),
|
||||
add_buffer: None,
|
||||
remove_buffer: None,
|
||||
process: Some(process),
|
||||
drained: None,
|
||||
command: None,
|
||||
trigger_done: None,
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
fn capture() -> Capture {
|
||||
Capture {
|
||||
stop: None,
|
||||
thread: None,
|
||||
error: Arc::new(Mutex::new(None)),
|
||||
ready: Arc::new(Mutex::new(false)),
|
||||
wire_cursor: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sprite(id: u64) -> DrmCursorData {
|
||||
DrmCursorData {
|
||||
id,
|
||||
width: 1,
|
||||
height: 1,
|
||||
hotx: 0,
|
||||
hoty: 0,
|
||||
colors: vec![255; 4],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_cursor_remains_active_until_a_sprite_is_published() {
|
||||
let capture = capture();
|
||||
assert!(!*capture.ready());
|
||||
let target = (-1614401, 1);
|
||||
publish(target, &capture.ready, sprite(7));
|
||||
assert!(*capture.ready());
|
||||
let mut cursors = super::super::DRM_CURSOR.lock().unwrap();
|
||||
assert_eq!(cursors.remove(&target.0).unwrap().1.id, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_publication_cannot_overwrite_the_first_mutter_sprite() {
|
||||
let capture = capture();
|
||||
let target = (-1614402, 1);
|
||||
let ready = capture.ready();
|
||||
assert!(!*ready);
|
||||
let worker_ready = capture.ready.clone();
|
||||
let (started, receiver) = mpsc::channel();
|
||||
let worker = thread::spawn(move || {
|
||||
started.send(()).unwrap();
|
||||
publish(target, &worker_ready, sprite(9));
|
||||
});
|
||||
receiver.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
super::super::set_drm_cursor(target.0, target.1, sprite(8));
|
||||
drop(ready);
|
||||
worker.join().unwrap();
|
||||
assert!(*capture.ready());
|
||||
let mut cursors = super::super::DRM_CURSOR.lock().unwrap();
|
||||
assert_eq!(cursors.remove(&target.0).unwrap().1.id, 9);
|
||||
}
|
||||
Reference in New Issue
Block a user