Merge branch 'master' into terminal-utf8-and-reconnect

This commit is contained in:
fufesou
2026-05-07 12:13:44 +08:00
91 changed files with 1702 additions and 227 deletions

View File

@@ -62,7 +62,13 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
return false
}
}
audioRecorder = builder.build()
val recorder = try {
builder.build()
} catch (e: Exception) {
Log.e(logTag, "createAudioRecorder failed", e)
return false
}
audioRecorder = recorder
Log.d(logTag, "createAudioRecorder done,minBufferSize:$minBufferSize")
return true
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}

View File

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

View File

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

View File

@@ -1729,7 +1729,7 @@ class RustdeskImpl {
}
String mainSupportedPrivacyModeImpls({dynamic hint}) {
throw UnimplementedError("mainSupportedPrivacyModeImpls");
return '[]';
}
String mainSupportedInputSource({dynamic hint}) {

View File

@@ -29,6 +29,80 @@ void try_set_transparent(GtkWindow* window, GdkScreen* screen, FlView* view);
extern bool gIsConnectionManager;
// --- Side mouse button support (back/forward) ---
// Flutter's Linux embedder doesn't deliver X11 button 8/9 events to Dart.
// We intercept them via GDK and forward through a dedicated platform channel.
static const char* kSideButtonChannelName = "org.rustdesk.rustdesk/side_buttons";
static gboolean on_side_button_event(GtkWidget* widget, GdkEventButton* event, gpointer user_data) {
if (event->button != 8 && event->button != 9) {
return FALSE;
}
// Ignore GDK_2BUTTON_PRESS / GDK_3BUTTON_PRESS (double/triple-click synthetic
// events) - only handle real press and release.
if (event->type != GDK_BUTTON_PRESS && event->type != GDK_BUTTON_RELEASE) {
return FALSE;
}
FlMethodChannel* channel = FL_METHOD_CHANNEL(user_data);
if (channel == NULL) return FALSE;
g_autoptr(FlValue) args = fl_value_new_map();
fl_value_set_string_take(args, "button",
fl_value_new_string(event->button == 8 ? "back" : "forward"));
fl_value_set_string_take(args, "type",
fl_value_new_string(event->type == GDK_BUTTON_PRESS ? "down" : "up"));
fl_method_channel_invoke_method(channel, "onSideMouseButton", args,
NULL, NULL, NULL);
return TRUE;
}
static FlMethodChannel* side_buttons_create_channel(FlEngine* engine) {
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
return fl_method_channel_new(
fl_engine_get_binary_messenger(engine),
kSideButtonChannelName,
FL_METHOD_CODEC(codec));
}
static void side_buttons_channel_destroy(gpointer data) {
g_object_unref(data);
}
static void side_buttons_init_for_window(GtkWindow* window, FlMethodChannel* channel) {
// Guard against double-initialization (would leave dangling signal user_data).
if (g_object_get_data(G_OBJECT(window), "side-buttons-channel") != NULL) return;
gtk_widget_add_events(GTK_WIDGET(window),
GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK);
// Store channel on the window so it stays alive and is freed with the window.
g_object_set_data_full(G_OBJECT(window), "side-buttons-channel",
g_object_ref(channel), side_buttons_channel_destroy);
g_signal_connect(window, "button-press-event",
G_CALLBACK(on_side_button_event), channel);
g_signal_connect(window, "button-release-event",
G_CALLBACK(on_side_button_event), channel);
}
static void on_subwindow_created(FlPluginRegistry* registry) {
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
wayland_shortcuts_inhibit_init_for_subwindow(registry);
#endif
// Set up side button forwarding for sub-windows.
if (registry == NULL || !FL_IS_VIEW(registry)) return;
FlView* view = FL_VIEW(registry);
GtkWidget* toplevel = gtk_widget_get_toplevel(GTK_WIDGET(view));
if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) {
FlMethodChannel* channel = side_buttons_create_channel(fl_view_get_engine(view));
if (channel == NULL) return;
side_buttons_init_for_window(GTK_WINDOW(toplevel), channel);
g_object_unref(channel); // window now owns a ref via g_object_set_data_full
}
}
GtkWidget *find_gl_area(GtkWidget *widget);
// Implements GApplication::activate.
@@ -96,12 +170,12 @@ static void my_application_activate(GApplication* application) {
gtk_widget_show(GTK_WIDGET(window));
gtk_widget_show(GTK_WIDGET(view));
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
// Register callback for sub-windows created by desktop_multi_window plugin
// Only sub-windows (remote windows) need keyboard shortcuts inhibition
// Register callback for sub-windows created by desktop_multi_window plugin.
// Handles both Wayland shortcuts inhibition (guarded inside) and side button
// forwarding. Safe to call on X11-only builds - the plugin just stores the
// callback pointer regardless of windowing system.
desktop_multi_window_plugin_set_window_created_callback(
(WindowCreatedCallback)wayland_shortcuts_inhibit_init_for_subwindow);
#endif
(WindowCreatedCallback)on_subwindow_created);
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
@@ -116,6 +190,11 @@ static void my_application_activate(GApplication* application) {
self,
nullptr);
// Forward side mouse button events (back/forward) to Dart on the main window.
FlMethodChannel* side_channel = side_buttons_create_channel(fl_view_get_engine(view));
side_buttons_init_for_window(window, side_channel);
g_object_unref(side_channel);
gtk_widget_grab_focus(GTK_WIDGET(view));
}

View File

@@ -113,8 +113,8 @@ dependencies:
dev_dependencies:
icons_launcher: ^2.0.4
#flutter_test:
#sdk: flutter
flutter_test:
sdk: flutter
build_runner: ^2.4.6
freezed: ^2.4.2
flutter_lints: ^2.0.2

View File

@@ -0,0 +1,125 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_hbb/models/input_modifier_utils.dart';
void main() {
group('shouldReleaseStaleMobileShift', () {
test('does not release when cached shift is already false', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: false,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.keyD,
hasTrackedShiftKeyDown: true,
),
isFalse,
);
});
test('releases one-shot mobile shift after a text key', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.keyD,
hasTrackedShiftKeyDown: true,
),
isTrue,
);
});
test('does not release manually toggled shift without tracked key down',
() {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.keyD,
hasTrackedShiftKeyDown: false,
),
isFalse,
);
});
test('does not release when shift is still physically pressed', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: true,
actualShiftPressed: true,
logicalKey: LogicalKeyboardKey.keyD,
hasTrackedShiftKeyDown: true,
),
isFalse,
);
});
test('does not release on non-mobile platforms', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: false,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.keyD,
hasTrackedShiftKeyDown: true,
),
isFalse,
);
});
test('releases on enter key', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.enter,
hasTrackedShiftKeyDown: true,
),
isTrue,
);
});
test('releases on arrow key', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.arrowLeft,
hasTrackedShiftKeyDown: true,
),
isTrue,
);
});
test('does not release on modifier events', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.shiftLeft,
hasTrackedShiftKeyDown: true,
),
isFalse,
);
});
test('does not release on shiftRight modifier events', () {
expect(
shouldReleaseStaleMobileShift(
isMobile: true,
cachedShiftPressed: true,
actualShiftPressed: false,
logicalKey: LogicalKeyboardKey.shiftRight,
hasTrackedShiftKeyDown: true,
),
isFalse,
);
});
});
}