feat: Add relative mouse mode (#13928)

* feat: Add relative mouse mode

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

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

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

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

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

* refact(relative mouse): shortcut

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

---------

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

View File

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

View File

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

View File

@@ -831,6 +831,7 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi;
final sessionId = ffi.sessionId;
final isDefaultConn = ffi.connType == ConnType.defaultConn;
List<TToggleMenu> v = [];
// swap key
@@ -852,6 +853,34 @@ List<TToggleMenu> 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 =

View File

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

View File

@@ -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<RemotePage>
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<RemotePage>
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<RemotePage>
_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<RemotePage>
_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<RemotePage>
_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<RemotePage>
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<RemotePage>
// 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<RemotePage>
}
}(),
// 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<RemotePage>
//
}
}
// See [onWindowBlur].
if (!isWindows) {
if (!_rawKeyFocusNode.hasFocus) {
@@ -440,6 +524,7 @@ class _RemotePageState extends State<RemotePage>
//
}
}
// See [onWindowBlur].
if (!isWindows) {
_ffi.inputModel.enterOrLeave(false);
@@ -487,32 +572,39 @@ class _RemotePageState extends State<RemotePage>
Widget getBodyForDesktop(BuildContext context) {
var paints = <Widget>[
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<CanvasModel>(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<RemotePage>
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<ImagePaint> {
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);

View File

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

View File

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

View File

@@ -569,7 +569,9 @@ class _RemotePageState extends State<RemotePage> 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<RemotePage> with WidgetsBindingObserver {
bind.mainSetLocalOption(key: kOptionTouchMode, value: v);
},
virtualMouseMode: gFFI.ffiModel.virtualMouseMode,
inputModel: gFFI.inputModel,
)));
}

View File

@@ -83,7 +83,10 @@ class _FloatingMouseWidgetsState extends State<FloatingMouseWidgets> {
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<VirtualJoystick> createState() => _VirtualJoystickState();
@@ -694,6 +703,10 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
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<VirtualJoystick> {
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<VirtualJoystick> {
);
}
/// Send movement delta to remote machine.
/// Uses relative mouse mode if enabled, otherwise uses absolute updatePan.
void _sendMovement(Offset delta) {
if (_useRelativeMouse) {
widget.inputModel.sendMobileRelativeMouseMove(
delta.dx * _kRelativeMouseScale, delta.dy * _kRelativeMouseScale);
} else {
// In absolute mode, use cursorModel.updatePan which tracks position.
widget.cursorModel.updatePan(delta, Offset.zero, false);
}
}
void _stopSendEventTimer() {
_dragStartTimer?.cancel();
_continuousMoveTimer?.cancel();
@@ -773,7 +801,7 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
// 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<VirtualJoystick> {
_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);
}
});
});

View File

@@ -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<StatefulWidget> createState() =>
@@ -61,6 +65,14 @@ class _GestureHelpState extends State<GestureHelp> {
_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<GestureHelp> {
_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<GestureHelp> {
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<GestureHelp> {
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<GestureHelp> {
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<GestureHelp> {
],
)),
),
// Relative mouse mode option - only visible when joystick is shown
if (!_touchMode &&
_virtualMouseMode.showVirtualMouse &&
_virtualMouseMode.showVirtualJoystick &&
widget.inputModel != null)
Obx(() => Transform.translate(
offset: const Offset(-10.0, -24.0),
child: Padding(
// Indent further for 'Relative mouse mode'
padding: const EdgeInsets.only(left: 48.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: widget.inputModel!
.relativeMouseMode.value,
onChanged: (value) {
if (value == null) return;
widget.inputModel!
.setRelativeMouseMode(value);
},
),
InkWell(
onTap: () {
widget.inputModel!
.toggleRelativeMouseMode();
},
child: Text(
translate('Relative mouse mode')),
),
],
)),
)),
],
),
),

View File

