feat: Add relative mouse mode (#13928)

* feat: Add relative mouse mode

- Add "Relative Mouse Mode" toggle in desktop toolbar and bind to InputModel
- Implement relative mouse movement path: Flutter pointer deltas -> `type: move_relative` -> new `MOUSE_TYPE_MOVE_RELATIVE` in Rust
- In server input service, simulate relative movement via Enigo and keep latest cursor position in sync
- Track pointer-lock center in Flutter (local widget + screen coordinates) and re-center OS cursor after each relative move
- Update pointer-lock center on window move/resize/restore/maximize and when remote display geometry changes
- Hide local cursor when relative mouse mode is active (both Flutter cursor and OS cursor), restore on leave/disable
- On Windows, clip OS cursor to the window rect while in relative mode and release clip when leaving/turning off
- Implement platform helpers: `get_cursor_pos`, `set_cursor_pos`, `show_cursor`, `clip_cursor` (no-op clip/hide on Linux for now)
- Add keyboard shortcut Ctrl+Alt+Shift+M to toggle relative mode (enabled by default, works on all platforms)
- Remove `enable-relative-mouse-shortcut` config option - shortcut is now always available when keyboard permission is granted
- Handle window blur/focus/minimize events to properly release/restore cursor constraints
- Add MOUSE_TYPE_MASK constant and unit tests for mouse event constants

Note: Relative mouse mode state is NOT persisted to config (session-only).
Note: On Linux, show_cursor and clip_cursor are no-ops; cursor hiding is handled by Flutter side.

Signed-off-by: fufesou <linlong1266@gmail.com>

* feat(mouse): relative mouse mode, exit hint

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(relative mouse): shortcut

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-01-09 10:03:14 +08:00
committed by GitHub
parent 3a9084006f
commit 998b75856d
90 changed files with 3089 additions and 165 deletions

View File

@@ -39,7 +39,7 @@ env:
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
VERSION: "1.4.4" VERSION: "1.4.5"
NDK_VERSION: "r27c" NDK_VERSION: "r27c"
#signing keys env variable checks #signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"

View File

@@ -17,7 +17,7 @@ env:
TAG_NAME: "nightly" TAG_NAME: "nightly"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
VERSION: "1.4.4" VERSION: "1.4.5"
NDK_VERSION: "r26d" NDK_VERSION: "r26d"
#signing keys env variable checks #signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"

View File

@@ -10,6 +10,6 @@ jobs:
- uses: vedantmgoyal9/winget-releaser@main - uses: vedantmgoyal9/winget-releaser@main
with: with:
identifier: RustDesk.RustDesk identifier: RustDesk.RustDesk
version: "1.4.4" version: "1.4.5"
release-tag: "1.4.4" release-tag: "1.4.5"
token: ${{ secrets.WINGET_TOKEN }} token: ${{ secrets.WINGET_TOKEN }}

4
Cargo.lock generated
View File

@@ -7134,7 +7134,7 @@ dependencies = [
[[package]] [[package]]
name = "rustdesk" name = "rustdesk"
version = "1.4.4" version = "1.4.5"
dependencies = [ dependencies = [
"android-wakelock", "android-wakelock",
"android_logger", "android_logger",
@@ -7249,7 +7249,7 @@ dependencies = [
[[package]] [[package]]
name = "rustdesk-portable-packer" name = "rustdesk-portable-packer"
version = "1.4.4" version = "1.4.5"
dependencies = [ dependencies = [
"brotli", "brotli",
"dirs 5.0.1", "dirs 5.0.1",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rustdesk" name = "rustdesk"
version = "1.4.4" version = "1.4.5"
authors = ["rustdesk <info@rustdesk.com>"] authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021" edition = "2021"
build= "build.rs" build= "build.rs"

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk id: rustdesk
name: rustdesk name: rustdesk
icon: rustdesk icon: rustdesk
version: 1.4.4 version: 1.4.5
exec: usr/share/rustdesk/rustdesk exec: usr/share/rustdesk/rustdesk
exec_args: $@ exec_args: $@
apt: apt:

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk id: rustdesk
name: rustdesk name: rustdesk
icon: rustdesk icon: rustdesk
version: 1.4.4 version: 1.4.5
exec: usr/share/rustdesk/rustdesk exec: usr/share/rustdesk/rustdesk
exec_args: $@ exec_args: $@
apt: apt:

View File

@@ -1011,13 +1011,15 @@ makeMobileActionsOverlayEntry(VoidCallback? onHide, {FFI? ffi}) {
}); });
} }
void showToast(String text, {Duration timeout = const Duration(seconds: 3)}) { void showToast(String text,
{Duration timeout = const Duration(seconds: 3),
Alignment alignment = const Alignment(0.0, 0.8)}) {
final overlayState = globalKey.currentState?.overlay; final overlayState = globalKey.currentState?.overlay;
if (overlayState == null) return; if (overlayState == null) return;
final entry = OverlayEntry(builder: (context) { final entry = OverlayEntry(builder: (context) {
return IgnorePointer( return IgnorePointer(
child: Align( child: Align(
alignment: const Alignment(0.0, 0.8), alignment: alignment,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: MyTheme.color(context).toastBg, color: MyTheme.color(context).toastBg,
@@ -4069,3 +4071,23 @@ String decode_http_response(http.Response resp) {
bool peerTabShowNote(PeerTabIndex peerTabIndex) { bool peerTabShowNote(PeerTabIndex peerTabIndex) {
return peerTabIndex == PeerTabIndex.ab || peerTabIndex == PeerTabIndex.group; return peerTabIndex == PeerTabIndex.ab || peerTabIndex == PeerTabIndex.group;
} }
// TODO: We should support individual bits combinations in the future.
// But for now, just keep it simple, because the old code only supports single button.
// No users have requested multi-button support yet.
String mouseButtonsToPeer(int buttons) {
switch (buttons) {
case kPrimaryMouseButton:
return 'left';
case kSecondaryMouseButton:
return 'right';
case kMiddleMouseButton:
return 'wheel';
case kBackMouseButton:
return 'back';
case kForwardMouseButton:
return 'forward';
default:
return '';
}
}

View File

@@ -372,7 +372,10 @@ class _RawTouchGestureDetectorRegionState
await ffi.cursorModel await ffi.cursorModel
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy); .move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
} }
// In relative mouse mode, skip mouse down - only send movement via sendMobileRelativeMouseMove
if (!inputModel.relativeMouseMode.value) {
await inputModel.sendMouse('down', MouseButtons.left); await inputModel.sendMouse('down', MouseButtons.left);
}
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
} else { } else {
final offset = ffi.cursorModel.offset; final offset = ffi.cursorModel.offset;
@@ -397,8 +400,13 @@ class _RawTouchGestureDetectorRegionState
if (handleTouch && !_touchModePanStarted) { if (handleTouch && !_touchModePanStarted) {
return; return;
} }
// In relative mouse mode, send delta directly without position tracking.
if (inputModel.relativeMouseMode.value) {
await inputModel.sendMobileRelativeMouseMove(d.delta.dx, d.delta.dy);
} else {
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch); await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
} }
}
onOneFingerPanEnd(DragEndDetails d) async { onOneFingerPanEnd(DragEndDetails d) async {
_touchModePanStarted = false; _touchModePanStarted = false;
@@ -409,9 +417,12 @@ class _RawTouchGestureDetectorRegionState
ffi.cursorModel.clearRemoteWindowCoords(); ffi.cursorModel.clearRemoteWindowCoords();
} }
if (handleTouch) { if (handleTouch) {
// In relative mouse mode, skip mouse up - matches the skipped mouse down in onOneFingerPanStart
if (!inputModel.relativeMouseMode.value) {
await inputModel.sendMouse('up', MouseButtons.left); await inputModel.sendMouse('up', MouseButtons.left);
} }
} }
}
// scale + pan event // scale + pan event
onTwoFingerScaleStart(ScaleStartDetails d) { onTwoFingerScaleStart(ScaleStartDetails d) {

View File

@@ -831,6 +831,7 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
final ffiModel = ffi.ffiModel; final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi; final pi = ffiModel.pi;
final sessionId = ffi.sessionId; final sessionId = ffi.sessionId;
final isDefaultConn = ffi.connType == ConnType.defaultConn;
List<TToggleMenu> v = []; List<TToggleMenu> v = [];
// swap key // swap key
@@ -852,6 +853,34 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
child: Text(translate('Swap control-command key')))); child: Text(translate('Swap control-command key'))));
} }
// Relative mouse mode (gaming mode).
// Only show when server supports MOUSE_TYPE_MOVE_RELATIVE (version >= 1.4.5)
// Note: This feature is only available in Flutter client. Sciter client does not support this.
// Web client is not supported yet due to Pointer Lock API integration complexity with Flutter's input system.
// Wayland is not supported due to cursor warping limitations.
// Mobile: This option is now in GestureHelp widget, shown only when joystick is visible.
final isWayland = isDesktop && isLinux && bind.mainCurrentIsWayland();
if (isDesktop &&
isDefaultConn &&
!isWeb &&
!isWayland &&
ffiModel.keyboard &&
!ffiModel.viewOnly &&
ffi.inputModel.isRelativeMouseModeSupported) {
v.add(TToggleMenu(
value: ffi.inputModel.relativeMouseMode.value,
onChanged: (value) {
if (value == null) return;
final previousValue = ffi.inputModel.relativeMouseMode.value;
final success = ffi.inputModel.setRelativeMouseMode(value);
if (!success) {
// Revert the observable toggle to reflect the actual state
ffi.inputModel.relativeMouseMode.value = previousValue;
}
},
child: Text(translate('Relative mouse mode'))));
}
// reverse mouse wheel // reverse mouse wheel
if (ffiModel.keyboard) { if (ffiModel.keyboard) {
var optionValue = var optionValue =

View File

@@ -258,6 +258,33 @@ const int kMinTrackpadSpeed = 10;
const int kDefaultTrackpadSpeed = 100; const int kDefaultTrackpadSpeed = 100;
const int kMaxTrackpadSpeed = 1000; const int kMaxTrackpadSpeed = 1000;
// relative mouse mode
/// Throttle duration (in milliseconds) for updating pointer lock center during
/// window move/resize events. Lower values provide more responsive updates but
/// may cause performance issues during rapid window operations.
const int kDefaultPointerLockCenterThrottleMs = 100;
/// Minimum server version required for relative mouse mode (MOUSE_TYPE_MOVE_RELATIVE).
/// Servers older than this version will ignore relative mouse events.
///
/// IMPORTANT: This value must be kept in sync with the Rust constant
/// `MIN_VERSION_RELATIVE_MOUSE_MODE` in `src/common.rs`.
const String kMinVersionForRelativeMouseMode = '1.4.5';
/// Maximum delta value for relative mouse movement.
/// Large values could cause issues with i32 overflow on server side,
/// and no reasonable mouse movement should exceed this bound.
///
/// IMPORTANT: This value must be kept in sync with the Rust constant
/// `MAX_RELATIVE_MOUSE_DELTA` in `src/server/input_service.rs`.
const int kMaxRelativeMouseDelta = 10000;
/// Debounce duration (in milliseconds) for relative mouse mode toggle.
/// This prevents double-toggle from race condition between Rust rdev grab loop
/// and Flutter keyboard handling. Value should be small enough to allow
/// intentional quick toggles but large enough to prevent accidental double-triggers.
const int kRelativeMouseModeToggleDebounceMs = 150;
// incomming (should be incoming) is kept, because change it will break the previous setting. // incomming (should be incoming) is kept, because change it will break the previous setting.
const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action'; const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action';
const String kValuePrinterIncomingJobDismiss = 'dismiss'; const String kValuePrinterIncomingJobDismiss = 'dismiss';

View File

@@ -15,6 +15,7 @@ import '../../common.dart';
import '../../common/widgets/dialog.dart'; import '../../common/widgets/dialog.dart';
import '../../common/widgets/toolbar.dart'; import '../../common/widgets/toolbar.dart';
import '../../models/model.dart'; import '../../models/model.dart';
import '../../models/input_model.dart';
import '../../models/platform_model.dart'; import '../../models/platform_model.dart';
import '../../common/shared_state.dart'; import '../../common/shared_state.dart';
import '../../utils/image.dart'; import '../../utils/image.dart';
@@ -90,6 +91,10 @@ class _RemotePageState extends State<RemotePage>
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode"); final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
// Debounce timer for pointer lock center updates during window events.
// Uses kDefaultPointerLockCenterThrottleMs from consts.dart for the duration.
Timer? _pointerLockCenterDebounceTimer;
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar` // We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
// to identify the toolbar instance and its callback function. // to identify the toolbar instance and its callback function.
int? _instanceIdOnEnterOrLeaveImage4Toolbar; int? _instanceIdOnEnterOrLeaveImage4Toolbar;
@@ -169,6 +174,16 @@ class _RemotePageState extends State<RemotePage>
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController?.onSelected?.call(widget.id); widget.tabController?.onSelected?.call(widget.id);
}); });
// Register callback to cancel debounce timer when relative mouse mode is disabled
_ffi.inputModel.onRelativeMouseModeDisabled =
_cancelPointerLockCenterDebounceTimer;
}
/// Cancel the pointer lock center debounce timer
void _cancelPointerLockCenterDebounceTimer() {
_pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = null;
} }
@override @override
@@ -184,6 +199,13 @@ class _RemotePageState extends State<RemotePage>
_rawKeyFocusNode.unfocus(); _rawKeyFocusNode.unfocus();
} }
stateGlobal.isFocused.value = false; stateGlobal.isFocused.value = false;
// When window loses focus, temporarily release relative mouse mode constraints
// to allow user to interact with other applications normally.
// The cursor will be re-hidden and re-centered when window regains focus.
if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.onWindowBlur();
}
} }
@override @override
@@ -194,6 +216,12 @@ class _RemotePageState extends State<RemotePage>
_isWindowBlur = false; _isWindowBlur = false;
} }
stateGlobal.isFocused.value = true; stateGlobal.isFocused.value = true;
// Restore relative mouse mode constraints when window regains focus.
if (_ffi.inputModel.relativeMouseMode.value) {
_rawKeyFocusNode.requestFocus();
_ffi.inputModel.onWindowFocus();
}
} }
@override @override
@@ -205,6 +233,8 @@ class _RemotePageState extends State<RemotePage>
_isWindowBlur = false; _isWindowBlur = false;
} }
WakelockManager.enable(_uniqueKey); WakelockManager.enable(_uniqueKey);
// Update pointer lock center when window is restored
_updatePointerLockCenterIfNeeded();
} }
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not. // When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
@@ -212,12 +242,50 @@ class _RemotePageState extends State<RemotePage>
void onWindowMaximize() { void onWindowMaximize() {
super.onWindowMaximize(); super.onWindowMaximize();
WakelockManager.enable(_uniqueKey); WakelockManager.enable(_uniqueKey);
// Update pointer lock center when window is maximized
_updatePointerLockCenterIfNeeded();
}
@override
void onWindowResize() {
super.onWindowResize();
// Update pointer lock center when window is resized
_updatePointerLockCenterIfNeeded();
}
@override
void onWindowMove() {
super.onWindowMove();
// Update pointer lock center when window is moved
_updatePointerLockCenterIfNeeded();
}
/// Update pointer lock center with debouncing to avoid excessive updates
/// during rapid window move/resize events.
void _updatePointerLockCenterIfNeeded() {
if (!_ffi.inputModel.relativeMouseMode.value) return;
// Cancel any pending update and schedule a new one (debounce pattern)
_pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = Timer(
const Duration(milliseconds: kDefaultPointerLockCenterThrottleMs),
() {
if (!mounted) return;
if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.updatePointerLockCenter();
}
},
);
} }
@override @override
void onWindowMinimize() { void onWindowMinimize() {
super.onWindowMinimize(); super.onWindowMinimize();
WakelockManager.disable(_uniqueKey); WakelockManager.disable(_uniqueKey);
// Release cursor constraints when minimized
if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.onWindowBlur();
}
} }
@override @override
@@ -243,6 +311,16 @@ class _RemotePageState extends State<RemotePage>
// https://github.com/flutter/flutter/issues/64935 // https://github.com/flutter/flutter/issues/64935
super.dispose(); super.dispose();
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}"); debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
// Defensive cleanup: ensure host system-key propagation is reset even if
// MouseRegion.onExit never fired (e.g., tab closed while cursor inside).
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
_pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = null;
// Clear callback reference to prevent memory leaks and stale references
_ffi.inputModel.onRelativeMouseModeDisabled = null;
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
_ffi.textureModel.onRemotePageDispose(closeSession); _ffi.textureModel.onRemotePageDispose(closeSession);
if (closeSession) { if (closeSession) {
// ensure we leave this session, this is a double check // ensure we leave this session, this is a double check
@@ -344,10 +422,15 @@ class _RemotePageState extends State<RemotePage>
} }
}(), }(),
// Use Overlay to enable rebuild every time on menu button click. // Use Overlay to enable rebuild every time on menu button click.
_ffi.ffiModel.pi.isSet.isTrue // Hide toolbar when relative mouse mode is active to prevent
? Overlay( // cursor from escaping to toolbar area.
initialEntries: [OverlayEntry(builder: remoteToolbar)]) Obx(() => _ffi.inputModel.relativeMouseMode.value
: remoteToolbar(context), ? const Offstage()
: _ffi.ffiModel.pi.isSet.isTrue
? Overlay(initialEntries: [
OverlayEntry(builder: remoteToolbar)
])
: remoteToolbar(context)),
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(), _ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
], ],
), ),
@@ -415,6 +498,7 @@ class _RemotePageState extends State<RemotePage>
// //
} }
} }
// See [onWindowBlur]. // See [onWindowBlur].
if (!isWindows) { if (!isWindows) {
if (!_rawKeyFocusNode.hasFocus) { if (!_rawKeyFocusNode.hasFocus) {
@@ -440,6 +524,7 @@ class _RemotePageState extends State<RemotePage>
// //
} }
} }
// See [onWindowBlur]. // See [onWindowBlur].
if (!isWindows) { if (!isWindows) {
_ffi.inputModel.enterOrLeave(false); _ffi.inputModel.enterOrLeave(false);
@@ -487,13 +572,17 @@ class _RemotePageState extends State<RemotePage>
Widget getBodyForDesktop(BuildContext context) { Widget getBodyForDesktop(BuildContext context) {
var paints = <Widget>[ var paints = <Widget>[
MouseRegion(onEnter: (evt) { MouseRegion(
onEnter: (evt) {
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false); if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
}, onExit: (evt) { },
onExit: (evt) {
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true); if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
}, child: LayoutBuilder(builder: (context, constraints) { },
final c = Provider.of<CanvasModel>(context, listen: false); child: _ViewStyleUpdater(
Future.delayed(Duration.zero, () => c.updateViewStyle()); canvasModel: _ffi.canvasModel,
inputModel: _ffi.inputModel,
child: Builder(builder: (context) {
final peerDisplay = CurrentDisplayState.find(widget.id); final peerDisplay = CurrentDisplayState.find(widget.id);
return Obx( return Obx(
() => _ffi.ffiModel.pi.isSet.isFalse () => _ffi.ffiModel.pi.isSet.isFalse
@@ -506,13 +595,16 @@ class _RemotePageState extends State<RemotePage>
cursorOverImage: _cursorOverImage, cursorOverImage: _cursorOverImage,
keyboardEnabled: _keyboardEnabled, keyboardEnabled: _keyboardEnabled,
remoteCursorMoved: _remoteCursorMoved, remoteCursorMoved: _remoteCursorMoved,
listenerBuilder: (child) => _buildRawTouchAndPointerRegion( listenerBuilder: (child) =>
_buildRawTouchAndPointerRegion(
child, enterView, leaveView), child, enterView, leaveView),
ffi: _ffi, ffi: _ffi,
); );
}), }),
); );
})) }),
),
)
]; ];
if (!_ffi.canvasModel.cursorEmbedded) { if (!_ffi.canvasModel.cursorEmbedded) {
@@ -541,6 +633,63 @@ class _RemotePageState extends State<RemotePage>
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
} }
/// A widget that tracks the view size and updates CanvasModel.updateViewStyle()
/// and InputModel.updateImageWidgetSize() only when size actually changes.
/// This avoids scheduling post-frame callbacks on every LayoutBuilder rebuild.
class _ViewStyleUpdater extends StatefulWidget {
final CanvasModel canvasModel;
final InputModel inputModel;
final Widget child;
const _ViewStyleUpdater({
Key? key,
required this.canvasModel,
required this.inputModel,
required this.child,
}) : super(key: key);
@override
State<_ViewStyleUpdater> createState() => _ViewStyleUpdaterState();
}
class _ViewStyleUpdaterState extends State<_ViewStyleUpdater> {
Size? _lastSize;
bool _callbackScheduled = false;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final maxHeight = constraints.maxHeight;
// Guard against infinite constraints (e.g., unconstrained ancestor).
if (!maxWidth.isFinite || !maxHeight.isFinite) {
return widget.child;
}
final newSize = Size(maxWidth, maxHeight);
if (_lastSize != newSize) {
_lastSize = newSize;
// Schedule the update for after the current frame to avoid setState during build.
// Use _callbackScheduled flag to prevent accumulating multiple callbacks
// when size changes rapidly before any callback executes.
if (!_callbackScheduled) {
_callbackScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((_) {
_callbackScheduled = false;
final currentSize = _lastSize;
if (mounted && currentSize != null) {
widget.canvasModel.updateViewStyle();
widget.inputModel.updateImageWidgetSize(currentSize);
}
});
}
}
return widget.child;
},
);
}
}
class ImagePaint extends StatefulWidget { class ImagePaint extends StatefulWidget {
final FFI ffi; final FFI ffi;
final String id; final String id;
@@ -604,6 +753,9 @@ class _ImagePaintState extends State<ImagePaint> {
return MouseRegion( return MouseRegion(
cursor: cursorOverImage.isTrue cursor: cursorOverImage.isTrue
? c.cursorEmbedded ? c.cursorEmbedded
? SystemMouseCursors.none
// Hide cursor when relative mouse mode is active
: widget.ffi.inputModel.relativeMouseMode.value
? SystemMouseCursors.none ? SystemMouseCursors.none
: keyboardEnabled.isTrue : keyboardEnabled.isTrue
? (() { ? (() {

View File

@@ -135,7 +135,13 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
body: DesktopTab( body: DesktopTab(
controller: tabController, controller: tabController,
onWindowCloseButton: handleWindowCloseButton, onWindowCloseButton: handleWindowCloseButton,
tail: const AddButton(), tail: Row(
mainAxisSize: MainAxisSize.min,
children: [
_RelativeMouseModeHint(tabController: tabController),
const AddButton(),
],
),
selectedBorderColor: MyTheme.accent, selectedBorderColor: MyTheme.accent,
pageViewBuilder: (pageView) => pageView, pageViewBuilder: (pageView) => pageView,
labelGetter: DesktopTab.tablabelGetter, labelGetter: DesktopTab.tablabelGetter,
@@ -374,6 +380,8 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
loopCloseWindow(); loopCloseWindow();
} }
ConnectionTypeState.delete(id); ConnectionTypeState.delete(id);
// Clean up relative mouse mode state for this peer.
stateGlobal.relativeMouseModeState.remove(id);
_update_remote_count(); _update_remote_count();
} }
@@ -548,3 +556,69 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
return returnValue; return returnValue;
} }
} }
/// A widget that displays a hint in the tab bar when relative mouse mode is active.
/// This helps users remember how to exit relative mouse mode.
class _RelativeMouseModeHint extends StatelessWidget {
final DesktopTabController tabController;
const _RelativeMouseModeHint({Key? key, required this.tabController})
: super(key: key);
@override
Widget build(BuildContext context) {
return Obx(() {
// Check if there are any tabs
if (tabController.state.value.tabs.isEmpty) {
return const SizedBox.shrink();
}
// Get current selected tab's RemotePage
final selectedTabInfo = tabController.state.value.selectedTabInfo;
if (selectedTabInfo.page is! RemotePage) {
return const SizedBox.shrink();
}
final remotePage = selectedTabInfo.page as RemotePage;
final String peerId = remotePage.id;
// Use global state to check relative mouse mode (synced from InputModel).
// This avoids timing issues with FFI registration.
final isRelativeMouseMode =
stateGlobal.relativeMouseModeState[peerId] ?? false;
if (!isRelativeMouseMode) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.orange.withOpacity(0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.mouse,
size: 14,
color: Colors.orange[700],
),
const SizedBox(width: 4),
Text(
translate(
'rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'),
style: TextStyle(
fontSize: 11,
color: Colors.orange[700],
),
),
],
),
);
});
}
}

View File

@@ -593,7 +593,6 @@ class _DesktopTabState extends State<DesktopTab>
Widget _buildBar() { Widget _buildBar() {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Expanded( Expanded(
child: GestureDetector( child: GestureDetector(

View File

@@ -569,7 +569,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
} }
bool get showCursorPaint => bool get showCursorPaint =>
!gFFI.ffiModel.isPeerAndroid && !gFFI.canvasModel.cursorEmbedded; !gFFI.ffiModel.isPeerAndroid &&
!gFFI.canvasModel.cursorEmbedded &&
!gFFI.inputModel.relativeMouseMode.value;
Widget getBodyForMobile() { Widget getBodyForMobile() {
final keyboardIsVisible = keyboardVisibilityController.isVisible; final keyboardIsVisible = keyboardVisibilityController.isVisible;
@@ -808,6 +810,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
bind.mainSetLocalOption(key: kOptionTouchMode, value: v); bind.mainSetLocalOption(key: kOptionTouchMode, value: v);
}, },
virtualMouseMode: gFFI.ffiModel.virtualMouseMode, virtualMouseMode: gFFI.ffiModel.virtualMouseMode,
inputModel: gFFI.inputModel,
))); )));
} }

View File

@@ -83,7 +83,10 @@ class _FloatingMouseWidgetsState extends State<FloatingMouseWidgets> {
cursorModel: _cursorModel, cursorModel: _cursorModel,
), ),
if (virtualMouseMode.showVirtualJoystick) if (virtualMouseMode.showVirtualJoystick)
VirtualJoystick(cursorModel: _cursorModel), VirtualJoystick(
cursorModel: _cursorModel,
inputModel: _inputModel,
),
FloatingLeftRightButton( FloatingLeftRightButton(
isLeft: true, isLeft: true,
inputModel: _inputModel, inputModel: _inputModel,
@@ -674,12 +677,18 @@ class _QuarterCirclePainter extends CustomPainter {
bool shouldRepaint(CustomPainter oldDelegate) => false; bool shouldRepaint(CustomPainter oldDelegate) => false;
} }
// Virtual joystick sends the absolute movement for now. // Virtual joystick can send either absolute movement (via updatePan)
// Maybe we need to change it to relative movement in the future. // or relative movement (via sendMobileRelativeMouseMove) depending on the
// InputModel.relativeMouseMode setting.
class VirtualJoystick extends StatefulWidget { class VirtualJoystick extends StatefulWidget {
final CursorModel cursorModel; final CursorModel cursorModel;
final InputModel inputModel;
const VirtualJoystick({super.key, required this.cursorModel}); const VirtualJoystick({
super.key,
required this.cursorModel,
required this.inputModel,
});
@override @override
State<VirtualJoystick> createState() => _VirtualJoystickState(); State<VirtualJoystick> createState() => _VirtualJoystickState();
@@ -694,6 +703,10 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
final double _moveStep = 3.0; final double _moveStep = 3.0;
final double _speed = 1.0; final double _speed = 1.0;
/// Scale factor for relative mouse movement sensitivity.
/// Higher values result in faster cursor movement on the remote machine.
static const double _kRelativeMouseScale = 3.0;
// One-shot timer to detect a drag gesture // One-shot timer to detect a drag gesture
Timer? _dragStartTimer; Timer? _dragStartTimer;
// Periodic timer for continuous movement // Periodic timer for continuous movement
@@ -701,6 +714,9 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
Size? _lastScreenSize; Size? _lastScreenSize;
bool _isPressed = false; bool _isPressed = false;
/// Check if relative mouse mode is enabled.
bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -746,6 +762,18 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
); );
} }
/// Send movement delta to remote machine.
/// Uses relative mouse mode if enabled, otherwise uses absolute updatePan.
void _sendMovement(Offset delta) {
if (_useRelativeMouse) {
widget.inputModel.sendMobileRelativeMouseMove(
delta.dx * _kRelativeMouseScale, delta.dy * _kRelativeMouseScale);
} else {
// In absolute mode, use cursorModel.updatePan which tracks position.
widget.cursorModel.updatePan(delta, Offset.zero, false);
}
}
void _stopSendEventTimer() { void _stopSendEventTimer() {
_dragStartTimer?.cancel(); _dragStartTimer?.cancel();
_continuousMoveTimer?.cancel(); _continuousMoveTimer?.cancel();
@@ -773,7 +801,7 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
// The movement is small for a gentle start. // The movement is small for a gentle start.
final initialDelta = _offsetToPanDelta(_offset); final initialDelta = _offsetToPanDelta(_offset);
if (initialDelta.distance > 0) { if (initialDelta.distance > 0) {
widget.cursorModel.updatePan(initialDelta, Offset.zero, false); _sendMovement(initialDelta);
} }
// 2. Start a one-shot timer to check if the user is holding for a drag. // 2. Start a one-shot timer to check if the user is holding for a drag.
@@ -784,10 +812,7 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
_continuousMoveTimer = _continuousMoveTimer =
periodic_immediate(const Duration(milliseconds: 20), () async { periodic_immediate(const Duration(milliseconds: 20), () async {
if (_offset != Offset.zero) { if (_offset != Offset.zero) {
widget.cursorModel.updatePan( _sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed);
_offsetToPanDelta(_offset) * _moveStep * _speed,
Offset.zero,
false);
} }
}); });
}); });

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/model.dart';
import 'package:get/get.dart';
import 'package:toggle_switch/toggle_switch.dart'; import 'package:toggle_switch/toggle_switch.dart';
class GestureIcons { class GestureIcons {
@@ -39,11 +41,13 @@ class GestureHelp extends StatefulWidget {
{Key? key, {Key? key,
required this.touchMode, required this.touchMode,
required this.onTouchModeChange, required this.onTouchModeChange,
required this.virtualMouseMode}) required this.virtualMouseMode,
this.inputModel})
: super(key: key); : super(key: key);
final bool touchMode; final bool touchMode;
final OnTouchModeChange onTouchModeChange; final OnTouchModeChange onTouchModeChange;
final VirtualMouseMode virtualMouseMode; final VirtualMouseMode virtualMouseMode;
final InputModel? inputModel;
@override @override
State<StatefulWidget> createState() => State<StatefulWidget> createState() =>
@@ -61,6 +65,14 @@ class _GestureHelpState extends State<GestureHelp> {
_selectedIndex = _touchMode ? 1 : 0; _selectedIndex = _touchMode ? 1 : 0;
} }
/// Helper to exit relative mouse mode when certain conditions are met.
/// This reduces code duplication across multiple UI callbacks.
void _exitRelativeMouseModeIf(bool condition) {
if (condition) {
widget.inputModel?.setRelativeMouseMode(false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = MediaQuery.of(context).size; final size = MediaQuery.of(context).size;
@@ -103,6 +115,8 @@ class _GestureHelpState extends State<GestureHelp> {
_selectedIndex = index ?? 0; _selectedIndex = index ?? 0;
_touchMode = index == 0 ? false : true; _touchMode = index == 0 ? false : true;
widget.onTouchModeChange(_touchMode); widget.onTouchModeChange(_touchMode);
// Exit relative mouse mode when switching to touch mode
_exitRelativeMouseModeIf(_touchMode);
} }
}); });
}, },
@@ -117,12 +131,18 @@ class _GestureHelpState extends State<GestureHelp> {
onChanged: (value) async { onChanged: (value) async {
if (value == null) return; if (value == null) return;
await _virtualMouseMode.toggleVirtualMouse(); await _virtualMouseMode.toggleVirtualMouse();
// Exit relative mouse mode when virtual mouse is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode.showVirtualMouse);
setState(() {}); setState(() {});
}, },
), ),
InkWell( InkWell(
onTap: () async { onTap: () async {
await _virtualMouseMode.toggleVirtualMouse(); await _virtualMouseMode.toggleVirtualMouse();
// Exit relative mouse mode when virtual mouse is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode.showVirtualMouse);
setState(() {}); setState(() {});
}, },
child: Text(translate('Show virtual mouse')), child: Text(translate('Show virtual mouse')),
@@ -196,6 +216,10 @@ class _GestureHelpState extends State<GestureHelp> {
if (value == null) return; if (value == null) return;
await _virtualMouseMode await _virtualMouseMode
.toggleVirtualJoystick(); .toggleVirtualJoystick();
// Exit relative mouse mode when joystick is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode
.showVirtualJoystick);
setState(() {}); setState(() {});
}, },
), ),
@@ -203,6 +227,10 @@ class _GestureHelpState extends State<GestureHelp> {
onTap: () async { onTap: () async {
await _virtualMouseMode await _virtualMouseMode
.toggleVirtualJoystick(); .toggleVirtualJoystick();
// Exit relative mouse mode when joystick is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode
.showVirtualJoystick);
setState(() {}); setState(() {});
}, },
child: Text( child: Text(
@@ -211,6 +239,39 @@ class _GestureHelpState extends State<GestureHelp> {
], ],
)), )),
), ),
// Relative mouse mode option - only visible when joystick is shown
if (!_touchMode &&
_virtualMouseMode.showVirtualMouse &&
_virtualMouseMode.showVirtualJoystick &&
widget.inputModel != null)
Obx(() => Transform.translate(
offset: const Offset(-10.0, -24.0),
child: Padding(
// Indent further for 'Relative mouse mode'
padding: const EdgeInsets.only(left: 48.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: widget.inputModel!
.relativeMouseMode.value,
onChanged: (value) {
if (value == null) return;
widget.inputModel!
.setRelativeMouseMode(value);
},
),
InkWell(
onTap: () {
widget.inputModel!
.toggleRelativeMouseMode();
},
child: Text(
translate('Relative mouse mode')),
),
],
)),
)),
], ],
), ),
), ),

View File

@@ -14,6 +14,8 @@ import 'package:get/get.dart';
import '../../models/model.dart'; import '../../models/model.dart';
import '../../models/platform_model.dart'; import '../../models/platform_model.dart';
import '../../models/state_model.dart';
import 'relative_mouse_model.dart';
import '../common.dart'; import '../common.dart';
import '../consts.dart'; import '../consts.dart';
@@ -349,15 +351,28 @@ class InputModel {
double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0; double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0;
var _trackpadScrollUnsent = Offset.zero; var _trackpadScrollUnsent = Offset.zero;
// Mobile relative mouse delta accumulators (for slow/fine movements).
double _mobileDeltaRemainderX = 0.0;
double _mobileDeltaRemainderY = 0.0;
var _lastScale = 1.0; var _lastScale = 1.0;
bool _pointerMovedAfterEnter = false; bool _pointerMovedAfterEnter = false;
bool _pointerInsideImage = false;
// mouse // mouse
final isPhysicalMouse = false.obs; final isPhysicalMouse = false.obs;
int _lastButtons = 0; int _lastButtons = 0;
Offset lastMousePos = Offset.zero; Offset lastMousePos = Offset.zero;
// Relative mouse mode (for games/3D apps).
final relativeMouseMode = false.obs;
late final RelativeMouseModel _relativeMouse;
// Callback to cancel external throttle timer when relative mouse mode is disabled.
VoidCallback? onRelativeMouseModeDisabled;
// Disposer for the relativeMouseMode observer (to prevent memory leaks).
Worker? _relativeMouseModeDisposer;
bool _queryOtherWindowCoords = false; bool _queryOtherWindowCoords = false;
Rect? _windowRect; Rect? _windowRect;
List<RemoteWindowCoords> _remoteWindowCoords = []; List<RemoteWindowCoords> _remoteWindowCoords = [];
@@ -367,15 +382,40 @@ class InputModel {
bool get keyboardPerm => parent.target!.ffiModel.keyboard; bool get keyboardPerm => parent.target!.ffiModel.keyboard;
String get id => parent.target?.id ?? ''; String get id => parent.target?.id ?? '';
String? get peerPlatform => parent.target?.ffiModel.pi.platform; String? get peerPlatform => parent.target?.ffiModel.pi.platform;
String get peerVersion => parent.target?.ffiModel.pi.version ?? '';
bool get isViewOnly => parent.target!.ffiModel.viewOnly; bool get isViewOnly => parent.target!.ffiModel.viewOnly;
bool get showMyCursor => parent.target!.ffiModel.showMyCursor; bool get showMyCursor => parent.target!.ffiModel.showMyCursor;
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio; double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
bool get isViewCamera => parent.target!.connType == ConnType.viewCamera; bool get isViewCamera => parent.target!.connType == ConnType.viewCamera;
int get trackpadSpeed => _trackpadSpeed; int get trackpadSpeed => _trackpadSpeed;
bool get useEdgeScroll => parent.target!.canvasModel.scrollStyle == ScrollStyle.scrolledge; bool get useEdgeScroll =>
parent.target!.canvasModel.scrollStyle == ScrollStyle.scrolledge;
/// Check if the connected server supports relative mouse mode.
bool get isRelativeMouseModeSupported => _relativeMouse.isSupported;
InputModel(this.parent) { InputModel(this.parent) {
sessionId = parent.target!.sessionId; sessionId = parent.target!.sessionId;
_relativeMouse = RelativeMouseModel(
sessionId: sessionId,
enabled: relativeMouseMode,
keyboardPerm: () => keyboardPerm,
isViewCamera: () => isViewCamera,
peerVersion: () => peerVersion,
peerPlatform: () => peerPlatform,
modify: (msg) => modify(msg),
getPointerInsideImage: () => _pointerInsideImage,
setPointerInsideImage: (inside) => _pointerInsideImage = inside,
);
_relativeMouse.onDisabled = () => onRelativeMouseModeDisabled?.call();
// Sync relative mouse mode state to global state for UI components (e.g., tab bar hint).
_relativeMouseModeDisposer = ever(relativeMouseMode, (bool value) {
final peerId = id;
if (peerId.isNotEmpty) {
stateGlobal.relativeMouseModeState[peerId] = value;
}
});
} }
// This function must be called after the peer info is received. // This function must be called after the peer info is received.
@@ -506,6 +546,10 @@ class InputModel {
} }
} }
if (_relativeMouse.handleRawKeyEvent(e)) {
return KeyEventResult.handled;
}
final key = e.logicalKey; final key = e.logicalKey;
if (e is RawKeyDownEvent) { if (e is RawKeyDownEvent) {
if (!e.repeat) { if (!e.repeat) {
@@ -568,6 +612,16 @@ class InputModel {
} }
} }
if (_relativeMouse.handleKeyEvent(
e,
ctrlPressed: ctrl,
shiftPressed: shift,
altPressed: alt,
commandPressed: command,
)) {
return KeyEventResult.handled;
}
if (e is KeyUpEvent) { if (e is KeyUpEvent) {
handleKeyUpEventModifiers(e); handleKeyUpEventModifiers(e);
} else if (e is KeyDownEvent) { } else if (e is KeyDownEvent) {
@@ -853,11 +907,13 @@ class InputModel {
toReleaseKeys.release(handleKeyEvent); toReleaseKeys.release(handleKeyEvent);
toReleaseRawKeys.release(handleRawKeyEvent); toReleaseRawKeys.release(handleRawKeyEvent);
_pointerMovedAfterEnter = false; _pointerMovedAfterEnter = false;
_pointerInsideImage = enter;
// Fix status // Fix status
if (!enter) { if (!enter) {
resetModifiers(); resetModifiers();
} }
_relativeMouse.onEnterOrLeaveImage(enter);
_flingTimer?.cancel(); _flingTimer?.cancel();
if (!isInputSourceFlutter) { if (!isInputSourceFlutter) {
bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter); bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter);
@@ -878,15 +934,134 @@ class InputModel {
msg: json.encode(modify({'x': '$x2', 'y': '$y2'}))); msg: json.encode(modify({'x': '$x2', 'y': '$y2'})));
} }
/// Send relative mouse movement for mobile clients (virtual joystick).
/// This method is for touch-based controls that want to send delta values.
/// Uses the 'move_relative' type which bypasses absolute position tracking.
///
/// Accumulates fractional deltas to avoid losing slow/fine movements.
/// Only sends events when relative mouse mode is enabled and supported.
Future<void> sendMobileRelativeMouseMove(double dx, double dy) async {
if (!keyboardPerm) return;
if (isViewCamera) return;
// Only send relative mouse events when relative mode is enabled and supported.
if (!isRelativeMouseModeSupported || !relativeMouseMode.value) return;
_mobileDeltaRemainderX += dx;
_mobileDeltaRemainderY += dy;
final x = _mobileDeltaRemainderX.truncate();
final y = _mobileDeltaRemainderY.truncate();
_mobileDeltaRemainderX -= x;
_mobileDeltaRemainderY -= y;
if (x == 0 && y == 0) return;
await bind.sessionSendMouse(
sessionId: sessionId,
msg: json.encode(modify({
'type': 'move_relative',
'x': '$x',
'y': '$y',
})));
}
/// Update the pointer lock center position based on current window frame.
Future<void> updatePointerLockCenter({Offset? localCenter}) {
return _relativeMouse.updatePointerLockCenter(localCenter: localCenter);
}
/// Get the current image widget size (for comparison to avoid unnecessary updates).
Size? get imageWidgetSize => _relativeMouse.imageWidgetSize;
/// Update the image widget size for center calculation.
void updateImageWidgetSize(Size size) {
_relativeMouse.updateImageWidgetSize(size);
}
void toggleRelativeMouseMode() {
_relativeMouse.toggleRelativeMouseMode();
}
bool setRelativeMouseMode(bool enabled) {
return _relativeMouse.setRelativeMouseMode(enabled);
}
/// Exit relative mouse mode and release all modifier keys to the remote.
/// This is called when the user presses the exit shortcut (Ctrl+Alt on Win/Linux, Cmd+G on macOS).
/// We need to send key-up events for all modifiers because the shortcut itself may have
/// blocked some key events, leaving the remote in a state where modifiers are stuck.
void exitRelativeMouseModeWithKeyRelease() {
if (!_relativeMouse.enabled.value) return;
// First, send release events for all modifier keys to the remote.
// This ensures the remote doesn't have stuck modifier keys after exiting.
// Use press: false, down: false to send key-up events without modifiers attached.
final modifiersToRelease = [
'Control_L',
'Control_R',
'Alt_L',
'Alt_R',
'Shift_L',
'Shift_R',
'Meta_L', // Command/Super left
'Meta_R', // Command/Super right
];
for (final key in modifiersToRelease) {
bind.sessionInputKey(
sessionId: sessionId,
name: key,
down: false,
press: false,
alt: false,
ctrl: false,
shift: false,
command: false,
);
}
// Reset local modifier state
resetModifiers();
// Now exit relative mouse mode
_relativeMouse.setRelativeMouseMode(false);
}
void disposeRelativeMouseMode() {
_relativeMouse.dispose();
onRelativeMouseModeDisabled = null;
// Cancel the relative mouse mode observer and clean up global state.
_relativeMouseModeDisposer?.dispose();
_relativeMouseModeDisposer = null;
final peerId = id;
if (peerId.isNotEmpty) {
stateGlobal.relativeMouseModeState.remove(peerId);
}
}
void onWindowBlur() {
_relativeMouse.onWindowBlur();
}
void onWindowFocus() {
_relativeMouse.onWindowFocus();
}
void onPointHoverImage(PointerHoverEvent e) { void onPointHoverImage(PointerHoverEvent e) {
_stopFling = true; _stopFling = true;
if (isViewOnly && !showMyCursor) return; if (isViewOnly && !showMyCursor) return;
if (e.kind != ui.PointerDeviceKind.mouse) return; if (e.kind != ui.PointerDeviceKind.mouse) return;
// Only update pointer region when relative mouse mode is enabled.
// This avoids unnecessary tracking when not in relative mode.
if (_relativeMouse.enabled.value) {
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
}
if (!isPhysicalMouse.value) { if (!isPhysicalMouse.value) {
isPhysicalMouse.value = true; isPhysicalMouse.value = true;
} }
if (isPhysicalMouse.value) { if (isPhysicalMouse.value) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position, edgeScroll: useEdgeScroll); if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
edgeScroll: useEdgeScroll);
}
} }
} }
@@ -1043,30 +1218,59 @@ class InputModel {
_windowRect = null; _windowRect = null;
if (isViewOnly && !showMyCursor) return; if (isViewOnly && !showMyCursor) return;
if (isViewCamera) return; if (isViewCamera) return;
if (_relativeMouse.enabled.value) {
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
}
if (e.kind != ui.PointerDeviceKind.mouse) { if (e.kind != ui.PointerDeviceKind.mouse) {
if (isPhysicalMouse.value) { if (isPhysicalMouse.value) {
isPhysicalMouse.value = false; isPhysicalMouse.value = false;
} }
} }
if (isPhysicalMouse.value) { if (isPhysicalMouse.value) {
// In relative mouse mode, send button events without position.
// Use _relativeMouse.enabled.value consistently with the guard above.
if (_relativeMouse.enabled.value) {
_relativeMouse
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
} else {
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position); handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
} }
} }
}
void onPointUpImage(PointerUpEvent e) { void onPointUpImage(PointerUpEvent e) {
if (isDesktop) _queryOtherWindowCoords = false; if (isDesktop) _queryOtherWindowCoords = false;
if (isViewOnly && !showMyCursor) return; if (isViewOnly && !showMyCursor) return;
if (isViewCamera) return; if (isViewCamera) return;
if (_relativeMouse.enabled.value) {
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
}
if (e.kind != ui.PointerDeviceKind.mouse) return; if (e.kind != ui.PointerDeviceKind.mouse) return;
if (isPhysicalMouse.value) { if (isPhysicalMouse.value) {
// In relative mouse mode, send button events without position.
// Use _relativeMouse.enabled.value consistently with the guard above.
if (_relativeMouse.enabled.value) {
_relativeMouse
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
} else {
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position); handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
} }
} }
}
void onPointMoveImage(PointerMoveEvent e) { void onPointMoveImage(PointerMoveEvent e) {
if (isViewOnly && !showMyCursor) return; if (isViewOnly && !showMyCursor) return;
if (isViewCamera) return; if (isViewCamera) return;
if (e.kind != ui.PointerDeviceKind.mouse) return; if (e.kind != ui.PointerDeviceKind.mouse) return;
if (_relativeMouse.enabled.value) {
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
}
if (_queryOtherWindowCoords) { if (_queryOtherWindowCoords) {
Future.delayed(Duration.zero, () async { Future.delayed(Duration.zero, () async {
_windowRect = await fillRemoteCoordsAndGetCurFrame(_remoteWindowCoords); _windowRect = await fillRemoteCoordsAndGetCurFrame(_remoteWindowCoords);
@@ -1074,7 +1278,10 @@ class InputModel {
_queryOtherWindowCoords = false; _queryOtherWindowCoords = false;
} }
if (isPhysicalMouse.value) { if (isPhysicalMouse.value) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position, edgeScroll: useEdgeScroll); if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
edgeScroll: useEdgeScroll);
}
} }
} }
@@ -1098,6 +1305,11 @@ class InputModel {
return null; return null;
} }
/// Handle scroll/wheel events.
/// Note: Scroll events intentionally use absolute positioning even in relative mouse mode.
/// This is because scroll events don't need relative positioning - they represent
/// scroll deltas that are independent of cursor position. Games and 3D applications
/// handle scroll events the same way regardless of mouse mode.
void onPointerSignalImage(PointerSignalEvent e) { void onPointerSignalImage(PointerSignalEvent e) {
if (isViewOnly) return; if (isViewOnly) return;
if (isViewCamera) return; if (isViewCamera) return;
@@ -1285,14 +1497,18 @@ class InputModel {
evt['y'] = '${pos.y.toInt()}'; evt['y'] = '${pos.y.toInt()}';
} }
Map<int, String> mapButtons = { final buttons = evt['buttons'];
kPrimaryMouseButton: 'left', if (buttons is int) {
kSecondaryMouseButton: 'right', evt['buttons'] = mouseButtonsToPeer(buttons);
kMiddleMouseButton: 'wheel', } else {
kBackMouseButton: 'back', // Log warning if buttons exists but is not an int (unexpected caller).
kForwardMouseButton: 'forward' // Keep empty string fallback for missing buttons to preserve move/hover behavior.
}; if (buttons != null) {
evt['buttons'] = mapButtons[evt['buttons']] ?? ''; debugPrint(
'[InputModel] processEventToPeer: unexpected buttons type: ${buttons.runtimeType}, value: $buttons');
}
evt['buttons'] = '';
}
return evt; return evt;
} }
@@ -1303,8 +1519,8 @@ class InputModel {
bool moveCanvas = true, bool moveCanvas = true,
bool edgeScroll = false, bool edgeScroll = false,
}) { }) {
final evtToPeer = final evtToPeer = processEventToPeer(evt, offset,
processEventToPeer(evt, offset, onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll); onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll);
if (evtToPeer != null) { if (evtToPeer != null) {
bind.sessionSendMouse( bind.sessionSendMouse(
sessionId: sessionId, msg: json.encode(modify(evtToPeer))); sessionId: sessionId, msg: json.encode(modify(evtToPeer)));

View File

@@ -213,6 +213,9 @@ class FfiModel with ChangeNotifier {
} }
updatePermission(Map<String, dynamic> evt, String id) { updatePermission(Map<String, dynamic> evt, String id) {
// Track previous keyboard permission to detect revocation.
final hadKeyboardPerm = _permissions['keyboard'] != false;
evt.forEach((k, v) { evt.forEach((k, v) {
if (k == 'name' || k.isEmpty) return; if (k == 'name' || k.isEmpty) return;
_permissions[k] = v == 'true'; _permissions[k] = v == 'true';
@@ -221,6 +224,18 @@ class FfiModel with ChangeNotifier {
if (parent.target?.connType == ConnType.defaultConn) { if (parent.target?.connType == ConnType.defaultConn) {
KeyboardEnabledState.find(id).value = _permissions['keyboard'] != false; KeyboardEnabledState.find(id).value = _permissions['keyboard'] != false;
} }
// If keyboard permission was revoked while relative mouse mode is active,
// forcefully disable relative mouse mode to prevent the user from being trapped.
final hasKeyboardPerm = _permissions['keyboard'] != false;
if (hadKeyboardPerm && !hasKeyboardPerm) {
final inputModel = parent.target?.inputModel;
if (inputModel != null && inputModel.relativeMouseMode.value) {
inputModel.setRelativeMouseMode(false);
showToast(translate('rel-mouse-permission-lost-tip'));
}
}
debugPrint('updatePermission: $_permissions'); debugPrint('updatePermission: $_permissions');
notifyListeners(); notifyListeners();
} }
@@ -457,6 +472,9 @@ class FfiModel with ChangeNotifier {
_handlePrinterRequest(evt, sessionId, peerId); _handlePrinterRequest(evt, sessionId, peerId);
} else if (name == 'screenshot') { } else if (name == 'screenshot') {
_handleScreenshot(evt, sessionId, peerId); _handleScreenshot(evt, sessionId, peerId);
} else if (name == 'exit_relative_mouse_mode') {
// Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS)
parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease();
} else { } else {
debugPrint('Event is not handled in the fixed branch: $name'); debugPrint('Event is not handled in the fixed branch: $name');
} }
@@ -765,7 +783,7 @@ class FfiModel with ChangeNotifier {
} }
} }
updateCurDisplay(SessionID sessionId, {updateCursorPos = false}) { Future<void> updateCurDisplay(SessionID sessionId, {updateCursorPos = false}) async {
final newRect = displaysRect(); final newRect = displaysRect();
if (newRect == null) { if (newRect == null) {
return; return;
@@ -777,9 +795,19 @@ class FfiModel with ChangeNotifier {
updateCursorPos: updateCursorPos); updateCursorPos: updateCursorPos);
} }
_rect = newRect; _rect = newRect;
parent.target?.canvasModel // Await updateViewStyle to ensure view geometry is fully updated before
// updating pointer lock center. This prevents stale center calculations.
await parent.target?.canvasModel
.updateViewStyle(refreshMousePos: updateCursorPos); .updateViewStyle(refreshMousePos: updateCursorPos);
_updateSessionWidthHeight(sessionId); _updateSessionWidthHeight(sessionId);
// Keep pointer lock center in sync when using relative mouse mode.
// Note: updatePointerLockCenter is async-safe (handles errors internally),
// so we fire-and-forget here.
final inputModel = parent.target?.inputModel;
if (inputModel != null && inputModel.relativeMouseMode.value) {
inputModel.updatePointerLockCenter();
}
} }
} }
@@ -863,6 +891,17 @@ class FfiModel with ChangeNotifier {
final title = evt['title']; final title = evt['title'];
final text = evt['text']; final text = evt['text'];
final link = evt['link']; final link = evt['link'];
// Disable relative mouse mode on any error-type message to ensure cursor is released.
// This includes connection errors, session-ending messages, elevation errors, etc.
// Safety: releasing pointer lock on errors prevents the user from being stuck.
if (title == 'Connection Error' ||
type == 'error' ||
type == 'restarting' ||
(type is String && type.contains('error'))) {
parent.target?.inputModel.setRelativeMouseMode(false);
}
if (type == 're-input-password') { if (type == 're-input-password') {
wrongPasswordDialog(sessionId, dialogManager, type, title, text); wrongPasswordDialog(sessionId, dialogManager, type, title, text);
} else if (type == 'input-2fa') { } else if (type == 'input-2fa') {
@@ -967,6 +1006,8 @@ class FfiModel with ChangeNotifier {
void reconnect(OverlayDialogManager dialogManager, SessionID sessionId, void reconnect(OverlayDialogManager dialogManager, SessionID sessionId,
bool forceRelay) { bool forceRelay) {
// Disable relative mouse mode before reconnecting to ensure cursor is released.
parent.target?.inputModel.setRelativeMouseMode(false);
bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay); bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay);
clearPermissions(); clearPermissions();
dialogManager.dismissAll(); dialogManager.dismissAll();
@@ -1192,9 +1233,6 @@ class FfiModel with ChangeNotifier {
_queryAuditGuid(peerId); _queryAuditGuid(peerId);
// This call is to ensuer the keyboard mode is updated depending on the peer version.
parent.target?.inputModel.updateKeyboardMode();
// Map clone is required here, otherwise "evt" may be changed by other threads through the reference. // Map clone is required here, otherwise "evt" may be changed by other threads through the reference.
// Because this function is asynchronous, there's an "await" in this function. // Because this function is asynchronous, there's an "await" in this function.
cachedPeerData.peerInfo = {...evt}; cachedPeerData.peerInfo = {...evt};
@@ -1206,6 +1244,17 @@ class FfiModel with ChangeNotifier {
parent.target?.dialogManager.dismissAll(); parent.target?.dialogManager.dismissAll();
_pi.version = evt['version']; _pi.version = evt['version'];
// Note: Relative mouse mode is NOT auto-enabled on connect.
// Users must manually enable it via toolbar or keyboard shortcut (Ctrl+Alt+Shift+M).
//
// For desktop/webDesktop, keyboard mode initialization is handled later by
// checkDesktopKeyboardMode() which may change the mode if not supported,
// followed by updateKeyboardMode() to sync InputModel.keyboardMode.
// For mobile, updateKeyboardMode() is currently a no-op (only executes on desktop/web),
// but we call it here for consistency and future-proofing.
if (isMobile) {
parent.target?.inputModel.updateKeyboardMode();
}
_pi.isSupportMultiUiSession = _pi.isSupportMultiUiSession =
bind.isSupportMultiUiSession(version: _pi.version); bind.isSupportMultiUiSession(version: _pi.version);
_pi.username = evt['username']; _pi.username = evt['username'];
@@ -1307,7 +1356,11 @@ class FfiModel with ChangeNotifier {
stateGlobal.resetLastResolutionGroupValues(peerId); stateGlobal.resetLastResolutionGroupValues(peerId);
if (isDesktop || isWebDesktop) { if (isDesktop || isWebDesktop) {
checkDesktopKeyboardMode(); // checkDesktopKeyboardMode may change the keyboard mode if the current
// mode is not supported. Re-sync InputModel.keyboardMode afterwards.
// Note: updateKeyboardMode() is a no-op on mobile (early-returns).
await checkDesktopKeyboardMode();
await parent.target?.inputModel.updateKeyboardMode();
} }
notifyListeners(); notifyListeners();
@@ -3768,6 +3821,8 @@ class FFI {
ffiModel.clear(); ffiModel.clear();
canvasModel.clear(); canvasModel.clear();
inputModel.resetModifiers(); inputModel.resetModifiers();
// Dispose relative mouse mode resources to ensure cursor is restored
inputModel.disposeRelativeMouseMode();
if (closeSession) { if (closeSession) {
await bind.sessionClose(sessionId: sessionId); await bind.sessionClose(sessionId: sessionId);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -30,6 +29,11 @@ class StateGlobal {
String _inputSource = ''; String _inputSource = '';
// Track relative mouse mode state for each peer connection.
// Key: peerId, Value: true if relative mouse mode is active.
// Note: This is session-only runtime state, NOT persisted to config.
final RxMap<String, bool> relativeMouseModeState = <String, bool>{}.obs;
// Use for desktop -> remote toolbar -> resolution // Use for desktop -> remote toolbar -> resolution
final Map<String, Map<int, String?>> _lastResolutionGroupValues = {}; final Map<String, Map<int, String?>> _lastResolutionGroupValues = {};

View File

@@ -0,0 +1,58 @@
/// A small helper for accumulating fractional mouse deltas and emitting integer deltas.
///
/// Relative mouse mode uses integer deltas on the wire, but Flutter pointer deltas
/// are doubles. This accumulator preserves sub-pixel movement by carrying the
/// fractional remainder across events.
class RelativeMouseDelta {
final int x;
final int y;
const RelativeMouseDelta(this.x, this.y);
}
/// Accumulates fractional mouse deltas and returns integer deltas when available.
class RelativeMouseAccumulator {
double _fracX = 0.0;
double _fracY = 0.0;
/// Adds a delta and returns an integer delta when at least one axis reaches a
/// magnitude of 1px (after truncation towards zero).
///
/// If [maxDelta] is > 0, the returned integer delta is clamped to
/// [-maxDelta, maxDelta] on each axis.
RelativeMouseDelta? add(
double dx,
double dy, {
required int maxDelta,
}) {
// Guard against misuse: negative maxDelta would silently disable clamping.
assert(maxDelta >= 0, 'maxDelta must be non-negative');
_fracX += dx;
_fracY += dy;
int intX = _fracX.truncate();
int intY = _fracY.truncate();
if (intX == 0 && intY == 0) {
return null;
}
// Clamp before subtracting so excess movement is preserved in the accumulator
// rather than being permanently discarded during spikes.
if (maxDelta > 0) {
intX = intX.clamp(-maxDelta, maxDelta);
intY = intY.clamp(-maxDelta, maxDelta);
}
_fracX -= intX;
_fracY -= intY;
return RelativeMouseDelta(intX, intY);
}
void reset() {
_fracX = 0.0;
_fracY = 0.0;
}
}

View File

@@ -2020,5 +2020,19 @@ class RustdeskImpl {
return js.context.callMethod('getByName', ['audit_guid']); return js.context.callMethod('getByName', ['audit_guid']);
} }
bool mainSetCursorPosition({required int x, required int y, dynamic hint}) {
return false;
}
bool mainClipCursor(
{required int left,
required int top,
required int right,
required int bottom,
required bool enable,
dynamic hint}) {
return false;
}
void dispose() {} void dispose() {}
} }

View File

@@ -19,6 +19,22 @@ import window_manager
import window_size import window_size
import texture_rgba_renderer import texture_rgba_renderer
// Global state for relative mouse mode
// All properties and methods must be accessed on the main thread since they
// interact with NSEvent monitors, CoreGraphics APIs, and Flutter channels.
// Note: We avoid @MainActor to maintain macOS 10.14 compatibility.
class RelativeMouseState {
static let shared = RelativeMouseState()
var enabled = false
var eventMonitor: Any?
var deltaChannel: FlutterMethodChannel?
var accumulatedDeltaX: CGFloat = 0
var accumulatedDeltaY: CGFloat = 0
private init() {}
}
class MainFlutterWindow: NSWindow { class MainFlutterWindow: NSWindow {
override func awakeFromNib() { override func awakeFromNib() {
rustdesk_core_main(); rustdesk_core_main();
@@ -64,6 +80,104 @@ class MainFlutterWindow: NSWindow {
window.appearance = NSAppearance(named: themeName == "light" ? .aqua : .darkAqua) window.appearance = NSAppearance(named: themeName == "light" ? .aqua : .darkAqua)
} }
private func enableNativeRelativeMouseMode(channel: FlutterMethodChannel) -> Bool {
assert(Thread.isMainThread, "enableNativeRelativeMouseMode must be called on the main thread")
let state = RelativeMouseState.shared
if state.enabled {
// Already enabled: update the channel so this caller receives deltas.
state.deltaChannel = channel
return true
}
// Dissociate mouse from cursor position - this locks the cursor in place
// Do this FIRST before setting any state
let result = CGAssociateMouseAndMouseCursorPosition(0)
if result != CGError.success {
NSLog("[RustDesk] Failed to dissociate mouse from cursor position: %d", result.rawValue)
return false
}
// Only set state after CG call succeeds
state.deltaChannel = channel
state.accumulatedDeltaX = 0
state.accumulatedDeltaY = 0
// Add local event monitor to capture mouse delta.
// Note: Local event monitors are always called on the main thread,
// so accessing main-thread-only state is safe here.
state.eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged]) { [weak state] event in
guard let state = state else { return event }
// Guard against race: mode may be disabled between weak capture and this check.
guard state.enabled else { return event }
let deltaX = event.deltaX
let deltaY = event.deltaY
if deltaX != 0 || deltaY != 0 {
// Accumulate delta (main thread only - NSEvent local monitors always run on main thread)
state.accumulatedDeltaX += deltaX
state.accumulatedDeltaY += deltaY
// Only send if we have integer movement
let intX = Int(state.accumulatedDeltaX)
let intY = Int(state.accumulatedDeltaY)
if intX != 0 || intY != 0 {
state.accumulatedDeltaX -= CGFloat(intX)
state.accumulatedDeltaY -= CGFloat(intY)
// Send delta to Flutter (already on main thread)
state.deltaChannel?.invokeMethod("onMouseDelta", arguments: ["dx": intX, "dy": intY])
}
}
return event
}
// Check if monitor was created successfully
if state.eventMonitor == nil {
NSLog("[RustDesk] Failed to create event monitor for relative mouse mode")
// Re-associate mouse since we failed
CGAssociateMouseAndMouseCursorPosition(1)
state.deltaChannel = nil
return false
}
// Set enabled LAST after everything succeeds
state.enabled = true
return true
}
private func disableNativeRelativeMouseMode() {
assert(Thread.isMainThread, "disableNativeRelativeMouseMode must be called on the main thread")
let state = RelativeMouseState.shared
if !state.enabled { return }
state.enabled = false
// Remove event monitor
if let monitor = state.eventMonitor {
NSEvent.removeMonitor(monitor)
state.eventMonitor = nil
}
state.deltaChannel = nil
state.accumulatedDeltaX = 0
state.accumulatedDeltaY = 0
// Re-associate mouse with cursor position (non-blocking with async retry)
let result = CGAssociateMouseAndMouseCursorPosition(1)
if result != CGError.success {
NSLog("[RustDesk] Failed to re-associate mouse with cursor position: %d, scheduling retry...", result.rawValue)
// Non-blocking retry after 50ms
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
let retryResult = CGAssociateMouseAndMouseCursorPosition(1)
if retryResult != CGError.success {
NSLog("[RustDesk] Retry failed to re-associate mouse: %d. Cursor may remain locked.", retryResult.rawValue)
}
}
}
}
public func setMethodHandler(registrar: FlutterPluginRegistrar) { public func setMethodHandler(registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/host", binaryMessenger: registrar.messenger) let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/host", binaryMessenger: registrar.messenger)
channel.setMethodCallHandler({ channel.setMethodCallHandler({
@@ -96,7 +210,9 @@ class MainFlutterWindow: NSWindow {
} }
case "requestRecordAudio": case "requestRecordAudio":
AVCaptureDevice.requestAccess(for: .audio, completionHandler: { granted in AVCaptureDevice.requestAccess(for: .audio, completionHandler: { granted in
DispatchQueue.main.async {
result(granted) result(granted)
}
}) })
break break
case "bumpMouse": case "bumpMouse":
@@ -145,11 +261,22 @@ class MainFlutterWindow: NSWindow {
// This function's main action is to toggle whether the mouse cursor is // This function's main action is to toggle whether the mouse cursor is
// associated with the mouse position, but setting it to true when it's // associated with the mouse position, but setting it to true when it's
// already true has the side-effect of cancelling this motion suppression. // already true has the side-effect of cancelling this motion suppression.
//
// However, we must NOT call this when relative mouse mode is active,
// as it would break the pointer lock established by enableNativeRelativeMouseMode.
if !RelativeMouseState.shared.enabled {
CGAssociateMouseAndMouseCursorPosition(1 /* true */) CGAssociateMouseAndMouseCursorPosition(1 /* true */)
}
result(true) result(true)
break case "enableNativeRelativeMouseMode":
let success = self.enableNativeRelativeMouseMode(channel: channel)
result(success)
case "disableNativeRelativeMouseMode":
self.disableNativeRelativeMouseMode()
result(true)
default: default:
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers # 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
version: 1.4.4+62 version: 1.4.5+63
environment: environment:
sdk: '^3.1.0' sdk: '^3.1.0'

View File

@@ -208,42 +208,56 @@ impl MouseControllable for Enigo {
} }
fn mouse_move_to(&mut self, x: i32, y: i32) { fn mouse_move_to(&mut self, x: i32, y: i32) {
let pressed = Self::pressed_buttons(); // For absolute movement, we don't set delta values
// This maintains backward compatibility
let event_type = if pressed & 1 > 0 { self.mouse_move_to_impl(x, y, None);
CGEventType::LeftMouseDragged
} else if pressed & 2 > 0 {
CGEventType::RightMouseDragged
} else {
CGEventType::MouseMoved
};
let dest = CGPoint::new(x as f64, y as f64);
if let Some(src) = self.event_source.as_ref() {
if let Ok(event) =
CGEvent::new_mouse_event(src.clone(), event_type, dest, CGMouseButton::Left)
{
self.post(event, None);
}
}
} }
fn mouse_move_relative(&mut self, x: i32, y: i32) { fn mouse_move_relative(&mut self, x: i32, y: i32) {
let (display_width, display_height) = Self::main_display_size(); let (display_width, display_height) = Self::main_display_size();
let (current_x, y_inv) = Self::mouse_location_raw_coords(); let (current_x, y_inv) = Self::mouse_location_raw_coords();
let current_y = (display_height as i32) - y_inv; let current_y = (display_height as i32) - y_inv;
let new_x = current_x + x; // Use saturating arithmetic to prevent overflow/wraparound
let new_y = current_y + y; let mut new_x = current_x.saturating_add(x);
let mut new_y = current_y.saturating_add(y);
if new_x < 0 // Define screen center and edge margins for cursor reset
|| new_x as usize > display_width let center_x = (display_width / 2) as i32;
|| new_y < 0 let center_y = (display_height / 2) as i32;
|| new_y as usize > display_height // Margin calculation: 5% of the smaller screen dimension with a minimum of 50px.
{ // This provides a comfortable buffer zone to detect when the cursor is approaching
return; // screen edges, allowing us to reset it to center before it hits the boundary.
// This ensures continuous relative mouse movement without getting stuck at edges.
let margin = (display_width.min(display_height) / 20).max(50) as i32;
// Check if cursor is approaching screen boundaries
// Use saturating_sub to prevent negative thresholds on very small displays
let right = (display_width as i32).saturating_sub(margin);
let bottom = (display_height as i32).saturating_sub(margin);
let near_edge = new_x < margin
|| new_x > right
|| new_y < margin
|| new_y > bottom;
if near_edge {
// Reset cursor to screen center to allow continuous movement
// The delta values are still passed correctly for games/apps
new_x = center_x;
new_y = center_y;
} }
self.mouse_move_to(new_x, new_y); // Clamp to screen bounds as a safety measure.
// Use saturating_sub(1) to ensure coordinates don't exceed the last valid pixel.
let max_x = (display_width as i32).saturating_sub(1).max(0);
let max_y = (display_height as i32).saturating_sub(1).max(0);
new_x = new_x.clamp(0, max_x);
new_y = new_y.clamp(0, max_y);
// Pass delta values for relative movement
// This is critical for browser Pointer Lock API support
// The delta fields (MOUSE_EVENT_DELTA_X/Y) are used by browsers
// to calculate movementX/Y in Pointer Lock mode
self.mouse_move_to_impl(new_x, new_y, Some((x, y)));
} }
fn mouse_down(&mut self, button: MouseButton) -> crate::ResultType { fn mouse_down(&mut self, button: MouseButton) -> crate::ResultType {
@@ -473,6 +487,43 @@ impl Enigo {
} }
} }
/// Internal implementation for mouse movement with optional delta values.
///
/// The `delta` parameter is crucial for browser Pointer Lock API support.
/// When a browser enters Pointer Lock mode, it reads mouse delta values
/// (MOUSE_EVENT_DELTA_X/Y) directly from CGEvent to calculate movementX/Y.
/// Without setting these fields, the browser sees zero movement.
fn mouse_move_to_impl(&mut self, x: i32, y: i32, delta: Option<(i32, i32)>) {
let pressed = Self::pressed_buttons();
// Determine event type and corresponding mouse button based on pressed buttons.
// The CGMouseButton must match the event type for drag events.
let (event_type, button) = if pressed & 1 > 0 {
(CGEventType::LeftMouseDragged, CGMouseButton::Left)
} else if pressed & 2 > 0 {
(CGEventType::RightMouseDragged, CGMouseButton::Right)
} else if pressed & 4 > 0 {
(CGEventType::OtherMouseDragged, CGMouseButton::Center)
} else {
(CGEventType::MouseMoved, CGMouseButton::Left) // Button doesn't matter for MouseMoved
};
let dest = CGPoint::new(x as f64, y as f64);
if let Some(src) = self.event_source.as_ref() {
if let Ok(event) =
CGEvent::new_mouse_event(src.clone(), event_type, dest, button)
{
// Set delta fields for relative mouse movement
// This is essential for Pointer Lock API in browsers
if let Some((dx, dy)) = delta {
event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_X, dx as i64);
event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_Y, dy as i64);
}
self.post(event, None);
}
}
}
/// Fetches the `(width, height)` in pixels of the main display /// Fetches the `(width, height)` in pixels of the main display
pub fn main_display_size() -> (usize, usize) { pub fn main_display_size() -> (usize, usize) {
let display_id = unsafe { CGMainDisplayID() }; let display_id = unsafe { CGMainDisplayID() };

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rustdesk-portable-packer" name = "rustdesk-portable-packer"
version = "1.4.4" version = "1.4.5"
edition = "2021" edition = "2021"
description = "RustDesk Remote Desktop" description = "RustDesk Remote Desktop"

View File

@@ -1,5 +1,5 @@
pkgname=rustdesk pkgname=rustdesk
pkgver=1.4.4 pkgver=1.4.5
pkgrel=0 pkgrel=0
epoch= epoch=
pkgdesc="" pkgdesc=""

View File

@@ -1,5 +1,5 @@
Name: rustdesk Name: rustdesk
Version: 1.4.4 Version: 1.4.5
Release: 0 Release: 0
Summary: RPM package Summary: RPM package
License: GPL-3.0 License: GPL-3.0

View File

@@ -1,5 +1,5 @@
Name: rustdesk Name: rustdesk
Version: 1.4.4 Version: 1.4.5
Release: 0 Release: 0
Summary: RPM package Summary: RPM package
License: GPL-3.0 License: GPL-3.0

View File

@@ -1,5 +1,5 @@
Name: rustdesk Name: rustdesk
Version: 1.4.4 Version: 1.4.5
Release: 0 Release: 0
Summary: RPM package Summary: RPM package
License: GPL-3.0 License: GPL-3.0

View File

@@ -71,6 +71,19 @@ pub mod input {
pub const MOUSE_TYPE_UP: i32 = 2; pub const MOUSE_TYPE_UP: i32 = 2;
pub const MOUSE_TYPE_WHEEL: i32 = 3; pub const MOUSE_TYPE_WHEEL: i32 = 3;
pub const MOUSE_TYPE_TRACKPAD: i32 = 4; pub const MOUSE_TYPE_TRACKPAD: i32 = 4;
/// Relative mouse movement type for gaming/3D applications.
/// This type sends delta (dx, dy) values instead of absolute coordinates.
/// NOTE: This is only supported by the Flutter client. The Sciter client (deprecated)
/// does not support relative mouse mode due to:
/// 1. Fixed send_mouse() function signature that doesn't allow type differentiation
/// 2. Lack of pointer lock API in Sciter/TIS
/// 3. No OS cursor control (hide/show/clip) FFI bindings in Sciter UI
pub const MOUSE_TYPE_MOVE_RELATIVE: i32 = 5;
/// Mask to extract the mouse event type from the mask field.
/// The lower 3 bits contain the event type (MOUSE_TYPE_*), giving a valid range of 0-7.
/// Currently defined types use values 0-5; values 6 and 7 are reserved for future use.
pub const MOUSE_TYPE_MASK: i32 = 0x7;
pub const MOUSE_BUTTON_LEFT: i32 = 0x01; pub const MOUSE_BUTTON_LEFT: i32 = 0x01;
pub const MOUSE_BUTTON_RIGHT: i32 = 0x02; pub const MOUSE_BUTTON_RIGHT: i32 = 0x02;
@@ -175,6 +188,20 @@ pub fn is_support_file_transfer_resume_num(ver: i64) -> bool {
ver >= hbb_common::get_version_number("1.4.2") ver >= hbb_common::get_version_number("1.4.2")
} }
/// Minimum server version required for relative mouse mode support.
/// This constant must mirror Flutter's `kMinVersionForRelativeMouseMode` in `consts.dart`.
const MIN_VERSION_RELATIVE_MOUSE_MODE: &str = "1.4.5";
#[inline]
pub fn is_support_relative_mouse_mode(ver: &str) -> bool {
is_support_relative_mouse_mode_num(hbb_common::get_version_number(ver))
}
#[inline]
pub fn is_support_relative_mouse_mode_num(ver: i64) -> bool {
ver >= hbb_common::get_version_number(MIN_VERSION_RELATIVE_MOUSE_MODE)
}
// is server process, with "--server" args // is server process, with "--server" args
#[inline] #[inline]
pub fn is_server() -> bool { pub fn is_server() -> bool {
@@ -2462,4 +2489,36 @@ mod tests {
assert!(!is_public("https://rustdesk.computer.com")); assert!(!is_public("https://rustdesk.computer.com"));
assert!(!is_public("rustdesk.comhello.com")); assert!(!is_public("rustdesk.comhello.com"));
} }
#[test]
fn test_mouse_event_constants_and_mask_layout() {
use super::input::*;
// Verify MOUSE_TYPE constants are unique and within the mask range.
let types = [
MOUSE_TYPE_MOVE,
MOUSE_TYPE_DOWN,
MOUSE_TYPE_UP,
MOUSE_TYPE_WHEEL,
MOUSE_TYPE_TRACKPAD,
MOUSE_TYPE_MOVE_RELATIVE,
];
let mut seen = std::collections::HashSet::new();
for t in types.iter() {
assert!(seen.insert(*t), "Duplicate mouse type: {}", t);
assert_eq!(
*t & MOUSE_TYPE_MASK,
*t,
"Mouse type {} exceeds mask {}",
t,
MOUSE_TYPE_MASK
);
}
// The mask layout is: lower 3 bits for type, upper bits for buttons (shifted by 3).
let combined_mask = MOUSE_TYPE_DOWN | ((MOUSE_BUTTON_LEFT | MOUSE_BUTTON_RIGHT) << 3);
assert_eq!(combined_mask & MOUSE_TYPE_MASK, MOUSE_TYPE_DOWN);
assert_eq!(combined_mask >> 3, MOUSE_BUTTON_LEFT | MOUSE_BUTTON_RIGHT);
}
} }

View File

@@ -1215,6 +1215,66 @@ pub fn main_set_input_source(session_id: SessionID, value: String) {
} }
} }
/// Set cursor position (for pointer lock re-centering).
///
/// # Returns
/// - `true`: cursor position was successfully set
/// - `false`: operation failed or not supported
///
/// # Platform behavior
/// - Windows/macOS/Linux: attempts to move the cursor to (x, y)
/// - Android/iOS: no-op, always returns `false`
pub fn main_set_cursor_position(x: i32, y: i32) -> SyncReturn<bool> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
SyncReturn(crate::set_cursor_pos(x, y))
}
#[cfg(any(target_os = "android", target_os = "ios"))]
{
let _ = (x, y);
SyncReturn(false)
}
}
/// Clip cursor to a rectangle (for pointer lock).
///
/// When `enable` is true, the cursor is clipped to the rectangle defined by
/// `left`, `top`, `right`, `bottom`. When `enable` is false, the rectangle
/// values are ignored and the cursor is unclipped.
///
/// # Returns
/// - `true`: operation succeeded or no-op completed
/// - `false`: operation failed
///
/// # Platform behavior
/// - Windows: uses ClipCursor API to confine cursor to the specified rectangle
/// - macOS: uses CGAssociateMouseAndMouseCursorPosition for pointer lock effect;
/// the rect coordinates are ignored (only Some/None matters)
/// - Linux: no-op, always returns `true`; use pointer warping for similar effect
/// - Android/iOS: no-op, always returns `false`
pub fn main_clip_cursor(
left: i32,
top: i32,
right: i32,
bottom: i32,
enable: bool,
) -> SyncReturn<bool> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
let rect = if enable {
Some((left, top, right, bottom))
} else {
None
};
SyncReturn(crate::clip_cursor(rect))
}
#[cfg(any(target_os = "android", target_os = "ios"))]
{
let _ = (left, top, right, bottom, enable);
SyncReturn(false)
}
}
pub fn main_get_my_id() -> String { pub fn main_get_my_id() -> String {
get_id() get_id()
} }
@@ -1748,8 +1808,99 @@ pub fn session_send_pointer(session_id: SessionID, msg: String) {
super::flutter::session_send_pointer(session_id, msg); super::flutter::session_send_pointer(session_id, msg);
} }
/// Send mouse event from Flutter to the remote peer.
///
/// # Relative Mouse Mode Message Contract
///
/// When the message contains a `relative_mouse_mode` field, this function validates
/// and filters activation/deactivation markers.
///
/// **Mode Authority:**
/// The Flutter InputModel is authoritative for relative mouse mode activation/deactivation.
/// The server (via `input_service.rs`) only consumes forwarded delta movements and tracks
/// relative movement processing state, but does NOT control mode activation/deactivation.
///
/// **Deactivation Markers are Local-Only:**
/// Deactivation markers (`relative_mouse_mode: "0"`) are NEVER forwarded to the server.
/// They are handled entirely on the client side to reset local UI state (cursor visibility,
/// pointer lock, etc.). The server does not rely on deactivation markers and should not
/// expect to receive them.
///
/// **Contract (Flutter side MUST adhere to):**
/// 1. `relative_mouse_mode` field is ONLY present on activation/deactivation marker messages,
/// NEVER on normal pointer events (move, button, scroll).
/// 2. Deactivation marker: `{"relative_mouse_mode": "0"}` - local-only, never forwarded.
/// 3. Activation marker: `{"relative_mouse_mode": "1", "type": "move_relative", "x": "0", "y": "0"}`
/// - MUST use `type="move_relative"` with `x="0"` and `y="0"` (safe no-op).
/// - Any other combination is dropped to prevent accidental cursor movement.
///
/// If these assumptions are violated (e.g., `relative_mouse_mode` is added to normal events),
/// legitimate mouse events may be silently dropped by the early-return logic below.
pub fn session_send_mouse(session_id: SessionID, msg: String) { pub fn session_send_mouse(session_id: SessionID, msg: String) {
if let Ok(m) = serde_json::from_str::<HashMap<String, String>>(&msg) { if let Ok(m) = serde_json::from_str::<HashMap<String, String>>(&msg) {
// Relative mouse mode marker validation (Flutter-only).
// This only validates and filters markers; the server tracks per-connection
// relative-movement processing state but not mode activation/deactivation.
// See doc comment above for the message contract.
if let Some(v) = m.get("relative_mouse_mode") {
let active = matches!(v.as_str(), "1" | "Y" | "on");
// Disable marker: local-only, never forwarded to the server.
// The server does not track mode deactivation; it simply stops receiving
// relative move events when the client exits relative mouse mode.
if !active {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
crate::keyboard::set_relative_mouse_mode_state(false);
return;
}
// Enable marker: validate BEFORE setting state to avoid desync.
// This ensures we only mark as active if the marker will actually be forwarded.
// Enable marker is allowed to go through only if it's a safe no-op relative move.
// This avoids accidentally moving the remote cursor (e.g. if type/x/y are missing).
let msg_type = m.get("type").map(|t| t.as_str());
if msg_type != Some("move_relative") {
log::warn!(
"relative_mouse_mode activation marker has invalid type: {:?}, expected 'move_relative'. Dropping.",
msg_type
);
return;
}
let x_marker = m
.get("x")
.map(|x| x.parse::<i32>().unwrap_or(0))
.unwrap_or(0);
let y_marker = m
.get("y")
.map(|y| y.parse::<i32>().unwrap_or(0))
.unwrap_or(0);
if x_marker != 0 || y_marker != 0 {
log::warn!(
"relative_mouse_mode activation marker has non-zero coordinates: x={}, y={}. Dropping.",
x_marker, y_marker
);
return;
}
// Guard against unexpected fields that could turn this no-op into a real event.
if m.contains_key("buttons")
|| m.contains_key("alt")
|| m.contains_key("ctrl")
|| m.contains_key("shift")
|| m.contains_key("command")
{
log::warn!(
"relative_mouse_mode activation marker contains unexpected fields (buttons/alt/ctrl/shift/command). Dropping."
);
return;
}
// All validation passed - marker will be forwarded as a no-op relative move.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
crate::keyboard::set_relative_mouse_mode_state(true);
}
let alt = m.get("alt").is_some(); let alt = m.get("alt").is_some();
let ctrl = m.get("ctrl").is_some(); let ctrl = m.get("ctrl").is_some();
let shift = m.get("shift").is_some(); let shift = m.get("shift").is_some();
@@ -1769,6 +1920,7 @@ pub fn session_send_mouse(session_id: SessionID, msg: String) {
"up" => MOUSE_TYPE_UP, "up" => MOUSE_TYPE_UP,
"wheel" => MOUSE_TYPE_WHEEL, "wheel" => MOUSE_TYPE_WHEEL,
"trackpad" => MOUSE_TYPE_TRACKPAD, "trackpad" => MOUSE_TYPE_TRACKPAD,
"move_relative" => MOUSE_TYPE_MOVE_RELATIVE,
_ => 0, _ => 0,
}; };
} }

View File

@@ -32,9 +32,33 @@ const OS_LOWER_MACOS: &str = "macos";
#[allow(dead_code)] #[allow(dead_code)]
const OS_LOWER_ANDROID: &str = "android"; const OS_LOWER_ANDROID: &str = "android";
#[cfg(any(target_os = "windows", target_os = "macos"))] #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
static KEYBOARD_HOOKED: AtomicBool = AtomicBool::new(false); static KEYBOARD_HOOKED: AtomicBool = AtomicBool::new(false);
// Track key down state for relative mouse mode exit shortcut.
// macOS: Cmd+G (track G key)
// Windows/Linux: Ctrl+Alt (track whichever modifier was pressed last)
// This prevents the exit from retriggering on OS key-repeat.
#[cfg(all(feature = "flutter", any(target_os = "windows", target_os = "macos", target_os = "linux")))]
static EXIT_SHORTCUT_KEY_DOWN: AtomicBool = AtomicBool::new(false);
// Track whether relative mouse mode is currently active.
// This is set by Flutter via set_relative_mouse_mode_state() and checked
// by the rdev grab loop to determine if exit shortcuts should be processed.
#[cfg(all(feature = "flutter", any(target_os = "windows", target_os = "macos", target_os = "linux")))]
static RELATIVE_MOUSE_MODE_ACTIVE: AtomicBool = AtomicBool::new(false);
/// Set the relative mouse mode state from Flutter.
/// This is called when entering or exiting relative mouse mode.
#[cfg(all(feature = "flutter", any(target_os = "windows", target_os = "macos", target_os = "linux")))]
pub fn set_relative_mouse_mode_state(active: bool) {
RELATIVE_MOUSE_MODE_ACTIVE.store(active, Ordering::SeqCst);
// Reset exit shortcut state when mode changes to avoid stale state
if !active {
EXIT_SHORTCUT_KEY_DOWN.store(false, Ordering::SeqCst);
}
}
#[cfg(feature = "flutter")] #[cfg(feature = "flutter")]
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
static IS_RDEV_ENABLED: AtomicBool = AtomicBool::new(false); static IS_RDEV_ENABLED: AtomicBool = AtomicBool::new(false);
@@ -82,7 +106,7 @@ pub mod client {
GrabState::Run => { GrabState::Run => {
#[cfg(windows)] #[cfg(windows)]
update_grab_get_key_name(keyboard_mode); update_grab_get_key_name(keyboard_mode);
#[cfg(any(target_os = "windows", target_os = "macos"))] #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
KEYBOARD_HOOKED.swap(true, Ordering::SeqCst); KEYBOARD_HOOKED.swap(true, Ordering::SeqCst);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -94,7 +118,7 @@ pub mod client {
release_remote_keys(keyboard_mode); release_remote_keys(keyboard_mode);
#[cfg(any(target_os = "windows", target_os = "macos"))] #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
KEYBOARD_HOOKED.swap(false, Ordering::SeqCst); KEYBOARD_HOOKED.swap(false, Ordering::SeqCst);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -266,6 +290,136 @@ fn get_keyboard_mode() -> String {
"legacy".to_string() "legacy".to_string()
} }
/// Check if exit shortcut for relative mouse mode is active.
/// Exit shortcuts (only exits, not toggles):
/// - macOS: Cmd+G
/// - Windows/Linux: Ctrl+Alt (triggered when both are pressed)
/// Note: This shortcut is only available in Flutter client. Sciter client does not support relative mouse mode.
#[cfg(feature = "flutter")]
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
fn is_exit_relative_mouse_shortcut(key: Key) -> bool {
let modifiers = MODIFIERS_STATE.lock().unwrap();
#[cfg(target_os = "macos")]
{
// macOS: Cmd+G to exit
if key != Key::KeyG {
return false;
}
let meta = *modifiers.get(&Key::MetaLeft).unwrap_or(&false)
|| *modifiers.get(&Key::MetaRight).unwrap_or(&false);
return meta;
}
#[cfg(not(target_os = "macos"))]
{
// Windows/Linux: Ctrl+Alt to exit
// Triggered when Ctrl is pressed while Alt is down, or Alt is pressed while Ctrl is down
let is_ctrl_key = key == Key::ControlLeft || key == Key::ControlRight;
let is_alt_key = key == Key::Alt || key == Key::AltGr;
if !is_ctrl_key && !is_alt_key {
return false;
}
let ctrl = *modifiers.get(&Key::ControlLeft).unwrap_or(&false)
|| *modifiers.get(&Key::ControlRight).unwrap_or(&false);
let alt = *modifiers.get(&Key::Alt).unwrap_or(&false)
|| *modifiers.get(&Key::AltGr).unwrap_or(&false);
// When Ctrl is pressed and Alt is already down, or vice versa
(is_ctrl_key && alt) || (is_alt_key && ctrl)
}
}
/// Notify Flutter to exit relative mouse mode.
/// Note: This is Flutter-only. Sciter client does not support relative mouse mode.
#[cfg(feature = "flutter")]
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
fn notify_exit_relative_mouse_mode() {
let session_id = flutter::get_cur_session_id();
flutter::push_session_event(&session_id, "exit_relative_mouse_mode", vec![]);
}
/// Handle relative mouse mode shortcuts in the rdev grab loop.
/// Returns true if the event should be blocked from being sent to the peer.
#[cfg(feature = "flutter")]
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
#[inline]
fn can_exit_relative_mouse_mode_from_grab_loop() -> bool {
// Only process exit shortcuts when relative mouse mode is actually active.
// This prevents blocking Ctrl+Alt (or Cmd+G) when not in relative mouse mode.
if !RELATIVE_MOUSE_MODE_ACTIVE.load(Ordering::SeqCst) {
return false;
}
let Some(session) = flutter::get_cur_session() else {
return false;
};
// Only for remote desktop sessions.
if !session.is_default() {
return false;
}
// Must have keyboard permission and not be in view-only mode.
if !*session.server_keyboard_enabled.read().unwrap() {
return false;
}
let lc = session.lc.read().unwrap();
if lc.view_only.v {
return false;
}
// Peer must support relative mouse mode.
crate::common::is_support_relative_mouse_mode_num(lc.version)
}
#[cfg(feature = "flutter")]
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
#[inline]
fn should_block_relative_mouse_shortcut(key: Key, is_press: bool) -> bool {
if !KEYBOARD_HOOKED.load(Ordering::SeqCst) {
return false;
}
// Determine which key to track for key-up blocking based on platform
#[cfg(target_os = "macos")]
let is_tracked_key = key == Key::KeyG;
#[cfg(not(target_os = "macos"))]
let is_tracked_key = key == Key::ControlLeft
|| key == Key::ControlRight
|| key == Key::Alt
|| key == Key::AltGr;
// Block key up if key down was blocked (to avoid orphan key up event on remote).
// This must be checked before clearing the flag below.
if is_tracked_key && !is_press && EXIT_SHORTCUT_KEY_DOWN.swap(false, Ordering::SeqCst) {
return true;
}
// Exit relative mouse mode shortcuts:
// - macOS: Cmd+G
// - Windows/Linux: Ctrl+Alt
// Guard it to supported/eligible sessions to avoid blocking the chord unexpectedly.
if is_exit_relative_mouse_shortcut(key) {
if !can_exit_relative_mouse_mode_from_grab_loop() {
return false;
}
if is_press {
// Only trigger exit on transition from "not pressed" to "pressed".
// This prevents retriggering on OS key-repeat.
if !EXIT_SHORTCUT_KEY_DOWN.swap(true, Ordering::SeqCst) {
notify_exit_relative_mouse_mode();
}
}
return true;
}
false
}
fn start_grab_loop() { fn start_grab_loop() {
std::env::set_var("KEYBOARD_ONLY", "y"); std::env::set_var("KEYBOARD_ONLY", "y");
#[cfg(any(target_os = "windows", target_os = "macos"))] #[cfg(any(target_os = "windows", target_os = "macos"))]
@@ -278,6 +432,12 @@ fn start_grab_loop() {
let _scan_code = event.position_code; let _scan_code = event.position_code;
let _code = event.platform_code as KeyCode; let _code = event.platform_code as KeyCode;
#[cfg(feature = "flutter")]
if should_block_relative_mouse_shortcut(key, is_press) {
return None;
}
let res = if KEYBOARD_HOOKED.load(Ordering::SeqCst) { let res = if KEYBOARD_HOOKED.load(Ordering::SeqCst) {
client::process_event(&get_keyboard_mode(), &event, None); client::process_event(&get_keyboard_mode(), &event, None);
if is_press { if is_press {
@@ -337,9 +497,14 @@ fn start_grab_loop() {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if let Err(err) = rdev::start_grab_listen(move |event: Event| match event.event_type { if let Err(err) = rdev::start_grab_listen(move |event: Event| match event.event_type {
EventType::KeyPress(key) | EventType::KeyRelease(key) => { EventType::KeyPress(key) | EventType::KeyRelease(key) => {
let is_press = matches!(event.event_type, EventType::KeyPress(_));
if let Key::Unknown(keycode) = key { if let Key::Unknown(keycode) = key {
log::error!("rdev get unknown key, keycode is {:?}", keycode); log::error!("rdev get unknown key, keycode is {:?}", keycode);
} else { } else {
#[cfg(feature = "flutter")]
if should_block_relative_mouse_shortcut(key, is_press) {
return None;
}
client::process_event(&get_keyboard_mode(), &event, None); client::process_event(&get_keyboard_mode(), &event, None);
} }
None None

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "أدخل الملاحظة هنا"), ("input note here", "أدخل الملاحظة هنا"),
("note-at-conn-end-tip", "سيتم عرض هذه الملاحظة عند نهاية الاتصال"), ("note-at-conn-end-tip", "سيتم عرض هذه الملاحظة عند نهاية الاتصال"),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "输入备注"), ("input note here", "输入备注"),
("note-at-conn-end-tip", "在连接结束时请求备注"), ("note-at-conn-end-tip", "在连接结束时请求备注"),
("Show terminal extra keys", "显示终端扩展键"), ("Show terminal extra keys", "显示终端扩展键"),
("Relative mouse mode", "相对鼠标模式"),
("rel-mouse-not-supported-peer-tip", "被控端不支持相对鼠标模式"),
("rel-mouse-not-ready-tip", "相对鼠标模式尚未准备好,请稍后再试"),
("rel-mouse-lock-failed-tip", "无法锁定鼠标,相对鼠标模式已禁用"),
("rel-mouse-exit-{}-tip", "按下 {} 退出"),
("rel-mouse-permission-lost-tip", "键盘权限被撤销。相对鼠标模式已被禁用。"),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "Hier eine Notiz eingeben"), ("input note here", "Hier eine Notiz eingeben"),
("note-at-conn-end-tip", "Am Ende der Verbindung um eine Notiz bitten."), ("note-at-conn-end-tip", "Am Ende der Verbindung um eine Notiz bitten."),
("Show terminal extra keys", "Zusätzliche Tasten des Terminals anzeigen"), ("Show terminal extra keys", "Zusätzliche Tasten des Terminals anzeigen"),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -262,5 +262,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("disable-udp-tip", "Controls whether to use TCP only.\nWhen this option enabled, RustDesk will not use UDP 21116 any more, TCP 21116 will be used instead."), ("disable-udp-tip", "Controls whether to use TCP only.\nWhen this option enabled, RustDesk will not use UDP 21116 any more, TCP 21116 will be used instead."),
("server-oss-not-support-tip", "NOTE: RustDesk server OSS doesn't include this feature."), ("server-oss-not-support-tip", "NOTE: RustDesk server OSS doesn't include this feature."),
("note-at-conn-end-tip", "Ask for note at end of connection"), ("note-at-conn-end-tip", "Ask for note at end of connection"),
("rel-mouse-not-supported-peer-tip", "Relative Mouse Mode is not supported by the connected peer."),
("rel-mouse-not-ready-tip", "Relative Mouse Mode is not ready yet. Please try again."),
("rel-mouse-lock-failed-tip", "Failed to lock cursor. Relative Mouse Mode has been disabled."),
("rel-mouse-exit-{}-tip", "Press {} to exit."),
("rel-mouse-permission-lost-tip", "Keyboard permission was revoked. Relative Mouse Mode has been disabled."),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "یادداشت را اینجا وارد کنید"), ("input note here", "یادداشت را اینجا وارد کنید"),
("note-at-conn-end-tip", "در پایان اتصال، یادداشت بخواهید"), ("note-at-conn-end-tip", "در پایان اتصال، یادداشت بخواهید"),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "saisir la note ici"), ("input note here", "saisir la note ici"),
("note-at-conn-end-tip", "Proposer de rédiger une note une fois la connexion terminée"), ("note-at-conn-end-tip", "Proposer de rédiger une note une fois la connexion terminée"),
("Show terminal extra keys", "Afficher les touches supplémentaires du terminal"), ("Show terminal extra keys", "Afficher les touches supplémentaires du terminal"),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "Megjegyzés bevitele"), ("input note here", "Megjegyzés bevitele"),
("note-at-conn-end-tip", "Megjegyzés a kapcsolat végén"), ("note-at-conn-end-tip", "Megjegyzés a kapcsolat végén"),
("Show terminal extra keys", "További terminálgombok megjelenítése"), ("Show terminal extra keys", "További terminálgombok megjelenítése"),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "Inserisci nota qui"), ("input note here", "Inserisci nota qui"),
("note-at-conn-end-tip", "Visualizza nota alla fine della connessione"), ("note-at-conn-end-tip", "Visualizza nota alla fine della connessione"),
("Show terminal extra keys", "Visualizza tasti aggiuntivi terminale"), ("Show terminal extra keys", "Visualizza tasti aggiuntivi terminale"),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "ここにメモを入力"), ("input note here", "ここにメモを入力"),
("note-at-conn-end-tip", "接続終了時にメモを要求する"), ("note-at-conn-end-tip", "接続終了時にメモを要求する"),
("Show terminal extra keys", "ターミナルの追加キーを表示する"), ("Show terminal extra keys", "ターミナルの追加キーを表示する"),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "여기에 노트 입력"), ("input note here", "여기에 노트 입력"),
("note-at-conn-end-tip", "연결이 끝날 때 메모 요청"), ("note-at-conn-end-tip", "연결이 끝날 때 메모 요청"),
("Show terminal extra keys", "터미널 추가 키 표시"), ("Show terminal extra keys", "터미널 추가 키 표시"),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "voeg hier een opmerking toe"), ("input note here", "voeg hier een opmerking toe"),
("note-at-conn-end-tip", "Vraag om een opmerking aan het einde van de verbinding"), ("note-at-conn-end-tip", "Vraag om een opmerking aan het einde van de verbinding"),
("Show terminal extra keys", "Toon extra toetsen voor terminal"), ("Show terminal extra keys", "Toon extra toetsen voor terminal"),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "Wstaw tutaj notatkę"), ("input note here", "Wstaw tutaj notatkę"),
("note-at-conn-end-tip", "Poproś o notatkę po zakończeniu połączenia."), ("note-at-conn-end-tip", "Poproś o notatkę po zakończeniu połączenia."),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "введите заметку"), ("input note here", "введите заметку"),
("note-at-conn-end-tip", "Запрашивать заметку в конце соединения"), ("note-at-conn-end-tip", "Запрашивать заметку в конце соединения"),
("Show terminal extra keys", "Показывать дополнительные кнопки терминала"), ("Show terminal extra keys", "Показывать дополнительные кнопки терминала"),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "Notu buraya girin"), ("input note here", "Notu buraya girin"),
("note-at-conn-end-tip", "Bağlantı bittiğinde not sorulsun"), ("note-at-conn-end-tip", "Bağlantı bittiğinde not sorulsun"),
("Show terminal extra keys", "Terminal ek tuşlarını göster"), ("Show terminal extra keys", "Terminal ek tuşlarını göster"),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", "輸入備註"), ("input note here", "輸入備註"),
("note-at-conn-end-tip", "在連接結束時請求備註"), ("note-at-conn-end-tip", "在連接結束時請求備註"),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input note here", ""), ("input note here", ""),
("note-at-conn-end-tip", ""), ("note-at-conn-end-tip", ""),
("Show terminal extra keys", ""), ("Show terminal extra keys", ""),
("Relative mouse mode", ""),
("rel-mouse-not-supported-peer-tip", ""),
("rel-mouse-not-ready-tip", ""),
("rel-mouse-lock-failed-tip", ""),
("rel-mouse-exit-{}-tip", ""),
("rel-mouse-permission-lost-tip", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -3,7 +3,8 @@ mod keyboard;
pub mod platform; pub mod platform;
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
pub use platform::{ pub use platform::{
get_cursor, get_cursor_data, get_cursor_pos, get_focused_display, start_os_service, clip_cursor, get_cursor, get_cursor_data, get_cursor_pos, get_focused_display,
set_cursor_pos, start_os_service,
}; };
#[cfg(not(any(target_os = "ios")))] #[cfg(not(any(target_os = "ios")))]
/// cbindgen:ignore /// cbindgen:ignore

View File

@@ -97,6 +97,7 @@ extern "C" {
y: *mut c_int, y: *mut c_int,
screen_num: *mut c_int, screen_num: *mut c_int,
) -> c_int; ) -> c_int;
fn xdo_move_mouse(xdo: Xdo, x: c_int, y: c_int, screen: c_int) -> c_int;
fn xdo_new(display: *const c_char) -> Xdo; fn xdo_new(display: *const c_char) -> Xdo;
fn xdo_get_active_window(xdo: Xdo, window: *mut *mut c_void) -> c_int; fn xdo_get_active_window(xdo: Xdo, window: *mut *mut c_void) -> c_int;
fn xdo_get_window_location( fn xdo_get_window_location(
@@ -174,6 +175,56 @@ pub fn get_cursor_pos() -> Option<(i32, i32)> {
res res
} }
pub fn set_cursor_pos(x: i32, y: i32) -> bool {
let mut res = false;
XDO.with(|xdo| {
match xdo.try_borrow_mut() {
Ok(xdo) => {
if xdo.is_null() {
log::debug!("set_cursor_pos: xdo is null");
return;
}
unsafe {
let ret = xdo_move_mouse(*xdo, x, y, 0);
if ret != 0 {
log::debug!(
"set_cursor_pos: xdo_move_mouse failed with code {} for coordinates ({}, {})",
ret, x, y
);
}
res = ret == 0;
}
}
Err(_) => {
log::debug!("set_cursor_pos: failed to borrow xdo");
}
}
});
res
}
/// Clip cursor - Linux implementation is a no-op.
///
/// On X11, there's no direct equivalent to Windows ClipCursor. XGrabPointer
/// can confine the pointer but requires a window handle and has side effects.
///
/// On Wayland, pointer constraints require the zwp_pointer_constraints_v1
/// protocol which is compositor-dependent.
///
/// For relative mouse mode on Linux, the Flutter side uses pointer warping
/// (set_cursor_pos) to re-center the cursor after each movement, which achieves
/// a similar effect without requiring cursor clipping.
///
/// Returns true (always succeeds as no-op).
pub fn clip_cursor(_rect: Option<(i32, i32, i32, i32)>) -> bool {
// Log only once per process to avoid flooding logs when called frequently.
static LOGGED: AtomicBool = AtomicBool::new(false);
if !LOGGED.swap(true, Ordering::Relaxed) {
log::debug!("clip_cursor called (no-op on Linux, this message is logged only once)");
}
true
}
pub fn reset_input_cache() {} pub fn reset_input_cache() {}
pub fn get_focused_display(displays: Vec<DisplayInfo>) -> Option<usize> { pub fn get_focused_display(displays: Vec<DisplayInfo>) -> Option<usize> {

View File

@@ -32,8 +32,12 @@ use std::{
os::unix::process::CommandExt, os::unix::process::CommandExt,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::{Command, Stdio}, process::{Command, Stdio},
sync::Mutex,
}; };
// macOS boolean_t is defined as `int` in <mach/boolean.h>
type BooleanT = hbb_common::libc::c_int;
static PRIVILEGES_SCRIPTS_DIR: Dir = static PRIVILEGES_SCRIPTS_DIR: Dir =
include_dir!("$CARGO_MANIFEST_DIR/src/platform/privileges_scripts"); include_dir!("$CARGO_MANIFEST_DIR/src/platform/privileges_scripts");
static mut LATEST_SEED: i32 = 0; static mut LATEST_SEED: i32 = 0;
@@ -42,6 +46,11 @@ static mut LATEST_SEED: i32 = 0;
// using one that includes the custom client name. // using one that includes the custom client name.
const UPDATE_TEMP_DIR: &str = "/tmp/.rustdeskupdate"; const UPDATE_TEMP_DIR: &str = "/tmp/.rustdeskupdate";
/// Global mutex to serialize CoreGraphics cursor operations.
/// This prevents race conditions between cursor visibility (hide depth tracking)
/// and cursor positioning/clipping operations.
static CG_CURSOR_MUTEX: Mutex<()> = Mutex::new(());
extern "C" { extern "C" {
fn CGSCurrentCursorSeed() -> i32; fn CGSCurrentCursorSeed() -> i32;
fn CGEventCreate(r: *const c_void) -> *const c_void; fn CGEventCreate(r: *const c_void) -> *const c_void;
@@ -64,6 +73,8 @@ extern "C" {
fn majorVersion() -> u32; fn majorVersion() -> u32;
fn MacGetMode(display: u32, width: *mut u32, height: *mut u32) -> BOOL; fn MacGetMode(display: u32, width: *mut u32, height: *mut u32) -> BOOL;
fn MacSetMode(display: u32, width: u32, height: u32, tryHiDPI: bool) -> BOOL; fn MacSetMode(display: u32, width: u32, height: u32, tryHiDPI: bool) -> BOOL;
fn CGWarpMouseCursorPosition(newCursorPosition: CGPoint) -> CGError;
fn CGAssociateMouseAndMouseCursorPosition(connected: BooleanT) -> CGError;
} }
pub fn major_version() -> u32 { pub fn major_version() -> u32 {
@@ -387,6 +398,99 @@ pub fn get_cursor_pos() -> Option<(i32, i32)> {
*/ */
} }
/// Warp the mouse cursor to the specified screen position.
///
/// # Thread Safety
/// This function affects global cursor state and acquires `CG_CURSOR_MUTEX`.
/// Callers must ensure no nested calls occur while the mutex is held.
///
/// # Arguments
/// * `x` - X coordinate in screen points (macOS uses points, not pixels)
/// * `y` - Y coordinate in screen points
pub fn set_cursor_pos(x: i32, y: i32) -> bool {
// Acquire lock with deadlock detection in debug builds.
// In debug builds, try_lock detects re-entrant calls early; on failure we return immediately.
// In release builds, we use blocking lock() which will wait if contended.
#[cfg(debug_assertions)]
let _guard = match CG_CURSOR_MUTEX.try_lock() {
Ok(guard) => guard,
Err(std::sync::TryLockError::WouldBlock) => {
log::error!("[BUG] set_cursor_pos: CG_CURSOR_MUTEX is already held - potential deadlock!");
debug_assert!(false, "Re-entrant call to set_cursor_pos detected");
return false;
}
Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(),
};
#[cfg(not(debug_assertions))]
let _guard = CG_CURSOR_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
let result = CGWarpMouseCursorPosition(CGPoint {
x: x as f64,
y: y as f64,
});
if result != CGError::Success {
log::error!(
"CGWarpMouseCursorPosition({}, {}) returned error: {:?}",
x,
y,
result
);
}
result == CGError::Success
}
}
/// Toggle pointer lock (dissociate/associate mouse from cursor position).
///
/// On macOS, cursor clipping is not supported directly like Windows ClipCursor.
/// Instead, we use CGAssociateMouseAndMouseCursorPosition to dissociate mouse
/// movement from cursor position, achieving a "pointer lock" effect.
///
/// # Thread Safety
/// This function affects global cursor state and acquires `CG_CURSOR_MUTEX`.
/// Callers must ensure only one owner toggles pointer lock at a time;
/// nested Some/None transitions from different call sites may cause unexpected behavior.
///
/// # Arguments
/// * `rect` - When `Some(_)`, dissociates mouse from cursor (enables pointer lock).
/// When `None`, re-associates mouse with cursor (disables pointer lock).
/// The rect coordinate values are ignored on macOS; only `Some`/`None` matters.
/// The parameter signature matches Windows for API consistency.
pub fn clip_cursor(rect: Option<(i32, i32, i32, i32)>) -> bool {
// Acquire lock with deadlock detection in debug builds.
// In debug builds, try_lock detects re-entrant calls early; on failure we return immediately.
// In release builds, we use blocking lock() which will wait if contended.
#[cfg(debug_assertions)]
let _guard = match CG_CURSOR_MUTEX.try_lock() {
Ok(guard) => guard,
Err(std::sync::TryLockError::WouldBlock) => {
log::error!("[BUG] clip_cursor: CG_CURSOR_MUTEX is already held - potential deadlock!");
debug_assert!(false, "Re-entrant call to clip_cursor detected");
return false;
}
Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(),
};
#[cfg(not(debug_assertions))]
let _guard = CG_CURSOR_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
// CGAssociateMouseAndMouseCursorPosition takes a boolean_t:
// 1 (true) = associate mouse with cursor position (normal mode)
// 0 (false) = dissociate mouse from cursor position (pointer lock mode)
// When rect is Some, we want pointer lock (dissociate), so associate = false (0).
// When rect is None, we want normal mode (associate), so associate = true (1).
let associate: BooleanT = if rect.is_some() { 0 } else { 1 };
unsafe {
let result = CGAssociateMouseAndMouseCursorPosition(associate);
if result != CGError::Success {
log::warn!(
"CGAssociateMouseAndMouseCursorPosition({}) returned error: {:?}",
associate,
result
);
}
result == CGError::Success
}
}
pub fn get_focused_display(displays: Vec<DisplayInfo>) -> Option<usize> { pub fn get_focused_display(displays: Vec<DisplayInfo>) -> Option<usize> {
autoreleasepool(|| unsafe_get_focused_display(displays)) autoreleasepool(|| unsafe_get_focused_display(displays))
} }

View File

@@ -26,18 +26,13 @@ pub mod linux_desktop_manager;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod gtk_sudo; pub mod gtk_sudo;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use hbb_common::{
message_proto::CursorData,
sysinfo::Pid,
ResultType,
};
#[cfg(all( #[cfg(all(
not(all(target_os = "windows", not(target_pointer_width = "64"))), not(all(target_os = "windows", not(target_pointer_width = "64"))),
not(any(target_os = "android", target_os = "ios"))))] not(any(target_os = "android", target_os = "ios"))
use hbb_common::{ ))]
sysinfo::System, use hbb_common::sysinfo::System;
}; #[cfg(not(any(target_os = "android", target_os = "ios")))]
use hbb_common::{message_proto::CursorData, sysinfo::Pid, ResultType};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
pub const SERVICE_INTERVAL: u64 = 300; pub const SERVICE_INTERVAL: u64 = 300;

View File

@@ -116,12 +116,51 @@ pub fn get_focused_display(displays: Vec<DisplayInfo>) -> Option<usize> {
pub fn get_cursor_pos() -> Option<(i32, i32)> { pub fn get_cursor_pos() -> Option<(i32, i32)> {
unsafe { unsafe {
#[allow(invalid_value)] let mut out = mem::MaybeUninit::<POINT>::uninit();
let mut out = mem::MaybeUninit::uninit().assume_init(); if GetCursorPos(out.as_mut_ptr()) == FALSE {
if GetCursorPos(&mut out) == FALSE {
return None; return None;
} }
return Some((out.x, out.y)); let out = out.assume_init();
Some((out.x, out.y))
}
}
pub fn set_cursor_pos(x: i32, y: i32) -> bool {
unsafe {
if SetCursorPos(x, y) == FALSE {
let err = GetLastError();
log::warn!("SetCursorPos failed: x={}, y={}, error_code={}", x, y, err);
return false;
}
true
}
}
/// Clip cursor to a rectangle. Pass None to unclip.
pub fn clip_cursor(rect: Option<(i32, i32, i32, i32)>) -> bool {
unsafe {
let result = match rect {
Some((left, top, right, bottom)) => {
let r = RECT {
left,
top,
right,
bottom,
};
ClipCursor(&r)
}
None => ClipCursor(std::ptr::null()),
};
if result == FALSE {
let err = GetLastError();
log::warn!(
"ClipCursor failed: rect={:?}, error_code={}",
rect,
err
);
return false;
}
true
} }
} }

View File

@@ -5173,9 +5173,13 @@ impl Retina {
#[inline] #[inline]
fn on_mouse_event(&mut self, e: &mut MouseEvent, current: usize) { fn on_mouse_event(&mut self, e: &mut MouseEvent, current: usize) {
let evt_type = e.mask & 0x7; let evt_type = e.mask & crate::input::MOUSE_TYPE_MASK;
if evt_type == crate::input::MOUSE_TYPE_WHEEL { // Delta-based events do not contain absolute coordinates.
// x and y are always 0, +1 or -1 // Avoid applying Retina coordinate scaling to them.
if evt_type == crate::input::MOUSE_TYPE_WHEEL
|| evt_type == crate::input::MOUSE_TYPE_TRACKPAD
|| evt_type == crate::input::MOUSE_TYPE_MOVE_RELATIVE
{
return; return;
} }
let Some(d) = self.displays.get(current) else { let Some(d) = self.displays.get(current) else {
@@ -5421,6 +5425,9 @@ mod raii {
.unwrap() .unwrap()
.on_connection_close(self.0); .on_connection_close(self.0);
} }
// Clear per-connection state to avoid stale behavior if conn ids are reused.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
clear_relative_mouse_active(self.0);
AUTHED_CONNS.lock().unwrap().retain(|c| c.conn_id != self.0); AUTHED_CONNS.lock().unwrap().retain(|c| c.conn_id != self.0);
let remote_count = AUTHED_CONNS let remote_count = AUTHED_CONNS
.lock() .lock()

View File

@@ -26,6 +26,7 @@ use std::{
thread, thread,
time::{self, Duration, Instant}, time::{self, Duration, Instant},
}; };
#[cfg(windows)] #[cfg(windows)]
use winapi::um::winuser::WHEEL_DELTA; use winapi::um::winuser::WHEEL_DELTA;
@@ -447,7 +448,36 @@ lazy_static::lazy_static! {
static ref KEYS_DOWN: Arc<Mutex<HashMap<KeysDown, Instant>>> = Default::default(); static ref KEYS_DOWN: Arc<Mutex<HashMap<KeysDown, Instant>>> = Default::default();
static ref LATEST_PEER_INPUT_CURSOR: Arc<Mutex<Input>> = Default::default(); static ref LATEST_PEER_INPUT_CURSOR: Arc<Mutex<Input>> = Default::default();
static ref LATEST_SYS_CURSOR_POS: Arc<Mutex<(Option<Instant>, (i32, i32))>> = Arc::new(Mutex::new((None, (INVALID_CURSOR_POS, INVALID_CURSOR_POS)))); static ref LATEST_SYS_CURSOR_POS: Arc<Mutex<(Option<Instant>, (i32, i32))>> = Arc::new(Mutex::new((None, (INVALID_CURSOR_POS, INVALID_CURSOR_POS))));
// Track connections that are currently using relative mouse movement.
// Used to disable whiteboard/cursor display for all events while in relative mode.
static ref RELATIVE_MOUSE_CONNS: Arc<Mutex<std::collections::HashSet<i32>>> = Default::default();
} }
#[inline]
fn set_relative_mouse_active(conn: i32, active: bool) {
let mut lock = RELATIVE_MOUSE_CONNS.lock().unwrap();
if active {
lock.insert(conn);
} else {
lock.remove(&conn);
}
}
#[inline]
fn is_relative_mouse_active(conn: i32) -> bool {
RELATIVE_MOUSE_CONNS.lock().unwrap().contains(&conn)
}
/// Clears the relative mouse mode state for a connection.
///
/// This must be called when an authenticated connection is dropped (during connection teardown)
/// to avoid leaking the connection id in `RELATIVE_MOUSE_CONNS` (a `Mutex<HashSet<i32>>`).
/// Callers are responsible for invoking this on disconnect.
#[inline]
pub(crate) fn clear_relative_mouse_active(conn: i32) {
set_relative_mouse_active(conn, false);
}
static EXITING: AtomicBool = AtomicBool::new(false); static EXITING: AtomicBool = AtomicBool::new(false);
const MOUSE_MOVE_PROTECTION_TIMEOUT: Duration = Duration::from_millis(1_000); const MOUSE_MOVE_PROTECTION_TIMEOUT: Duration = Duration::from_millis(1_000);
@@ -644,8 +674,8 @@ async fn set_uinput_resolution(minx: i32, maxx: i32, miny: i32, maxy: i32) -> Re
pub fn is_left_up(evt: &MouseEvent) -> bool { pub fn is_left_up(evt: &MouseEvent) -> bool {
let buttons = evt.mask >> 3; let buttons = evt.mask >> 3;
let evt_type = evt.mask & 0x7; let evt_type = evt.mask & MOUSE_TYPE_MASK;
return buttons == 1 && evt_type == 2; buttons == MOUSE_BUTTON_LEFT && evt_type == MOUSE_TYPE_UP
} }
#[cfg(windows)] #[cfg(windows)]
@@ -1003,9 +1033,17 @@ pub fn handle_mouse_(
handle_mouse_simulation_(evt, conn); handle_mouse_simulation_(evt, conn);
} }
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
if _show_cursor { {
let evt_type = evt.mask & MOUSE_TYPE_MASK;
// Relative (delta) mouse events do not include absolute coordinates, so
// whiteboard/cursor rendering must be disabled during relative mode to prevent
// incorrect cursor/whiteboard updates. We check both is_relative_mouse_active(conn)
// (connection already in relative mode from prior events) and evt_type (current
// event is relative) to guard against the first relative event before the flag is set.
if _show_cursor && !is_relative_mouse_active(conn) && evt_type != MOUSE_TYPE_MOVE_RELATIVE {
handle_mouse_show_cursor_(evt, conn, _username, _argb); handle_mouse_show_cursor_(evt, conn, _username, _argb);
} }
}
} }
pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) { pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
@@ -1020,7 +1058,7 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
#[cfg(windows)] #[cfg(windows)]
crate::platform::windows::try_change_desktop(); crate::platform::windows::try_change_desktop();
let buttons = evt.mask >> 3; let buttons = evt.mask >> 3;
let evt_type = evt.mask & 0x7; let evt_type = evt.mask & MOUSE_TYPE_MASK;
let mut en = ENIGO.lock().unwrap(); let mut en = ENIGO.lock().unwrap();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
en.set_ignore_flags(enigo_ignore_flags()); en.set_ignore_flags(enigo_ignore_flags());
@@ -1048,6 +1086,8 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
} }
match evt_type { match evt_type {
MOUSE_TYPE_MOVE => { MOUSE_TYPE_MOVE => {
// Switching back to absolute movement implicitly disables relative mouse mode.
set_relative_mouse_active(conn, false);
en.mouse_move_to(evt.x, evt.y); en.mouse_move_to(evt.x, evt.y);
*LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input { *LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input {
conn, conn,
@@ -1056,6 +1096,28 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
y: evt.y, y: evt.y,
}; };
} }
// MOUSE_TYPE_MOVE_RELATIVE: Relative mouse movement for gaming/3D applications.
// Each client independently decides whether to use relative mode.
// Multiple clients can mix absolute and relative movements without conflict,
// as the server simply applies the delta to the current cursor position.
MOUSE_TYPE_MOVE_RELATIVE => {
set_relative_mouse_active(conn, true);
// Clamp delta to prevent extreme/malicious values from reaching OS APIs.
// This matches the Flutter client's kMaxRelativeMouseDelta constant.
const MAX_RELATIVE_MOUSE_DELTA: i32 = 10000;
let dx = evt.x.clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA);
let dy = evt.y.clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA);
en.mouse_move_relative(dx, dy);
// Get actual cursor position after relative movement for tracking
if let Some((x, y)) = crate::get_cursor_pos() {
*LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input {
conn,
time: get_time(),
x,
y,
};
}
}
MOUSE_TYPE_DOWN => match buttons { MOUSE_TYPE_DOWN => match buttons {
MOUSE_BUTTON_LEFT => { MOUSE_BUTTON_LEFT => {
allow_err!(en.mouse_down(MouseButton::Left)); allow_err!(en.mouse_down(MouseButton::Left));
@@ -1154,7 +1216,7 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
pub fn handle_mouse_show_cursor_(evt: &MouseEvent, conn: i32, username: String, argb: u32) { pub fn handle_mouse_show_cursor_(evt: &MouseEvent, conn: i32, username: String, argb: u32) {
let buttons = evt.mask >> 3; let buttons = evt.mask >> 3;
let evt_type = evt.mask & 0x7; let evt_type = evt.mask & MOUSE_TYPE_MASK;
match evt_type { match evt_type {
MOUSE_TYPE_MOVE => { MOUSE_TYPE_MOVE => {
whiteboard::update_whiteboard( whiteboard::update_whiteboard(
@@ -1170,11 +1232,22 @@ pub fn handle_mouse_show_cursor_(evt: &MouseEvent, conn: i32, username: String,
} }
MOUSE_TYPE_UP => { MOUSE_TYPE_UP => {
if buttons == MOUSE_BUTTON_LEFT { if buttons == MOUSE_BUTTON_LEFT {
// Some clients intentionally send button events without coordinates.
// Fall back to the last known cursor position to avoid jumping to (0, 0).
// TODO(protocol): (0, 0) is a valid screen coordinate. Consider using a dedicated
// sentinel value (e.g. INVALID_CURSOR_POS) or a protocol-level flag to distinguish
// "coordinates not provided" from "coordinates are (0, 0)". Impact is minor since
// this only affects whiteboard rendering and clicking exactly at (0, 0) is rare.
let (x, y) = if evt.x == 0 && evt.y == 0 {
get_last_input_cursor_pos()
} else {
(evt.x, evt.y)
};
whiteboard::update_whiteboard( whiteboard::update_whiteboard(
whiteboard::get_key_cursor(conn), whiteboard::get_key_cursor(conn),
whiteboard::CustomEvent::Cursor(whiteboard::Cursor { whiteboard::CustomEvent::Cursor(whiteboard::Cursor {
x: evt.x as _, x: x as _,
y: evt.y as _, y: y as _,
argb, argb,
btns: buttons, btns: buttons,
text: username, text: username,

View File

@@ -1,6 +1,9 @@
use crate::{ use crate::{
common::{get_supported_keyboard_modes, is_keyboard_mode_supported}, common::{get_supported_keyboard_modes, is_keyboard_mode_supported},
input::{MOUSE_BUTTON_LEFT, MOUSE_TYPE_DOWN, MOUSE_TYPE_UP, MOUSE_TYPE_WHEEL}, input::{
MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_TYPE_DOWN, MOUSE_TYPE_MASK,
MOUSE_TYPE_TRACKPAD, MOUSE_TYPE_UP, MOUSE_TYPE_WHEEL,
},
ui_interface::use_texture_render, ui_interface::use_texture_render,
}; };
use async_trait::async_trait; use async_trait::async_trait;
@@ -1222,7 +1225,9 @@ impl<T: InvokeUiSession> Session<T> {
} }
} }
let (x, y) = if mask == MOUSE_TYPE_WHEEL || mask == MOUSE_TYPE_TRACKPAD { // Compute event type once using MOUSE_TYPE_MASK for reuse
let event_type = mask & MOUSE_TYPE_MASK;
let (x, y) = if event_type == MOUSE_TYPE_WHEEL || event_type == MOUSE_TYPE_TRACKPAD {
self.get_scroll_xy((x, y)) self.get_scroll_xy((x, y))
} else { } else {
(x, y) (x, y)
@@ -1231,8 +1236,6 @@ impl<T: InvokeUiSession> Session<T> {
// #[cfg(not(any(target_os = "android", target_os = "ios")))] // #[cfg(not(any(target_os = "android", target_os = "ios")))]
let (alt, ctrl, shift, command) = let (alt, ctrl, shift, command) =
keyboard::client::get_modifiers_state(alt, ctrl, shift, command); keyboard::client::get_modifiers_state(alt, ctrl, shift, command);
use crate::input::*;
let is_left = (mask & (MOUSE_BUTTON_LEFT << 3)) > 0; let is_left = (mask & (MOUSE_BUTTON_LEFT << 3)) > 0;
let is_right = (mask & (MOUSE_BUTTON_RIGHT << 3)) > 0; let is_right = (mask & (MOUSE_BUTTON_RIGHT << 3)) > 0;
if is_left ^ is_right { if is_left ^ is_right {
@@ -1252,9 +1255,8 @@ impl<T: InvokeUiSession> Session<T> {
// to-do: how about ctrl + left from win to macos // to-do: how about ctrl + left from win to macos
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
let buttons = mask >> 3; let buttons = mask >> 3;
let evt_type = mask & 0x7;
if buttons == MOUSE_BUTTON_LEFT if buttons == MOUSE_BUTTON_LEFT
&& evt_type == MOUSE_TYPE_DOWN && event_type == MOUSE_TYPE_DOWN
&& ctrl && ctrl
&& self.peer_platform() != "Mac OS" && self.peer_platform() != "Mac OS"
{ {