feat(shortcuts): user-configurable keyboard shortcuts for session actions

Adds a keyboard shortcut feature (Rust matcher + Dart UI + cross-language
  parity tests) that lets users bind combinations like Ctrl+Alt+Shift+P to
  session actions. Bindings are stored in LocalConfig under
  `keyboard-shortcuts`; the matcher gates dispatch on `enabled` and
  `pass_through` flags so flipping the master switch off is a hard stop.

  Wire-up summary:
  - src/keyboard/shortcuts.rs: matcher, default bindings, parity test against
    flutter/test/fixtures/default_keyboard_shortcuts.json
  - src/keyboard.rs: shortcut intercept in process_event{,_with_session},
    feature-gated to `flutter`; runs before key swapping so users bind to
    physical keys
  - src/flutter_ffi.rs: main_reload_keyboard_shortcuts +
    main_get_default_keyboard_shortcuts; reload_from_config seeded in main_init
  - flutter/lib/common/widgets/keyboard_shortcuts/: shared config page body,
    recording dialog, shortcut display formatter, action group registry
  - flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart and
    flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart: platform
    shells around the shared body
  - flutter/lib/models/shortcut_model.dart: per-session ShortcutModel +
    registerSessionShortcutActions for actions with no toolbar TToggleMenu /
    TRadioMenu (fullscreen, switch display/tab, close tab, voice call, etc.)
  - flutter/lib/common/widgets/toolbar.dart: optional `actionId` field on
    TToggleMenu / TRadioMenu, plus per-helper auto-register pass that wires
    tagged entries' existing onChanged into the ShortcutModel
  - flutter/test/keyboard_shortcuts_test.dart + fixtures: cross-language
    parity (default bindings, supported key vocabulary)

  Design principles applied during review:

  1. Additions are fine; modifications to original logic must be deliberate.
     Tagging an existing TToggleMenu entry with `actionId:` is an addition.
     Rewriting its onChanged to satisfy a new contract is a modification —
     and was reverted for every case where the original click behavior was
     working. Four closures were touched and then reverted (mobile View
     Mode, Privacy mode multi-impl, Relative mouse mode, Reverse mouse
     wheel); their shortcuts are wired via standalone closures in
     shortcut_model.dart instead.

  2. Toolbar auto-register is reserved for entries whose onChanged is
     inherently self-flipping — typically `sessionToggleOption(name)` where
     the named option is flipped in place and the input bool is unused. The
     register pass passes `!menu.value` from registration time, which is
     harmless under self-flipping but wrong for closures that consume the
     input bool directly. Tagging a non-self-flipping entry forces a closure
     rewrite; choose non-toolbar registration in that case.

  3. When shortcuts are disabled, toolbar behavior must be bit-for-bit
     unchanged. The matcher's `enabled`-gate already guarantees no
     dispatch; the auto-register pass is left unconditional (its only effect
     is HashMap operations on a separate ShortcutModel) so mid-session
     enable works without a reconnect. The trade-off is intentional and
     documented at the top of toolbarControls.

  4. Comments stay terse. Rationale lives in one place — the doc comment of
     the helper or registration site, not duplicated at every call site.

  5. Where an existing helper needs a new optional behavior (e.g.
     `_OptionCheckBox` gaining a tooltip slot), the new branch must reduce
     to byte-identical output for existing callers (`trailing == null`
     case → original `Expanded(Text)` layout). Verified.

  6. Action IDs and labels stay consistent. Renamed `reset_cursor` →
     `reset_canvas` so the action ID matches its user-facing label
     ("Reset canvas") and capability flag.

  Out-of-scope but included:
  - AGENTS.md: documents flutter_rust_bridge no-codegen workflow and the
    Web target's hand-written TS client, since both are load-bearing for
    any new FFI work.
  - remote_toolbar.dart: i18n fix for the per-monitor tooltip ("All
    monitors" / "Monitor #N"), unrelated to shortcuts but kept here.
This commit is contained in:
rustdesk
2026-04-30 16:40:42 +08:00
parent 68e07ed7eb
commit cd7686baa2
25 changed files with 3729 additions and 78 deletions

View File

@@ -11,26 +11,17 @@ import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/shortcut_model.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
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.
/// Action IDs that `toolbarControls` is the sole registrar for. Wiped on
/// every call so stale closures don't outlive the menu entry that owned
/// them. Actions registered by `registerSessionShortcutActions` MUST NOT
/// appear here. `kShortcutActionToggleRecording` is platform-conditional
/// and handled separately in the unregister pass below.
const _kToolbarOwnedActionIds = <String>[
kShortcutActionSendCtrlAltDel,
kShortcutActionRestartRemote,
@@ -39,6 +30,8 @@ const _kToolbarOwnedActionIds = <String>[
kShortcutActionSwitchSides,
kShortcutActionRefresh,
kShortcutActionScreenshot,
kShortcutActionResetCanvas,
kShortcutActionSendClipboardKeystrokes,
];
class TTextMenu {
@@ -74,20 +67,61 @@ class TRadioMenu<T> {
final T value;
final T groupValue;
final ValueChanged<T?>? onChanged;
final String? actionId;
TRadioMenu(
{required this.child,
required this.value,
required this.groupValue,
required this.onChanged});
required this.onChanged,
this.actionId});
}
class TToggleMenu {
final Widget child;
final bool value;
final ValueChanged<bool?>? onChanged;
final String? actionId;
TToggleMenu(
{required this.child, required this.value, required this.onChanged});
{required this.child,
required this.value,
required this.onChanged,
this.actionId});
}
/// Register each tagged entry's `onChanged` with the session [ShortcutModel].
/// Passthrough — returns [menus] so a caller can wrap `return [...]` directly.
List<TToggleMenu> _registerToggleMenuShortcuts(
FFI ffi, List<TToggleMenu> menus) {
for (final menu in menus) {
final actionId = menu.actionId;
if (actionId == null) continue;
final onChanged = menu.onChanged;
if (onChanged == null) {
ffi.shortcutModel.unregister(actionId);
} else {
final value = menu.value;
ffi.shortcutModel.register(actionId, () => onChanged(!value));
}
}
return menus;
}
/// Radio variant of [_registerToggleMenuShortcuts].
List<TRadioMenu<T>> _registerRadioMenuShortcuts<T>(
FFI ffi, List<TRadioMenu<T>> menus) {
for (final menu in menus) {
final actionId = menu.actionId;
if (actionId == null) continue;
final onChanged = menu.onChanged;
if (onChanged == null) {
ffi.shortcutModel.unregister(actionId);
} else {
final value = menu.value;
ffi.shortcutModel.register(actionId, () => onChanged(value));
}
}
return menus;
}
handleOsPasswordEditIcon(
@@ -121,16 +155,13 @@ 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.
// Wipe stale registrations from previous menu builds before re-registering
// below; runs unconditionally so mid-session enable works without reconnect.
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.
// toggle_recording is mobile-only here; desktop's registration is owned by
// `registerSessionShortcutActions` and must not be touched.
if (!(isDesktop || isWeb)) {
ffi.shortcutModel.unregister(kShortcutActionToggleRecording);
}
@@ -188,13 +219,15 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
bind.sessionInputString(
sessionId: sessionId, value: data.text ?? "");
}
}));
},
actionId: kShortcutActionSendClipboardKeystrokes));
}
// reset canvas
if (isDefaultConn && isMobile) {
v.add(TTextMenu(
child: Text(translate('Reset canvas')),
onPressed: () => ffi.cursorModel.reset()));
onPressed: () => ffi.cursorModel.reset(),
actionId: kShortcutActionResetCanvas));
}
// https://github.com/rustdesk/rustdesk/pull/9731
@@ -409,19 +442,8 @@ 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.
// Register tagged TTextMenu callbacks. The else-unregister is defense in
// depth for actionIds tagged but missing from `_kToolbarOwnedActionIds`.
for (final menu in v) {
final actionId = menu.actionId;
if (actionId == null) continue;
@@ -445,23 +467,26 @@ Future<List<TRadioMenu<String>>> toolbarViewStyle(
.then((_) => ffi.canvasModel.updateViewStyle());
}
return [
return _registerRadioMenuShortcuts(ffi, [
TRadioMenu<String>(
child: Text(translate('Scale original')),
value: kRemoteViewStyleOriginal,
groupValue: groupValue,
onChanged: onChanged),
onChanged: onChanged,
actionId: kShortcutActionViewModeOriginal),
TRadioMenu<String>(
child: Text(translate('Scale adaptive')),
value: kRemoteViewStyleAdaptive,
groupValue: groupValue,
onChanged: onChanged),
onChanged: onChanged,
actionId: kShortcutActionViewModeAdaptive),
TRadioMenu<String>(
child: Text(translate('Scale custom')),
value: kRemoteViewStyleCustom,
groupValue: groupValue,
onChanged: onChanged)
];
onChanged: onChanged,
actionId: kShortcutActionViewModeCustom)
]);
}
Future<List<TRadioMenu<String>>> toolbarImageQuality(
@@ -473,22 +498,25 @@ Future<List<TRadioMenu<String>>> toolbarImageQuality(
await bind.sessionSetImageQuality(sessionId: ffi.sessionId, value: value);
}
return [
return _registerRadioMenuShortcuts(ffi, [
TRadioMenu<String>(
child: Text(translate('Good image quality')),
value: kRemoteImageQualityBest,
groupValue: groupValue,
onChanged: onChanged),
onChanged: onChanged,
actionId: kShortcutActionImageQualityBest),
TRadioMenu<String>(
child: Text(translate('Balanced')),
value: kRemoteImageQualityBalanced,
groupValue: groupValue,
onChanged: onChanged),
onChanged: onChanged,
actionId: kShortcutActionImageQualityBalanced),
TRadioMenu<String>(
child: Text(translate('Optimize reaction time')),
value: kRemoteImageQualityLow,
groupValue: groupValue,
onChanged: onChanged),
onChanged: onChanged,
actionId: kShortcutActionImageQualityLow),
TRadioMenu<String>(
child: Text(translate('Custom')),
value: kRemoteImageQualityCustom,
@@ -498,7 +526,7 @@ Future<List<TRadioMenu<String>>> toolbarImageQuality(
customImageQualityDialog(ffi.sessionId, id, ffi);
},
),
];
]);
}
Future<List<TRadioMenu<String>>> toolbarCodec(
@@ -533,12 +561,14 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
bind.sessionChangePreferCodec(sessionId: sessionId);
}
TRadioMenu<String> radio(String label, String value, bool enabled) {
TRadioMenu<String> radio(
String label, String value, bool enabled, String actionId) {
return TRadioMenu<String>(
child: Text(label),
value: value,
groupValue: groupValue,
onChanged: enabled ? onChanged : null);
onChanged: enabled ? onChanged : null,
actionId: actionId);
}
var autoLabel = translate('Auto');
@@ -546,14 +576,14 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
ffi.qualityMonitorModel.data.codecFormat != null) {
autoLabel = '$autoLabel (${ffi.qualityMonitorModel.data.codecFormat})';
}
return [
radio(autoLabel, 'auto', true),
if (codecs[0]) radio('VP8', 'vp8', codecs[0]),
radio('VP9', 'vp9', true),
if (codecs[1]) radio('AV1', 'av1', codecs[1]),
if (codecs[2]) radio('H264', 'h264', codecs[2]),
if (codecs[3]) radio('H265', 'h265', codecs[3]),
];
return _registerRadioMenuShortcuts(ffi, [
radio(autoLabel, 'auto', true, kShortcutActionCodecAuto),
if (codecs[0]) radio('VP8', 'vp8', codecs[0], kShortcutActionCodecVp8),
radio('VP9', 'vp9', true, kShortcutActionCodecVp9),
if (codecs[1]) radio('AV1', 'av1', codecs[1], kShortcutActionCodecAv1),
if (codecs[2]) radio('H264', 'h264', codecs[2], kShortcutActionCodecH264),
if (codecs[3]) radio('H265', 'h265', codecs[3], kShortcutActionCodecH265),
]);
}
Future<List<TToggleMenu>> toolbarCursor(
@@ -578,6 +608,7 @@ Future<List<TToggleMenu>> toolbarCursor(
v.add(TToggleMenu(
child: Text(translate('Show remote cursor')),
value: state.value,
actionId: kShortcutActionToggleShowRemoteCursor,
onChanged: enabled && !lockState.value
? (value) async {
if (value == null) return;
@@ -614,6 +645,7 @@ Future<List<TToggleMenu>> toolbarCursor(
v.add(TToggleMenu(
child: Text(translate('Follow remote cursor')),
value: value,
actionId: kShortcutActionToggleFollowRemoteCursor,
onChanged: (value) async {
if (value == null) return;
await bind.sessionToggleOption(sessionId: sessionId, value: option);
@@ -642,6 +674,7 @@ Future<List<TToggleMenu>> toolbarCursor(
v.add(TToggleMenu(
child: Text(translate('Follow remote window focus')),
value: value,
actionId: kShortcutActionToggleFollowRemoteWindow,
onChanged: (value) async {
if (value == null) return;
await bind.sessionToggleOption(sessionId: sessionId, value: option);
@@ -659,6 +692,7 @@ Future<List<TToggleMenu>> toolbarCursor(
v.add(TToggleMenu(
child: Text(translate('Zoom cursor')),
value: peerState.value,
actionId: kShortcutActionToggleZoomCursor,
onChanged: (value) async {
if (value == null) return;
await bind.sessionToggleOption(sessionId: sessionId, value: option);
@@ -667,7 +701,7 @@ Future<List<TToggleMenu>> toolbarCursor(
},
));
}
return v;
return _registerToggleMenuShortcuts(ffi, v);
}
Future<List<TToggleMenu>> toolbarDisplayToggle(
@@ -683,6 +717,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
final option = 'show-quality-monitor';
v.add(TToggleMenu(
value: bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option),
actionId: kShortcutActionToggleQualityMonitor,
onChanged: (value) async {
if (value == null) return;
await bind.sessionToggleOption(sessionId: sessionId, value: option);
@@ -696,6 +731,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleMute,
onChanged: (value) {
if (value == null) return;
bind.sessionToggleOption(sessionId: sessionId, value: option);
@@ -720,6 +756,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
sessionId: sessionId, arg: kOptionEnableFileCopyPaste);
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleEnableFileCopyPaste,
onChanged: enabled
? (value) {
if (value == null) return;
@@ -738,6 +775,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
if (ffiModel.viewOnly) value = true;
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleDisableClipboard,
onChanged: enabled
? (value) {
if (value == null) return;
@@ -754,6 +792,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleLockAfterSessionEnd,
onChanged: enabled
? (value) {
if (value == null) return;
@@ -804,6 +843,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleTrueColor,
onChanged: (value) async {
if (value == null) return;
await bind.sessionToggleOption(sessionId: sessionId, value: option);
@@ -828,7 +868,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
},
child: Text(translate('View Mode'))));
}
return v;
return _registerToggleMenuShortcuts(ffi, v);
}
var togglePrivacyModeTime = DateTime.now().subtract(const Duration(hours: 1));
@@ -927,6 +967,7 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
final enabled = !ffi.ffiModel.viewOnly;
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleSwapCtrlCmd,
onChanged: enabled ? onChanged : null,
child: Text(translate('Swap control-command key'))));
}
@@ -992,10 +1033,26 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
final enabled = !ffi.ffiModel.viewOnly;
v.add(TToggleMenu(
value: value,
actionId: kShortcutActionToggleSwapLeftRightMouse,
onChanged: enabled ? onChanged : null,
child: Text(translate('swap-left-right-mouse'))));
}
return v;
return _registerToggleMenuShortcuts(ffi, v);
}
/// Drive each toolbar helper for its registration side effect, so a shortcut
/// fires from the first keystroke without needing the user to open the
/// matching submenu. Mobile gets `toolbarKeyboardToggles` via
/// `toolbarDisplayToggle`'s `isMobile` branch — calling it explicitly there
/// would double-register.
void registerToolbarShortcuts(BuildContext context, String id, FFI ffi) {
if (isDesktop) toolbarKeyboardToggles(ffi);
unawaited(toolbarCursor(context, id, ffi));
unawaited(toolbarDisplayToggle(context, id, ffi));
unawaited(toolbarViewStyle(context, id, ffi));
unawaited(toolbarImageQuality(context, id, ffi));
unawaited(toolbarCodec(context, id, ffi));
toolbarPrivacyMode(PrivacyModeState.find(id), context, id, ffi);
}
bool showVirtualDisplayMenu(FFI ffi) {