Files
rustdesk/flutter/lib/models/input_model.dart
Luke 6ad56075d6 Drag whole toolbar; snap to all four edges of the remote session window (#15051)
* Drag whole toolbar; snap to all four edges

Today the drag handle on the remote-session toolbar repositions only
the handle row -- the icons themselves stay centered at the top. This
change applies the position to the entire toolbar wrapper so dragging
the handle moves the whole thing, and extends snapping from top-only
to any of the four window edges.

When docked left/right the toolbar reflows vertically. A live ghost
preview shows where the toolbar will land while you drag, with a small
hysteresis bias to keep the preview from flickering near corners.

The legacy 'remote-menubar-drag-x' session option is read as a fallback
on first load so existing users keep their saved horizontal position;
new option keys are 'remote-menubar-edge' and 'remote-menubar-frac'.

Tested locally on Windows. macOS / Linux / web desktop use the same
shared widget with no platform-specific calls, but I did not verify
them.

* Load edge independently and clamp loaded fraction

Addresses CodeRabbit review on #15051: parse the saved edge regardless
of whether the new fraction option is present so a partial write of
frac doesn't reset the toolbar back to top, and clamp the loaded
fraction to the kOptionRemoteMenubarDragLeft/Right contract so a
corrupted or out-of-range saved value can't bypass the bounds until
the user drags again.

* Require edge activation zone to switch dock; preserve horizontal slide

Per review feedback on #15051: nearest-edge-wins made a low-intent
horizontal slide too easy to escalate into a high-impact orientation
change (vertical reflow on left/right dock). The default drag now
keeps the toolbar on its current dock edge and just updates the
fraction along that edge -- the prior horizontal-slide behavior.

An alternate edge is only previewed/committed when the cursor enters
its 32 px activation zone; once previewed, the cursor has to move
back 64 px before reverting (hysteresis at the zone boundary).

* Gate multi-edge docking behind a settings toggle; default = horizontal slide

Replaces the activation-zone approach with an explicit opt-in setting
in Settings -> Other ("Allow docking remote toolbar to any window
edge"). This addresses the concern that a low-intent horizontal drag
shouldn't be able to trigger a high-impact orientation change, while
still letting users who want multi-edge docking opt in cleanly.

Default (toggle off):
  - The original horizontal slide is preserved.
  - The bug fix from the first commit still applies: dragging the
    handle moves the whole toolbar, and the position persists across
    collapse/expand (no more re-center on re-open).
  - Draggable is axis-locked to horizontal so the feedback widget
    stays on the top line during drag.

Opt-in (toggle on):
  - Full nearest-edge wins with the live preview ghost and corner
    hysteresis; toolbar reflows vertically on left/right docks.
  - Draggable is unlocked for 2D drag.

Reads the option via mainGetLocalBoolOptionSync so the toolbar's
default state matches what the settings checkbox shows; the option
key uses the allow- prefix so unset defaults to off.

Takes effect on next session (setting is read at session init).

The setting key (allow-multi-edge-toolbar-dock) is read by the
existing local-options machinery and persists per-install without
needing to be registered in libs/hbb_common's KEYS_LOCAL_SETTINGS.
Can add that registration in a parallel hbb_common PR if preferred.

* Fix remote toolbar drag positioning & persistence

Align drag fraction calculation with the toolbar's actual travel range,
keep preview sizing stable during drag, and preserve legacy horizontal
position storage when multi-edge docking is disabled.

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

* Remote toolbar snap edges

1. Translations
2. Apply option to remote windows on changed

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

* fix: avoid remote toolbar docking jumps on setting reload

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

* Fix remote toolbar docking updates and drag sync

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

* refact: translation key

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

* feat(toolbar-snap-edges): test web

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

* Fix remote toolbar docking sync and vertical layout

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

* Fix remote toolbar monitor controls on side docks

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-05-24 19:08:45 +08:00

2111 lines
66 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'dart:ui' as ui;
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_hbb/main.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:get/get.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../../models/state_model.dart';
import 'input_modifier_utils.dart';
import 'relative_mouse_model.dart';
import '../common.dart';
import '../consts.dart';
/// Mouse button enum.
enum MouseButtons { left, right, wheel, back, forward }
const _kMouseEventDown = 'mousedown';
const _kMouseEventUp = 'mouseup';
const _kMouseEventMove = 'mousemove';
class CanvasCoords {
double x = 0;
double y = 0;
double scale = 1.0;
double scrollX = 0;
double scrollY = 0;
ScrollStyle scrollStyle = ScrollStyle.scrollauto;
Size size = Size.zero;
CanvasCoords();
Map<String, dynamic> toJson() {
return {
'x': x,
'y': y,
'scale': scale,
'scrollX': scrollX,
'scrollY': scrollY,
'scrollStyle': scrollStyle.toJson(),
'size': {
'w': size.width,
'h': size.height,
}
};
}
static CanvasCoords fromJson(Map<String, dynamic> json) {
final model = CanvasCoords();
model.x = json['x'];
model.y = json['y'];
model.scale = json['scale'];
model.scrollX = json['scrollX'];
model.scrollY = json['scrollY'];
model.scrollStyle =
ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto);
model.size = Size(json['size']['w'], json['size']['h']);
return model;
}
static CanvasCoords fromCanvasModel(CanvasModel model) {
final coords = CanvasCoords();
coords.x = model.x;
coords.y = model.y;
coords.scale = model.scale;
coords.scrollX = model.scrollX;
coords.scrollY = model.scrollY;
coords.scrollStyle = model.scrollStyle;
coords.size = model.size;
return coords;
}
}
class CursorCoords {
Offset offset = Offset.zero;
CursorCoords();
Map<String, dynamic> toJson() {
return {
'offset_x': offset.dx,
'offset_y': offset.dy,
};
}
static CursorCoords fromJson(Map<String, dynamic> json) {
final model = CursorCoords();
model.offset = Offset(json['offset_x'], json['offset_y']);
return model;
}
static CursorCoords fromCursorModel(CursorModel model) {
final coords = CursorCoords();
coords.offset = model.offset;
return coords;
}
}
class RemoteWindowCoords {
RemoteWindowCoords(
this.windowRect, this.canvas, this.cursor, this.remoteRect);
Rect windowRect;
CanvasCoords canvas;
CursorCoords cursor;
Rect remoteRect;
Offset relativeOffset = Offset.zero;
Map<String, dynamic> toJson() {
return {
'canvas': canvas.toJson(),
'cursor': cursor.toJson(),
'windowRect': rectToJson(windowRect),
'remoteRect': rectToJson(remoteRect),
};
}
static Map<String, dynamic> rectToJson(Rect r) {
return {
'l': r.left,
't': r.top,
'w': r.width,
'h': r.height,
};
}
static Rect rectFromJson(Map<String, dynamic> json) {
return Rect.fromLTWH(
json['l'],
json['t'],
json['w'],
json['h'],
);
}
RemoteWindowCoords.fromJson(Map<String, dynamic> json)
: windowRect = rectFromJson(json['windowRect']),
canvas = CanvasCoords.fromJson(json['canvas']),
cursor = CursorCoords.fromJson(json['cursor']),
remoteRect = rectFromJson(json['remoteRect']);
}
extension ToString on MouseButtons {
String get value {
switch (this) {
case MouseButtons.left:
return 'left';
case MouseButtons.right:
return 'right';
case MouseButtons.wheel:
return 'wheel';
case MouseButtons.back:
return 'back';
case MouseButtons.forward:
return 'forward';
}
}
}
class PointerEventToRust {
final String kind;
final String type;
final dynamic value;
PointerEventToRust(this.kind, this.type, this.value);
Map<String, dynamic> toJson() {
return {
'k': kind,
'v': {
't': type,
'v': value,
}
};
}
}
class ToReleaseRawKeys {
RawKeyEvent? lastLShiftKeyEvent;
RawKeyEvent? lastRShiftKeyEvent;
RawKeyEvent? lastLCtrlKeyEvent;
RawKeyEvent? lastRCtrlKeyEvent;
RawKeyEvent? lastLAltKeyEvent;
RawKeyEvent? lastRAltKeyEvent;
RawKeyEvent? lastLCommandKeyEvent;
RawKeyEvent? lastRCommandKeyEvent;
RawKeyEvent? lastSuperKeyEvent;
reset() {
lastLShiftKeyEvent = null;
lastRShiftKeyEvent = null;
lastLCtrlKeyEvent = null;
lastRCtrlKeyEvent = null;
lastLAltKeyEvent = null;
lastRAltKeyEvent = null;
lastLCommandKeyEvent = null;
lastRCommandKeyEvent = null;
lastSuperKeyEvent = null;
}
updateKeyDown(LogicalKeyboardKey logicKey, RawKeyDownEvent e) {
if (e.isAltPressed) {
if (logicKey == LogicalKeyboardKey.altLeft) {
lastLAltKeyEvent = e;
} else if (logicKey == LogicalKeyboardKey.altRight) {
lastRAltKeyEvent = e;
}
} else if (e.isControlPressed) {
if (logicKey == LogicalKeyboardKey.controlLeft) {
lastLCtrlKeyEvent = e;
} else if (logicKey == LogicalKeyboardKey.controlRight) {
lastRCtrlKeyEvent = e;
}
} else if (e.isShiftPressed) {
if (logicKey == LogicalKeyboardKey.shiftLeft) {
lastLShiftKeyEvent = e;
} else if (logicKey == LogicalKeyboardKey.shiftRight) {
lastRShiftKeyEvent = e;
}
} else if (e.isMetaPressed) {
if (logicKey == LogicalKeyboardKey.metaLeft) {
lastLCommandKeyEvent = e;
} else if (logicKey == LogicalKeyboardKey.metaRight) {
lastRCommandKeyEvent = e;
} else if (logicKey == LogicalKeyboardKey.superKey) {
lastSuperKeyEvent = e;
}
}
}
updateKeyUp(LogicalKeyboardKey logicKey, RawKeyUpEvent e) {
if (e.isAltPressed) {
if (logicKey == LogicalKeyboardKey.altLeft) {
lastLAltKeyEvent = null;
} else if (logicKey == LogicalKeyboardKey.altRight) {
lastRAltKeyEvent = null;
}
} else if (e.isControlPressed) {
if (logicKey == LogicalKeyboardKey.controlLeft) {
lastLCtrlKeyEvent = null;
} else if (logicKey == LogicalKeyboardKey.controlRight) {
lastRCtrlKeyEvent = null;
}
} else if (e.isShiftPressed) {
if (logicKey == LogicalKeyboardKey.shiftLeft) {
lastLShiftKeyEvent = null;
} else if (logicKey == LogicalKeyboardKey.shiftRight) {
lastRShiftKeyEvent = null;
}
} else if (e.isMetaPressed) {
if (logicKey == LogicalKeyboardKey.metaLeft) {
lastLCommandKeyEvent = null;
} else if (logicKey == LogicalKeyboardKey.metaRight) {
lastRCommandKeyEvent = null;
} else if (logicKey == LogicalKeyboardKey.superKey) {
lastSuperKeyEvent = null;
}
}
}
release(KeyEventResult Function(RawKeyEvent e) handleRawKeyEvent) {
for (final key in [
lastLShiftKeyEvent,
lastRShiftKeyEvent,
lastLCtrlKeyEvent,
lastRCtrlKeyEvent,
lastLAltKeyEvent,
lastRAltKeyEvent,
lastLCommandKeyEvent,
lastRCommandKeyEvent,
lastSuperKeyEvent,
]) {
if (key != null) {
handleRawKeyEvent(RawKeyUpEvent(
data: key.data,
character: key.character,
));
}
}
}
}
class ToReleaseKeys {
KeyEvent? lastLShiftKeyEvent;
KeyEvent? lastRShiftKeyEvent;
KeyEvent? lastLCtrlKeyEvent;
KeyEvent? lastRCtrlKeyEvent;
KeyEvent? lastLAltKeyEvent;
KeyEvent? lastRAltKeyEvent;
KeyEvent? lastLCommandKeyEvent;
KeyEvent? lastRCommandKeyEvent;
KeyEvent? lastSuperKeyEvent;
reset() {
lastLShiftKeyEvent = null;
lastRShiftKeyEvent = null;
lastLCtrlKeyEvent = null;
lastRCtrlKeyEvent = null;
lastLAltKeyEvent = null;
lastRAltKeyEvent = null;
lastLCommandKeyEvent = null;
lastRCommandKeyEvent = null;
lastSuperKeyEvent = null;
}
release(KeyEventResult Function(KeyEvent e) handleKeyEvent) {
for (final key in [
lastLShiftKeyEvent,
lastRShiftKeyEvent,
lastLCtrlKeyEvent,
lastRCtrlKeyEvent,
lastLAltKeyEvent,
lastRAltKeyEvent,
lastLCommandKeyEvent,
lastRCommandKeyEvent,
lastSuperKeyEvent,
]) {
if (key != null) {
handleKeyEvent(key);
}
}
}
}
class InputModel {
// Side mouse button support for Linux.
// Flutter's Linux embedder drops X11 button 8/9 events, so we capture them
// natively via GDK and forward through the platform channel.
static InputModel? _activeSideButtonModel;
// Tracks per-button which model received a side button down event, so the
// matching up event is routed there even if the pointer has left the view
// or a different button was pressed in between.
static final Map<MouseButtons, InputModel> _sideButtonDownModels = {};
static bool _sideButtonChannelInitialized = false;
/// Each Flutter engine (main window + sub-windows from desktop_multi_window)
/// runs its own Dart isolate with its own statics. Called from initEnv()
/// which runs per-engine, so each isolate registers its own handler tied
/// to its own set of InputModels.
static void initSideButtonChannel() {
if (!isLinux) return;
if (_sideButtonChannelInitialized) return;
_sideButtonChannelInitialized = true;
const channel = MethodChannel('org.rustdesk.rustdesk/side_buttons');
channel.setMethodCallHandler((call) async {
if (call.method == 'onSideMouseButton') {
final args = call.arguments as Map<dynamic, dynamic>;
final button = args['button'] as String;
final type = args['type'] as String;
final mb = button == 'back' ? MouseButtons.back : MouseButtons.forward;
if (type == 'down') {
final model = _activeSideButtonModel;
if (model != null &&
!(model.isViewOnly && !model.showMyCursor) &&
model.keyboardPerm &&
!model.isViewCamera) {
_sideButtonDownModels[mb] = model;
// Fire-and-forget to avoid blocking the platform channel handler.
unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) {
debugPrint('[InputModel] failed to send side button $type for $mb: $e');
}));
}
} else {
// Only route 'up' when we recorded the matching 'down';
// dropping avoids sending unpaired 'up' to an unrelated session.
// Use _sendMouseUnchecked to bypass permission checks so the
// release always goes through even if permissions changed.
final model = _sideButtonDownModels.remove(mb);
if (model != null) {
unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) {
debugPrint('[InputModel] failed to send side button $type for $mb: $e');
}));
}
}
}
return null;
});
}
/// Clear any static references to this model (prevents stale routing).
/// Releases any held side buttons on the peer so closing a session
/// mid-press does not leave a stuck button.
void disposeSideButtonTracking() {
if (_activeSideButtonModel == this) _activeSideButtonModel = null;
final held = _sideButtonDownModels.entries
.where((e) => e.value == this)
.map((e) => e.key)
.toList();
for (final mb in held) {
_sideButtonDownModels.remove(mb);
// Best-effort release; session may already be tearing down.
unawaited(_sendMouseUnchecked('up', mb).catchError((Object e) {
debugPrint('[InputModel] failed to release side button $mb: $e');
}));
}
}
final WeakReference<FFI> parent;
String keyboardMode = '';
// keyboard
var shift = false;
var ctrl = false;
var alt = false;
var command = false;
final ToReleaseRawKeys toReleaseRawKeys = ToReleaseRawKeys();
final ToReleaseKeys toReleaseKeys = ToReleaseKeys();
// trackpad
var _trackpadLastDelta = Offset.zero;
var _stopFling = true;
var _fling = false;
Timer? _flingTimer;
final _flingBaseDelay = 30;
final _trackpadAdjustPeerLinux = 0.06;
// This is an experience value.
final _trackpadAdjustMacToWin = 2.50;
// Ignore directional locking for very small deltas on both axes (including
// tiny single-axis movement) to avoid over-filtering near zero.
static const double _trackpadAxisNoiseThreshold = 0.2;
// Lock to dominant axis only when one axis is clearly stronger.
// 1.6 means the dominant axis must be >= 60% larger than the other.
static const double _trackpadAxisLockRatio = 1.6;
int _trackpadSpeed = kDefaultTrackpadSpeed;
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;
int _lastWheelTsUs = 0;
// Wheel acceleration thresholds.
static const int _wheelAccelFastThresholdUs = 40000; // 40ms
static const int _wheelAccelMediumThresholdUs = 80000; // 80ms
static const double _wheelBurstVelocityThreshold =
0.002; // delta units per microsecond
// Wheel burst acceleration (empirical tuning).
// Applies only to fast, non-smooth bursts to preserve single-step scrolling.
// Flutter uses microseconds for dt, so velocity is in delta/us.
// 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 = [];
late final SessionID sessionId;
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;
/// Check if the connected server supports relative mouse mode.
bool get isRelativeMouseModeSupported => _relativeMouse.isSupported;
InputModel(this.parent) {
initSideButtonChannel();
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;
}
});
}
// https://github.com/flutter/flutter/issues/157241
// Infer CapsLock state from the character output.
// This is needed because Flutter's HardwareKeyboard.lockModesEnabled may report
// incorrect CapsLock state on iOS.
bool _getIosCapsFromCharacter(KeyEvent e) {
if (!isIOS) return false;
final ch = e.character;
return _getIosCapsFromCharacterImpl(
ch, HardwareKeyboard.instance.isShiftPressed);
}
// RawKeyEvent version of _getIosCapsFromCharacter.
bool _getIosCapsFromRawCharacter(RawKeyEvent e) {
if (!isIOS) return false;
final ch = e.character;
return _getIosCapsFromCharacterImpl(ch, e.isShiftPressed);
}
// Shared implementation for inferring CapsLock state from character.
// Uses Unicode-aware case detection to support non-ASCII letters (e.g., ü/Ü, é/É).
//
// Limitations:
// 1. This inference assumes the client and server use the same keyboard layout.
// If layouts differ (e.g., client uses EN, server uses DE), the character output
// may not match expectations. For example, ';' on EN layout maps to 'ö' on DE
// layout, making it impossible to correctly infer CapsLock state from the
// character alone.
// 2. On iOS, CapsLock+Shift produces uppercase letters (unlike desktop where it
// produces lowercase). This method cannot handle that case correctly.
bool _getIosCapsFromCharacterImpl(String? ch, bool shiftPressed) {
if (ch == null || ch.length != 1) return false;
// Use Dart's built-in Unicode-aware case detection
final upper = ch.toUpperCase();
final lower = ch.toLowerCase();
final isUpper = upper == ch && lower != ch;
final isLower = lower == ch && upper != ch;
// Skip non-letter characters (e.g., numbers, symbols, CJK characters without case)
if (!isUpper && !isLower) return false;
return isUpper != shiftPressed;
}
int _buildLockModes(bool iosCapsLock) {
const capslock = 1;
const numlock = 2;
const scrolllock = 3;
int lockModes = 0;
if (isIOS) {
if (iosCapsLock) {
lockModes |= (1 << capslock);
}
// Ignore "NumLock/ScrollLock" on iOS for now.
} else {
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.capsLock)) {
lockModes |= (1 << capslock);
}
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.numLock)) {
lockModes |= (1 << numlock);
}
if (HardwareKeyboard.instance.lockModesEnabled
.contains(KeyboardLockMode.scrollLock)) {
lockModes |= (1 << scrolllock);
}
}
return lockModes;
}
// This function must be called after the peer info is received.
// Because `sessionGetKeyboardMode` relies on the peer version.
updateKeyboardMode() async {
// * Currently mobile does not enable map mode
if (isDesktop || isWebDesktop) {
keyboardMode = await bind.sessionGetKeyboardMode(sessionId: sessionId) ??
kKeyLegacyMode;
}
}
/// Updates the trackpad speed based on the session value.
///
/// The expected format of the retrieved value is a string that can be parsed into a double.
/// If parsing fails or the value is out of bounds (less than `kMinTrackpadSpeed` or greater
/// than `kMaxTrackpadSpeed`), the trackpad speed is reset to the default
/// value (`kDefaultTrackpadSpeed`).
///
/// Bounds:
/// - Minimum: `kMinTrackpadSpeed`
/// - Maximum: `kMaxTrackpadSpeed`
/// - Default: `kDefaultTrackpadSpeed`
Future<void> updateTrackpadSpeed() async {
_trackpadSpeed =
(await bind.sessionGetTrackpadSpeed(sessionId: sessionId) ??
kDefaultTrackpadSpeed);
if (_trackpadSpeed < kMinTrackpadSpeed ||
_trackpadSpeed > kMaxTrackpadSpeed) {
_trackpadSpeed = kDefaultTrackpadSpeed;
}
_trackpadSpeedInner = _trackpadSpeed / 100.0;
}
void handleKeyDownEventModifiers(KeyEvent e) {
KeyUpEvent upEvent(e) => KeyUpEvent(
physicalKey: e.physicalKey,
logicalKey: e.logicalKey,
timeStamp: e.timeStamp,
);
if (e.logicalKey == LogicalKeyboardKey.altLeft) {
if (!alt) {
alt = true;
}
toReleaseKeys.lastLAltKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.altRight) {
if (!alt) {
alt = true;
}
toReleaseKeys.lastLAltKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.controlLeft) {
if (!ctrl) {
ctrl = true;
}
toReleaseKeys.lastLCtrlKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.controlRight) {
if (!ctrl) {
ctrl = true;
}
toReleaseKeys.lastRCtrlKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.shiftLeft) {
if (!shift) {
shift = true;
}
toReleaseKeys.lastLShiftKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.shiftRight) {
if (!shift) {
shift = true;
}
toReleaseKeys.lastRShiftKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.metaLeft) {
if (!command) {
command = true;
}
toReleaseKeys.lastLCommandKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.metaRight) {
if (!command) {
command = true;
}
toReleaseKeys.lastRCommandKeyEvent = upEvent(e);
} else if (e.logicalKey == LogicalKeyboardKey.superKey) {
if (!command) {
command = true;
}
toReleaseKeys.lastSuperKeyEvent = upEvent(e);
}
}
void handleKeyUpEventModifiers(KeyEvent e) {
if (e.logicalKey == LogicalKeyboardKey.altLeft) {
alt = false;
toReleaseKeys.lastLAltKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.altRight) {
alt = false;
toReleaseKeys.lastRAltKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.controlLeft) {
ctrl = false;
toReleaseKeys.lastLCtrlKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.controlRight) {
ctrl = false;
toReleaseKeys.lastRCtrlKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.shiftLeft) {
shift = false;
toReleaseKeys.lastLShiftKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.shiftRight) {
shift = false;
toReleaseKeys.lastRShiftKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.metaLeft) {
command = false;
toReleaseKeys.lastLCommandKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.metaRight) {
command = false;
toReleaseKeys.lastRCommandKeyEvent = null;
} else if (e.logicalKey == LogicalKeyboardKey.superKey) {
command = false;
toReleaseKeys.lastSuperKeyEvent = null;
}
}
// Safe: this only re-dispatches synthesized Shift key-up events.
// The key-up path clears the tracked Shift state so this does not loop.
void _releaseTrackedShiftKeyEventIfNeeded() {
final leftShift = toReleaseKeys.lastLShiftKeyEvent;
final rightShift = toReleaseKeys.lastRShiftKeyEvent;
if (leftShift != null) {
handleKeyEvent(leftShift);
}
if (rightShift != null) {
handleKeyEvent(rightShift);
}
}
// Safe: this only re-dispatches synthesized Shift key-up events.
// The raw key-up path clears the tracked Shift state so this does not loop.
void _releaseTrackedRawShiftKeyEventIfNeeded() {
final leftShift = toReleaseRawKeys.lastLShiftKeyEvent;
final rightShift = toReleaseRawKeys.lastRShiftKeyEvent;
if (leftShift != null) {
handleRawKeyEvent(RawKeyUpEvent(
data: leftShift.data,
character: leftShift.character,
));
}
if (rightShift != null) {
handleRawKeyEvent(RawKeyUpEvent(
data: rightShift.data,
character: rightShift.character,
));
}
}
KeyEventResult handleRawKeyEvent(RawKeyEvent e) {
if (isViewOnly) return KeyEventResult.handled;
if (isViewCamera) return KeyEventResult.handled;
if (!isInputSourceFlutter) {
if (isDesktop) {
return KeyEventResult.handled;
} else if (isWeb) {
return KeyEventResult.ignored;
}
}
if (_relativeMouse.handleRawKeyEvent(e)) {
return KeyEventResult.handled;
}
bool iosCapsLock = false;
if (isIOS && e is RawKeyDownEvent) {
iosCapsLock = _getIosCapsFromRawCharacter(e);
}
final key = e.logicalKey;
if (e is RawKeyDownEvent) {
if (!e.repeat) {
if (e.isAltPressed && !alt) {
alt = true;
} else if (e.isControlPressed && !ctrl) {
ctrl = true;
} else if (e.isShiftPressed && !shift) {
shift = true;
} else if (e.isMetaPressed && !command) {
command = true;
}
}
toReleaseRawKeys.updateKeyDown(key, e);
}
if (e is RawKeyUpEvent) {
if (key == LogicalKeyboardKey.altLeft ||
key == LogicalKeyboardKey.altRight) {
alt = false;
} else if (key == LogicalKeyboardKey.controlLeft ||
key == LogicalKeyboardKey.controlRight) {
ctrl = false;
} else if (key == LogicalKeyboardKey.shiftRight ||
key == LogicalKeyboardKey.shiftLeft) {
shift = false;
} else if (key == LogicalKeyboardKey.metaLeft ||
key == LogicalKeyboardKey.metaRight ||
key == LogicalKeyboardKey.superKey) {
command = false;
}
toReleaseRawKeys.updateKeyUp(key, e);
}
// On some mobile soft-keyboard paths, Flutter may leave cached Shift state
// set even though the current raw key event is not shifted anymore.
if (e is RawKeyDownEvent &&
shouldReleaseStaleMobileShift(
isMobile: isMobile,
cachedShiftPressed: shift,
actualShiftPressed: e.isShiftPressed,
logicalKey: e.logicalKey,
hasTrackedShiftKeyDown: toReleaseRawKeys.lastLShiftKeyEvent != null ||
toReleaseRawKeys.lastRShiftKeyEvent != null,
)) {
if (kDebugMode) {
debugPrint(
'input: releasing stale mobile Shift before replaying tracked raw '
'key-up (logicalKey=${e.logicalKey.keyLabel}, '
'actualShiftPressed=${e.isShiftPressed}, cachedShiftPressed=$shift)',
);
}
_releaseTrackedRawShiftKeyEventIfNeeded();
}
// * Currently mobile does not enable map mode
if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) {
mapKeyboardModeRaw(e, iosCapsLock);
} else {
legacyKeyboardModeRaw(e);
}
return KeyEventResult.handled;
}
KeyEventResult handleKeyEvent(KeyEvent e) {
if (isViewOnly) return KeyEventResult.handled;
if (isViewCamera) return KeyEventResult.handled;
if (!isInputSourceFlutter) {
if (isDesktop) {
return KeyEventResult.handled;
} else if (isWeb) {
return KeyEventResult.ignored;
}
}
if (isWindows || isLinux) {
// Ignore meta keys. Because flutter window will loose focus if meta key is pressed.
if (e.physicalKey == PhysicalKeyboardKey.metaLeft ||
e.physicalKey == PhysicalKeyboardKey.metaRight) {
return KeyEventResult.handled;
}
}
if (_relativeMouse.handleKeyEvent(
e,
ctrlPressed: ctrl,
shiftPressed: shift,
altPressed: alt,
commandPressed: command,
)) {
return KeyEventResult.handled;
}
bool iosCapsLock = false;
if (isIOS && (e is KeyDownEvent || e is KeyRepeatEvent)) {
iosCapsLock = _getIosCapsFromCharacter(e);
}
// Update cached modifier state before sending the event. The stale mobile
// Shift release check below relies on this cached state.
if (e is KeyUpEvent) {
handleKeyUpEventModifiers(e);
} else if (e is KeyDownEvent) {
handleKeyDownEventModifiers(e);
}
bool isMobileAndMapMode = false;
if (isMobile) {
// Do not use map mode if mobile -> Android. Android does not support map mode for now.
// Because simulating the physical key events(uhid) which requires root permission is not supported.
if (peerPlatform != kPeerPlatformAndroid) {
if (isIOS) {
isMobileAndMapMode = true;
} else {
// The physicalKey.usbHidUsage may be not correct for soft keyboard on Android.
// iOS does not have this issue.
// 1. Open the soft keyboard on Android
// 2. Switch to input method like zh/ko/ja
// 3. Click Backspace and Enter on the soft keyboard or physical keyboard
// 4. The physicalKey.usbHidUsage is not correct.
// PhysicalKeyboardKey#8ac83(usbHidUsage: "0x1100000042", debugName: "Key with ID 0x1100000042")
// LogicalKeyboardKey#2604c(keyId: "0x10000000d", keyLabel: "Enter", debugName: "Enter")
//
// The correct PhysicalKeyboardKey should be
// PhysicalKeyboardKey#e14a9(usbHidUsage: "0x00070028", debugName: "Enter")
// https://github.com/flutter/flutter/issues/157771
// We cannot use the debugName to determine the key is correct or not, because it's null in release mode.
// The normal `usbHidUsage` for keyboard shoud be between [0x00000010, 0x000c029f]
// https://github.com/flutter/flutter/blob/c051b69e2a2224300e20d93dbd15f4b91e8844d1/packages/flutter/lib/src/services/keyboard_key.g.dart#L5332 - 5600
final isNormalHsbHidUsage = (e.physicalKey.usbHidUsage >> 20) == 0;
isMobileAndMapMode = isNormalHsbHidUsage &&
// No need to check `!['Backspace', 'Enter'].contains(e.logicalKey.keyLabel)`
// But we still add it for more reliability.
!['Backspace', 'Enter'].contains(e.logicalKey.keyLabel);
}
}
}
// On some mobile soft-keyboard paths, Flutter may leave cached Shift state
// set even though the current key event is not shifted anymore.
if (e is KeyDownEvent &&
shouldReleaseStaleMobileShift(
isMobile: isMobile,
cachedShiftPressed: shift,
actualShiftPressed: HardwareKeyboard.instance.isShiftPressed,
logicalKey: e.logicalKey,
hasTrackedShiftKeyDown: toReleaseKeys.lastLShiftKeyEvent != null ||
toReleaseKeys.lastRShiftKeyEvent != null,
)) {
_releaseTrackedShiftKeyEventIfNeeded();
}
final isDesktopAndMapMode =
isDesktop || (isWebDesktop && keyboardMode == kKeyMapMode);
if (isMobileAndMapMode || isDesktopAndMapMode) {
// FIXME: e.character is wrong for dead keys, eg: ^ in de
newKeyboardMode(
e.character ?? '',
e.physicalKey.usbHidUsage & 0xFFFF,
// Show repeat event be converted to "release+press" events?
e is KeyDownEvent || e is KeyRepeatEvent,
iosCapsLock);
} else {
legacyKeyboardMode(e);
}
return KeyEventResult.handled;
}
/// Send Key Event
void newKeyboardMode(
String character, int usbHid, bool down, bool iosCapsLock) {
final lockModes = _buildLockModes(iosCapsLock);
bind.sessionHandleFlutterKeyEvent(
sessionId: sessionId,
character: character,
usbHid: usbHid,
lockModes: lockModes,
downOrUp: down);
}
void mapKeyboardModeRaw(RawKeyEvent e, bool iosCapsLock) {
int positionCode = -1;
int platformCode = -1;
bool down;
if (e.data is RawKeyEventDataMacOs) {
RawKeyEventDataMacOs newData = e.data as RawKeyEventDataMacOs;
positionCode = newData.keyCode;
platformCode = newData.keyCode;
} else if (e.data is RawKeyEventDataWindows) {
RawKeyEventDataWindows newData = e.data as RawKeyEventDataWindows;
positionCode = newData.scanCode;
platformCode = newData.keyCode;
} else if (e.data is RawKeyEventDataLinux) {
RawKeyEventDataLinux newData = e.data as RawKeyEventDataLinux;
// scanCode and keyCode of RawKeyEventDataLinux are incorrect.
// 1. scanCode means keycode
// 2. keyCode means keysym
positionCode = newData.scanCode;
platformCode = newData.keyCode;
} else if (e.data is RawKeyEventDataAndroid) {
RawKeyEventDataAndroid newData = e.data as RawKeyEventDataAndroid;
positionCode = newData.scanCode + 8;
platformCode = newData.keyCode;
} else {}
if (e is RawKeyDownEvent) {
down = true;
} else {
down = false;
}
inputRawKey(
e.character ?? '', platformCode, positionCode, down, iosCapsLock);
}
/// Send raw Key Event
void inputRawKey(String name, int platformCode, int positionCode, bool down,
bool iosCapsLock) {
final lockModes = _buildLockModes(iosCapsLock);
bind.sessionHandleFlutterRawKeyEvent(
sessionId: sessionId,
name: name,
platformCode: platformCode,
positionCode: positionCode,
lockModes: lockModes,
downOrUp: down);
}
void legacyKeyboardModeRaw(RawKeyEvent e) {
if (e is RawKeyDownEvent) {
if (e.repeat) {
sendRawKey(e, press: true);
} else {
sendRawKey(e, down: true);
}
}
if (e is RawKeyUpEvent) {
sendRawKey(e);
}
}
void sendRawKey(RawKeyEvent e, {bool? down, bool? press}) {
// for maximum compatibility
final label = physicalKeyMap[e.physicalKey.usbHidUsage] ??
logicalKeyMap[e.logicalKey.keyId] ??
e.logicalKey.keyLabel;
inputKey(label, down: down, press: press ?? false);
}
void legacyKeyboardMode(KeyEvent e) {
if (e is KeyDownEvent) {
sendKey(e, down: true);
} else if (e is KeyRepeatEvent) {
sendKey(e, press: true);
} else if (e is KeyUpEvent) {
sendKey(e);
}
}
void sendKey(KeyEvent e, {bool? down, bool? press}) {
// for maximum compatibility
final label = physicalKeyMap[e.physicalKey.usbHidUsage] ??
logicalKeyMap[e.logicalKey.keyId] ??
e.logicalKey.keyLabel;
inputKey(label, down: down, press: press ?? false);
}
/// Send key stroke event.
/// [down] indicates the key's state(down or up).
/// [press] indicates a click event(down and up).
void inputKey(String name, {bool? down, bool? press}) {
if (!keyboardPerm) return;
if (isViewCamera) return;
bind.sessionInputKey(
sessionId: sessionId,
name: name,
down: down ?? false,
press: press ?? true,
alt: alt,
ctrl: ctrl,
shift: shift,
command: command);
}
static Map<String, dynamic> getMouseEventMove() => {
'type': _kMouseEventMove,
'buttons': 0,
};
Map<String, dynamic> _getMouseEvent(PointerEvent evt, String type) {
final Map<String, dynamic> out = {};
bool hasStaleButtonsOnMouseUp =
type == _kMouseEventUp && evt.buttons == _lastButtons;
// Check update event type and set buttons to be sent.
int buttons = _lastButtons;
if (type == _kMouseEventMove) {
// flutter may emit move event if one button is pressed and another button
// is pressing or releasing.
if (evt.buttons != _lastButtons) {
// For simplicity
// Just consider 3 - 1 ((Left + Right buttons) - Left button)
// Do not consider 2 - 1 (Right button - Left button)
// or 6 - 5 ((Right + Mid buttons) - (Left + Mid buttons))
// and so on
buttons = evt.buttons - _lastButtons;
if (buttons > 0) {
type = _kMouseEventDown;
} else {
type = _kMouseEventUp;
buttons = -buttons;
}
}
} else {
if (evt.buttons != 0) {
buttons = evt.buttons;
}
}
_lastButtons = hasStaleButtonsOnMouseUp ? 0 : evt.buttons;
out['buttons'] = buttons;
out['type'] = type;
return out;
}
/// Send a mouse tap event(down and up).
Future<void> tap(MouseButtons button) async {
await sendMouse('down', button);
await sendMouse('up', button);
}
Future<void> tapDown(MouseButtons button) async {
await sendMouse('down', button);
}
Future<void> tapUp(MouseButtons button) async {
await sendMouse('up', button);
}
/// Send scroll event with scroll distance [y].
Future<void> scroll(int y) async {
if (isViewCamera) return;
await bind.sessionSendMouse(
sessionId: sessionId,
msg: json
.encode(modify({'id': id, 'type': 'wheel', 'y': y.toString()})));
}
/// Reset key modifiers to false, including [shift], [ctrl], [alt] and [command].
void resetModifiers() {
shift = ctrl = alt = command = false;
}
/// Modify the given modifier map [evt] based on current modifier key status.
Map<String, dynamic> modify(Map<String, dynamic> evt) {
if (ctrl) evt['ctrl'] = 'true';
if (shift) evt['shift'] = 'true';
if (alt) evt['alt'] = 'true';
if (command) evt['command'] = 'true';
return evt;
}
/// Send mouse event unconditionally (no permission checks).
/// Used for side button releases that must go through even if permissions
/// changed after the matching down was sent.
Future<void> _sendMouseUnchecked(String type, MouseButtons button) async {
await bind.sessionSendMouse(
sessionId: sessionId,
msg: json.encode(modify({'type': type, 'buttons': button.value})));
}
/// Send mouse press event.
Future<void> sendMouse(String type, MouseButtons button) async {
if (!keyboardPerm) return;
if (isViewCamera) return;
await _sendMouseUnchecked(type, button);
}
void enterOrLeave(bool enter) {
toReleaseKeys.release(handleKeyEvent);
toReleaseRawKeys.release(handleRawKeyEvent);
_pointerMovedAfterEnter = false;
_pointerInsideImage = enter;
_lastWheelTsUs = 0;
// Track active model for side button events (Linux).
if (enter) {
_activeSideButtonModel = this;
} else if (_activeSideButtonModel == this) {
_activeSideButtonModel = null;
}
// Fix status
if (!enter) {
resetModifiers();
}
_relativeMouse.onEnterOrLeaveImage(enter);
_flingTimer?.cancel();
if (!isInputSourceFlutter) {
bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter);
}
if (!isWeb && enter) {
bind.setCurSessionId(sessionId: sessionId);
}
}
/// Send mouse movement event with distance in [x] and [y].
Future<void> moveMouse(double x, double y) async {
if (!keyboardPerm) return;
if (isViewCamera) return;
var x2 = x.toInt();
var y2 = y.toInt();
await bind.sessionSendMouse(
sessionId: sessionId,
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;
// May fix https://github.com/rustdesk/rustdesk/issues/13009
if (isIOS && e.synthesized && e.position == Offset.zero && e.buttons == 0) {
// iOS may emit a synthesized hover event at (0,0) when the mouse is disconnected.
// Ignore this event to prevent cursor jumping.
debugPrint('Ignored synthesized hover at (0,0) on iOS');
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) {
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
edgeScroll: useEdgeScroll);
}
}
}
void onPointerPanZoomStart(PointerPanZoomStartEvent e) {
_lastScale = 1.0;
_stopFling = true;
if (isViewOnly) return;
if (isViewCamera) return;
if (peerPlatform == kPeerPlatformAndroid) {
handlePointerEvent('touch', kMouseEventTypePanStart, e.position);
}
}
// https://docs.flutter.dev/release/breaking-changes/trackpad-gestures
void onPointerPanZoomUpdate(PointerPanZoomUpdateEvent e) {
if (isViewOnly) return;
if (isViewCamera) return;
if (peerPlatform != kPeerPlatformAndroid) {
final scale = ((e.scale - _lastScale) * 1000).toInt();
_lastScale = e.scale;
if (scale != 0) {
bind.sessionSendPointer(
sessionId: sessionId,
msg: json.encode(
PointerEventToRust(kPointerEventKindTouch, 'scale', scale)
.toJson()));
return;
}
}
var delta = e.panDelta * _trackpadSpeedInner;
if (isMacOS && peerPlatform == kPeerPlatformWindows) {
delta *= _trackpadAdjustMacToWin;
}
delta = _filterTrackpadDeltaAxis(delta);
_trackpadLastDelta = delta;
var x = delta.dx.toInt();
var y = delta.dy.toInt();
if (peerPlatform == kPeerPlatformLinux) {
_trackpadScrollUnsent += (delta * _trackpadAdjustPeerLinux);
x = _trackpadScrollUnsent.dx.truncate();
y = _trackpadScrollUnsent.dy.truncate();
_trackpadScrollUnsent -= Offset(x.toDouble(), y.toDouble());
} else {
if (x == 0 && y == 0) {
final thr = 0.1;
if (delta.dx.abs() > delta.dy.abs()) {
x = delta.dx > thr ? 1 : (delta.dx < -thr ? -1 : 0);
} else {
y = delta.dy > thr ? 1 : (delta.dy < -thr ? -1 : 0);
}
}
}
if (x != 0 || y != 0) {
if (peerPlatform == kPeerPlatformAndroid) {
handlePointerEvent('touch', kMouseEventTypePanUpdate,
Offset(x.toDouble(), y.toDouble()));
} else {
if (isViewCamera) return;
bind.sessionSendMouse(
sessionId: sessionId,
msg: '{"type": "trackpad", "x": "$x", "y": "$y"}');
}
}
}
Offset _filterTrackpadDeltaAxis(Offset delta) {
final absDx = delta.dx.abs();
final absDy = delta.dy.abs();
// Keep diagonal intent when movement is tiny on both axes.
if (absDx < _trackpadAxisNoiseThreshold &&
absDy < _trackpadAxisNoiseThreshold) {
return delta;
}
// Dominant-axis lock to reduce accidental cross-axis scrolling noise.
if (absDy >= absDx * _trackpadAxisLockRatio) {
return Offset(0, delta.dy);
}
if (absDx >= absDy * _trackpadAxisLockRatio) {
return Offset(delta.dx, 0);
}
return delta;
}
void _scheduleFling(double x, double y, int delay) {
if (isViewCamera) return;
if ((x == 0 && y == 0) || _stopFling) {
_fling = false;
return;
}
_flingTimer = Timer(Duration(milliseconds: delay), () {
if (_stopFling) {
_fling = false;
return;
}
final d = 0.97;
x *= d;
y *= d;
// Try set delta (x,y) and delay.
var dx = x.toInt();
var dy = y.toInt();
if (parent.target?.ffiModel.pi.platform == kPeerPlatformLinux) {
dx = (x * _trackpadAdjustPeerLinux).toInt();
dy = (y * _trackpadAdjustPeerLinux).toInt();
}
var delay = _flingBaseDelay;
if (dx == 0 && dy == 0) {
_fling = false;
return;
}
bind.sessionSendMouse(
sessionId: sessionId,
msg: '{"type": "trackpad", "x": "$dx", "y": "$dy"}');
_scheduleFling(x, y, delay);
});
}
void waitLastFlingDone() {
if (_fling) {
_stopFling = true;
}
for (var i = 0; i < 5; i++) {
if (!_fling) {
break;
}
sleep(Duration(milliseconds: 10));
}
_flingTimer?.cancel();
}
void onPointerPanZoomEnd(PointerPanZoomEndEvent e) {
if (isViewCamera) return;
if (peerPlatform == kPeerPlatformAndroid) {
handlePointerEvent('touch', kMouseEventTypePanEnd, e.position);
return;
}
bind.sessionSendPointer(
sessionId: sessionId,
msg: json.encode(
PointerEventToRust(kPointerEventKindTouch, 'scale', 0).toJson()));
waitLastFlingDone();
_stopFling = false;
// 2.0 is an experience value
double minFlingValue = 2.0 * _trackpadSpeedInner;
if (isMacOS && peerPlatform == kPeerPlatformWindows) {
minFlingValue *= _trackpadAdjustMacToWin;
}
if (_trackpadLastDelta.dx.abs() > minFlingValue ||
_trackpadLastDelta.dy.abs() > minFlingValue) {
_fling = true;
_scheduleFling(
_trackpadLastDelta.dx, _trackpadLastDelta.dy, _flingBaseDelay);
}
_trackpadLastDelta = Offset.zero;
}
// iOS Magic Mouse duplicate event detection.
// When using Magic Mouse on iPad, iOS may emit both mouse and touch events
// for the same click in certain areas (like top-left corner).
int _lastMouseDownTimeMs = 0;
ui.Offset _lastMouseDownPos = ui.Offset.zero;
/// Check if a touch tap event should be ignored because it's a duplicate
/// of a recent mouse event (iOS Magic Mouse issue).
bool shouldIgnoreTouchTap(ui.Offset pos) {
if (!isIOS) return false;
final nowMs = DateTime.now().millisecondsSinceEpoch;
final dt = nowMs - _lastMouseDownTimeMs;
final distance = (_lastMouseDownPos - pos).distance;
// If touch tap is within 2000ms and 80px of the last mouse down,
// it's likely a duplicate event from the same Magic Mouse click.
if (dt >= 0 && dt < 2000 && distance < 80.0) {
debugPrint("shouldIgnoreTouchTap: IGNORED (dt=$dt, dist=$distance)");
return true;
}
return false;
}
/// iOS may emit a synthesized touch event after a real mouse click.
/// This helper ignores touch-down events that arrive shortly after a mouse down,
/// even when the position is far (e.g., near the top edge).
bool _shouldIgnoreTouchAfterMouse(int nowMs) {
if (!isIOS) return false;
const int kTouchAfterMouseWindowMs = 700;
final dt = nowMs - _lastMouseDownTimeMs;
return dt >= 0 && dt < kTouchAfterMouseWindowMs;
}
void onPointDownImage(PointerDownEvent e) {
debugPrint("onPointDownImage ${e.kind}");
_stopFling = true;
if (isDesktop) _queryOtherWindowCoords = true;
_remoteWindowCoords = [];
_windowRect = null;
if (isViewOnly && !showMyCursor) return;
if (isViewCamera) return;
// Track mouse down events for duplicate detection on iOS.
final nowMs = DateTime.now().millisecondsSinceEpoch;
if (e.kind == ui.PointerDeviceKind.mouse) {
if (!isPhysicalMouse.value) {
isPhysicalMouse.value = true;
}
_lastMouseDownTimeMs = nowMs;
_lastMouseDownPos = e.position;
}
if (_relativeMouse.enabled.value) {
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
}
if (e.kind != ui.PointerDeviceKind.mouse) {
// Ignore duplicate touch events that follow a recent mouse click (iOS Magic Mouse issue).
if (isPhysicalMouse.value && _shouldIgnoreTouchAfterMouse(nowMs)) {
return;
}
if (isPhysicalMouse.value) {
isPhysicalMouse.value = false;
}
}
if (isPhysicalMouse.value) {
// In relative mouse mode, send button events without position.
// Use _relativeMouse.enabled.value consistently with the guard above.
if (_relativeMouse.enabled.value) {
_relativeMouse
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
} else {
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
}
}
}
void onPointUpImage(PointerUpEvent e) {
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) {
// 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);
}
}
}
void onPointMoveImage(PointerMoveEvent e) {
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);
});
_queryOtherWindowCoords = false;
}
if (isPhysicalMouse.value) {
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
edgeScroll: useEdgeScroll);
}
}
}
static Future<Rect?> fillRemoteCoordsAndGetCurFrame(
List<RemoteWindowCoords> remoteWindowCoords) async {
final coords =
await rustDeskWinManager.getOtherRemoteWindowCoordsFromMain();
final wc = WindowController.fromWindowId(kWindowId!);
try {
final frame = await wc.getFrame();
for (final c in coords) {
c.relativeOffset = Offset(
c.windowRect.left - frame.left, c.windowRect.top - frame.top);
remoteWindowCoords.add(c);
}
return frame;
} catch (e) {
// Unreachable code
debugPrint("Failed to get frame of window $kWindowId, it may be hidden");
}
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;
if (e is PointerScrollEvent) {
final rawDx = e.scrollDelta.dx;
final rawDy = e.scrollDelta.dy;
final dominantDelta = rawDx.abs() > rawDy.abs() ? rawDx.abs() : rawDy.abs();
final isSmooth = dominantDelta < 1;
final nowUs = DateTime.now().microsecondsSinceEpoch;
final dtUs = _lastWheelTsUs == 0 ? 0 : nowUs - _lastWheelTsUs;
_lastWheelTsUs = nowUs;
int accel = 1;
if (!isSmooth &&
dtUs > 0 &&
dtUs <= _wheelAccelMediumThresholdUs &&
(isWindows || isLinux) &&
peerPlatform == kPeerPlatformMacOS) {
final velocity = dominantDelta / dtUs;
if (velocity >= _wheelBurstVelocityThreshold) {
if (dtUs < _wheelAccelFastThresholdUs) {
accel = 3;
} else {
accel = 2;
}
}
}
var dx = rawDx.toInt();
var dy = rawDy.toInt();
if (rawDx.abs() > rawDy.abs()) {
dy = 0;
} else {
dx = 0;
}
if (dx > 0) {
dx = -accel;
} else if (dx < 0) {
dx = accel;
}
if (dy > 0) {
dy = -accel;
} else if (dy < 0) {
dy = accel;
}
bind.sessionSendMouse(
sessionId: sessionId,
msg: '{"type": "wheel", "x": "$dx", "y": "$dy"}');
}
}
void refreshMousePos() => handleMouse({
'buttons': 0,
'type': _kMouseEventMove,
}, lastMousePos, edgeScroll: useEdgeScroll);
void tryMoveEdgeOnExit(Offset pos) => handleMouse(
{
'buttons': 0,
'type': _kMouseEventMove,
},
pos,
onExit: true,
);
static double tryGetNearestRange(double v, double min, double max, double n) {
if (v < min && v >= min - n) {
v = min;
}
if (v > max && v <= max + n) {
v = max;
}
return v;
}
Offset setNearestEdge(double x, double y, Rect rect) {
double left = x - rect.left;
double right = rect.right - 1 - x;
double top = y - rect.top;
double bottom = rect.bottom - 1 - y;
if (left < right && left < top && left < bottom) {
x = rect.left;
}
if (right < left && right < top && right < bottom) {
x = rect.right - 1;
}
if (top < left && top < right && top < bottom) {
y = rect.top;
}
if (bottom < left && bottom < right && bottom < top) {
y = rect.bottom - 1;
}
return Offset(x, y);
}
void handlePointerEvent(String kind, String type, Offset offset) {
double x = offset.dx;
double y = offset.dy;
if (_checkPeerControlProtected(x, y)) {
return;
}
// Only touch events are handled for now. So we can just ignore buttons.
// to-do: handle mouse events
late final dynamic evtValue;
if (type == kMouseEventTypePanUpdate) {
evtValue = {
'x': x.toInt(),
'y': y.toInt(),
};
} else {
final isMoveTypes = [kMouseEventTypePanStart, kMouseEventTypePanEnd];
final pos = handlePointerDevicePos(
kPointerEventKindTouch,
x,
y,
isMoveTypes.contains(type),
type,
);
if (pos == null) {
return;
}
evtValue = {
'x': pos.x.toInt(),
'y': pos.y.toInt(),
};
}
final evt = PointerEventToRust(kind, type, evtValue).toJson();
if (isViewCamera) return;
bind.sessionSendPointer(
sessionId: sessionId, msg: json.encode(modify(evt)));
}
bool _checkPeerControlProtected(double x, double y) {
final cursorModel = parent.target!.cursorModel;
if (cursorModel.isPeerControlProtected) {
lastMousePos = ui.Offset(x, y);
return true;
}
if (!cursorModel.gotMouseControl) {
bool selfGetControl =
(x - lastMousePos.dx).abs() > kMouseControlDistance ||
(y - lastMousePos.dy).abs() > kMouseControlDistance;
if (selfGetControl) {
cursorModel.gotMouseControl = true;
} else {
lastMousePos = ui.Offset(x, y);
return true;
}
}
lastMousePos = ui.Offset(x, y);
return false;
}
Map<String, dynamic>? processEventToPeer(
Map<String, dynamic> evt,
Offset offset, {
bool onExit = false,
bool moveCanvas = true,
bool edgeScroll = false,
}) {
if (isViewCamera) return null;
double x = offset.dx;
double y = max(0.0, offset.dy);
if (_checkPeerControlProtected(x, y)) {
return null;
}
var type = kMouseEventTypeDefault;
var isMove = false;
switch (evt['type']) {
case _kMouseEventDown:
type = kMouseEventTypeDown;
break;
case _kMouseEventUp:
type = kMouseEventTypeUp;
break;
case _kMouseEventMove:
_pointerMovedAfterEnter = true;
isMove = true;
break;
default:
return null;
}
evt['type'] = type;
if (type == kMouseEventTypeDown && !_pointerMovedAfterEnter) {
// Move mouse to the position of the down event first.
lastMousePos = ui.Offset(x, y);
refreshMousePos();
}
final pos = handlePointerDevicePos(
kPointerEventKindMouse,
x,
y,
isMove,
type,
onExit: onExit,
buttons: evt['buttons'],
moveCanvas: moveCanvas,
edgeScroll: edgeScroll,
);
if (pos == null) {
return null;
}
if (type != '') {
evt['x'] = '0';
evt['y'] = '0';
} else {
evt['x'] = '${pos.x.toInt()}';
evt['y'] = '${pos.y.toInt()}';
}
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;
}
Map<String, dynamic>? handleMouse(
Map<String, dynamic> evt,
Offset offset, {
bool onExit = false,
bool moveCanvas = true,
bool edgeScroll = false,
}) {
final evtToPeer = processEventToPeer(evt, offset,
onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll);
if (evtToPeer != null) {
bind.sessionSendMouse(
sessionId: sessionId, msg: json.encode(modify(evtToPeer)));
}
return evtToPeer;
}
Point? handlePointerDevicePos(
String kind,
double x,
double y,
bool isMove,
String evtType, {
bool onExit = false,
int buttons = kPrimaryMouseButton,
bool moveCanvas = true,
bool edgeScroll = false,
}) {
final ffiModel = parent.target!.ffiModel;
CanvasCoords canvas =
CanvasCoords.fromCanvasModel(parent.target!.canvasModel);
Rect? rect = ffiModel.rect;
if (isMove) {
if (_remoteWindowCoords.isNotEmpty &&
_windowRect != null &&
!_isInCurrentWindow(x, y)) {
final coords =
findRemoteCoords(x, y, _remoteWindowCoords, devicePixelRatio);
if (coords != null) {
isMove = false;
canvas = coords.canvas;
rect = coords.remoteRect;
x -= isWindows
? coords.relativeOffset.dx / devicePixelRatio
: coords.relativeOffset.dx;
y -= isWindows
? coords.relativeOffset.dy / devicePixelRatio
: coords.relativeOffset.dy;
}
}
}
y -= CanvasModel.topToEdge;
x -= CanvasModel.leftToEdge;
if (isMove) {
final canvasModel = parent.target!.canvasModel;
if (edgeScroll) {
canvasModel.edgeScrollMouse(x, y);
} else if (moveCanvas) {
canvasModel.moveDesktopMouse(x, y);
}
canvasModel.updateLocalCursor(x, y);
}
return _handlePointerDevicePos(
kind,
x,
y,
isMove,
canvas,
rect,
evtType,
onExit: onExit,
buttons: buttons,
);
}
bool _isInCurrentWindow(double x, double y) {
var w = _windowRect!.width;
var h = _windowRect!.height;
if (isWindows) {
w /= devicePixelRatio;
h /= devicePixelRatio;
}
return x >= 0 && y >= 0 && x <= w && y <= h;
}
static RemoteWindowCoords? findRemoteCoords(double x, double y,
List<RemoteWindowCoords> remoteWindowCoords, double devicePixelRatio) {
if (isWindows) {
x *= devicePixelRatio;
y *= devicePixelRatio;
}
for (final c in remoteWindowCoords) {
if (x >= c.relativeOffset.dx &&
y >= c.relativeOffset.dy &&
x <= c.relativeOffset.dx + c.windowRect.width &&
y <= c.relativeOffset.dy + c.windowRect.height) {
return c;
}
}
return null;
}
Point? _handlePointerDevicePos(
String kind,
double x,
double y,
bool moveInCanvas,
CanvasCoords canvas,
Rect? rect,
String evtType, {
bool onExit = false,
int buttons = kPrimaryMouseButton,
}) {
if (rect == null) {
return null;
}
final nearThr = 3;
var nearRight = (canvas.size.width - x) < nearThr;
var nearBottom = (canvas.size.height - y) < nearThr;
final imageWidth = rect.width * canvas.scale;
final imageHeight = rect.height * canvas.scale;
if (canvas.scrollStyle != ScrollStyle.scrollauto) {
x += imageWidth * canvas.scrollX;
y += imageHeight * canvas.scrollY;
// boxed size is a center widget
if (canvas.size.width > imageWidth) {
x -= ((canvas.size.width - imageWidth) / 2);
}
if (canvas.size.height > imageHeight) {
y -= ((canvas.size.height - imageHeight) / 2);
}
} else {
x -= canvas.x;
y -= canvas.y;
}
x /= canvas.scale;
y /= canvas.scale;
if (canvas.scale > 0 && canvas.scale < 1) {
final step = 1.0 / canvas.scale - 1;
if (nearRight) {
x += step;
}
if (nearBottom) {
y += step;
}
}
x += rect.left;
y += rect.top;
if (onExit) {
final pos = setNearestEdge(x, y, rect);
x = pos.dx;
y = pos.dy;
}
return InputModel.getPointInRemoteRect(
true, peerPlatform, kind, evtType, x, y, rect,
buttons: buttons);
}
static Point<double>? getPointInRemoteRect(
bool isLocalDesktop,
String? peerPlatform,
String kind,
String evtType,
double evtX,
double evtY,
Rect rect,
{int buttons = kPrimaryMouseButton}) {
double minX = rect.left;
// https://github.com/rustdesk/rustdesk/issues/6678
// For Windows, [0,maxX], [0,maxY] should be set to enable window snapping.
double maxX = (rect.left + rect.width) -
(peerPlatform == kPeerPlatformWindows ? 0 : 1);
double minY = rect.top;
double maxY = (rect.top + rect.height) -
(peerPlatform == kPeerPlatformWindows ? 0 : 1);
evtX = InputModel.tryGetNearestRange(evtX, minX, maxX, 5);
evtY = InputModel.tryGetNearestRange(evtY, minY, maxY, 5);
if (isLocalDesktop) {
if (kind == kPointerEventKindMouse) {
if (evtX < minX || evtY < minY || evtX > maxX || evtY > maxY) {
// If left mouse up, no early return.
if (!(buttons == kPrimaryMouseButton &&
evtType == kMouseEventTypeUp)) {
return null;
}
}
}
} else {
bool evtXInRange = evtX >= minX && evtX <= maxX;
bool evtYInRange = evtY >= minY && evtY <= maxY;
if (!(evtXInRange || evtYInRange)) {
return null;
}
if (evtX < minX) {
evtX = minX;
} else if (evtX > maxX) {
evtX = maxX;
}
if (evtY < minY) {
evtY = minY;
} else if (evtY > maxY) {
evtY = maxY;
}
}
return Point(evtX, evtY);
}
/// Web only
void listenToMouse(bool yesOrNo) {
if (yesOrNo) {
platformFFI.startDesktopWebListener();
} else {
platformFFI.stopDesktopWebListener();
}
}
void onMobileBack() {
final minBackButtonVersion = "1.3.8";
final peerVersion =
parent.target?.ffiModel.pi.version ?? minBackButtonVersion;
var btn = MouseButtons.back;
// For compatibility with old versions
if (versionCmp(peerVersion, minBackButtonVersion) < 0) {
btn = MouseButtons.right;
}
tap(btn);
}
void onMobileHome() => tap(MouseButtons.wheel);
Future<void> onMobileApps() async {
sendMouse('down', MouseButtons.wheel);
await Future.delayed(const Duration(milliseconds: 500));
sendMouse('up', MouseButtons.wheel);
}
// Simulate a key press event.
// `usbHidUsage` is the USB HID usage code of the key.
Future<void> tapHidKey(int usbHidUsage) async {
newKeyboardMode(kKeyFlutterKey, usbHidUsage, true, false);
await Future.delayed(Duration(milliseconds: 100));
newKeyboardMode(kKeyFlutterKey, usbHidUsage, false, false);
}
Future<void> onMobileVolumeUp() async =>
await tapHidKey(PhysicalKeyboardKey.audioVolumeUp.usbHidUsage & 0xFFFF);
Future<void> onMobileVolumeDown() async =>
await tapHidKey(PhysicalKeyboardKey.audioVolumeDown.usbHidUsage & 0xFFFF);
Future<void> onMobilePower() async =>
await tapHidKey(PhysicalKeyboardKey.power.usbHidUsage & 0xFFFF);
}