From 998b75856da4199ac009ce4135b4fba1b48099e6 Mon Sep 17 00:00:00 2001 From: fufesou <13586388+fufesou@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:03:14 +0800 Subject: [PATCH] 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 * feat(mouse): relative mouse mode, exit hint Signed-off-by: fufesou * refact(relative mouse): shortcut Signed-off-by: fufesou --------- Signed-off-by: fufesou --- .github/workflows/flutter-build.yml | 2 +- .github/workflows/playground.yml | 2 +- .github/workflows/winget.yml | 4 +- Cargo.lock | 4 +- Cargo.toml | 2 +- appimage/AppImageBuilder-aarch64.yml | 2 +- appimage/AppImageBuilder-x86_64.yml | 2 +- flutter/lib/common.dart | 26 +- flutter/lib/common/widgets/remote_input.dart | 17 +- flutter/lib/common/widgets/toolbar.dart | 29 + flutter/lib/consts.dart | 27 + flutter/lib/desktop/pages/remote_page.dart | 242 +++- .../lib/desktop/pages/remote_tab_page.dart | 76 +- .../lib/desktop/widgets/tabbar_widget.dart | 1 - flutter/lib/mobile/pages/remote_page.dart | 5 +- .../widgets/floating_mouse_widgets.dart | 43 +- flutter/lib/mobile/widgets/gesture_help.dart | 63 +- flutter/lib/models/input_model.dart | 246 +++- flutter/lib/models/model.dart | 67 +- flutter/lib/models/relative_mouse_model.dart | 1061 +++++++++++++++++ flutter/lib/models/state_model.dart | 6 +- .../lib/utils/relative_mouse_accumulator.dart | 58 + flutter/lib/web/bridge.dart | 14 + flutter/macos/Runner/MainFlutterWindow.swift | 133 ++- flutter/pubspec.yaml | 2 +- libs/enigo/src/macos/macos_impl.rs | 105 +- libs/portable/Cargo.toml | 2 +- res/PKGBUILD | 2 +- res/rpm-flutter-suse.spec | 2 +- res/rpm-flutter.spec | 2 +- res/rpm.spec | 2 +- src/common.rs | 59 + src/flutter_ffi.rs | 152 +++ src/keyboard.rs | 171 ++- src/lang/ar.rs | 6 + src/lang/be.rs | 6 + src/lang/bg.rs | 6 + src/lang/ca.rs | 6 + src/lang/cn.rs | 6 + src/lang/cs.rs | 6 + src/lang/da.rs | 6 + src/lang/de.rs | 6 + src/lang/el.rs | 6 + src/lang/en.rs | 5 + src/lang/eo.rs | 6 + src/lang/es.rs | 6 + src/lang/et.rs | 6 + src/lang/eu.rs | 6 + src/lang/fa.rs | 6 + src/lang/fi.rs | 6 + src/lang/fr.rs | 6 + src/lang/ge.rs | 6 + src/lang/he.rs | 6 + src/lang/hr.rs | 6 + src/lang/hu.rs | 6 + src/lang/id.rs | 6 + src/lang/it.rs | 6 + src/lang/ja.rs | 6 + src/lang/ko.rs | 6 + src/lang/kz.rs | 6 + src/lang/lt.rs | 6 + src/lang/lv.rs | 6 + src/lang/nb.rs | 6 + src/lang/nl.rs | 6 + src/lang/pl.rs | 6 + src/lang/pt_PT.rs | 6 + src/lang/ptbr.rs | 6 + src/lang/ro.rs | 6 + src/lang/ru.rs | 6 + src/lang/sc.rs | 6 + src/lang/sk.rs | 6 + src/lang/sl.rs | 6 + src/lang/sq.rs | 6 + src/lang/sr.rs | 6 + src/lang/sv.rs | 6 + src/lang/ta.rs | 6 + src/lang/template.rs | 6 + src/lang/th.rs | 6 + src/lang/tr.rs | 6 + src/lang/tw.rs | 6 + src/lang/uk.rs | 6 + src/lang/vi.rs | 6 + src/lib.rs | 3 +- src/platform/linux.rs | 51 + src/platform/macos.rs | 104 ++ src/platform/mod.rs | 15 +- src/platform/windows.rs | 47 +- src/server/connection.rs | 13 +- src/server/input_service.rs | 89 +- src/ui_session_interface.rs | 14 +- 90 files changed, 3089 insertions(+), 165 deletions(-) create mode 100644 flutter/lib/models/relative_mouse_model.dart create mode 100644 flutter/lib/utils/relative_mouse_accumulator.dart diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index df5b68eb4..d2828b819 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -39,7 +39,7 @@ env: # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. 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 - VERSION: "1.4.4" + VERSION: "1.4.5" NDK_VERSION: "r27c" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index 377b47ed4..0c7b450a3 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -17,7 +17,7 @@ env: TAG_NAME: "nightly" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" - VERSION: "1.4.4" + VERSION: "1.4.5" NDK_VERSION: "r26d" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml index 6fa17c9da..ce54723e9 100644 --- a/.github/workflows/winget.yml +++ b/.github/workflows/winget.yml @@ -10,6 +10,6 @@ jobs: - uses: vedantmgoyal9/winget-releaser@main with: identifier: RustDesk.RustDesk - version: "1.4.4" - release-tag: "1.4.4" + version: "1.4.5" + release-tag: "1.4.5" token: ${{ secrets.WINGET_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index e3e40ec06..2c8cf996d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7134,7 +7134,7 @@ dependencies = [ [[package]] name = "rustdesk" -version = "1.4.4" +version = "1.4.5" dependencies = [ "android-wakelock", "android_logger", @@ -7249,7 +7249,7 @@ dependencies = [ [[package]] name = "rustdesk-portable-packer" -version = "1.4.4" +version = "1.4.5" dependencies = [ "brotli", "dirs 5.0.1", diff --git a/Cargo.toml b/Cargo.toml index 71894b660..890da5647 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk" -version = "1.4.4" +version = "1.4.5" authors = ["rustdesk "] edition = "2021" build= "build.rs" diff --git a/appimage/AppImageBuilder-aarch64.yml b/appimage/AppImageBuilder-aarch64.yml index d4409a1bb..d4af2d13a 100644 --- a/appimage/AppImageBuilder-aarch64.yml +++ b/appimage/AppImageBuilder-aarch64.yml @@ -18,7 +18,7 @@ AppDir: id: rustdesk name: rustdesk icon: rustdesk - version: 1.4.4 + version: 1.4.5 exec: usr/share/rustdesk/rustdesk exec_args: $@ apt: diff --git a/appimage/AppImageBuilder-x86_64.yml b/appimage/AppImageBuilder-x86_64.yml index 767bf6bc0..d85bd381e 100644 --- a/appimage/AppImageBuilder-x86_64.yml +++ b/appimage/AppImageBuilder-x86_64.yml @@ -18,7 +18,7 @@ AppDir: id: rustdesk name: rustdesk icon: rustdesk - version: 1.4.4 + version: 1.4.5 exec: usr/share/rustdesk/rustdesk exec_args: $@ apt: diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index bd7948de0..eca7fa05a 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -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; if (overlayState == null) return; final entry = OverlayEntry(builder: (context) { return IgnorePointer( child: Align( - alignment: const Alignment(0.0, 0.8), + alignment: alignment, child: Container( decoration: BoxDecoration( color: MyTheme.color(context).toastBg, @@ -4069,3 +4071,23 @@ String decode_http_response(http.Response resp) { bool peerTabShowNote(PeerTabIndex peerTabIndex) { 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 ''; + } +} diff --git a/flutter/lib/common/widgets/remote_input.dart b/flutter/lib/common/widgets/remote_input.dart index f75e0027b..95a716042 100644 --- a/flutter/lib/common/widgets/remote_input.dart +++ b/flutter/lib/common/widgets/remote_input.dart @@ -372,7 +372,10 @@ class _RawTouchGestureDetectorRegionState await ffi.cursorModel .move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy); } - await inputModel.sendMouse('down', MouseButtons.left); + // In relative mouse mode, skip mouse down - only send movement via sendMobileRelativeMouseMove + if (!inputModel.relativeMouseMode.value) { + await inputModel.sendMouse('down', MouseButtons.left); + } await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); } else { final offset = ffi.cursorModel.offset; @@ -397,7 +400,12 @@ class _RawTouchGestureDetectorRegionState if (handleTouch && !_touchModePanStarted) { return; } - await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch); + // 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); + } } onOneFingerPanEnd(DragEndDetails d) async { @@ -409,7 +417,10 @@ class _RawTouchGestureDetectorRegionState ffi.cursorModel.clearRemoteWindowCoords(); } if (handleTouch) { - await inputModel.sendMouse('up', MouseButtons.left); + // In relative mouse mode, skip mouse up - matches the skipped mouse down in onOneFingerPanStart + if (!inputModel.relativeMouseMode.value) { + await inputModel.sendMouse('up', MouseButtons.left); + } } } diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 929acbfcf..a46ce54fd 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -831,6 +831,7 @@ List toolbarKeyboardToggles(FFI ffi) { final ffiModel = ffi.ffiModel; final pi = ffiModel.pi; final sessionId = ffi.sessionId; + final isDefaultConn = ffi.connType == ConnType.defaultConn; List v = []; // swap key @@ -852,6 +853,34 @@ List toolbarKeyboardToggles(FFI ffi) { 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 if (ffiModel.keyboard) { var optionValue = diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index aea744a78..78b1f261a 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -258,6 +258,33 @@ const int kMinTrackpadSpeed = 10; const int kDefaultTrackpadSpeed = 100; 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. const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action'; const String kValuePrinterIncomingJobDismiss = 'dismiss'; diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 3c5245bb3..29e710bbc 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -15,6 +15,7 @@ import '../../common.dart'; import '../../common/widgets/dialog.dart'; import '../../common/widgets/toolbar.dart'; import '../../models/model.dart'; +import '../../models/input_model.dart'; import '../../models/platform_model.dart'; import '../../common/shared_state.dart'; import '../../utils/image.dart'; @@ -90,6 +91,10 @@ class _RemotePageState extends State 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` // to identify the toolbar instance and its callback function. int? _instanceIdOnEnterOrLeaveImage4Toolbar; @@ -169,6 +174,16 @@ class _RemotePageState extends State WidgetsBinding.instance.addPostFrameCallback((_) { 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 @@ -184,6 +199,13 @@ class _RemotePageState extends State _rawKeyFocusNode.unfocus(); } 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 @@ -194,6 +216,12 @@ class _RemotePageState extends State _isWindowBlur = false; } stateGlobal.isFocused.value = true; + + // Restore relative mouse mode constraints when window regains focus. + if (_ffi.inputModel.relativeMouseMode.value) { + _rawKeyFocusNode.requestFocus(); + _ffi.inputModel.onWindowFocus(); + } } @override @@ -205,6 +233,8 @@ class _RemotePageState extends State _isWindowBlur = false; } 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. @@ -212,12 +242,50 @@ class _RemotePageState extends State void onWindowMaximize() { super.onWindowMaximize(); 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 void onWindowMinimize() { super.onWindowMinimize(); WakelockManager.disable(_uniqueKey); + // Release cursor constraints when minimized + if (_ffi.inputModel.relativeMouseMode.value) { + _ffi.inputModel.onWindowBlur(); + } } @override @@ -243,6 +311,16 @@ class _RemotePageState extends State // https://github.com/flutter/flutter/issues/64935 super.dispose(); 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); if (closeSession) { // ensure we leave this session, this is a double check @@ -344,10 +422,15 @@ class _RemotePageState extends State } }(), // Use Overlay to enable rebuild every time on menu button click. - _ffi.ffiModel.pi.isSet.isTrue - ? Overlay( - initialEntries: [OverlayEntry(builder: remoteToolbar)]) - : remoteToolbar(context), + // Hide toolbar when relative mouse mode is active to prevent + // cursor from escaping to toolbar area. + Obx(() => _ffi.inputModel.relativeMouseMode.value + ? const Offstage() + : _ffi.ffiModel.pi.isSet.isTrue + ? Overlay(initialEntries: [ + OverlayEntry(builder: remoteToolbar) + ]) + : remoteToolbar(context)), _ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(), ], ), @@ -415,6 +498,7 @@ class _RemotePageState extends State // } } + // See [onWindowBlur]. if (!isWindows) { if (!_rawKeyFocusNode.hasFocus) { @@ -440,6 +524,7 @@ class _RemotePageState extends State // } } + // See [onWindowBlur]. if (!isWindows) { _ffi.inputModel.enterOrLeave(false); @@ -487,32 +572,39 @@ class _RemotePageState extends State Widget getBodyForDesktop(BuildContext context) { var paints = [ - MouseRegion(onEnter: (evt) { - if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false); - }, onExit: (evt) { - if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true); - }, child: LayoutBuilder(builder: (context, constraints) { - final c = Provider.of(context, listen: false); - Future.delayed(Duration.zero, () => c.updateViewStyle()); - final peerDisplay = CurrentDisplayState.find(widget.id); - return Obx( - () => _ffi.ffiModel.pi.isSet.isFalse - ? Container(color: Colors.transparent) - : Obx(() { - _ffi.textureModel.updateCurrentDisplay(peerDisplay.value); - return ImagePaint( - id: widget.id, - zoomCursor: _zoomCursor, - cursorOverImage: _cursorOverImage, - keyboardEnabled: _keyboardEnabled, - remoteCursorMoved: _remoteCursorMoved, - listenerBuilder: (child) => _buildRawTouchAndPointerRegion( - child, enterView, leaveView), - ffi: _ffi, - ); - }), - ); - })) + MouseRegion( + onEnter: (evt) { + if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false); + }, + onExit: (evt) { + if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true); + }, + child: _ViewStyleUpdater( + canvasModel: _ffi.canvasModel, + inputModel: _ffi.inputModel, + child: Builder(builder: (context) { + final peerDisplay = CurrentDisplayState.find(widget.id); + return Obx( + () => _ffi.ffiModel.pi.isSet.isFalse + ? Container(color: Colors.transparent) + : Obx(() { + _ffi.textureModel.updateCurrentDisplay(peerDisplay.value); + return ImagePaint( + id: widget.id, + zoomCursor: _zoomCursor, + cursorOverImage: _cursorOverImage, + keyboardEnabled: _keyboardEnabled, + remoteCursorMoved: _remoteCursorMoved, + listenerBuilder: (child) => + _buildRawTouchAndPointerRegion( + child, enterView, leaveView), + ffi: _ffi, + ); + }), + ); + }), + ), + ) ]; if (!_ffi.canvasModel.cursorEmbedded) { @@ -541,6 +633,63 @@ class _RemotePageState extends State 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 { final FFI ffi; final String id; @@ -605,21 +754,24 @@ class _ImagePaintState extends State { cursor: cursorOverImage.isTrue ? c.cursorEmbedded ? SystemMouseCursors.none - : keyboardEnabled.isTrue - ? (() { - if (remoteCursorMoved.isTrue) { - _lastRemoteCursorMoved = true; - return SystemMouseCursors.none; - } else { - if (_lastRemoteCursorMoved) { - _lastRemoteCursorMoved = false; - _firstEnterImage.value = true; - } - return _buildCustomCursor( - context, getCursorScale()); - } - }()) - : _buildDisabledCursor(context, getCursorScale()) + // Hide cursor when relative mouse mode is active + : widget.ffi.inputModel.relativeMouseMode.value + ? SystemMouseCursors.none + : keyboardEnabled.isTrue + ? (() { + if (remoteCursorMoved.isTrue) { + _lastRemoteCursorMoved = true; + return SystemMouseCursors.none; + } else { + if (_lastRemoteCursorMoved) { + _lastRemoteCursorMoved = false; + _firstEnterImage.value = true; + } + return _buildCustomCursor( + context, getCursorScale()); + } + }()) + : _buildDisabledCursor(context, getCursorScale()) : MouseCursor.defer, onHover: (evt) {}, child: child); diff --git a/flutter/lib/desktop/pages/remote_tab_page.dart b/flutter/lib/desktop/pages/remote_tab_page.dart index af285ac35..ccd5935ce 100644 --- a/flutter/lib/desktop/pages/remote_tab_page.dart +++ b/flutter/lib/desktop/pages/remote_tab_page.dart @@ -135,7 +135,13 @@ class _ConnectionTabPageState extends State { body: DesktopTab( controller: tabController, onWindowCloseButton: handleWindowCloseButton, - tail: const AddButton(), + tail: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _RelativeMouseModeHint(tabController: tabController), + const AddButton(), + ], + ), selectedBorderColor: MyTheme.accent, pageViewBuilder: (pageView) => pageView, labelGetter: DesktopTab.tablabelGetter, @@ -374,6 +380,8 @@ class _ConnectionTabPageState extends State { loopCloseWindow(); } ConnectionTypeState.delete(id); + // Clean up relative mouse mode state for this peer. + stateGlobal.relativeMouseModeState.remove(id); _update_remote_count(); } @@ -548,3 +556,69 @@ class _ConnectionTabPageState extends State { 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], + ), + ), + ], + ), + ); + }); + } +} diff --git a/flutter/lib/desktop/widgets/tabbar_widget.dart b/flutter/lib/desktop/widgets/tabbar_widget.dart index cf601557a..ac7d80017 100644 --- a/flutter/lib/desktop/widgets/tabbar_widget.dart +++ b/flutter/lib/desktop/widgets/tabbar_widget.dart @@ -593,7 +593,6 @@ class _DesktopTabState extends State Widget _buildBar() { return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: GestureDetector( diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index dd783055a..22dbebce6 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -569,7 +569,9 @@ class _RemotePageState extends State with WidgetsBindingObserver { } bool get showCursorPaint => - !gFFI.ffiModel.isPeerAndroid && !gFFI.canvasModel.cursorEmbedded; + !gFFI.ffiModel.isPeerAndroid && + !gFFI.canvasModel.cursorEmbedded && + !gFFI.inputModel.relativeMouseMode.value; Widget getBodyForMobile() { final keyboardIsVisible = keyboardVisibilityController.isVisible; @@ -808,6 +810,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { bind.mainSetLocalOption(key: kOptionTouchMode, value: v); }, virtualMouseMode: gFFI.ffiModel.virtualMouseMode, + inputModel: gFFI.inputModel, ))); } diff --git a/flutter/lib/mobile/widgets/floating_mouse_widgets.dart b/flutter/lib/mobile/widgets/floating_mouse_widgets.dart index ddb20860c..dbcc606af 100644 --- a/flutter/lib/mobile/widgets/floating_mouse_widgets.dart +++ b/flutter/lib/mobile/widgets/floating_mouse_widgets.dart @@ -83,7 +83,10 @@ class _FloatingMouseWidgetsState extends State { cursorModel: _cursorModel, ), if (virtualMouseMode.showVirtualJoystick) - VirtualJoystick(cursorModel: _cursorModel), + VirtualJoystick( + cursorModel: _cursorModel, + inputModel: _inputModel, + ), FloatingLeftRightButton( isLeft: true, inputModel: _inputModel, @@ -674,12 +677,18 @@ class _QuarterCirclePainter extends CustomPainter { bool shouldRepaint(CustomPainter oldDelegate) => false; } -// Virtual joystick sends the absolute movement for now. -// Maybe we need to change it to relative movement in the future. +// Virtual joystick can send either absolute movement (via updatePan) +// or relative movement (via sendMobileRelativeMouseMove) depending on the +// InputModel.relativeMouseMode setting. class VirtualJoystick extends StatefulWidget { final CursorModel cursorModel; + final InputModel inputModel; - const VirtualJoystick({super.key, required this.cursorModel}); + const VirtualJoystick({ + super.key, + required this.cursorModel, + required this.inputModel, + }); @override State createState() => _VirtualJoystickState(); @@ -694,6 +703,10 @@ class _VirtualJoystickState extends State { final double _moveStep = 3.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 Timer? _dragStartTimer; // Periodic timer for continuous movement @@ -701,6 +714,9 @@ class _VirtualJoystickState extends State { Size? _lastScreenSize; bool _isPressed = false; + /// Check if relative mouse mode is enabled. + bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value; + @override void initState() { super.initState(); @@ -746,6 +762,18 @@ class _VirtualJoystickState extends State { ); } + /// 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() { _dragStartTimer?.cancel(); _continuousMoveTimer?.cancel(); @@ -773,7 +801,7 @@ class _VirtualJoystickState extends State { // The movement is small for a gentle start. final initialDelta = _offsetToPanDelta(_offset); 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. @@ -784,10 +812,7 @@ class _VirtualJoystickState extends State { _continuousMoveTimer = periodic_immediate(const Duration(milliseconds: 20), () async { if (_offset != Offset.zero) { - widget.cursorModel.updatePan( - _offsetToPanDelta(_offset) * _moveStep * _speed, - Offset.zero, - false); + _sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed); } }); }); diff --git a/flutter/lib/mobile/widgets/gesture_help.dart b/flutter/lib/mobile/widgets/gesture_help.dart index 30150be5a..8e86681b4 100644 --- a/flutter/lib/mobile/widgets/gesture_help.dart +++ b/flutter/lib/mobile/widgets/gesture_help.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/models/input_model.dart'; import 'package:flutter_hbb/models/model.dart'; +import 'package:get/get.dart'; import 'package:toggle_switch/toggle_switch.dart'; class GestureIcons { @@ -39,11 +41,13 @@ class GestureHelp extends StatefulWidget { {Key? key, required this.touchMode, required this.onTouchModeChange, - required this.virtualMouseMode}) + required this.virtualMouseMode, + this.inputModel}) : super(key: key); final bool touchMode; final OnTouchModeChange onTouchModeChange; final VirtualMouseMode virtualMouseMode; + final InputModel? inputModel; @override State createState() => @@ -61,6 +65,14 @@ class _GestureHelpState extends State { _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 Widget build(BuildContext context) { final size = MediaQuery.of(context).size; @@ -103,6 +115,8 @@ class _GestureHelpState extends State { _selectedIndex = index ?? 0; _touchMode = index == 0 ? false : true; widget.onTouchModeChange(_touchMode); + // Exit relative mouse mode when switching to touch mode + _exitRelativeMouseModeIf(_touchMode); } }); }, @@ -117,12 +131,18 @@ class _GestureHelpState extends State { onChanged: (value) async { if (value == null) return; await _virtualMouseMode.toggleVirtualMouse(); + // Exit relative mouse mode when virtual mouse is hidden + _exitRelativeMouseModeIf( + !_virtualMouseMode.showVirtualMouse); setState(() {}); }, ), InkWell( onTap: () async { await _virtualMouseMode.toggleVirtualMouse(); + // Exit relative mouse mode when virtual mouse is hidden + _exitRelativeMouseModeIf( + !_virtualMouseMode.showVirtualMouse); setState(() {}); }, child: Text(translate('Show virtual mouse')), @@ -196,6 +216,10 @@ class _GestureHelpState extends State { if (value == null) return; await _virtualMouseMode .toggleVirtualJoystick(); + // Exit relative mouse mode when joystick is hidden + _exitRelativeMouseModeIf( + !_virtualMouseMode + .showVirtualJoystick); setState(() {}); }, ), @@ -203,6 +227,10 @@ class _GestureHelpState extends State { onTap: () async { await _virtualMouseMode .toggleVirtualJoystick(); + // Exit relative mouse mode when joystick is hidden + _exitRelativeMouseModeIf( + !_virtualMouseMode + .showVirtualJoystick); setState(() {}); }, child: Text( @@ -211,6 +239,39 @@ class _GestureHelpState extends State { ], )), ), + // 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')), + ), + ], + )), + )), ], ), ), diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 29d0cc0fd..c14a23739 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -14,6 +14,8 @@ import 'package:get/get.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; +import '../../models/state_model.dart'; +import 'relative_mouse_model.dart'; import '../common.dart'; import '../consts.dart'; @@ -349,15 +351,28 @@ class InputModel { double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0; 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; bool _pointerMovedAfterEnter = false; + bool _pointerInsideImage = false; // mouse final isPhysicalMouse = false.obs; int _lastButtons = 0; 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; Rect? _windowRect; List _remoteWindowCoords = []; @@ -367,15 +382,40 @@ class InputModel { bool get keyboardPerm => parent.target!.ffiModel.keyboard; String get id => parent.target?.id ?? ''; 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 showMyCursor => parent.target!.ffiModel.showMyCursor; double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio; bool get isViewCamera => parent.target!.connType == ConnType.viewCamera; 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) { 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. @@ -506,6 +546,10 @@ class InputModel { } } + if (_relativeMouse.handleRawKeyEvent(e)) { + return KeyEventResult.handled; + } + final key = e.logicalKey; if (e is RawKeyDownEvent) { 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) { handleKeyUpEventModifiers(e); } else if (e is KeyDownEvent) { @@ -853,11 +907,13 @@ class InputModel { toReleaseKeys.release(handleKeyEvent); toReleaseRawKeys.release(handleRawKeyEvent); _pointerMovedAfterEnter = false; + _pointerInsideImage = enter; // Fix status if (!enter) { resetModifiers(); } + _relativeMouse.onEnterOrLeaveImage(enter); _flingTimer?.cancel(); if (!isInputSourceFlutter) { bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter); @@ -878,15 +934,134 @@ class InputModel { 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 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 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) { _stopFling = true; if (isViewOnly && !showMyCursor) 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) { isPhysicalMouse.value = true; } 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,13 +1218,25 @@ class InputModel { _windowRect = null; if (isViewOnly && !showMyCursor) return; if (isViewCamera) return; + + if (_relativeMouse.enabled.value) { + _relativeMouse.updatePointerRegionTopLeftGlobal(e); + } + if (e.kind != ui.PointerDeviceKind.mouse) { if (isPhysicalMouse.value) { isPhysicalMouse.value = false; } } if (isPhysicalMouse.value) { - handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position); + // 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); + } } } @@ -1057,9 +1244,21 @@ class InputModel { if (isDesktop) _queryOtherWindowCoords = false; if (isViewOnly && !showMyCursor) return; if (isViewCamera) return; + + if (_relativeMouse.enabled.value) { + _relativeMouse.updatePointerRegionTopLeftGlobal(e); + } + if (e.kind != ui.PointerDeviceKind.mouse) return; if (isPhysicalMouse.value) { - handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position); + // 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); + } } } @@ -1067,6 +1266,11 @@ class InputModel { if (isViewOnly && !showMyCursor) return; if (isViewCamera) return; if (e.kind != ui.PointerDeviceKind.mouse) return; + + if (_relativeMouse.enabled.value) { + _relativeMouse.updatePointerRegionTopLeftGlobal(e); + } + if (_queryOtherWindowCoords) { Future.delayed(Duration.zero, () async { _windowRect = await fillRemoteCoordsAndGetCurFrame(_remoteWindowCoords); @@ -1074,7 +1278,10 @@ class InputModel { _queryOtherWindowCoords = false; } 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; } + /// 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) { if (isViewOnly) return; if (isViewCamera) return; @@ -1285,14 +1497,18 @@ class InputModel { evt['y'] = '${pos.y.toInt()}'; } - Map mapButtons = { - kPrimaryMouseButton: 'left', - kSecondaryMouseButton: 'right', - kMiddleMouseButton: 'wheel', - kBackMouseButton: 'back', - kForwardMouseButton: 'forward' - }; - evt['buttons'] = mapButtons[evt['buttons']] ?? ''; + final buttons = evt['buttons']; + if (buttons is int) { + evt['buttons'] = mouseButtonsToPeer(buttons); + } else { + // Log warning if buttons exists but is not an int (unexpected caller). + // Keep empty string fallback for missing buttons to preserve move/hover behavior. + if (buttons != null) { + debugPrint( + '[InputModel] processEventToPeer: unexpected buttons type: ${buttons.runtimeType}, value: $buttons'); + } + evt['buttons'] = ''; + } return evt; } @@ -1303,8 +1519,8 @@ class InputModel { bool moveCanvas = true, bool edgeScroll = false, }) { - final evtToPeer = - processEventToPeer(evt, offset, onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll); + final evtToPeer = processEventToPeer(evt, offset, + onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll); if (evtToPeer != null) { bind.sessionSendMouse( sessionId: sessionId, msg: json.encode(modify(evtToPeer))); diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index e2f509c13..578ba3ce3 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -213,6 +213,9 @@ class FfiModel with ChangeNotifier { } updatePermission(Map evt, String id) { + // Track previous keyboard permission to detect revocation. + final hadKeyboardPerm = _permissions['keyboard'] != false; + evt.forEach((k, v) { if (k == 'name' || k.isEmpty) return; _permissions[k] = v == 'true'; @@ -221,6 +224,18 @@ class FfiModel with ChangeNotifier { if (parent.target?.connType == ConnType.defaultConn) { 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'); notifyListeners(); } @@ -457,6 +472,9 @@ class FfiModel with ChangeNotifier { _handlePrinterRequest(evt, sessionId, peerId); } else if (name == 'screenshot') { _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 { debugPrint('Event is not handled in the fixed branch: $name'); } @@ -765,7 +783,7 @@ class FfiModel with ChangeNotifier { } } - updateCurDisplay(SessionID sessionId, {updateCursorPos = false}) { + Future updateCurDisplay(SessionID sessionId, {updateCursorPos = false}) async { final newRect = displaysRect(); if (newRect == null) { return; @@ -777,9 +795,19 @@ class FfiModel with ChangeNotifier { updateCursorPos: updateCursorPos); } _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); _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 text = evt['text']; 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') { wrongPasswordDialog(sessionId, dialogManager, type, title, text); } else if (type == 'input-2fa') { @@ -967,6 +1006,8 @@ class FfiModel with ChangeNotifier { void reconnect(OverlayDialogManager dialogManager, SessionID sessionId, bool forceRelay) { + // Disable relative mouse mode before reconnecting to ensure cursor is released. + parent.target?.inputModel.setRelativeMouseMode(false); bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay); clearPermissions(); dialogManager.dismissAll(); @@ -1192,9 +1233,6 @@ class FfiModel with ChangeNotifier { _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. // Because this function is asynchronous, there's an "await" in this function. cachedPeerData.peerInfo = {...evt}; @@ -1206,6 +1244,17 @@ class FfiModel with ChangeNotifier { parent.target?.dialogManager.dismissAll(); _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 = bind.isSupportMultiUiSession(version: _pi.version); _pi.username = evt['username']; @@ -1307,7 +1356,11 @@ class FfiModel with ChangeNotifier { stateGlobal.resetLastResolutionGroupValues(peerId); 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(); @@ -3768,6 +3821,8 @@ class FFI { ffiModel.clear(); canvasModel.clear(); inputModel.resetModifiers(); + // Dispose relative mouse mode resources to ensure cursor is restored + inputModel.disposeRelativeMouseMode(); if (closeSession) { await bind.sessionClose(sessionId: sessionId); } diff --git a/flutter/lib/models/relative_mouse_model.dart b/flutter/lib/models/relative_mouse_model.dart new file mode 100644 index 000000000..2673cb8ae --- /dev/null +++ b/flutter/lib/models/relative_mouse_model.dart @@ -0,0 +1,1061 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hbb/main.dart'; +import 'package:flutter_hbb/utils/relative_mouse_accumulator.dart'; +import 'package:get/get.dart'; + +import '../common.dart'; +import '../consts.dart'; +import 'platform_model.dart'; + +class RelativeMouseModel { + final SessionID sessionId; + final RxBool enabled; + + final bool Function() keyboardPerm; + final bool Function() isViewCamera; + final String Function() peerVersion; + final String? Function() peerPlatform; + + final Map Function(Map msg) modify; + + final bool Function() getPointerInsideImage; + final void Function(bool inside) setPointerInsideImage; + + RelativeMouseModel({ + required this.sessionId, + required this.enabled, + required this.keyboardPerm, + required this.isViewCamera, + required this.peerVersion, + required this.peerPlatform, + required this.modify, + required this.getPointerInsideImage, + required this.setPointerInsideImage, + }); + + final RelativeMouseAccumulator _accumulator = RelativeMouseAccumulator(); + + // Native relative mouse mode support (macOS only) + // Uses CGAssociateMouseAndMouseCursorPosition to lock cursor and NSEvent monitor for raw delta. + static MethodChannel? _hostChannel; + // The currently active model receiving native mouse delta events. + // Note: Race condition between multiple sessions is not a concern here because + // when relative mouse mode is active, the cursor is locked and the user cannot + // switch to another session window. The user must first exit relative mouse mode + // (via Cmd+G on macOS or Ctrl+Alt on Windows/Linux) before they can interact + // with a different session. + static RelativeMouseModel? _activeNativeModel; + static bool _hostChannelInitialized = false; + + /// Initialize the host channel for native relative mouse mode. + /// This should be called once when the app starts on macOS. + static void initHostChannel() { + if (!isMacOS) return; + if (_hostChannelInitialized) return; + _hostChannelInitialized = true; + + _hostChannel = const MethodChannel('org.rustdesk.rustdesk/host'); + _hostChannel!.setMethodCallHandler((call) async { + if (call.method == 'onMouseDelta') { + final args = call.arguments as Map; + final dx = args['dx'] as int; + final dy = args['dy'] as int; + _activeNativeModel?._onNativeMouseDelta(dx, dy); + } + return null; + }); + } + + // TODO(perf): Consider routing native delta through RelativeMouseAccumulator/throttle + // if high-polling mice (e.g. 1000Hz+) cause message flooding on the network. + void _onNativeMouseDelta(int dx, int dy) { + if (!enabled.value) return; + // Send directly to remote without accumulator (native already provides integer deltas) + _sendMouseMessageToSession({ + 'type': 'move_relative', + 'x': '$dx', + 'y': '$dy', + }); + } + + Future _enableNativeRelativeMouseMode() async { + if (!isMacOS) return false; + if (_hostChannel == null) { + initHostChannel(); + if (_hostChannel == null) return false; + } + + // Defensive guard: prevent overwriting an already-active native session. + // In practice, this should not happen because when relative mouse mode is active, + // the cursor is locked and the user cannot switch to another session window. + // The user must first exit relative mouse mode (via Cmd+G on macOS or Ctrl+Alt on + // Windows/Linux) before interacting with a different session. + if (_activeNativeModel != null && _activeNativeModel != this) { + debugPrint( + '[RelMouse] Another model already has native relative mouse mode active'); + return false; + } + + try { + final result = + await _hostChannel!.invokeMethod('enableNativeRelativeMouseMode'); + if (result == true) { + _activeNativeModel = this; + return true; + } + } catch (e) { + debugPrint('[RelMouse] Failed to enable native relative mouse mode: $e'); + } + return false; + } + + Future _disableNativeRelativeMouseMode() async { + if (!isMacOS) return; + if (_hostChannel == null) return; + + // Only the owning model should disable native mode to avoid + // one session inadvertently disrupting another's native relative mouse state. + if (_activeNativeModel != this) { + return; + } + + try { + await _hostChannel!.invokeMethod('disableNativeRelativeMouseMode'); + } catch (e) { + debugPrint('[RelMouse] Failed to disable native relative mouse mode: $e'); + } finally { + if (_activeNativeModel == this) { + _activeNativeModel = null; + } + } + } + + // Whether native relative mouse mode is currently active for this model + bool get _isNativeRelativeMouseModeActive => + isMacOS && _activeNativeModel == this; + + // Pointer lock center in LOCAL widget coordinates (for delta calculation) + Offset? _pointerLockCenterLocal; + // Pointer lock center in SCREEN coordinates (for OS cursor re-centering) + Offset? _pointerLockCenterScreen; + // Pointer region top-left in Flutter view coordinates. + // Computed from PointerEvent.position - PointerEvent.localPosition. + Offset? _pointerRegionTopLeftGlobal; + // Last pointer position in LOCAL widget coordinates (fallback when center is not ready). + Offset? _lastPointerLocalPos; + + // Track whether we currently have an OS-level cursor clip active (Windows only). + // TODO(accuracy): Revisit window/client/border clipping math if users report misaligned + // clipping on custom or maximized window decorations. Consider using platform APIs + // (e.g. GetClientRect on Windows) instead of Flutter's window coordinates. + bool _cursorClipApplied = false; + + // Track whether a recenter operation is in progress to prevent overlapping calls. + bool _recenterInProgress = false; + + // Request token for async enable operation to prevent stale callbacks. + // Incremented on each enable attempt, callbacks check if token still matches. + int _enableRequestId = 0; + + // Throttle buffer for batching mouse move messages (reduces network flooding). + int _pendingDeltaX = 0; + int _pendingDeltaY = 0; + Timer? _throttleTimer; + static const Duration _throttleInterval = Duration(milliseconds: 16); + + // Size of the remote image widget (for center calculation) + Size? _imageWidgetSize; + + // Debounce timestamp for relative mouse mode toggle to prevent race conditions + // between Rust rdev grab loop and Flutter keyboard handling. + DateTime? _lastToggle; + + // Track key down state for exit shortcut. + // macOS: Cmd+G - track G key + // Windows/Linux: Ctrl+Alt - track whichever modifier was pressed last + // When key down is blocked (shortcut triggered), we also need to block + // the corresponding key up to avoid orphan key up events being sent to remote. + bool _exitShortcutKeyDown = false; + + // Callback to cancel external throttle timer when relative mouse mode is disabled. + VoidCallback? onDisabled; + + bool get isSupported { + // On Linux/Wayland, cursor warping is not supported, hide the option entirely. + if (isDesktop && isLinux && bind.mainCurrentIsWayland()) { + return false; + } + // Relative mouse mode is unsupported on remote Linux: + // 1. Long-press key events are unsupported. + // 2. The Wayland display server lacks cursor warping support. + final platform = peerPlatform(); + if (platform == kPeerPlatformLinux) { + return false; + } + final v = peerVersion(); + if (v.isEmpty) return false; + return versionCmp(v, kMinVersionForRelativeMouseMode) >= 0; + } + + Size? get imageWidgetSize => _imageWidgetSize; + + void updateImageWidgetSize(Size size) { + _imageWidgetSize = size; + if (enabled.value) { + _pointerLockCenterLocal = Offset(size.width / 2, size.height / 2); + } + } + + void updatePointerRegionTopLeftGlobal(PointerEvent e) { + _pointerRegionTopLeftGlobal = e.position - e.localPosition; + } + + /// Shared helper for handling exit shortcut for relative mouse mode. + /// Returns true if the event was handled and should not be forwarded. + /// + /// Exit shortcuts (only work when relative mouse mode is active): + /// - macOS: Cmd+G + /// - Windows/Linux: Ctrl+Alt (any order - triggered when both are pressed) + /// + /// [logicalKey] - the logical key of the event + /// [isKeyUp] - whether the event is a key up event + /// [isKeyDown] - whether the event is a key down event + /// [ctrlPressed], [altPressed], [commandPressed] - modifier states + bool _handleExitShortcut({ + required LogicalKeyboardKey logicalKey, + required bool isKeyUp, + required bool isKeyDown, + required bool ctrlPressed, + required bool altPressed, + required bool commandPressed, + }) { + if (!isDesktop || !keyboardPerm() || isViewCamera()) return false; + + // Only handle exit shortcuts when relative mouse mode is active + if (!enabled.value) return false; + + // Block key up if key down was blocked (to avoid orphan key up event on remote). + if (isKeyUp && _exitShortcutKeyDown) { + _exitShortcutKeyDown = false; + return true; + } + + if (!isKeyDown) return false; + + // macOS: Cmd+G to exit + if (isMacOS) { + final isGKey = logicalKey == LogicalKeyboardKey.keyG; + if (isGKey && commandPressed) { + _exitShortcutKeyDown = true; + setRelativeMouseMode(false); + return true; + } + return false; + } + + // Windows/Linux: Ctrl+Alt to exit + // Triggered when both modifiers are pressed (check on either Ctrl or Alt key down) + final isCtrlKey = logicalKey == LogicalKeyboardKey.controlLeft || + logicalKey == LogicalKeyboardKey.controlRight; + final isAltKey = logicalKey == LogicalKeyboardKey.altLeft || + logicalKey == LogicalKeyboardKey.altRight; + + // When Ctrl is pressed and Alt is already down, or vice versa + if ((isCtrlKey && altPressed) || (isAltKey && ctrlPressed)) { + _exitShortcutKeyDown = true; + setRelativeMouseMode(false); + return true; + } + + return false; + } + + bool handleKeyEvent( + KeyEvent e, { + required bool ctrlPressed, + required bool shiftPressed, + required bool altPressed, + required bool commandPressed, + }) { + return _handleExitShortcut( + logicalKey: e.logicalKey, + isKeyUp: e is KeyUpEvent, + isKeyDown: e is KeyDownEvent, + ctrlPressed: ctrlPressed, + altPressed: altPressed, + commandPressed: commandPressed, + ); + } + + /// Handle raw key events for relative mouse mode. + /// Returns true if the event was handled and should not be forwarded. + bool handleRawKeyEvent(RawKeyEvent e) { + final modifiers = e.data; + return _handleExitShortcut( + logicalKey: e.logicalKey, + isKeyUp: e is RawKeyUpEvent, + isKeyDown: e is RawKeyDownEvent, + ctrlPressed: modifiers.isControlPressed, + altPressed: modifiers.isAltPressed, + commandPressed: modifiers.isMetaPressed, + ); + } + + void onEnterOrLeaveImage(bool enter) { + if (!enabled.value) return; + + // Keep the shared pointer-in-image flag in sync. + setPointerInsideImage(enter); + + // macOS native mode: cursor is locked by CGAssociateMouseAndMouseCursorPosition, + // no need for recenter logic. + if (_isNativeRelativeMouseModeActive) { + return; + } + + if (!enter) { + _releaseCursorClip(); + return; + } + + // Windows: clip cursor to window rect + // Linux: use recenter method + updatePointerLockCenter().then((_) { + _recenterMouse(); + }); + } + + void onWindowBlur() { + if (!enabled.value) return; + + // Focus can change while the pointer is outside the window (e.g. taskbar activation). + // Do not rely on the previous "pointer inside" state across focus boundaries. + setPointerInsideImage(false); + // macOS native mode: don't call _releaseCursorClip as it would break CGAssociateMouseAndMouseCursorPosition + if (!_isNativeRelativeMouseModeActive) { + _releaseCursorClip(); + } + } + + void onWindowFocus() { + if (!enabled.value) return; + + // macOS native mode: cursor is already locked + if (_isNativeRelativeMouseModeActive) { + setPointerInsideImage(false); + return; + } + + // Guard: image widget size must be available for proper center calculation. + if (_imageWidgetSize == null) { + _disableWithCleanup(); + return; + } + + // Fail-safe: keep cursor usable on focus gain. Pointer lock will be re-engaged + // on the next pointer enter/move/hover inside the remote image. + setPointerInsideImage(false); + _releaseCursorClip(); + + // Best-effort: refresh center so the next engage is immediate. + updatePointerLockCenter(); + } + + void toggleRelativeMouseMode() { + final now = DateTime.now(); + if (_lastToggle != null && + now.difference(_lastToggle!).inMilliseconds < + kRelativeMouseModeToggleDebounceMs) { + return; + } + _lastToggle = now; + setRelativeMouseMode(!enabled.value); + } + + bool setRelativeMouseMode(bool value) { + // Web is not supported due to Pointer Lock API integration complexity with Flutter's input system + if (isWeb) { + return false; + } + + if (value) { + if (!keyboardPerm() || isViewCamera()) { + return false; + } + + if (isDesktop && _imageWidgetSize == null) { + // Desktop only: Ensure image widget size is available for proper center calculation. + showToast(translate('rel-mouse-not-ready-tip')); + return false; + } + + if (!isSupported) { + // Check server version support before enabling. + showToast(translate('rel-mouse-not-supported-peer-tip')); + return false; + } + } + + if (value) { + try { + if (isDesktop) { + final requestId = ++_enableRequestId; + if (isMacOS) { + // macOS: Use native relative mouse mode with CGAssociateMouseAndMouseCursorPosition + // This locks the cursor in place and provides raw delta via NSEvent monitor. + _enableNativeRelativeMouseMode().then((success) { + // Guard against stale callback: user may have toggled off relative mode + // while the async enable was in progress. + if (_enableRequestId != requestId) { + return; + } + if (success) { + _completeEnableRelativeMouseMode(); + } + // Note: _enableNativeRelativeMouseMode already handles its own cleanup on failure + }); + } else { + // Windows/Linux: Use Flutter-based cursor recenter approach + if (!getPointerInsideImage()) { + _releaseCursorClip(); + } + + updatePointerLockCenter().then((_) => _recenterMouse()).then((_) { + if (_enableRequestId != requestId) { + return; + } + _completeEnableRelativeMouseMode(); + }).catchError((e) { + if (_enableRequestId != requestId) { + return; + } + debugPrint('[RelMouse] Platform setup failed: $e'); + _resetState(); + }); + } + } else { + // Mobile: enable immediately (no platform-specific setup needed) + _completeEnableRelativeMouseMode(); + } + } catch (e) { + _disableWithCleanup(); + return false; + } + } else { + // Best-effort marker for Rust rdev grab loop (ESC behavior). + // Bypass keyboardPerm check to ensure Rust state is always synced, + // even if permission was revoked while relative mode was active. + _sendMouseMessageToSession( + { + 'relative_mouse_mode': '0', + }, + disableRelativeOnError: false, + bypassKeyboardPerm: true, + ); + + // Desktop only: cursor manipulation + if (isDesktop) { + if (isMacOS) { + // macOS: Disable native relative mouse mode + // This already calls CGAssociateMouseAndMouseCursorPosition(1) to re-associate mouse + _disableNativeRelativeMouseMode(); + } else { + _releaseCursorClip(); + } + } + enabled.value = false; + _resetState(); + onDisabled?.call(); + } + + return true; + } + + /// Called when platform setup completes successfully to finalize enabling relative mouse mode. + void _completeEnableRelativeMouseMode() { + enabled.value = true; + + // Show toast notification so user knows how to exit relative mouse mode (desktop only). + if (isDesktop) { + showToast( + translate('rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'), + alignment: Alignment.center); + } + + // Best-effort marker for Rust rdev grab loop (ESC behavior) and peer/server state. + // This uses a no-op delta so it does not move the remote cursor. + // Intentionally fire-and-forget: we don't block enabling on this marker message. + // Failures are logged but do not disable relative mouse mode. + _sendMouseMessageToSession( + { + 'relative_mouse_mode': '1', + 'type': 'move_relative', + 'x': '0', + 'y': '0', + }, + disableRelativeOnError: false, + ).catchError((e) { + debugPrint('[RelMouse] Failed to send enable marker: $e'); + return false; + }); + } + + // Flag to skip the first mouse move event after recenter (it's the recenter itself). + bool _skipNextMouseMove = false; + + /// Handle relative mouse movement based on current local pointer position. + /// Returns true if the event was handled in relative mode, false otherwise. + bool handleRelativeMouseMove(Offset localPosition) { + if (!enabled.value) return false; + + // macOS: Native mode handles delta via callback, skip Flutter-based handling. + if (_isNativeRelativeMouseModeActive) { + return true; + } + + // Pointer move/hover implies we're inside the remote image. + _ensurePointerLockEngaged(); + + // Skip the mouse move event triggered by recenter operation itself. + if (_skipNextMouseMove) { + _skipNextMouseMove = false; + _lastPointerLocalPos = localPosition; + return true; + } + + final lastLocal = _lastPointerLocalPos; + _lastPointerLocalPos = localPosition; + + // Linux-specific: Proactive recenter check before processing delta. + // On Linux, we don't have clip_cursor, so if the cursor moves too fast + // it may escape the window before _recenterIfNearEdge can catch it. + // Check now and recenter immediately if needed. + if (isLinux) { + _recenterIfNearEdgeLinux(localPosition); + } + + // Calculate delta from last position (not from center). + // This avoids issues with CGWarpMouseCursorPosition integer rounding. + if (lastLocal != null) { + final delta = localPosition - lastLocal; + if (delta.dx != 0 || delta.dy != 0) { + sendRelativeMouseMove(delta.dx, delta.dy); + } + } + + return true; + } + + /// Linux-specific: More aggressive recenter check to prevent cursor escape. + /// Called synchronously before processing mouse delta to ensure cursor stays within bounds. + void _recenterIfNearEdgeLinux(Offset localPosition) { + final size = _imageWidgetSize; + if (size == null) return; + + final edgeThreshold = _calculateEdgeThreshold(size); + + final nearLeft = localPosition.dx < edgeThreshold; + final nearRight = localPosition.dx > size.width - edgeThreshold; + final nearTop = localPosition.dy < edgeThreshold; + final nearBottom = localPosition.dy > size.height - edgeThreshold; + + if (nearLeft || nearRight || nearTop || nearBottom) { + _recenterMouse(); + } + } + + void sendRelativeMouseMove(double dx, double dy) { + if (!isDesktop) return; + + final delta = _accumulator.add(dx, dy, maxDelta: kMaxRelativeMouseDelta); + if (delta == null) return; + + // Buffer the delta for throttled sending. + _pendingDeltaX += delta.x; + _pendingDeltaY += delta.y; + + // Start or refresh the throttle timer. + if (_throttleTimer == null || !_throttleTimer!.isActive) { + _throttleTimer = Timer(_throttleInterval, () => _flushPendingDelta()); + } + } + + Future _flushPendingDelta() async { + if (!isDesktop) return; + if (_pendingDeltaX == 0 && _pendingDeltaY == 0) return; + + final x = _pendingDeltaX; + final y = _pendingDeltaY; + _pendingDeltaX = 0; + _pendingDeltaY = 0; + + final ok = await _sendMouseMessageToSession({ + 'type': 'move_relative', + 'x': '$x', + 'y': '$y', + }); + if (!ok) return; + + // Only recenter when mouse is near the edge of the image widget. + // This allows smooth mouse movement without constant recentering. + _recenterIfNearEdge(); + } + + // Edge threshold parameters for recenter detection. + // Threshold is calculated as: min(maxThreshold, min(width, height) * fraction) + static const double _edgeThresholdFraction = 0.1; // 10% of smaller dimension + static const double _edgeThresholdMax = + 100.0; // Maximum threshold in logical pixels + static const double _edgeThresholdMin = + 20.0; // Minimum threshold for very small widgets + + // Linux-specific edge threshold parameters (more aggressive to prevent cursor escape). + // On Linux, we don't have clip_cursor capability, so we need to recenter earlier + // to prevent the cursor from escaping the window when moving fast. + static const double _edgeThresholdFractionLinux = + 0.25; // 25% of smaller dimension + static const double _edgeThresholdMaxLinux = + 200.0; // Larger maximum threshold for Linux + static const double _edgeThresholdMinLinux = + 50.0; // Larger minimum threshold for Linux + + /// Calculate dynamic edge threshold based on widget size. + double _calculateEdgeThreshold(Size size) { + final smallerDimension = math.min(size.width, size.height); + if (isLinux) { + // Use more aggressive thresholds on Linux to prevent cursor escape. + final dynamicThreshold = smallerDimension * _edgeThresholdFractionLinux; + return dynamicThreshold.clamp( + _edgeThresholdMinLinux, _edgeThresholdMaxLinux); + } + final dynamicThreshold = smallerDimension * _edgeThresholdFraction; + // Clamp between min and max thresholds + return dynamicThreshold.clamp(_edgeThresholdMin, _edgeThresholdMax); + } + + /// Recenter the cursor only if it's near the edge of the image widget. + void _recenterIfNearEdge() { + final lastPos = _lastPointerLocalPos; + final size = _imageWidgetSize; + if (lastPos == null || size == null) return; + + // Dynamic threshold based on widget size + final edgeThreshold = _calculateEdgeThreshold(size); + + final nearLeft = lastPos.dx < edgeThreshold; + final nearRight = lastPos.dx > size.width - edgeThreshold; + final nearTop = lastPos.dy < edgeThreshold; + final nearBottom = lastPos.dy > size.height - edgeThreshold; + + if (nearLeft || nearRight || nearTop || nearBottom) { + _recenterMouse(); + } + } + + /// Send mouse button event without position (for relative mouse mode). + Future sendRelativeMouseButton(Map evt) async { + if (!enabled.value) return; + _ensurePointerLockEngaged(); + + final rawType = evt['type']; + final rawButtons = evt['buttons']; + if (rawType is! String || rawButtons is! int) return; + + final type = _mouseEventTypeToPeer(rawType); + if (type.isEmpty) return; + + final buttons = mouseButtonsToPeer(rawButtons); + if (buttons.isEmpty) return; + + await _sendMouseMessageToSession({ + 'type': type, + 'buttons': buttons, + }); + } + + static String _mouseEventTypeToPeer(String type) { + switch (type) { + case 'mousedown': + return kMouseEventTypeDown; + case 'mouseup': + return kMouseEventTypeUp; + default: + return ''; + } + } + + Future _sendMouseMessageToSession( + Map msg, { + bool disableRelativeOnError = true, + bool bypassKeyboardPerm = false, + }) async { + if (!bypassKeyboardPerm && !keyboardPerm()) return false; + if (isViewCamera()) return false; + + try { + await bind.sessionSendMouse( + sessionId: sessionId, + msg: json.encode(modify(msg)), + ); + return true; + } catch (e) { + debugPrint('[RelMouse] Error sending mouse message: $e'); + if (disableRelativeOnError && enabled.value) { + _disableWithCleanup(); + } + return false; + } + } + + /// Retry parameters for cursor re-centering. + static const int _recenterMaxRetries = 3; + static const Duration _recenterRetryDelay = Duration(milliseconds: 100); + + /// Recenter the cursor to the pointer lock center. + /// Fire-and-forget safe: prevents overlapping calls and catches errors internally. + Future _recenterMouse() async { + // Prevent overlapping recenter operations under high-frequency mouse moves. + if (_recenterInProgress) return; + _recenterInProgress = true; + + try { + if (!enabled.value) return; + if (!getPointerInsideImage()) return; + + final center = _pointerLockCenterScreen; + if (center == null) { + return; + } + + for (int attempt = 0; attempt < _recenterMaxRetries; attempt++) { + // Check preconditions before each attempt. + if (!enabled.value || !getPointerInsideImage()) return; + + final ok = bind.mainSetCursorPosition( + x: center.dx.toInt(), + y: center.dy.toInt(), + ); + if (ok) { + // Skip the next mouse move event - it's triggered by the recenter itself. + _skipNextMouseMove = true; + return; + } + + // Wait before retrying (except on the last attempt). + if (attempt < _recenterMaxRetries - 1) { + await Future.delayed(_recenterRetryDelay); + } + } + + // All attempts failed. + _disableWithCleanup(); + showToast(translate('rel-mouse-lock-failed-tip')); + } catch (e, st) { + debugPrint('[RelMouse] Unexpected error in _recenterMouse: $e\n$st'); + } finally { + _recenterInProgress = false; + } + } + + Future updatePointerLockCenter({Offset? localCenter}) async { + if (!isDesktop) return; + + // Null safety check for kWindowId. + if (kWindowId == null) { + if (enabled.value) { + _disableWithCleanup(); + } + return; + } + + try { + final wc = WindowController.fromWindowId(kWindowId!); + final frame = await wc.getFrame(); + + if (frame.width <= 0 || frame.height <= 0) { + if (enabled.value) { + _disableWithCleanup(); + } + return; + } + + if (localCenter != null) { + _pointerLockCenterLocal = localCenter; + } else if (_imageWidgetSize != null) { + _pointerLockCenterLocal = Offset( + _imageWidgetSize!.width / 2, + _imageWidgetSize!.height / 2, + ); + } else { + if (enabled.value) { + _disableWithCleanup(); + } + return; + } + + // Calculate screen coordinates for OS cursor positioning. + // Use PlatformDispatcher instead of deprecated ui.window. + final view = ui.PlatformDispatcher.instance.views.firstOrNull; + if (view == null) { + debugPrint('[RelMouse] No view available for coordinate calculation'); + if (enabled.value) { + _disableWithCleanup(); + } + return; + } + final scale = view.devicePixelRatio; + + if (_pointerRegionTopLeftGlobal != null && scale > 0) { + // On macOS, window frame and CGWarpMouseCursorPosition use points (not pixels). + // On Windows, they use pixels. + // Flutter's logical coordinates are in points on macOS. + final centerInView = + _pointerRegionTopLeftGlobal! + _pointerLockCenterLocal!; + + // Calculate client area offset (excluding title bar and borders) + final clientPhysical = view.physicalSize; + + // macOS: Window frame and CGWarpMouseCursorPosition both use points (not pixels). + // We convert clientPhysical (pixels) to points via `/ scale` to compute titleBarHeight, + // which is the difference between the total window height and the Flutter view height. + if (isMacOS) { + final clientHeightPoints = clientPhysical.height / scale; + final titleBarHeight = frame.height - clientHeightPoints; + + _pointerLockCenterScreen = Offset( + frame.left + centerInView.dx, + frame.top + titleBarHeight + centerInView.dy, + ); + } else { + // Windows/Linux: Use pixel coordinates. We estimate the client-area offset using + // a heuristic based on the difference between frame size and client physical size. + // This assumes symmetric horizontal borders (extraW / 2) and that the remaining + // vertical space (extraH - borderBottom) is the title bar height. + // Limitation: This heuristic may be inaccurate for maximized windows, custom window + // decorations, or when the OS uses different border styles. + // TODO: Replace this heuristic with platform API calls (e.g., GetClientRect on Windows) + // if precise client-area offsets are required. + final extraW = frame.width - clientPhysical.width; + final extraH = frame.height - clientPhysical.height; + final borderX = extraW > 0 ? extraW / 2 : 0.0; + final borderBottom = borderX; + final borderTop = extraH > borderBottom ? extraH - borderBottom : 0.0; + final clientTopLeftScreen = + Offset(frame.left + borderX, frame.top + borderTop); + + // Calculate tentative center, then validate it's within frame bounds. + // This guards against heuristic inaccuracies (e.g., maximized windows). + final tentativeCenter = Offset( + clientTopLeftScreen.dx + centerInView.dx * scale, + clientTopLeftScreen.dy + centerInView.dy * scale, + ); + final withinFrame = tentativeCenter.dx >= frame.left && + tentativeCenter.dx <= frame.left + frame.width && + tentativeCenter.dy >= frame.top && + tentativeCenter.dy <= frame.top + frame.height; + _pointerLockCenterScreen = withinFrame + ? tentativeCenter + : Offset( + frame.left + frame.width / 2, frame.top + frame.height / 2); + } + } else { + _pointerLockCenterScreen = Offset( + frame.left + frame.width / 2, + frame.top + frame.height / 2, + ); + } + + if (enabled.value && isWindows && getPointerInsideImage()) { + _applyCursorClipForFrame(frame); + } else if (enabled.value && isWindows && _cursorClipApplied) { + // Only release if we actually have a clip applied to avoid redundant FFI calls. + _releaseCursorClip(); + } + // macOS: no clip_cursor (CGAssociateMouseAndMouseCursorPosition stops mouse events) + // Instead, we use recenter method like other platforms. + } catch (e) { + if (enabled.value) { + _disableWithCleanup(); + } else { + _pointerLockCenterLocal = null; + _pointerLockCenterScreen = null; + } + } + } + + void _ensurePointerLockEngaged() { + if (!enabled.value) return; + if (!isDesktop) return; + + setPointerInsideImage(true); + + final needsCenter = + _pointerLockCenterLocal == null || _pointerLockCenterScreen == null; + // Windows only: cursor clip + final needsClip = isWindows && !_cursorClipApplied; + if (needsCenter || needsClip) { + updatePointerLockCenter() + .then((_) => _recenterMouse()) + .catchError((Object e, StackTrace st) { + debugPrint('[RelMouse] updatePointerLockCenter failed: $e\n$st'); + _disableWithCleanup(); + }); + } + } + + void _applyCursorClipForFrame(Rect frame) { + if (!isWindows) return; + + // Use PlatformDispatcher to get the device pixel ratio for proper scaling. + final view = ui.PlatformDispatcher.instance.views.firstOrNull; + final scale = view?.devicePixelRatio ?? 1.0; + + // Get the Flutter view's physical size (client area in pixels). + final clientPhysical = view?.physicalSize ?? ui.Size.zero; + + // Calculate the non-client area (OS window title bar, borders). + // frame includes the entire window (title bar + borders + client area). + final extraW = frame.width - clientPhysical.width; + final extraH = frame.height - clientPhysical.height; + + // Assume symmetric horizontal borders. + final borderX = extraW > 0 ? extraW / 2 : 0.0; + // Bottom border is typically the same as side borders. + final borderBottom = borderX; + // OS window title bar height is the remaining vertical non-client space. + final borderTop = extraH > borderBottom ? extraH - borderBottom : 0.0; + + // Calculate client area top-left in screen coordinates. + final clientTopLeftScreen = + Offset(frame.left + borderX, frame.top + borderTop); + + int left, top, right, bottom; + + // If we have precise image widget info, clip to the remote image area. + // This excludes the Flutter app's internal title bar and toolbar. + if (_pointerRegionTopLeftGlobal != null && + _imageWidgetSize != null && + scale > 0) { + // _pointerRegionTopLeftGlobal is in Flutter logical coordinates (relative to client area). + // Convert to screen physical coordinates. + left = (clientTopLeftScreen.dx + _pointerRegionTopLeftGlobal!.dx * scale) + .toInt(); + top = (clientTopLeftScreen.dy + _pointerRegionTopLeftGlobal!.dy * scale) + .toInt(); + right = (left + _imageWidgetSize!.width * scale).toInt(); + bottom = (top + _imageWidgetSize!.height * scale).toInt(); + } else { + // Fallback: clip to client area (excluding OS window decorations). + left = clientTopLeftScreen.dx.toInt(); + top = clientTopLeftScreen.dy.toInt(); + right = (frame.left + frame.width - borderX).toInt(); + bottom = (frame.top + frame.height - borderBottom).toInt(); + } + + _cursorClipApplied = bind.mainClipCursor( + left: left, + top: top, + right: right, + bottom: bottom, + enable: true, + ); + } + + void _releaseCursorClip() { + if (!_cursorClipApplied) return; + _cursorClipApplied = false; + if (!isWindows) return; + + bind.mainClipCursor( + left: 0, + top: 0, + right: 0, + bottom: 0, + enable: false, + ); + } + + void _resetState() { + // Flush any pending delta before clearing state. + // This ensures the last buffered movement is sent before values are zeroed. + // Fire-and-forget: we don't wait for the async send to complete. + if (_throttleTimer != null || _pendingDeltaX != 0 || _pendingDeltaY != 0) { + _throttleTimer?.cancel(); + _throttleTimer = null; + if (_pendingDeltaX != 0 || _pendingDeltaY != 0) { + final x = _pendingDeltaX; + final y = _pendingDeltaY; + _pendingDeltaX = 0; + _pendingDeltaY = 0; + // Send without awaiting; skip recenter since we're disabling. + _sendMouseMessageToSession({ + 'type': 'move_relative', + 'x': '$x', + 'y': '$y', + }, disableRelativeOnError: false); + } + } + _accumulator.reset(); + _pointerLockCenterLocal = null; + _pointerLockCenterScreen = null; + _pointerRegionTopLeftGlobal = null; + _lastPointerLocalPos = null; + _skipNextMouseMove = false; + setPointerInsideImage(false); + _cursorClipApplied = false; + _exitShortcutKeyDown = false; + } + + /// Core cleanup logic shared by [_disableWithCleanup] and [dispose]. + /// Sends disable message to Rust, releases platform resources, and resets state. + void _performCleanupCore() { + // Best-effort marker for Rust rdev grab loop (ESC behavior). + // Bypass keyboardPerm check to ensure Rust state is always synced. + _sendMouseMessageToSession( + { + 'relative_mouse_mode': '0', + }, + disableRelativeOnError: false, + bypassKeyboardPerm: true, + ); + + // macOS: Disable native relative mouse mode + // This already calls CGAssociateMouseAndMouseCursorPosition(1) to re-associate mouse + if (isMacOS) { + _disableNativeRelativeMouseMode(); + } else { + _releaseCursorClip(); + } + + _resetState(); + } + + void _disableWithCleanup() { + _performCleanupCore(); + enabled.value = false; + onDisabled?.call(); + } + + bool _disposed = false; + + void dispose() { + if (_disposed) return; + _disposed = true; + + _performCleanupCore(); + _imageWidgetSize = null; + _lastToggle = null; + // Set enabled to false BEFORE calling onDisabled, consistent with _disableWithCleanup(). + enabled.value = false; + // Trigger callback before clearing it, so external cleanup can run. + onDisabled?.call(); + onDisabled = null; + } +} diff --git a/flutter/lib/models/state_model.dart b/flutter/lib/models/state_model.dart index 2e1b516df..77195d662 100644 --- a/flutter/lib/models/state_model.dart +++ b/flutter/lib/models/state_model.dart @@ -1,5 +1,4 @@ import 'package:desktop_multi_window/desktop_multi_window.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; import 'package:get/get.dart'; @@ -30,6 +29,11 @@ class StateGlobal { 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 relativeMouseModeState = {}.obs; + // Use for desktop -> remote toolbar -> resolution final Map> _lastResolutionGroupValues = {}; diff --git a/flutter/lib/utils/relative_mouse_accumulator.dart b/flutter/lib/utils/relative_mouse_accumulator.dart new file mode 100644 index 000000000..0b1426449 --- /dev/null +++ b/flutter/lib/utils/relative_mouse_accumulator.dart @@ -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; + } +} diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index d703a4dca..4a4e89233 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -2020,5 +2020,19 @@ class RustdeskImpl { 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() {} } diff --git a/flutter/macos/Runner/MainFlutterWindow.swift b/flutter/macos/Runner/MainFlutterWindow.swift index d27d7f228..1cc72419b 100644 --- a/flutter/macos/Runner/MainFlutterWindow.swift +++ b/flutter/macos/Runner/MainFlutterWindow.swift @@ -19,6 +19,22 @@ import window_manager import window_size 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 { override func awakeFromNib() { rustdesk_core_main(); @@ -64,6 +80,104 @@ class MainFlutterWindow: NSWindow { 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) { let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/host", binaryMessenger: registrar.messenger) channel.setMethodCallHandler({ @@ -96,7 +210,9 @@ class MainFlutterWindow: NSWindow { } case "requestRecordAudio": AVCaptureDevice.requestAccess(for: .audio, completionHandler: { granted in - result(granted) + DispatchQueue.main.async { + result(granted) + } }) break case "bumpMouse": @@ -145,11 +261,22 @@ class MainFlutterWindow: NSWindow { // 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 // already true has the side-effect of cancelling this motion suppression. - CGAssociateMouseAndMouseCursorPosition(1 /* true */) + // + // 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 */) + } result(true) - break + case "enableNativeRelativeMouseMode": + let success = self.enableNativeRelativeMouseMode(channel: channel) + result(success) + + case "disableNativeRelativeMouseMode": + self.disableNativeRelativeMouseMode() + result(true) default: result(FlutterMethodNotImplemented) diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 448eae4db..b8360db58 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # 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 -version: 1.4.4+62 +version: 1.4.5+63 environment: sdk: '^3.1.0' diff --git a/libs/enigo/src/macos/macos_impl.rs b/libs/enigo/src/macos/macos_impl.rs index d85f3576f..20f5d0cbf 100644 --- a/libs/enigo/src/macos/macos_impl.rs +++ b/libs/enigo/src/macos/macos_impl.rs @@ -208,42 +208,56 @@ impl MouseControllable for Enigo { } fn mouse_move_to(&mut self, x: i32, y: i32) { - let pressed = Self::pressed_buttons(); - - let event_type = if pressed & 1 > 0 { - 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); - } - } + // For absolute movement, we don't set delta values + // This maintains backward compatibility + self.mouse_move_to_impl(x, y, None); } fn mouse_move_relative(&mut self, x: i32, y: i32) { let (display_width, display_height) = Self::main_display_size(); let (current_x, y_inv) = Self::mouse_location_raw_coords(); let current_y = (display_height as i32) - y_inv; - let new_x = current_x + x; - let new_y = current_y + y; + // Use saturating arithmetic to prevent overflow/wraparound + let mut new_x = current_x.saturating_add(x); + let mut new_y = current_y.saturating_add(y); - if new_x < 0 - || new_x as usize > display_width - || new_y < 0 - || new_y as usize > display_height - { - return; + // Define screen center and edge margins for cursor reset + let center_x = (display_width / 2) as i32; + let center_y = (display_height / 2) as i32; + // 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 + // 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 { @@ -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 pub fn main_display_size() -> (usize, usize) { let display_id = unsafe { CGMainDisplayID() }; diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index 00b47e976..a4a71e14f 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk-portable-packer" -version = "1.4.4" +version = "1.4.5" edition = "2021" description = "RustDesk Remote Desktop" diff --git a/res/PKGBUILD b/res/PKGBUILD index bd890d1ed..3b4096760 100644 --- a/res/PKGBUILD +++ b/res/PKGBUILD @@ -1,5 +1,5 @@ pkgname=rustdesk -pkgver=1.4.4 +pkgver=1.4.5 pkgrel=0 epoch= pkgdesc="" diff --git a/res/rpm-flutter-suse.spec b/res/rpm-flutter-suse.spec index 38a3fb12b..d11e0b69a 100644 --- a/res/rpm-flutter-suse.spec +++ b/res/rpm-flutter-suse.spec @@ -1,5 +1,5 @@ Name: rustdesk -Version: 1.4.4 +Version: 1.4.5 Release: 0 Summary: RPM package License: GPL-3.0 diff --git a/res/rpm-flutter.spec b/res/rpm-flutter.spec index 192d31156..3b6ad5f5d 100644 --- a/res/rpm-flutter.spec +++ b/res/rpm-flutter.spec @@ -1,5 +1,5 @@ Name: rustdesk -Version: 1.4.4 +Version: 1.4.5 Release: 0 Summary: RPM package License: GPL-3.0 diff --git a/res/rpm.spec b/res/rpm.spec index b2162039d..67c7abe36 100644 --- a/res/rpm.spec +++ b/res/rpm.spec @@ -1,5 +1,5 @@ Name: rustdesk -Version: 1.4.4 +Version: 1.4.5 Release: 0 Summary: RPM package License: GPL-3.0 diff --git a/src/common.rs b/src/common.rs index 66a12994d..5f8772414 100644 --- a/src/common.rs +++ b/src/common.rs @@ -71,6 +71,19 @@ pub mod input { pub const MOUSE_TYPE_UP: i32 = 2; pub const MOUSE_TYPE_WHEEL: i32 = 3; 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_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") } +/// 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 #[inline] pub fn is_server() -> bool { @@ -2462,4 +2489,36 @@ mod tests { assert!(!is_public("https://rustdesk.computer.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); + } } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index f2d3e34ef..a46cfd8b6 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -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 { + #[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 { + #[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 { get_id() } @@ -1748,8 +1808,99 @@ pub fn session_send_pointer(session_id: SessionID, msg: String) { 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) { if let Ok(m) = serde_json::from_str::>(&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::().unwrap_or(0)) + .unwrap_or(0); + let y_marker = m + .get("y") + .map(|y| y.parse::().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 ctrl = m.get("ctrl").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, "wheel" => MOUSE_TYPE_WHEEL, "trackpad" => MOUSE_TYPE_TRACKPAD, + "move_relative" => MOUSE_TYPE_MOVE_RELATIVE, _ => 0, }; } diff --git a/src/keyboard.rs b/src/keyboard.rs index 0497459a8..c5d4dfde8 100644 --- a/src/keyboard.rs +++ b/src/keyboard.rs @@ -32,9 +32,33 @@ const OS_LOWER_MACOS: &str = "macos"; #[allow(dead_code)] 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); +// 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(not(any(target_os = "android", target_os = "ios")))] static IS_RDEV_ENABLED: AtomicBool = AtomicBool::new(false); @@ -82,7 +106,7 @@ pub mod client { GrabState::Run => { #[cfg(windows)] 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); #[cfg(target_os = "linux")] @@ -94,7 +118,7 @@ pub mod client { 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); #[cfg(target_os = "linux")] @@ -266,6 +290,136 @@ fn get_keyboard_mode() -> 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() { std::env::set_var("KEYBOARD_ONLY", "y"); #[cfg(any(target_os = "windows", target_os = "macos"))] @@ -278,6 +432,12 @@ fn start_grab_loop() { let _scan_code = event.position_code; 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) { client::process_event(&get_keyboard_mode(), &event, None); if is_press { @@ -337,9 +497,14 @@ fn start_grab_loop() { #[cfg(target_os = "linux")] if let Err(err) = rdev::start_grab_listen(move |event: Event| match event.event_type { EventType::KeyPress(key) | EventType::KeyRelease(key) => { + let is_press = matches!(event.event_type, EventType::KeyPress(_)); if let Key::Unknown(keycode) = key { log::error!("rdev get unknown key, keycode is {:?}", keycode); } else { + #[cfg(feature = "flutter")] + if should_block_relative_mouse_shortcut(key, is_press) { + return None; + } client::process_event(&get_keyboard_mode(), &event, None); } None diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 93ba2987e..0a9b4f60a 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "أدخل الملاحظة هنا"), ("note-at-conn-end-tip", "سيتم عرض هذه الملاحظة عند نهاية الاتصال"), ("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(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index 03e833701..52cb7a683 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index d88f3745f..04c3fadd8 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 60ccbcbd8..1b7a5d38d 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index a125a9f41..f710bbc86 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "输入备注"), ("note-at-conn-end-tip", "在连接结束时请求备注"), ("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(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 7600f5f54..bfcf1a94f 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index 2898629fe..48008bc51 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index 897eb88a1..1efa68150 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "Hier eine Notiz eingeben"), ("note-at-conn-end-tip", "Am Ende der Verbindung um eine Notiz bitten."), ("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(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index fb51a8001..d10b3fed4 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index f94fc49d4..60cb7b123 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -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."), ("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"), + ("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(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index bc9fedfb9..31026afe1 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index 7a402cd9a..008b60ba0 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index 0dbfde469..6ce75fee6 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index f7f7b02ca..abeb81805 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 1bca741d7..6cfac9f4a 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "یادداشت را اینجا وارد کنید"), ("note-at-conn-end-tip", "در پایان اتصال، یادداشت بخواهید"), ("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(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index e97263258..f79fd9208 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 85815893e..c64ffb918 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "saisir la note ici"), ("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"), + ("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(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index c104a3a34..d9ec41195 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 39a3742c2..0b0a775d2 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index d030f482d..24b0b0b80 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index b3777e58d..d2cd48dff 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "Megjegyzés bevitele"), ("note-at-conn-end-tip", "Megjegyzés a kapcsolat végén"), ("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(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index ce2b34a6e..091ea996f 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index b5700bf05..2f4ee009c 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "Inserisci nota qui"), ("note-at-conn-end-tip", "Visualizza nota alla fine della connessione"), ("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(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 9a9b08ec2..2cc68c4ec 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "ここにメモを入力"), ("note-at-conn-end-tip", "接続終了時にメモを要求する"), ("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(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 8ffdeefa1..77833d713 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "여기에 노트 입력"), ("note-at-conn-end-tip", "연결이 끝날 때 메모 요청"), ("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(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index e3eb5b44b..f32d56fb0 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index a821391cf..1db3f6286 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 79b26c243..20872d7e1 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 7c06d7699..690cbfb8c 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index cafdc74a0..142e4f972 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "voeg hier een opmerking toe"), ("note-at-conn-end-tip", "Vraag om een opmerking aan het einde van de verbinding"), ("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(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 1e4af5aa9..b06a92fc2 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "Wstaw tutaj notatkę"), ("note-at-conn-end-tip", "Poproś o notatkę po zakończeniu połączenia."), ("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(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 29ff24b89..1e489cd43 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index a4715b47f..8cf598b36 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index efbe758ef..cd8b0f929 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index ad9c84989..877e87a4f 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "введите заметку"), ("note-at-conn-end-tip", "Запрашивать заметку в конце соединения"), ("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(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 19b599d5e..156391842 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index eafe3f244..872603a63 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index eb9102ac7..276d042cc 100755 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 734bca256..94dc602ec 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index fb91966ec..1b180eb7e 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 773f74e62..914e937be 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index bb6ef6f35..48e8fb575 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index 3eda9e83e..bd6bbfbdd 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index 932970d3f..5b8d1eb86 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 1ab02da5b..24b735243 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "Notu buraya girin"), ("note-at-conn-end-tip", "Bağlantı bittiğinde not sorulsun"), ("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(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 55b7c89b3..36a111960 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", "輸入備註"), ("note-at-conn-end-tip", "在連接結束時請求備註"), ("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(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 70108e8b6..dc695e0b9 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 090501015..f00a7ec77 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -730,5 +730,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("input note here", ""), ("note-at-conn-end-tip", ""), ("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(); } diff --git a/src/lib.rs b/src/lib.rs index 1f5061015..5621d5e2a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,8 @@ mod keyboard; pub mod platform; #[cfg(not(any(target_os = "android", target_os = "ios")))] 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")))] /// cbindgen:ignore diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 5e608aa08..c546673eb 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -97,6 +97,7 @@ extern "C" { y: *mut c_int, screen_num: *mut 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_get_active_window(xdo: Xdo, window: *mut *mut c_void) -> c_int; fn xdo_get_window_location( @@ -174,6 +175,56 @@ pub fn get_cursor_pos() -> Option<(i32, i32)> { 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 get_focused_display(displays: Vec) -> Option { diff --git a/src/platform/macos.rs b/src/platform/macos.rs index bc13260a5..b923c6c17 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -32,8 +32,12 @@ use std::{ os::unix::process::CommandExt, path::{Path, PathBuf}, process::{Command, Stdio}, + sync::Mutex, }; +// macOS boolean_t is defined as `int` in +type BooleanT = hbb_common::libc::c_int; + static PRIVILEGES_SCRIPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/platform/privileges_scripts"); static mut LATEST_SEED: i32 = 0; @@ -42,6 +46,11 @@ static mut LATEST_SEED: i32 = 0; // using one that includes the custom client name. 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" { fn CGSCurrentCursorSeed() -> i32; fn CGEventCreate(r: *const c_void) -> *const c_void; @@ -64,6 +73,8 @@ extern "C" { fn majorVersion() -> u32; fn MacGetMode(display: u32, width: *mut u32, height: *mut u32) -> 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 { @@ -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) -> Option { autoreleasepool(|| unsafe_get_focused_display(displays)) } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 34700e614..c1bc38232 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -26,18 +26,13 @@ pub mod linux_desktop_manager; #[cfg(target_os = "linux")] pub mod gtk_sudo; -#[cfg(not(any(target_os = "android", target_os = "ios")))] -use hbb_common::{ - message_proto::CursorData, - sysinfo::Pid, - ResultType, -}; #[cfg(all( not(all(target_os = "windows", not(target_pointer_width = "64"))), - not(any(target_os = "android", target_os = "ios"))))] -use hbb_common::{ - sysinfo::System, -}; + not(any(target_os = "android", target_os = "ios")) +))] +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}; #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] pub const SERVICE_INTERVAL: u64 = 300; diff --git a/src/platform/windows.rs b/src/platform/windows.rs index bddeb4302..c40e87441 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -116,12 +116,51 @@ pub fn get_focused_display(displays: Vec) -> Option { pub fn get_cursor_pos() -> Option<(i32, i32)> { unsafe { - #[allow(invalid_value)] - let mut out = mem::MaybeUninit::uninit().assume_init(); - if GetCursorPos(&mut out) == FALSE { + let mut out = mem::MaybeUninit::::uninit(); + if GetCursorPos(out.as_mut_ptr()) == FALSE { 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 } } diff --git a/src/server/connection.rs b/src/server/connection.rs index 1e7758887..d28373459 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -5173,9 +5173,13 @@ impl Retina { #[inline] fn on_mouse_event(&mut self, e: &mut MouseEvent, current: usize) { - let evt_type = e.mask & 0x7; - if evt_type == crate::input::MOUSE_TYPE_WHEEL { - // x and y are always 0, +1 or -1 + let evt_type = e.mask & crate::input::MOUSE_TYPE_MASK; + // Delta-based events do not contain absolute coordinates. + // 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; } let Some(d) = self.displays.get(current) else { @@ -5421,6 +5425,9 @@ mod raii { .unwrap() .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); let remote_count = AUTHED_CONNS .lock() diff --git a/src/server/input_service.rs b/src/server/input_service.rs index adb6a7a97..b1c2d66b6 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -26,6 +26,7 @@ use std::{ thread, time::{self, Duration, Instant}, }; + #[cfg(windows)] use winapi::um::winuser::WHEEL_DELTA; @@ -447,7 +448,36 @@ lazy_static::lazy_static! { static ref KEYS_DOWN: Arc>> = Default::default(); static ref LATEST_PEER_INPUT_CURSOR: Arc> = Default::default(); static ref LATEST_SYS_CURSOR_POS: Arc, (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>> = 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>`). +/// 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); 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 { let buttons = evt.mask >> 3; - let evt_type = evt.mask & 0x7; - return buttons == 1 && evt_type == 2; + let evt_type = evt.mask & MOUSE_TYPE_MASK; + buttons == MOUSE_BUTTON_LEFT && evt_type == MOUSE_TYPE_UP } #[cfg(windows)] @@ -1003,8 +1033,16 @@ pub fn handle_mouse_( handle_mouse_simulation_(evt, conn); } #[cfg(not(any(target_os = "android", target_os = "ios")))] - if _show_cursor { - handle_mouse_show_cursor_(evt, conn, _username, _argb); + { + 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); + } } } @@ -1020,7 +1058,7 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) { #[cfg(windows)] crate::platform::windows::try_change_desktop(); 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(); #[cfg(target_os = "macos")] en.set_ignore_flags(enigo_ignore_flags()); @@ -1048,6 +1086,8 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) { } match evt_type { 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); *LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input { conn, @@ -1056,6 +1096,28 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) { 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_BUTTON_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")))] pub fn handle_mouse_show_cursor_(evt: &MouseEvent, conn: i32, username: String, argb: u32) { let buttons = evt.mask >> 3; - let evt_type = evt.mask & 0x7; + let evt_type = evt.mask & MOUSE_TYPE_MASK; match evt_type { MOUSE_TYPE_MOVE => { whiteboard::update_whiteboard( @@ -1170,11 +1232,22 @@ pub fn handle_mouse_show_cursor_(evt: &MouseEvent, conn: i32, username: String, } MOUSE_TYPE_UP => { 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::get_key_cursor(conn), whiteboard::CustomEvent::Cursor(whiteboard::Cursor { - x: evt.x as _, - y: evt.y as _, + x: x as _, + y: y as _, argb, btns: buttons, text: username, diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 88ee7bc9b..9ea0cba5b 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1,6 +1,9 @@ use crate::{ 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, }; use async_trait::async_trait; @@ -1222,7 +1225,9 @@ impl Session { } } - 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)) } else { (x, y) @@ -1231,8 +1236,6 @@ impl Session { // #[cfg(not(any(target_os = "android", target_os = "ios")))] let (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_right = (mask & (MOUSE_BUTTON_RIGHT << 3)) > 0; if is_left ^ is_right { @@ -1252,9 +1255,8 @@ impl Session { // to-do: how about ctrl + left from win to macos if cfg!(target_os = "macos") { let buttons = mask >> 3; - let evt_type = mask & 0x7; if buttons == MOUSE_BUTTON_LEFT - && evt_type == MOUSE_TYPE_DOWN + && event_type == MOUSE_TYPE_DOWN && ctrl && self.peer_platform() != "Mac OS" {