@@ -14,6 +14,8 @@ import 'package:get/get.dart';
import '../../models/model.dart';
import '../../models/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> _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<void> sendMobileRelativeMouseMove(double dx, double dy) async {
if (!keyboardPerm) return;
if (isViewCamera) return;
// Only send relative mouse events when relative mode is enabled and supported.
if (!isRelativeMouseModeSupported || !relativeMouseMode.value) return;
_mobileDeltaRemainderX += dx;
_mobileDeltaRemainderY += dy;
final x = _mobileDeltaRemainderX.truncate();
final y = _mobileDeltaRemainderY.truncate();
_mobileDeltaRemainderX -= x;
_mobileDeltaRemainderY -= y;
if (x == 0 && y == 0) return;
await bind.sessionSendMouse(
sessionId: sessionId,
msg: json.encode(modify({
'type': 'move_relative',
'x': '$x',
'y': '$y',
})));
}
/// Update the pointer lock center position based on current window frame.
Future<void> updatePointerLockCenter({Offset? localCenter}) {
return _relativeMouse.updatePointerLockCenter(localCenter: localCenter);
}
/// Get the current image widget size (for comparison to avoid unnecessary updates).
Size? get imageWidgetSize => _relativeMouse.imageWidgetSize;
/// Update the image widget size for center calculation.
void updateImageWidgetSize(Size size) {
_relativeMouse.updateImageWidgetSize(size);
}
void toggleRelativeMouseMode() {
_relativeMouse.toggleRelativeMouseMode();
}
bool setRelativeMouseMode(bool enabled) {
return _relativeMouse.setRelativeMouseMode(enabled);
}
/// Exit relative mouse mode and release all modifier keys to the remote.
/// This is called when the user presses the exit shortcut (Ctrl+Alt on Win/Linux, Cmd+G on macOS).
/// We need to send key-up events for all modifiers because the shortcut itself may have
/// blocked some key events, leaving the remote in a state where modifiers are stuck.
void exitRelativeMouseModeWithKeyRelease() {
if (!_relativeMouse.enabled.value) return;
// First, send release events for all modifier keys to the remote.
// This ensures the remote doesn't have stuck modifier keys after exiting.
// Use press: false, down: false to send key-up events without modifiers attached.
final modifiersToRelease = [
'Control_L',
'Control_R',
'Alt_L',
'Alt_R',
'Shift_L',
'Shift_R',
'Meta_L', // Command/Super left
'Meta_R', // Command/Super right
];
for (final key in modifiersToRelease) {
bind.sessionInputKey(
sessionId: sessionId,
name: key,
down: false,
press: false,
alt: false,
ctrl: false,
shift: false,
command: false,
);
}
// Reset local modifier state
resetModifiers();
// Now exit relative mouse mode
_relativeMouse.setRelativeMouseMode(false);
}
void disposeRelativeMouseMode() {
_relativeMouse.dispose();
onRelativeMouseModeDisabled = null;
// Cancel the relative mouse mode observer and clean up global state.
_relativeMouseModeDisposer?.dispose();
_relativeMouseModeDisposer = null;
final peerId = id;
if (peerId.isNotEmpty) {
stateGlobal.relativeMouseModeState.remove(peerId);
}
}
void onWindowBlur() {
_relativeMouse.onWindowBlur();
}
void onWindowFocus() {
_relativeMouse.onWindowFocus();
}
void onPointHoverImage(PointerHoverEvent e) {
_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<int, String> 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)));

View File

@@ -213,6 +213,9 @@ class FfiModel with ChangeNotifier {
}
updatePermission(Map<String, dynamic> 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<void> 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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package: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<String, bool> relativeMouseModeState = <String, bool>{}.obs;
// Use for desktop -> remote toolbar -> resolution
final Map<String, Map<int, String?>> _lastResolutionGroupValues = {};

View File

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

View File

@@ -2020,5 +2020,19 @@ class RustdeskImpl {
return js.context.callMethod('getByName', ['audit_guid']);
}
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() {}
}

View File

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

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# Read more about iOS versioning at
# 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'