mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-03-30 00:21:02 +03:00
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:
@@ -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 '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,7 +593,6 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
|
||||
Widget _buildBar() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
|
||||
@@ -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,
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
],
|
||||
)),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
1061
flutter/lib/models/relative_mouse_model.dart
Normal file
1061
flutter/lib/models/relative_mouse_model.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 = {};
|
||||
|
||||
|
||||
58
flutter/lib/utils/relative_mouse_accumulator.dart
Normal file
58
flutter/lib/utils/relative_mouse_accumulator.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user