mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-09 07:38:09 +03:00
Merge branch 'master' into terminal-utf8-and-reconnect
This commit is contained in:
@@ -532,7 +532,9 @@ class _RawTouchGestureDetectorRegionState
|
||||
// Official
|
||||
TapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(), (instance) {
|
||||
() => TapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance
|
||||
..onTapDown = onTapDown
|
||||
..onTapUp = onTapUp
|
||||
@@ -540,14 +542,18 @@ class _RawTouchGestureDetectorRegionState
|
||||
}),
|
||||
DoubleTapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||
() => DoubleTapGestureRecognizer(), (instance) {
|
||||
() => DoubleTapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance
|
||||
..onDoubleTapDown = onDoubleTapDown
|
||||
..onDoubleTap = onDoubleTap;
|
||||
}),
|
||||
LongPressGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(), (instance) {
|
||||
() => LongPressGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance
|
||||
..onLongPressDown = onLongPressDown
|
||||
..onLongPressUp = onLongPressUp
|
||||
@@ -557,7 +563,9 @@ class _RawTouchGestureDetectorRegionState
|
||||
// Customized
|
||||
HoldTapMoveGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
|
||||
() => HoldTapMoveGestureRecognizer(),
|
||||
() => HoldTapMoveGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
),
|
||||
(instance) => instance
|
||||
..onHoldDragStart = onHoldDragStart
|
||||
..onHoldDragUpdate = onHoldDragUpdate
|
||||
@@ -565,14 +573,18 @@ class _RawTouchGestureDetectorRegionState
|
||||
..onHoldDragEnd = onHoldDragEnd),
|
||||
DoubleFinerTapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<DoubleFinerTapGestureRecognizer>(
|
||||
() => DoubleFinerTapGestureRecognizer(), (instance) {
|
||||
() => DoubleFinerTapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance
|
||||
..onDoubleFinerTap = onDoubleFinerTap
|
||||
..onDoubleFinerTapDown = onDoubleFinerTapDown;
|
||||
}),
|
||||
CustomTouchGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
|
||||
() => CustomTouchGestureRecognizer(), (instance) {
|
||||
() => CustomTouchGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance.onOneFingerPanStart =
|
||||
(DragStartDetails d) => onOneFingerPanStart(context, d);
|
||||
instance
|
||||
|
||||
@@ -759,9 +759,18 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
final ffiModel = ffi.ffiModel;
|
||||
final pi = ffiModel.pi;
|
||||
final sessionId = ffi.sessionId;
|
||||
final hasPrivacyModePermission = ffiModel.permissions['privacy_mode'] != false;
|
||||
|
||||
// Backend revocation already attempts to turn privacy mode off.
|
||||
// Still keep this menu when privacy mode is active, so users can turn it off
|
||||
// if there is a sync delay, version mismatch, or off attempt failure.
|
||||
if (!hasPrivacyModePermission && privacyModeState.isEmpty) {
|
||||
return []; // No permission and not active, hide options.
|
||||
}
|
||||
|
||||
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) {
|
||||
final enabled = !ffi.ffiModel.viewOnly;
|
||||
final enabled =
|
||||
!ffiModel.viewOnly && (hasPrivacyModePermission || privacyModeState.isNotEmpty);
|
||||
return TToggleMenu(
|
||||
value: privacyModeState.isNotEmpty,
|
||||
onChanged: enabled
|
||||
@@ -810,18 +819,29 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
})
|
||||
];
|
||||
} else {
|
||||
return privacyModeImpls.map((e) {
|
||||
final visibleImpls = hasPrivacyModePermission
|
||||
? privacyModeImpls
|
||||
: privacyModeImpls.where((e) {
|
||||
final implKey = (e as List<dynamic>)[0] as String;
|
||||
return privacyModeState.value == implKey;
|
||||
}).toList();
|
||||
return visibleImpls.map((e) {
|
||||
final implKey = (e as List<dynamic>)[0] as String;
|
||||
final implName = (e)[1] as String;
|
||||
final enabled = !ffiModel.viewOnly &&
|
||||
(hasPrivacyModePermission || privacyModeState.value == implKey);
|
||||
return TToggleMenu(
|
||||
child: Text(translate(implName)),
|
||||
value: privacyModeState.value == implKey,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
});
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (value && !hasPrivacyModePermission) return;
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
}
|
||||
: null);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,9 @@ const String kOptionTerminalPersistent = "terminal-persistent";
|
||||
const String kOptionEnableTunnel = "enable-tunnel";
|
||||
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
||||
const String kOptionEnableBlockInput = "enable-block-input";
|
||||
const String kOptionEnablePrivacyMode = "enable-privacy-mode";
|
||||
const String kOptionEnablePermChangeInAcceptWindow =
|
||||
"enable-perm-change-in-accept-window";
|
||||
const String kOptionAllowRemoteConfigModification =
|
||||
"allow-remote-config-modification";
|
||||
const String kOptionVerificationMethod = "verification-method";
|
||||
|
||||
@@ -1062,6 +1062,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
_OptionCheckBox(context, 'Enable blocking user input',
|
||||
kOptionEnableBlockInput,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
if (bind.mainSupportedPrivacyModeImpls() != '[]')
|
||||
_OptionCheckBox(
|
||||
context, 'Enable privacy mode', kOptionEnablePrivacyMode,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(context, 'Enable remote configuration modification',
|
||||
kOptionAllowRemoteConfigModification,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
|
||||
@@ -610,19 +610,24 @@ class _PrivilegeBoard extends StatefulWidget {
|
||||
class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
late final client = widget.client;
|
||||
Widget buildPermissionIcon(bool enabled, IconData iconData,
|
||||
Function(bool)? onTap, String tooltipText) {
|
||||
Function(bool)? onTap, String tooltipText,
|
||||
{required bool canModify}) {
|
||||
return Tooltip(
|
||||
message: "$tooltipText: ${enabled ? "ON" : "OFF"}",
|
||||
waitDuration: Duration.zero,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? MyTheme.accent : Colors.grey[700],
|
||||
color: enabled
|
||||
? (canModify ? MyTheme.accent : MyTheme.accent.withOpacity(0.6))
|
||||
: Colors.grey[700],
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
),
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
checkClickTime(widget.client.id, () => onTap?.call(!enabled)),
|
||||
onTap: canModify
|
||||
? () =>
|
||||
checkClickTime(widget.client.id, () => onTap?.call(!enabled))
|
||||
: null,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
@@ -643,6 +648,9 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
Widget build(BuildContext context) {
|
||||
final crossAxisCount = 4;
|
||||
final spacing = 10.0;
|
||||
final canModifyPermission =
|
||||
bind.mainGetBuildinOption(key: kOptionEnablePermChangeInAcceptWindow) !=
|
||||
'N';
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 160.0,
|
||||
@@ -689,6 +697,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
@@ -703,6 +712,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
]
|
||||
: [
|
||||
@@ -719,6 +729,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable keyboard/mouse'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.clipboard,
|
||||
@@ -733,6 +744,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable clipboard'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
@@ -747,6 +759,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.file,
|
||||
@@ -761,6 +774,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable file copy and paste'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.restart,
|
||||
@@ -775,6 +789,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable remote restart'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
@@ -789,6 +804,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
// only windows support block input
|
||||
if (isWindows)
|
||||
@@ -805,6 +821,23 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable blocking user input'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
if (bind.mainSupportedPrivacyModeImpls() != '[]')
|
||||
buildPermissionIcon(
|
||||
client.privacyMode,
|
||||
Icons.visibility_off,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "privacy_mode",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.privacyMode = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable privacy mode'),
|
||||
canModify: canModifyPermission,
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
@@ -996,10 +996,10 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
toggles(),
|
||||
];
|
||||
// privacy mode
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (ffi.connType == ConnType.defaultConn &&
|
||||
ffiModel.keyboard &&
|
||||
pi.features.privacyMode) {
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
(pi.features.privacyMode || privacyModeState.isNotEmpty) &&
|
||||
(ffiModel.keyboard || privacyModeState.isNotEmpty)) {
|
||||
final privacyModeList =
|
||||
toolbarPrivacyMode(privacyModeState, context, id, ffi);
|
||||
if (privacyModeList.length == 1) {
|
||||
|
||||
@@ -426,12 +426,10 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
}
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: inputModel.isPhysicalMouse.value
|
||||
? getBodyForMobile()
|
||||
: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
),
|
||||
child: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -1185,7 +1183,8 @@ void showOptions(
|
||||
List<TToggleMenu> privacyModeList = [];
|
||||
// privacy mode
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) {
|
||||
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
|
||||
privacyModeState.isNotEmpty) {
|
||||
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
|
||||
if (privacyModeList.length == 1) {
|
||||
displayToggles.add(privacyModeList[0]);
|
||||
|
||||
@@ -583,9 +583,16 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
||||
Widget build(BuildContext context) {
|
||||
final serverModel = Provider.of<ServerModel>(context);
|
||||
final hasAudioPermission = androidVersion >= 30;
|
||||
final hideStopService =
|
||||
isAndroid &&
|
||||
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
|
||||
final hideStopService = isAndroid &&
|
||||
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
|
||||
final allowPermChangeInAcceptWindow = option2bool(
|
||||
kOptionEnablePermChangeInAcceptWindow,
|
||||
bind.mainGetBuildinOption(
|
||||
key: kOptionEnablePermChangeInAcceptWindow,
|
||||
));
|
||||
final permissionChangeLocked = isAndroid &&
|
||||
serverModel.clients.any((c) => !c.disconnected) &&
|
||||
!allowPermChangeInAcceptWindow;
|
||||
return PaddingCard(
|
||||
title: translate("Permissions"),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
@@ -608,13 +615,21 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
||||
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
|
||||
? () => showScamWarning(context, serverModel)
|
||||
: serverModel.toggleService),
|
||||
PermissionRow(translate("Input Control"), serverModel.inputOk,
|
||||
serverModel.toggleInput),
|
||||
PermissionRow(translate("Transfer file"), serverModel.fileOk,
|
||||
serverModel.toggleFile),
|
||||
PermissionRow(
|
||||
translate("Input Control"),
|
||||
serverModel.inputOk,
|
||||
serverModel.toggleInput,
|
||||
),
|
||||
PermissionRow(
|
||||
translate("Transfer file"),
|
||||
serverModel.fileOk,
|
||||
serverModel.toggleFile,
|
||||
enabled: !permissionChangeLocked,
|
||||
),
|
||||
hasAudioPermission
|
||||
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
|
||||
serverModel.toggleAudio)
|
||||
serverModel.toggleAudio,
|
||||
enabled: !permissionChangeLocked)
|
||||
: Row(children: [
|
||||
Icon(Icons.info_outline).marginOnly(right: 15),
|
||||
Expanded(
|
||||
@@ -623,19 +638,25 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
||||
style: const TextStyle(color: MyTheme.darkGray),
|
||||
))
|
||||
]),
|
||||
PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk,
|
||||
serverModel.toggleClipboard),
|
||||
PermissionRow(
|
||||
translate("Enable clipboard"),
|
||||
serverModel.clipboardOk,
|
||||
serverModel.toggleClipboard,
|
||||
enabled: !permissionChangeLocked,
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionRow extends StatelessWidget {
|
||||
const PermissionRow(this.name, this.isOk, this.onPressed, {Key? key})
|
||||
const PermissionRow(this.name, this.isOk, this.onPressed,
|
||||
{Key? key, this.enabled = true})
|
||||
: super(key: key);
|
||||
|
||||
final String name;
|
||||
final bool isOk;
|
||||
final VoidCallback onPressed;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -644,9 +665,11 @@ class PermissionRow extends StatelessWidget {
|
||||
contentPadding: EdgeInsets.all(0),
|
||||
title: Text(name),
|
||||
value: isOk,
|
||||
onChanged: (bool value) {
|
||||
onPressed();
|
||||
});
|
||||
onChanged: enabled
|
||||
? (bool value) {
|
||||
onPressed();
|
||||
}
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -259,13 +259,11 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
}
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: inputModel.isPhysicalMouse.value
|
||||
? getBodyForMobile()
|
||||
: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
isCamera: true,
|
||||
),
|
||||
child: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
isCamera: true,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -15,12 +16,13 @@ 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 }
|
||||
enum MouseButtons { left, right, wheel, back, forward }
|
||||
|
||||
const _kMouseEventDown = 'mousedown';
|
||||
const _kMouseEventUp = 'mouseup';
|
||||
@@ -157,6 +159,8 @@ extension ToString on MouseButtons {
|
||||
return 'wheel';
|
||||
case MouseButtons.back:
|
||||
return 'back';
|
||||
case MouseButtons.forward:
|
||||
return 'forward';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,6 +331,80 @@ class ToReleaseKeys {
|
||||
}
|
||||
|
||||
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 (!Platform.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 = '';
|
||||
|
||||
@@ -412,6 +490,7 @@ class InputModel {
|
||||
bool get isRelativeMouseModeSupported => _relativeMouse.isSupported;
|
||||
|
||||
InputModel(this.parent) {
|
||||
initSideButtonChannel();
|
||||
sessionId = parent.target!.sessionId;
|
||||
_relativeMouse = RelativeMouseModel(
|
||||
sessionId: sessionId,
|
||||
@@ -620,6 +699,38 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -674,6 +785,27 @@ class InputModel {
|
||||
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);
|
||||
@@ -717,6 +849,8 @@ class InputModel {
|
||||
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) {
|
||||
@@ -754,6 +888,21 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -966,13 +1115,20 @@ class InputModel {
|
||||
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 bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(modify({'type': type, 'buttons': button.value})));
|
||||
await _sendMouseUnchecked(type, button);
|
||||
}
|
||||
|
||||
void enterOrLeave(bool enter) {
|
||||
@@ -982,6 +1138,13 @@ class InputModel {
|
||||
_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();
|
||||
@@ -1332,6 +1495,16 @@ class InputModel {
|
||||
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;
|
||||
@@ -1344,6 +1517,9 @@ class InputModel {
|
||||
// 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;
|
||||
}
|
||||
@@ -1353,6 +1529,10 @@ class InputModel {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
38
flutter/lib/models/input_modifier_utils.dart
Normal file
38
flutter/lib/models/input_modifier_utils.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Returns true when a stale mobile one-shot Shift state should be released
|
||||
/// by replaying a tracked Shift key-down as a synthesized key-up.
|
||||
///
|
||||
/// This is only valid on mobile when Flutter's cached Shift state is still on
|
||||
/// (`cachedShiftPressed == true`) but the current hardware/raw event reports
|
||||
/// Shift as off (`actualShiftPressed == false`).
|
||||
///
|
||||
/// A tracked Shift key-down is required so the caller can safely synthesize the
|
||||
/// matching key-up. Both `shiftLeft` and `shiftRight` are excluded because the
|
||||
/// Shift key event itself must be processed first; otherwise we could release
|
||||
/// the tracked key while still handling the original Shift press/release.
|
||||
/// Callers should evaluate this only after their cached modifier state has been
|
||||
/// updated for the current event.
|
||||
///
|
||||
/// When this returns true, the caller logs a line like:
|
||||
/// `input: releasing stale mobile Shift before replaying tracked raw key-up`
|
||||
/// immediately before calling `_releaseTrackedRawShiftKeyEventIfNeeded()`.
|
||||
bool shouldReleaseStaleMobileShift({
|
||||
required bool isMobile,
|
||||
required bool cachedShiftPressed,
|
||||
required bool actualShiftPressed,
|
||||
required LogicalKeyboardKey logicalKey,
|
||||
required bool hasTrackedShiftKeyDown,
|
||||
}) {
|
||||
if (!isMobile || !cachedShiftPressed || actualShiftPressed) {
|
||||
return false;
|
||||
}
|
||||
if (!hasTrackedShiftKeyDown) {
|
||||
return false;
|
||||
}
|
||||
if (logicalKey == LogicalKeyboardKey.shiftLeft ||
|
||||
logicalKey == LogicalKeyboardKey.shiftRight) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -3932,6 +3932,7 @@ class FFI {
|
||||
inputModel.resetModifiers();
|
||||
// Dispose relative mouse mode resources to ensure cursor is restored
|
||||
inputModel.disposeRelativeMouseMode();
|
||||
inputModel.disposeSideButtonTracking();
|
||||
if (closeSession) {
|
||||
await bind.sessionClose(sessionId: sessionId);
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
toggleAudio() async {
|
||||
if (clients.isNotEmpty) {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) {
|
||||
@@ -316,7 +316,7 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
toggleFile() async {
|
||||
if (clients.isNotEmpty) {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_fileOk &&
|
||||
@@ -345,7 +345,7 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
toggleInput() async {
|
||||
if (clients.isNotEmpty) {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (_inputOk) {
|
||||
@@ -549,10 +549,19 @@ class ServerModel with ChangeNotifier {
|
||||
if (index < 0) {
|
||||
_clients.add(client);
|
||||
} else {
|
||||
if (_clients[index].authorized) {
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_clients[index].authorized = true;
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
}
|
||||
} else {
|
||||
if (_clients.any((c) => c.id == client.id)) {
|
||||
final index = _clients.indexWhere((c) => c.id == client.id);
|
||||
if (index >= 0) {
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_clients.add(client);
|
||||
@@ -818,6 +827,7 @@ class Client {
|
||||
bool restart = false;
|
||||
bool recording = false;
|
||||
bool blockInput = false;
|
||||
bool privacyMode = false;
|
||||
bool disconnected = false;
|
||||
bool fromSwitch = false;
|
||||
bool inVoiceCall = false;
|
||||
@@ -846,6 +856,7 @@ class Client {
|
||||
restart = json['restart'];
|
||||
recording = json['recording'];
|
||||
blockInput = json['block_input'];
|
||||
privacyMode = json['privacy_mode'] ?? privacyMode;
|
||||
disconnected = json['disconnected'];
|
||||
fromSwitch = json['from_switch'];
|
||||
inVoiceCall = json['in_voice_call'];
|
||||
@@ -870,6 +881,7 @@ class Client {
|
||||
data['restart'] = restart;
|
||||
data['recording'] = recording;
|
||||
data['block_input'] = blockInput;
|
||||
data['privacy_mode'] = privacyMode;
|
||||
data['disconnected'] = disconnected;
|
||||
data['from_switch'] = fromSwitch;
|
||||
data['in_voice_call'] = inVoiceCall;
|
||||
|
||||
@@ -1729,7 +1729,7 @@ class RustdeskImpl {
|
||||
}
|
||||
|
||||
String mainSupportedPrivacyModeImpls({dynamic hint}) {
|
||||
throw UnimplementedError("mainSupportedPrivacyModeImpls");
|
||||
return '[]';
|
||||
}
|
||||
|
||||
String mainSupportedInputSource({dynamic hint}) {
|
||||
|
||||
Reference in New Issue
Block a user