From d5d0b01266edc8af6baabc2004a1096dd7088a02 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 29 Apr 2026 17:37:46 +0800 Subject: [PATCH 1/7] fix web break introduced in 38f130071 fix(linux): enable mouse side buttons in remote sessions (#14848) --- flutter/lib/common/widgets/toolbar.dart | 93 +++++++++++++++++-- flutter/lib/consts.dart | 2 + .../desktop/pages/desktop_setting_page.dart | 73 ++++++++++++++- flutter/lib/desktop/pages/remote_page.dart | 15 +++ .../lib/desktop/widgets/remote_toolbar.dart | 26 +++++- flutter/lib/mobile/pages/remote_page.dart | 13 +++ flutter/lib/mobile/pages/settings_page.dart | 19 ++++ flutter/lib/models/input_model.dart | 2 +- flutter/lib/models/model.dart | 8 ++ flutter/lib/web/bridge.dart | 25 +++++ 10 files changed, 266 insertions(+), 10 deletions(-) diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 2e7247d95c3..da79c106e22 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -16,16 +16,43 @@ import 'package:get/get.dart'; bool isEditOsPassword = false; +/// Action IDs that `toolbarControls` is the sole registrar for. Each call to +/// `toolbarControls` (e.g. opening the toolbar menu after a permission was +/// revoked or a state changed) wipes these so a previously-registered closure +/// can't outlive the menu entry that owns it. The for-loop at the bottom of +/// `toolbarControls` then re-registers whichever entries are still present in +/// the rebuilt menu list. +/// +/// Actions registered elsewhere — `registerSessionShortcutActions` on desktop +/// owns toggle_recording, fullscreen, switch_display, switch_tab, close_tab, +/// toggle_toolbar — MUST NOT appear here, otherwise this list would clobber +/// their registration on every menu rebuild. +/// +/// `kShortcutActionToggleRecording` is platform-conditional (mobile-only — +/// see the `!(isDesktop || isWeb)` guard in `toolbarControls`). It is handled +/// separately in the unregister pass rather than appearing in this const list. +const _kToolbarOwnedActionIds = [ + kShortcutActionSendCtrlAltDel, + kShortcutActionRestartRemote, + kShortcutActionInsertLock, + kShortcutActionToggleBlockInput, + kShortcutActionSwitchSides, + kShortcutActionRefresh, + kShortcutActionScreenshot, +]; + class TTextMenu { final Widget child; final VoidCallback? onPressed; Widget? trailingIcon; bool divider; + final String? actionId; TTextMenu( {required this.child, required this.onPressed, this.trailingIcon, - this.divider = false}); + this.divider = false, + this.actionId}); Widget getChild() { if (trailingIcon != null) { @@ -94,6 +121,20 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { final sessionId = ffi.sessionId; final isDefaultConn = ffi.connType == ConnType.defaultConn; + // Wipe everything `toolbarControls` could have registered last call so + // stale closures (e.g. for a menu entry whose permission has since been + // revoked) don't outlive the menu rebuild. See _kToolbarOwnedActionIds. + for (final actionId in _kToolbarOwnedActionIds) { + ffi.shortcutModel.unregister(actionId); + } + // toggle_recording is platform-conditional — toolbarControls only builds + // the menu entry on `!(isDesktop || isWeb)`. On desktop the registration + // is owned by `registerSessionShortcutActions` and must NOT be touched + // here. See the recording menu entry below. + if (!(isDesktop || isWeb)) { + ffi.shortcutModel.unregister(kShortcutActionToggleRecording); + } + List v = []; // elevation if (isDefaultConn && @@ -229,7 +270,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text('${translate("Insert Ctrl + Alt + Del")}'), - onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId)), + onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId), + actionId: kShortcutActionSendCtrlAltDel), ); } // restart @@ -242,7 +284,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { TTextMenu( child: Text(translate('Restart remote device')), onPressed: () => - showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager)), + showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager), + actionId: kShortcutActionRestartRemote), ); } // insertLock @@ -250,7 +293,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text(translate('Insert Lock')), - onPressed: () => bind.sessionLockScreen(sessionId: sessionId)), + onPressed: () => bind.sessionLockScreen(sessionId: sessionId), + actionId: kShortcutActionInsertLock), ); } // blockUserInput @@ -268,7 +312,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { sessionId: sessionId, value: '${blockInput.value ? 'un' : ''}block-input'); blockInput.value = !blockInput.value; - })); + }, + actionId: kShortcutActionToggleBlockInput)); } // switchSides if (isDefaultConn && @@ -280,13 +325,15 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add(TTextMenu( child: Text(translate('Switch Sides')), onPressed: () => - showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager))); + showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager), + actionId: kShortcutActionSwitchSides)); } // refresh if (pi.version.isNotEmpty) { v.add(TTextMenu( child: Text(translate('Refresh')), onPressed: () => sessionRefreshVideo(sessionId, pi), + actionId: kShortcutActionRefresh, )); } // record @@ -308,7 +355,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ) ], ), - onPressed: () => ffi.recordingModel.toggle())); + onPressed: () => ffi.recordingModel.toggle(), + actionId: kShortcutActionToggleRecording)); } // to-do: @@ -325,6 +373,14 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { onPressed: ffi.ffiModel.timerScreenshot != null ? null : () { + // Live cooldown check: the menu rebuilds onPressed=null + // whenever toolbarControls runs and finds timerScreenshot + // != null, but the keyboard-shortcut callback holds onto + // the originally-enabled closure across cooldown periods + // (toolbarControls only re-runs on menu open). Without + // this guard the second shortcut press during the 30s + // cooldown still fires sessionTakeScreenshot. + if (ffi.ffiModel.timerScreenshot != null) return; if (pi.currentDisplay == kAllDisplayValue) { msgBox( sessionId, @@ -342,6 +398,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { }); } }, + actionId: kShortcutActionScreenshot, )); } } @@ -352,6 +409,28 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { onPressed: () => onCopyFingerprint(FingerprintState.find(id).value), )); } + // Register tagged callbacks with the shortcut model so global keyboard + // shortcuts can dispatch the same actions as the toolbar menu items. + // + // For action IDs already cleared at the top of this function (i.e. those + // in [_kToolbarOwnedActionIds] plus the conditional toggle_recording), + // the `else` branch below is a redundant idempotent no-op — `unregister` + // just calls `Map.remove` on something already absent. + // + // The branch is kept as **defense in depth** for the case where a future + // contributor tags a menu item with an actionId that they forget to add + // to [_kToolbarOwnedActionIds]: without this `else`, the original + // "stale-closure-outlives-disabled-state" bug (e.g. Screenshot cooldown + // bypass) would silently come back for that new action only. + for (final menu in v) { + final actionId = menu.actionId; + if (actionId == null) continue; + if (menu.onPressed != null) { + ffi.shortcutModel.register(actionId, menu.onPressed!); + } else { + ffi.shortcutModel.unregister(actionId); + } + } return v; } diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 832b96d245a..8362ed36e90 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -4,6 +4,8 @@ import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; +export 'common/widgets/keyboard_shortcuts/shortcut_constants.dart'; + const int kMaxVirtualDisplayCount = 4; const int kAllVirtualDisplay = -1; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 2841c1d273a..b13b2c9cd58 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -10,12 +10,14 @@ import 'package:flutter_hbb/common/widgets/audio_input.dart'; import 'package:flutter_hbb/common/widgets/setting_widgets.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart'; +import 'package:flutter_hbb/desktop/pages/desktop_keyboard_shortcuts_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; import 'package:flutter_hbb/mobile/widgets/dialog.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; +import 'package:flutter_hbb/models/shortcut_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/plugin/manager.dart'; import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart'; @@ -421,11 +423,49 @@ class _GeneralState extends State<_General> { if (!isWeb) audio(context), if (!isWeb) record(context), if (!isWeb) WaylandCard(), - other() + other(), + if (!bind.isIncomingOnly()) keyboardShortcuts(), ], ).marginOnly(bottom: _kListViewBottomMargin); } + Widget keyboardShortcuts() { + // The bindings JSON (LocalConfig key `keyboard-shortcuts`) holds three + // flags + the bindings list: {enabled, pass_through, bindings}. When the + // master is off, the pass-through toggle and the Configure entry are + // hidden — both are meaningless without an active matcher. + return StatefulBuilder(builder: (context, setLocalState) { + final enabled = ShortcutModel.isEnabled(); + return _Card(title: 'Keyboard Shortcuts', children: [ + _OptionCheckBox( + context, + 'Enable keyboard shortcuts in remote session', + kShortcutLocalConfigKey, + isServer: false, + optGetter: ShortcutModel.isEnabled, + optSetter: (_, v) async { + await ShortcutModel.setEnabled(v); + setLocalState(() {}); + }, + ), + if (enabled) ...[ + _OptionCheckBox( + context, + 'Pass-through to remote', + kShortcutLocalConfigKey, + isServer: false, + optGetter: ShortcutModel.isPassThrough, + optSetter: (_, v) async { + await ShortcutModel.setPassThrough(v); + setLocalState(() {}); + }, + ), + _ShortcutsConfigureRow(), + ], + ]); + }); + } + Widget theme() { final current = MyTheme.getThemeModePreference().toShortString(); onChanged(String value) async { @@ -2950,6 +2990,37 @@ class _CountDownButtonState extends State<_CountDownButton> { } } +// Tappable row that pushes the shortcut configuration page. +class _ShortcutsConfigureRow extends StatelessWidget { + // ignore: unused_element + const _ShortcutsConfigureRow({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => const DesktopKeyboardShortcutsPage(), + )); + }, + child: Row( + children: [ + Expanded( + child: Text(translate('Configure shortcuts...')), + ), + Icon(Icons.arrow_forward_ios, + size: 16, color: disabledTextColor(context, true)) + .marginOnly(right: 4), + ], + ).marginOnly( + left: _kCheckBoxLeftMargin, + top: 6, + bottom: 6, + ), + ); + } +} + //#endregion //#region dialogs diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 29e710bbc0a..9449625735d 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -17,6 +17,7 @@ import '../../common/widgets/toolbar.dart'; import '../../models/model.dart'; import '../../models/input_model.dart'; import '../../models/platform_model.dart'; +import '../../models/shortcut_model.dart'; import '../../common/shared_state.dart'; import '../../utils/image.dart'; import '../widgets/remote_toolbar.dart'; @@ -126,6 +127,20 @@ class _RemotePageState extends State _ffi.ffiModel.pi.platform, _ffi.dialogManager); _ffi.recordingModel .updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId)); + // Seed shortcut action callbacks once the session is ready, so that + // global keyboard shortcuts work even if the user never opens the + // toolbar menu. The returned list is intentionally discarded — the + // side effect of registering callbacks (inside toolbarControls) is + // what we want here. + if (mounted) { + toolbarControls(context, widget.id, _ffi); + // Register the default-bound actions that `toolbarControls` doesn't + // own (fullscreen, switch display, switch tab). Done in addition, + // not instead of, the toolbar registration above. + registerSessionShortcutActions(_ffi, + tabController: widget.tabController, + toolbarState: widget.toolbarState); + } }); _ffi.canvasModel.initializeEdgeScrollFallback(this); _ffi.start( diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 5da253e808b..038c264aa71 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/widgets/audio_input.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart'; +import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/display.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; @@ -763,8 +764,31 @@ class _ControlMenu extends StatelessWidget { if (e.divider) { return Divider(); } else { + final hint = e.actionId == null + ? null + : ShortcutDisplay.formatFor(e.actionId!); + final child = hint == null + ? e.child + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: e.child), + Padding( + padding: const EdgeInsets.only(left: 16), + child: Text( + hint, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context).hintColor, + ), + ), + ), + ], + ); return MenuButton( - child: e.child, + child: child, onPressed: e.onPressed, ffi: ffi, trailingIcon: e.trailingIcon); diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 74a5af45c48..3a5256841cb 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -21,6 +21,7 @@ import '../../common/widgets/remote_input.dart'; import '../../models/input_model.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; +import '../../models/shortcut_model.dart'; import '../../utils/image.dart'; import '../widgets/dialog.dart'; import '../widgets/custom_scale_widget.dart'; @@ -119,6 +120,18 @@ class _RemotePageState extends State with WidgetsBindingObserver { } _disableAndroidSoftKeyboard( isKeyboardVisible: keyboardVisibilityController.isVisible); + // Seed shortcut action callbacks once the session is ready, so that + // global keyboard shortcuts work even if the user never opens the + // toolbar menu. The returned list is intentionally discarded — the + // side effect of registering callbacks (inside toolbarControls) is + // what we want here. + if (mounted) { + toolbarControls(context, widget.id, gFFI); + // Mobile has no DesktopTabController, so tab-switch shortcuts + // remain unregistered (they will simply log a no-handler debug + // line if a mobile user binds one — they have no tabs to switch). + registerSessionShortcutActions(gFFI); + } }); WidgetsBinding.instance.addObserver(this); } diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index 5092606361e..ed766cf76f1 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -17,8 +17,10 @@ import '../../common/widgets/login.dart'; import '../../consts.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; +import '../../models/shortcut_model.dart'; import '../widgets/dialog.dart'; import 'home_page.dart'; +import 'mobile_keyboard_shortcuts_page.dart'; import 'scan_page.dart'; class SettingsPage extends StatefulWidget implements PageShape { @@ -819,6 +821,22 @@ class _SettingsState extends State with WidgetsBindingObserver { showThemeSettings(gFFI.dialogManager); }, ), + SettingsTile.navigation( + leading: Icon(Icons.keyboard_outlined), + title: Text(translate('Keyboard Shortcuts')), + description: Text(ShortcutModel.isEnabled() + ? translate('On') + : translate('Off')), + onPressed: (context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const MobileKeyboardShortcutsPage(), + )).then((_) { + if (mounted) setState(() {}); + }); + }, + ), if (!bind.isDisableAccount()) SettingsTile.switchTile( title: Text(translate('note-at-conn-end-tip')), @@ -1352,3 +1370,4 @@ SettingsTile _getPopupDialogRadioEntry({ ), ); } + diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 6fdffd79658..984d6a25cc9 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -346,7 +346,7 @@ class InputModel { /// 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 (!isLinux) return; if (_sideButtonChannelInitialized) return; _sideButtonChannelInitialized = true; diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index e94834a2b38..72ecdc99da9 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -21,6 +21,7 @@ import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; +import 'package:flutter_hbb/models/shortcut_model.dart'; import 'package:flutter_hbb/models/user_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/desktop_render_texture.dart'; @@ -476,6 +477,11 @@ class FfiModel with ChangeNotifier { } else if (name == 'exit_relative_mouse_mode') { // Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS) parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease(); + } else if (name == kShortcutEventName) { + final action = evt['action']; + if (action is String) { + parent.target?.shortcutModel.onTriggered(action); + } } else { debugPrint('Event is not handled in the fixed branch: $name'); } @@ -3623,6 +3629,7 @@ class FFI { late final ElevationModel elevationModel; // session late final CmFileModel cmFileModel; // cm late final TextureModel textureModel; //session + late final ShortcutModel shortcutModel; // session late final Peers recentPeersModel; // global late final Peers favoritePeersModel; // global late final Peers lanPeersModel; // global @@ -3652,6 +3659,7 @@ class FFI { elevationModel = ElevationModel(WeakReference(this)); cmFileModel = CmFileModel(WeakReference(this)); textureModel = TextureModel(WeakReference(this)); + shortcutModel = ShortcutModel(WeakReference(this)); recentPeersModel = Peers( name: PeersModelName.recent, loadEvent: LoadEvent.recent, diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index 54e6a9a9b9b..f151a6e46ca 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart'; import 'dart:html' as html; import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/common.dart' as common; final _privateConstructorUsedError = UnsupportedError( 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); @@ -930,6 +931,21 @@ class RustdeskImpl { ])); } + // Tell the JS-side matcher (flutter/web/js/src/shortcut_matcher.ts) to + // re-read its bindings from LocalStorage. Mirrors the native call which + // refreshes the Rust matcher's in-memory cache. + void mainReloadKeyboardShortcuts({dynamic hint}) { + js.context.callMethod('reloadShortcuts', []); + } + + // Web has no Rust at runtime, so the defaults seed comes from the + // [kDefaultShortcutBindings] canonical in shortcut_constants.dart. Parity + // with Rust's `default_bindings()` is enforced by tests on both sides + // against `flutter/test/fixtures/default_keyboard_shortcuts.json`. + String mainGetDefaultKeyboardShortcuts({dynamic hint}) { + return jsonEncode(kDefaultShortcutBindings); + } + String mainGetInputSource({dynamic hint}) { final inputSource = js.context.callMethod('getByName', ['option:local', 'input-source']); @@ -1176,6 +1192,15 @@ class RustdeskImpl { } Future mainInit({required String appDir, dynamic hint}) { + // JS -> Dart shortcut bridge. The matcher in flutter/web/js/src/ + // shortcut_matcher.ts calls `window.onShortcutTriggered(actionId)` when a + // binding fires; route it to the active session's ShortcutModel. + // Web is single-window so `gFFI` is always the active session. + js.context['onShortcutTriggered'] = (dynamic action) { + if (action is String) { + common.gFFI.shortcutModel.onTriggered(action); + } + }; return Future.value(); } From f29dec7b13c25e2d7f1c5db4a2310522a2112836 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 6 May 2026 19:27:56 +0800 Subject: [PATCH 2/7] harden switch side --- libs/hbb_common | 2 +- src/client.rs | 77 ++++++++++++++++++++++++++++++++++--- src/client/io_loop.rs | 18 ++++++++- src/flutter_ffi.rs | 2 +- src/ipc.rs | 22 +++++++++++ src/server/connection.rs | 39 ++++++++++++++++++- src/ui_cm_interface.rs | 2 +- src/ui_session_interface.rs | 5 ++- 8 files changed, 153 insertions(+), 14 deletions(-) diff --git a/libs/hbb_common b/libs/hbb_common index 3e31a94939e..87b11a79596 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 3e31a94939e026ab2c05d21a2c436960aa9bfea8 +Subproject commit 87b11a795964b00deded250657a63626f2c1efa0 diff --git a/src/client.rs b/src/client.rs index 72652776a92..321a49ee69e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1745,6 +1745,9 @@ pub struct LoginConfigHandler { pub direct: Option, pub received: bool, switch_uuid: Option, + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + switch_back_allowed: bool, pub save_ab_password_to_recent: bool, // true: connected with ab password pub other_server: Option<(String, String, String)>, pub custom_fps: Arc>>, @@ -1861,6 +1864,11 @@ impl LoginConfigHandler { self.direct = None; self.received = false; + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + self.switch_back_allowed = false; + } self.switch_uuid = switch_uuid; self.adapter_luid = adapter_luid; self.selected_windows_session_id = None; @@ -1874,6 +1882,23 @@ impl LoginConfigHandler { self.is_terminal_admin = is_terminal_admin; } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + pub fn allow_switch_back_once(&mut self) { + self.switch_back_allowed = true; + } + + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + pub fn consume_switch_back_permission(&mut self) -> bool { + if self.switch_back_allowed { + self.switch_back_allowed = false; + true + } else { + false + } + } + /// Check if the client should auto login. /// Return password if the client should auto login, otherwise return empty string. pub fn should_auto_login(&self) -> String { @@ -3377,6 +3402,36 @@ pub fn handle_login_error( } } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { + let Ok(mut conn) = crate::ipc::connect(1000, "").await else { + return false; + }; + let uuid = uuid.to_string(); + if conn + .send(&crate::ipc::Data::SwitchSidesUuid( + uuid.clone(), + id.to_owned(), + None, + )) + .await + .is_err() + { + return false; + } + match conn.next_timeout(1000).await { + Ok(Some(crate::ipc::Data::SwitchSidesUuid( + returned_uuid, + returned_id, + Some(true), + ))) => { + returned_uuid == uuid && returned_id == id + } + _ => false, + } +} + /// Handle hash message sent by peer. /// Hash will be used for login. /// @@ -3397,12 +3452,22 @@ pub async fn handle_hash( // Take care of password application order // switch_uuid - let uuid = lc.write().unwrap().switch_uuid.take(); - if let Some(uuid) = uuid { - if let Ok(uuid) = uuid::Uuid::from_str(&uuid) { - send_switch_login_request(lc.clone(), peer, uuid).await; - lc.write().unwrap().password_source = Default::default(); - return; + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let uuid = lc.write().unwrap().switch_uuid.take(); + if let Some(uuid) = uuid { + if let Ok(uuid) = uuid::Uuid::from_str(&uuid) { + let id = lc.read().unwrap().id.clone(); + if !consume_local_switch_sides_uuid(&id, &uuid).await { + log::warn!("Ignored untrusted switch_uuid"); + } else { + lc.write().unwrap().allow_switch_back_once(); + send_switch_login_request(lc.clone(), peer, uuid).await; + lc.write().unwrap().password_source = Default::default(); + return; + } + } } } // last password diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 78ba9ebc680..5eb7a273ac8 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1923,9 +1923,23 @@ impl Remote { ); } } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] Some(misc::Union::SwitchBack(_)) => { - #[cfg(feature = "flutter")] - self.handler.switch_back(&self.handler.get_id()); + let allow_switch_back = self + .handler + .lc + .write() + .unwrap() + .consume_switch_back_permission(); + if allow_switch_back { + self.handler.switch_back(&self.handler.get_id()); + } else { + log::warn!( + "Ignored unsolicited SwitchBack from {}", + self.handler.get_id() + ); + } } #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 3f97df078e2..4b62b4fca2b 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2213,7 +2213,7 @@ pub fn cm_elevate_portable(conn_id: i32) { } pub fn cm_switch_back(conn_id: i32) { - #[cfg(not(any(target_os = "ios")))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] crate::ui_cm_interface::switch_back(conn_id); } diff --git a/src/ipc.rs b/src/ipc.rs index e6d4fc8345b..82b52a60cfd 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -285,7 +285,14 @@ pub enum Data { Empty, Disconnected, DataPortableService(DataPortableService), + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesRequest(String), + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + SwitchSidesUuid(String, String, Option), + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesBack, UrlLink(String), VoiceCallIncoming, @@ -771,6 +778,8 @@ async fn handle(data: Data, stream: &mut Connection) { Data::TestRendezvousServer => { crate::test_rendezvous_server(); } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::SwitchSidesRequest(id) => { let uuid = uuid::Uuid::new_v4(); crate::server::insert_switch_sides_uuid(id, uuid.clone()); @@ -780,6 +789,19 @@ async fn handle(data: Data, stream: &mut Connection) { .await ); } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + Data::SwitchSidesUuid(uuid, id, None) => { + let allowed = uuid + .parse::() + .map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid)) + .unwrap_or(false); + allow_err!( + stream + .send(&Data::SwitchSidesUuid(uuid, id, Some(allowed))) + .await + ); + } #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::Plugin(plugin) => crate::plugin::ipc::handle_plugin(plugin, stream).await, diff --git a/src/server/connection.rs b/src/server/connection.rs index bd5327bb23c..a960daac173 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -73,11 +73,17 @@ lazy_static::lazy_static! { static ref ALIVE_CONNS: Arc::>> = Default::default(); pub static ref AUTHED_CONNS: Arc::>> = Default::default(); pub static ref CONTROL_PERMISSIONS_ARRAY: Arc::>> = Default::default(); - static ref SWITCH_SIDES_UUID: Arc::>> = Default::default(); static ref WAKELOCK_SENDER: Arc::>> = Arc::new(Mutex::new(start_wakelock_thread())); static ref WAKELOCK_KEEP_AWAKE_OPTION: Arc::>> = Default::default(); } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +lazy_static::lazy_static! { + static ref SWITCH_SIDES_UUID: Arc::>> = Default::default(); + static ref PENDING_SWITCH_SIDES_UUID: Arc::>> = Default::default(); +} + fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; @@ -775,6 +781,8 @@ impl Connection { log::error!("Failed to start portable service from cm: {:?}", e); } } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] ipc::Data::SwitchSidesBack => { let mut misc = Misc::new(); misc.set_switch_back(SwitchBack::default()); @@ -2579,6 +2587,7 @@ impl Connection { } } else if let Some(message::Union::SwitchSidesResponse(_s)) = msg.union { #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] if let Some(lr) = _s.lr.clone().take() { self.handle_login_request_without_validation(&lr).await; SWITCH_SIDES_UUID @@ -3294,8 +3303,13 @@ impl Connection { } } #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] Some(misc::Union::SwitchSidesRequest(s)) => { if let Ok(uuid) = uuid::Uuid::from_slice(&s.uuid.to_vec()[..]) { + crate::server::insert_pending_switch_sides_uuid( + self.lr.my_id.clone(), + uuid.clone(), + ); crate::run_me(vec![ "--connect", &self.lr.my_id, @@ -4938,6 +4952,8 @@ impl Connection { } } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) { SWITCH_SIDES_UUID .lock() @@ -4945,6 +4961,27 @@ pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) { .insert(id, (tokio::time::Instant::now(), uuid)); } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn insert_pending_switch_sides_uuid(id: String, uuid: uuid::Uuid) { + let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); + uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10)); + uuids.insert(id, (tokio::time::Instant::now(), uuid)); +} + +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn remove_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { + let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); + uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10)); + if uuids.get(id).map(|(_, stored_uuid)| stored_uuid == uuid) == Some(true) { + uuids.remove(id); + true + } else { + false + } +} + #[cfg(not(any(target_os = "android", target_os = "ios")))] async fn start_ipc( mut rx_to_cm: mpsc::UnboundedReceiver, diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index 83182494737..cab0d7f1c1d 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -464,7 +464,7 @@ pub fn has_active_clients() -> bool { #[inline] #[cfg(feature = "flutter")] -#[cfg(not(any(target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn switch_back(id: i32) { if let Some(client) = CLIENTS.read().unwrap().get(&id) { allow_err!(client.tx.send(Data::SwitchSidesBack)); diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index c18c17fe213..e6c8ac6a238 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1464,10 +1464,11 @@ impl Session { self.send(Data::ElevateWithLogon(username, password)); } - #[cfg(any(target_os = "ios"))] + #[cfg(any(target_os = "android", target_os = "ios", not(feature = "flutter")))] pub fn switch_sides(&self) {} - #[cfg(not(any(target_os = "ios")))] + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] #[tokio::main(flavor = "current_thread")] pub async fn switch_sides(&self) { match crate::ipc::connect(1000, "").await { From 9d1f86fbc6f5abdab7af6133abaf56003b9ad82f Mon Sep 17 00:00:00 2001 From: Mr-Update <37781396+Mr-Update@users.noreply.github.com> Date: Wed, 6 May 2026 13:32:41 +0200 Subject: [PATCH 3/7] Update de.rs (#14953) --- src/lang/de.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/de.rs b/src/lang/de.rs index 7d18cd7a137..030bc626d18 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Anzeigename"), ("password-hidden-tip", "Ein permanentes Passwort wurde festgelegt (ausgeblendet)."), ("preset-password-in-use-tip", "Das voreingestellte Passwort wird derzeit verwendet."), - ("Enable privacy mode", ""), + ("Enable privacy mode", "Datenschutzmodus aktivieren"), ].iter().cloned().collect(); } From 0221634a4da93c0f35a491d0ae55cbd284538d17 Mon Sep 17 00:00:00 2001 From: Lynilia <89228568+Lynilia@users.noreply.github.com> Date: Wed, 6 May 2026 13:32:59 +0200 Subject: [PATCH 4/7] Update fr.rs (#14955) --- src/lang/fr.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/fr.rs b/src/lang/fr.rs index ab6ed2e7668..6f7bb2880bb 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -743,6 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "Nom d’affichage"), ("password-hidden-tip", "Le mot de passe permanent est défini (masqué)."), ("preset-password-in-use-tip", "Le mot de passe prédéfini est actuellement utilisé."), - ("Enable privacy mode", ""), + ("Enable privacy mode", "Activer le mode de confidentialité"), ].iter().cloned().collect(); } From 92509f8e8a17f07d881c4f566fc3ad6cddb3e074 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 6 May 2026 19:35:13 +0800 Subject: [PATCH 5/7] update hbb_common --- libs/hbb_common | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hbb_common b/libs/hbb_common index 87b11a79596..6490a8655c2 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 87b11a795964b00deded250657a63626f2c1efa0 +Subproject commit 6490a8655c25801e16c3b30d161d9f2b9e458b36 From 8b8a64f870c5126cef9deb9cf168ca3a6fa1e9e4 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 6 May 2026 19:40:52 +0800 Subject: [PATCH 6/7] revert hbb_common to old one --- libs/hbb_common | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hbb_common b/libs/hbb_common index 6490a8655c2..3e31a94939e 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 6490a8655c25801e16c3b30d161d9f2b9e458b36 +Subproject commit 3e31a94939e026ab2c05d21a2c436960aa9bfea8 From 5439ec38b663c2ff9de1063ac125f6ac61d78ae2 Mon Sep 17 00:00:00 2001 From: 21pages Date: Wed, 6 May 2026 20:20:17 +0800 Subject: [PATCH 7/7] Revert "fix web break introduced in 38f130071 fix(linux): enable mouse side buttons in remote sessions (#14848)" (#14973) This reverts commit d5d0b01266edc8af6baabc2004a1096dd7088a02. --- flutter/lib/common/widgets/toolbar.dart | 93 ++----------------- flutter/lib/consts.dart | 2 - .../desktop/pages/desktop_setting_page.dart | 73 +-------------- flutter/lib/desktop/pages/remote_page.dart | 15 --- .../lib/desktop/widgets/remote_toolbar.dart | 26 +----- flutter/lib/mobile/pages/remote_page.dart | 13 --- flutter/lib/mobile/pages/settings_page.dart | 19 ---- flutter/lib/models/input_model.dart | 2 +- flutter/lib/models/model.dart | 8 -- flutter/lib/web/bridge.dart | 25 ----- 10 files changed, 10 insertions(+), 266 deletions(-) diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index da79c106e22..2e7247d95c3 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -16,43 +16,16 @@ import 'package:get/get.dart'; bool isEditOsPassword = false; -/// Action IDs that `toolbarControls` is the sole registrar for. Each call to -/// `toolbarControls` (e.g. opening the toolbar menu after a permission was -/// revoked or a state changed) wipes these so a previously-registered closure -/// can't outlive the menu entry that owns it. The for-loop at the bottom of -/// `toolbarControls` then re-registers whichever entries are still present in -/// the rebuilt menu list. -/// -/// Actions registered elsewhere — `registerSessionShortcutActions` on desktop -/// owns toggle_recording, fullscreen, switch_display, switch_tab, close_tab, -/// toggle_toolbar — MUST NOT appear here, otherwise this list would clobber -/// their registration on every menu rebuild. -/// -/// `kShortcutActionToggleRecording` is platform-conditional (mobile-only — -/// see the `!(isDesktop || isWeb)` guard in `toolbarControls`). It is handled -/// separately in the unregister pass rather than appearing in this const list. -const _kToolbarOwnedActionIds = [ - kShortcutActionSendCtrlAltDel, - kShortcutActionRestartRemote, - kShortcutActionInsertLock, - kShortcutActionToggleBlockInput, - kShortcutActionSwitchSides, - kShortcutActionRefresh, - kShortcutActionScreenshot, -]; - class TTextMenu { final Widget child; final VoidCallback? onPressed; Widget? trailingIcon; bool divider; - final String? actionId; TTextMenu( {required this.child, required this.onPressed, this.trailingIcon, - this.divider = false, - this.actionId}); + this.divider = false}); Widget getChild() { if (trailingIcon != null) { @@ -121,20 +94,6 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { final sessionId = ffi.sessionId; final isDefaultConn = ffi.connType == ConnType.defaultConn; - // Wipe everything `toolbarControls` could have registered last call so - // stale closures (e.g. for a menu entry whose permission has since been - // revoked) don't outlive the menu rebuild. See _kToolbarOwnedActionIds. - for (final actionId in _kToolbarOwnedActionIds) { - ffi.shortcutModel.unregister(actionId); - } - // toggle_recording is platform-conditional — toolbarControls only builds - // the menu entry on `!(isDesktop || isWeb)`. On desktop the registration - // is owned by `registerSessionShortcutActions` and must NOT be touched - // here. See the recording menu entry below. - if (!(isDesktop || isWeb)) { - ffi.shortcutModel.unregister(kShortcutActionToggleRecording); - } - List v = []; // elevation if (isDefaultConn && @@ -270,8 +229,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text('${translate("Insert Ctrl + Alt + Del")}'), - onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId), - actionId: kShortcutActionSendCtrlAltDel), + onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId)), ); } // restart @@ -284,8 +242,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { TTextMenu( child: Text(translate('Restart remote device')), onPressed: () => - showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager), - actionId: kShortcutActionRestartRemote), + showRestartRemoteDevice(pi, id, sessionId, ffi.dialogManager)), ); } // insertLock @@ -293,8 +250,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text(translate('Insert Lock')), - onPressed: () => bind.sessionLockScreen(sessionId: sessionId), - actionId: kShortcutActionInsertLock), + onPressed: () => bind.sessionLockScreen(sessionId: sessionId)), ); } // blockUserInput @@ -312,8 +268,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { sessionId: sessionId, value: '${blockInput.value ? 'un' : ''}block-input'); blockInput.value = !blockInput.value; - }, - actionId: kShortcutActionToggleBlockInput)); + })); } // switchSides if (isDefaultConn && @@ -325,15 +280,13 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add(TTextMenu( child: Text(translate('Switch Sides')), onPressed: () => - showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager), - actionId: kShortcutActionSwitchSides)); + showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager))); } // refresh if (pi.version.isNotEmpty) { v.add(TTextMenu( child: Text(translate('Refresh')), onPressed: () => sessionRefreshVideo(sessionId, pi), - actionId: kShortcutActionRefresh, )); } // record @@ -355,8 +308,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ) ], ), - onPressed: () => ffi.recordingModel.toggle(), - actionId: kShortcutActionToggleRecording)); + onPressed: () => ffi.recordingModel.toggle())); } // to-do: @@ -373,14 +325,6 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { onPressed: ffi.ffiModel.timerScreenshot != null ? null : () { - // Live cooldown check: the menu rebuilds onPressed=null - // whenever toolbarControls runs and finds timerScreenshot - // != null, but the keyboard-shortcut callback holds onto - // the originally-enabled closure across cooldown periods - // (toolbarControls only re-runs on menu open). Without - // this guard the second shortcut press during the 30s - // cooldown still fires sessionTakeScreenshot. - if (ffi.ffiModel.timerScreenshot != null) return; if (pi.currentDisplay == kAllDisplayValue) { msgBox( sessionId, @@ -398,7 +342,6 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { }); } }, - actionId: kShortcutActionScreenshot, )); } } @@ -409,28 +352,6 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { onPressed: () => onCopyFingerprint(FingerprintState.find(id).value), )); } - // Register tagged callbacks with the shortcut model so global keyboard - // shortcuts can dispatch the same actions as the toolbar menu items. - // - // For action IDs already cleared at the top of this function (i.e. those - // in [_kToolbarOwnedActionIds] plus the conditional toggle_recording), - // the `else` branch below is a redundant idempotent no-op — `unregister` - // just calls `Map.remove` on something already absent. - // - // The branch is kept as **defense in depth** for the case where a future - // contributor tags a menu item with an actionId that they forget to add - // to [_kToolbarOwnedActionIds]: without this `else`, the original - // "stale-closure-outlives-disabled-state" bug (e.g. Screenshot cooldown - // bypass) would silently come back for that new action only. - for (final menu in v) { - final actionId = menu.actionId; - if (actionId == null) continue; - if (menu.onPressed != null) { - ffi.shortcutModel.register(actionId, menu.onPressed!); - } else { - ffi.shortcutModel.unregister(actionId); - } - } return v; } diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 8362ed36e90..832b96d245a 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -4,8 +4,6 @@ import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; -export 'common/widgets/keyboard_shortcuts/shortcut_constants.dart'; - const int kMaxVirtualDisplayCount = 4; const int kAllVirtualDisplay = -1; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index b13b2c9cd58..2841c1d273a 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -10,14 +10,12 @@ import 'package:flutter_hbb/common/widgets/audio_input.dart'; import 'package:flutter_hbb/common/widgets/setting_widgets.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart'; -import 'package:flutter_hbb/desktop/pages/desktop_keyboard_shortcuts_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; import 'package:flutter_hbb/mobile/widgets/dialog.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; -import 'package:flutter_hbb/models/shortcut_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/plugin/manager.dart'; import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart'; @@ -423,49 +421,11 @@ class _GeneralState extends State<_General> { if (!isWeb) audio(context), if (!isWeb) record(context), if (!isWeb) WaylandCard(), - other(), - if (!bind.isIncomingOnly()) keyboardShortcuts(), + other() ], ).marginOnly(bottom: _kListViewBottomMargin); } - Widget keyboardShortcuts() { - // The bindings JSON (LocalConfig key `keyboard-shortcuts`) holds three - // flags + the bindings list: {enabled, pass_through, bindings}. When the - // master is off, the pass-through toggle and the Configure entry are - // hidden — both are meaningless without an active matcher. - return StatefulBuilder(builder: (context, setLocalState) { - final enabled = ShortcutModel.isEnabled(); - return _Card(title: 'Keyboard Shortcuts', children: [ - _OptionCheckBox( - context, - 'Enable keyboard shortcuts in remote session', - kShortcutLocalConfigKey, - isServer: false, - optGetter: ShortcutModel.isEnabled, - optSetter: (_, v) async { - await ShortcutModel.setEnabled(v); - setLocalState(() {}); - }, - ), - if (enabled) ...[ - _OptionCheckBox( - context, - 'Pass-through to remote', - kShortcutLocalConfigKey, - isServer: false, - optGetter: ShortcutModel.isPassThrough, - optSetter: (_, v) async { - await ShortcutModel.setPassThrough(v); - setLocalState(() {}); - }, - ), - _ShortcutsConfigureRow(), - ], - ]); - }); - } - Widget theme() { final current = MyTheme.getThemeModePreference().toShortString(); onChanged(String value) async { @@ -2990,37 +2950,6 @@ class _CountDownButtonState extends State<_CountDownButton> { } } -// Tappable row that pushes the shortcut configuration page. -class _ShortcutsConfigureRow extends StatelessWidget { - // ignore: unused_element - const _ShortcutsConfigureRow({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: () { - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const DesktopKeyboardShortcutsPage(), - )); - }, - child: Row( - children: [ - Expanded( - child: Text(translate('Configure shortcuts...')), - ), - Icon(Icons.arrow_forward_ios, - size: 16, color: disabledTextColor(context, true)) - .marginOnly(right: 4), - ], - ).marginOnly( - left: _kCheckBoxLeftMargin, - top: 6, - bottom: 6, - ), - ); - } -} - //#endregion //#region dialogs diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 9449625735d..29e710bbc0a 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -17,7 +17,6 @@ import '../../common/widgets/toolbar.dart'; import '../../models/model.dart'; import '../../models/input_model.dart'; import '../../models/platform_model.dart'; -import '../../models/shortcut_model.dart'; import '../../common/shared_state.dart'; import '../../utils/image.dart'; import '../widgets/remote_toolbar.dart'; @@ -127,20 +126,6 @@ class _RemotePageState extends State _ffi.ffiModel.pi.platform, _ffi.dialogManager); _ffi.recordingModel .updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId)); - // Seed shortcut action callbacks once the session is ready, so that - // global keyboard shortcuts work even if the user never opens the - // toolbar menu. The returned list is intentionally discarded — the - // side effect of registering callbacks (inside toolbarControls) is - // what we want here. - if (mounted) { - toolbarControls(context, widget.id, _ffi); - // Register the default-bound actions that `toolbarControls` doesn't - // own (fullscreen, switch display, switch tab). Done in addition, - // not instead of, the toolbar registration above. - registerSessionShortcutActions(_ffi, - tabController: widget.tabController, - toolbarState: widget.toolbarState); - } }); _ffi.canvasModel.initializeEdgeScrollFallback(this); _ffi.start( diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 038c264aa71..5da253e808b 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/widgets/audio_input.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart'; -import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/display.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; @@ -764,31 +763,8 @@ class _ControlMenu extends StatelessWidget { if (e.divider) { return Divider(); } else { - final hint = e.actionId == null - ? null - : ShortcutDisplay.formatFor(e.actionId!); - final child = hint == null - ? e.child - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible(child: e.child), - Padding( - padding: const EdgeInsets.only(left: 16), - child: Text( - hint, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith( - color: Theme.of(context).hintColor, - ), - ), - ), - ], - ); return MenuButton( - child: child, + child: e.child, onPressed: e.onPressed, ffi: ffi, trailingIcon: e.trailingIcon); diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 3a5256841cb..74a5af45c48 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -21,7 +21,6 @@ import '../../common/widgets/remote_input.dart'; import '../../models/input_model.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; -import '../../models/shortcut_model.dart'; import '../../utils/image.dart'; import '../widgets/dialog.dart'; import '../widgets/custom_scale_widget.dart'; @@ -120,18 +119,6 @@ class _RemotePageState extends State with WidgetsBindingObserver { } _disableAndroidSoftKeyboard( isKeyboardVisible: keyboardVisibilityController.isVisible); - // Seed shortcut action callbacks once the session is ready, so that - // global keyboard shortcuts work even if the user never opens the - // toolbar menu. The returned list is intentionally discarded — the - // side effect of registering callbacks (inside toolbarControls) is - // what we want here. - if (mounted) { - toolbarControls(context, widget.id, gFFI); - // Mobile has no DesktopTabController, so tab-switch shortcuts - // remain unregistered (they will simply log a no-handler debug - // line if a mobile user binds one — they have no tabs to switch). - registerSessionShortcutActions(gFFI); - } }); WidgetsBinding.instance.addObserver(this); } diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index ed766cf76f1..5092606361e 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -17,10 +17,8 @@ import '../../common/widgets/login.dart'; import '../../consts.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; -import '../../models/shortcut_model.dart'; import '../widgets/dialog.dart'; import 'home_page.dart'; -import 'mobile_keyboard_shortcuts_page.dart'; import 'scan_page.dart'; class SettingsPage extends StatefulWidget implements PageShape { @@ -821,22 +819,6 @@ class _SettingsState extends State with WidgetsBindingObserver { showThemeSettings(gFFI.dialogManager); }, ), - SettingsTile.navigation( - leading: Icon(Icons.keyboard_outlined), - title: Text(translate('Keyboard Shortcuts')), - description: Text(ShortcutModel.isEnabled() - ? translate('On') - : translate('Off')), - onPressed: (context) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const MobileKeyboardShortcutsPage(), - )).then((_) { - if (mounted) setState(() {}); - }); - }, - ), if (!bind.isDisableAccount()) SettingsTile.switchTile( title: Text(translate('note-at-conn-end-tip')), @@ -1370,4 +1352,3 @@ SettingsTile _getPopupDialogRadioEntry({ ), ); } - diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 984d6a25cc9..6fdffd79658 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -346,7 +346,7 @@ class InputModel { /// 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 (!Platform.isLinux) return; if (_sideButtonChannelInitialized) return; _sideButtonChannelInitialized = true; diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 72ecdc99da9..e94834a2b38 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -21,7 +21,6 @@ import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; -import 'package:flutter_hbb/models/shortcut_model.dart'; import 'package:flutter_hbb/models/user_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/desktop_render_texture.dart'; @@ -477,11 +476,6 @@ class FfiModel with ChangeNotifier { } else if (name == 'exit_relative_mouse_mode') { // Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS) parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease(); - } else if (name == kShortcutEventName) { - final action = evt['action']; - if (action is String) { - parent.target?.shortcutModel.onTriggered(action); - } } else { debugPrint('Event is not handled in the fixed branch: $name'); } @@ -3629,7 +3623,6 @@ class FFI { late final ElevationModel elevationModel; // session late final CmFileModel cmFileModel; // cm late final TextureModel textureModel; //session - late final ShortcutModel shortcutModel; // session late final Peers recentPeersModel; // global late final Peers favoritePeersModel; // global late final Peers lanPeersModel; // global @@ -3659,7 +3652,6 @@ class FFI { elevationModel = ElevationModel(WeakReference(this)); cmFileModel = CmFileModel(WeakReference(this)); textureModel = TextureModel(WeakReference(this)); - shortcutModel = ShortcutModel(WeakReference(this)); recentPeersModel = Peers( name: PeersModelName.recent, loadEvent: LoadEvent.recent, diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index f151a6e46ca..54e6a9a9b9b 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -7,7 +7,6 @@ import 'package:uuid/uuid.dart'; import 'dart:html' as html; import 'package:flutter_hbb/consts.dart'; -import 'package:flutter_hbb/common.dart' as common; final _privateConstructorUsedError = UnsupportedError( 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); @@ -931,21 +930,6 @@ class RustdeskImpl { ])); } - // Tell the JS-side matcher (flutter/web/js/src/shortcut_matcher.ts) to - // re-read its bindings from LocalStorage. Mirrors the native call which - // refreshes the Rust matcher's in-memory cache. - void mainReloadKeyboardShortcuts({dynamic hint}) { - js.context.callMethod('reloadShortcuts', []); - } - - // Web has no Rust at runtime, so the defaults seed comes from the - // [kDefaultShortcutBindings] canonical in shortcut_constants.dart. Parity - // with Rust's `default_bindings()` is enforced by tests on both sides - // against `flutter/test/fixtures/default_keyboard_shortcuts.json`. - String mainGetDefaultKeyboardShortcuts({dynamic hint}) { - return jsonEncode(kDefaultShortcutBindings); - } - String mainGetInputSource({dynamic hint}) { final inputSource = js.context.callMethod('getByName', ['option:local', 'input-source']); @@ -1192,15 +1176,6 @@ class RustdeskImpl { } Future mainInit({required String appDir, dynamic hint}) { - // JS -> Dart shortcut bridge. The matcher in flutter/web/js/src/ - // shortcut_matcher.ts calls `window.onShortcutTriggered(actionId)` when a - // binding fires; route it to the active session's ShortcutModel. - // Web is single-window so `gFFI` is always the active session. - js.context['onShortcutTriggered'] = (dynamic action) { - if (action is String) { - common.gFFI.shortcutModel.onTriggered(action); - } - }; return Future.value(); }