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

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

View File

@@ -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"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,187 +0,0 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/utils/image.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
const _hotspot = Offset(4, 9);
const _remotePosition = Offset(100.25, 80.75);
const _canvasOffset = Offset(15.125, 10.25);
const _viewport = Size(200, 160);
class _CursorModel extends ChangeNotifier implements CursorModel {
_CursorModel(this.image);
@override
final ui.Image image;
@override
double get hotx => _hotspot.dx;
@override
double get hoty => _hotspot.dy;
@override
double get x => _remotePosition.dx;
@override
double get y => _remotePosition.dy;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _ImageModel extends Fake implements ImageModel {
_ImageModel(this.useTextureRender);
@override
final bool useTextureRender;
}
class _Peer extends Fake implements FfiModel {
@override
final pi = PeerInfo();
@override
bool get isPeerLinux => false;
}
class _FFI extends Fake implements FFI {
_FFI(bool useTexture) : imageModel = _ImageModel(useTexture);
@override
final ImageModel imageModel;
@override
final ffiModel = _Peer();
}
class _CanvasModel extends ChangeNotifier implements CanvasModel {
_CanvasModel(String style, this.scale, bool useTexture)
: _ffi = _FFI(useTexture),
viewStyle = ViewStyle(
style: style,
width: _viewport.width,
height: _viewport.height,
displayWidth: _viewport.width.toInt(),
displayHeight: _viewport.height.toInt(),
);
final FFI _ffi;
@override
WeakReference<FFI> get parent => WeakReference(_ffi);
@override
final imageOverflow = false.obs;
@override
final ViewStyle viewStyle;
@override
double get x => _canvasOffset.dx;
@override
double get y => _canvasOffset.dy;
@override
final double scale;
@override
ScrollStyle get scrollStyle => ScrollStyle.scrollauto;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Canvas extends Fake implements Canvas {
double factor = 1;
Offset? position;
@override
void scale(double sx, [double? sy]) => factor *= sx;
@override
void drawImage(ui.Image image, Offset offset, Paint paint) {
position = offset * factor;
}
}
void main() {
for (final (style, zoom, dpr, source, canvasScale, scale, texture) in [
(kRemoteViewStyleAdaptive, false, 2.0, (48, 64), 0.375, 0.375, true),
(kRemoteViewStyleAdaptive, false, 3.0, (48, 64), 0.25, 0.25, true),
(kRemoteViewStyleAdaptive, true, 3.0, (48, 64), 0.375, 0.375, true),
(kRemoteViewStyleOriginal, false, 2.0, (48, 64), 0.5, 0.5, true),
(kRemoteViewStyleOriginal, true, 2.0, (48, 64), 0.5, 0.5, true),
for (final zoom in [false, true])
for (final scale in [0.25, 2.0])
for (final texture in [false, true])
(kRemoteViewStyleCustom, zoom, 2.0, (48, 64), scale, scale, texture),
(
kRemoteViewStyleAdaptive,
false,
2.0,
(9, 18),
0.1,
Platform.isWindows ? 2 / 3 : 4 / 3,
true
),
(
kRemoteViewStyleAdaptive,
true,
2.0,
(9, 18),
0.1,
Platform.isWindows ? 2 / 3 : 4 / 3,
true
),
(kRemoteViewStyleAdaptive, false, 2.25, (48, 48), 0.375, 0.375, false),
(
kRemoteViewStyleAdaptive,
true,
2.0,
(9, 18),
0.1,
Platform.isWindows ? 2 / 3 : 4 / 3,
false
),
]) {
testWidgets(
'$style zoom=$zoom dpr=$dpr source=$source texture=$texture keeps remote geometry',
(tester) async {
final image = (await tester.runAsync(
() => createTestImage(width: source.$1, height: source.$2)))!;
addTearDown(image.dispose);
await tester.pumpWidget(MediaQuery(
data: MediaQueryData(devicePixelRatio: dpr),
child: MultiProvider(
providers: [
ChangeNotifierProvider<CursorModel>(
create: (_) => _CursorModel(image)),
ChangeNotifierProvider<CanvasModel>(
create: (_) => _CanvasModel(style, canvasScale, texture)),
],
child: CursorPaint(id: 'cursor-test', zoomCursor: zoom.obs),
),
));
final painter = tester
.widget<CustomPaint>(find.byType(CustomPaint))
.painter! as ImagePainter;
expect(painter.image, same(image));
expect(painter.scale, scale);
var imageOrigin = _canvasOffset;
if (!texture) {
final background = _Canvas();
ImagePainter(
image: image,
x: _canvasOffset.dx / canvasScale,
y: _canvasOffset.dy / canvasScale,
scale: canvasScale,
).paint(background, _viewport);
imageOrigin = background.position!;
}
final target = _remotePosition * canvasScale + imageOrigin;
expect((Offset(painter.x, painter.y) + _hotspot) * scale, target);
final canvas = _Canvas();
painter.paint(canvas, _viewport);
final position = canvas.position! + _hotspot * canvas.factor;
expect(position.dx, closeTo(target.dx, 1e-9));
expect(position.dy, closeTo(target.dy, 1e-9));
});
}
}

View File

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

View File

@@ -1,64 +0,0 @@
@TestOn('browser')
library;
import 'dart:convert';
import 'dart:js' as js;
import 'dart:typed_data';
import 'package:flutter_hbb/models/model.dart' as model;
import 'package:flutter_hbb/web/custom_cursor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
class _CursorModel extends Fake implements model.CursorModel {
@override
final Set<String> cachedKeys = {};
@override
void addKey(String key) => cachedKeys.add(key);
}
void main() {
test('Web cursor rounds its bitmap and hotspot together', () async {
Map<String, dynamic>? registered;
final original = js.context['setByName'];
js.context['setByName'] = js.allowInterop((String name, String value) {
expect(name, 'cursor');
registered = jsonDecode(value) as Map<String, dynamic>;
});
addTearDown(() => js.context['setByName'] = original);
const sourceSide = 48;
final image = img.Image(width: sourceSide, height: sourceSide, numChannels: 4);
img.fill(image, color: img.ColorRgba8(255, 255, 255, 255));
for (final (sourceHotspot, scale, outputSide, expectedHotspot) in [
((7.0, 7.0), 633 / 1600, 19, (3, 3)),
((21.0, 23.0), 633 / 1600, 19, (8, 9)),
((22.0, 22.0), 633 / 1600, 19, (9, 9)),
((21.0, 23.0), 0.05, 12, (5, 6)),
((21.0, 23.0), 0.5, 24, (11, 12)),
((7.0, 7.0), 1.0, 48, (7, 7)),
]) {
final cache = model.CursorData(
peerId: 'web-cursor-test',
id: '$sourceHotspot',
image: image,
scale: 1,
data: Uint8List.fromList(img.encodePng(image)),
hotxOrigin: sourceHotspot.$1,
hotyOrigin: sourceHotspot.$2,
width: sourceSide,
height: sourceSide,
);
final cursor = buildCursorOfCache(_CursorModel(), scale, cache);
final session = cursor.createSession(1);
await session.activate();
final uri = Uri.parse(registered!['url'] as String);
final bitmap = img.decodePng(uri.data!.contentAsBytes())!;
expect((bitmap.width, bitmap.height), (outputSide, outputSide));
expect((registered!['hotx'], registered!['hoty']), expectedHotspot);
session.dispose();
await deleteCustomCursor(cache.updateGetKey(scale));
}
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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++)
{

View File

@@ -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.
//

View File

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

View File

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

View File

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

View File

@@ -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,
},
}
}

View File

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

View File

@@ -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,
};

View File

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