mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-27 16:08:41 +03:00
* 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>
2111 lines
66 KiB
Dart
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);
|
|
}
|