fix web break introduced in 38f130071 fix(linux): enable mouse side buttons in remote sessions (#14848)

This commit is contained in:
rustdesk
2026-04-29 17:37:46 +08:00
parent d4a1430c27
commit 68e07ed7eb
10 changed files with 266 additions and 10 deletions
+86 -7
View File
@@ -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 = <String>[
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<TTextMenu> 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<TTextMenu> v = [];
// elevation
if (isDefaultConn &&
@@ -229,7 +270,8 @@ List<TTextMenu> 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<TTextMenu> 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<TTextMenu> 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<TTextMenu> 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<TTextMenu> 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<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
)
],
),
onPressed: () => ffi.recordingModel.toggle()));
onPressed: () => ffi.recordingModel.toggle(),
actionId: kShortcutActionToggleRecording));
}
// to-do:
@@ -325,6 +373,14 @@ List<TTextMenu> 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<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
});
}
},
actionId: kShortcutActionScreenshot,
));
}
}
@@ -352,6 +409,28 @@ List<TTextMenu> 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;
}
+2
View File
@@ -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;
@@ -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 {
@@ -2946,6 +2986,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
@@ -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<RemotePage>
_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(
@@ -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);
+13
View File
@@ -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<RemotePage> 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);
}
@@ -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<SettingsPage> 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({
),
);
}
+1 -1
View File
@@ -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;
+8
View File
@@ -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,
+25
View File
@@ -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<void> 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();
}