Files
rustdesk/flutter/test/keyboard_shortcuts_test.dart
rustdesk cd7686baa2 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.
2026-04-30 16:40:42 +08:00

427 lines
17 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_actions.dart';
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_constants.dart';
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_utils.dart';
ShortcutPlatformCapabilities capabilities({
bool includeFullscreenShortcut = true,
bool includeScreenshotShortcut = true,
bool includeTabShortcuts = true,
bool includeToolbarShortcut = true,
bool includeCloseTabShortcut = true,
bool includeSwitchSidesShortcut = true,
bool includeRecordingShortcut = true,
bool includeResetCanvasShortcut = true,
bool includePinToolbarShortcut = true,
bool includeViewModeShortcut = true,
bool includeInputSourceShortcut = true,
bool includeVoiceCallShortcut = true,
}) {
return ShortcutPlatformCapabilities(
includeFullscreenShortcut: includeFullscreenShortcut,
includeScreenshotShortcut: includeScreenshotShortcut,
includeTabShortcuts: includeTabShortcuts,
includeToolbarShortcut: includeToolbarShortcut,
includeCloseTabShortcut: includeCloseTabShortcut,
includeSwitchSidesShortcut: includeSwitchSidesShortcut,
includeRecordingShortcut: includeRecordingShortcut,
includeResetCanvasShortcut: includeResetCanvasShortcut,
includePinToolbarShortcut: includePinToolbarShortcut,
includeViewModeShortcut: includeViewModeShortcut,
includeInputSourceShortcut: includeInputSourceShortcut,
includeVoiceCallShortcut: includeVoiceCallShortcut,
);
}
void main() {
test('kDefaultShortcutBindings matches fixture', () {
// The fixture is the cross-language source of truth for default
// bindings. Rust has its own parity test against the same file
// (`default_bindings_match_fixture_json` in src/keyboard/shortcuts.rs),
// so a drift on either side breaks CI.
final fixturePath = 'test/fixtures/default_keyboard_shortcuts.json';
final fixture =
jsonDecode(File(fixturePath).readAsStringSync()) as List<dynamic>;
expect(kDefaultShortcutBindings, equals(fixture),
reason: 'kDefaultShortcutBindings drifted from $fixturePath — update '
'shortcut_constants.dart, the fixture, and Rust default_bindings() '
'together');
});
test('save order preserves macOS control modifier', () {
expect(canonicalShortcutModsForSave({'ctrl'}), ['ctrl']);
expect(canonicalShortcutModsForSave({'shift', 'ctrl', 'primary', 'alt'}),
['primary', 'ctrl', 'alt', 'shift']);
});
test('non-desktop defaults exclude desktop-only and tab shortcuts', () {
final defaults = [
{
'action': kShortcutActionSendCtrlAltDel,
'mods': ['primary', 'alt', 'shift'],
'key': 'delete',
},
{
'action': kShortcutActionToggleFullscreen,
'mods': ['primary', 'alt', 'shift'],
'key': 'enter',
},
{
'action': kShortcutActionSwitchDisplayNext,
'mods': ['primary', 'alt', 'shift'],
'key': 'arrow_right',
},
{
'action': kShortcutActionScreenshot,
'mods': ['primary', 'alt', 'shift'],
'key': 'p',
},
{
'action': kShortcutActionSwitchTabNext,
'mods': ['primary', 'alt', 'shift'],
'key': 'right_bracket',
},
];
final filtered = filterDefaultBindingsForPlatform(
defaults,
capabilities(
includeFullscreenShortcut: false,
includeScreenshotShortcut: false,
includeTabShortcuts: false,
includeToolbarShortcut: false,
includeCloseTabShortcut: false,
includeSwitchSidesShortcut: false,
includeRecordingShortcut: false,
includeResetCanvasShortcut: false,
includePinToolbarShortcut: false,
includeViewModeShortcut: false,
includeInputSourceShortcut: false,
includeVoiceCallShortcut: false,
),
);
expect(filtered.map((binding) => binding['action']), [
kShortcutActionSendCtrlAltDel,
kShortcutActionSwitchDisplayNext,
]);
});
Set<String> idSet(Iterable<KeyboardShortcutActionGroup> groups) =>
{for (final e in allActionEntries(groups)) e.id};
/// Convenience: extract the children of the named group as a flat list of
/// human-readable tokens. Subgroups appear as `'group:<title>'` followed
/// by their entries, so call sites can assert on full ordering (subgroups
/// interleaved with direct items) in one expectation.
List<String> childTokens(
List<KeyboardShortcutActionGroup> groups, String titleKey) {
final group = groups.firstWhere((g) => g.titleKey == titleKey);
final out = <String>[];
for (final child in group.children) {
switch (child) {
case KeyboardShortcutActionEntry():
out.add(child.id);
case KeyboardShortcutActionSubgroup():
out.add('group:${child.titleKey}');
for (final entry in child.entries) {
out.add(' ${entry.id}');
}
}
}
return out;
}
test('filterKeyboardShortcutActionGroupsForPlatform strips desktop-only', () {
final groups = filterKeyboardShortcutActionGroupsForPlatform(
capabilities(
includeFullscreenShortcut: false,
includeScreenshotShortcut: false,
includeTabShortcuts: false,
includeToolbarShortcut: false,
includeCloseTabShortcut: false,
includeSwitchSidesShortcut: false,
// Recording / Reset Canvas are intentionally still included here —
// they have non-desktop platforms (mobile Android / mobile both).
includeRecordingShortcut: true,
includeResetCanvasShortcut: true,
includePinToolbarShortcut: false,
includeViewModeShortcut: false,
includeInputSourceShortcut: false,
includeVoiceCallShortcut: false,
),
);
final ids = idSet(groups);
// Desktop-only actions are stripped.
expect(ids, isNot(contains(kShortcutActionToggleFullscreen)));
expect(ids, isNot(contains(kShortcutActionScreenshot)));
expect(ids, isNot(contains(kShortcutActionToggleToolbar)));
expect(ids, isNot(contains(kShortcutActionCloseTab)));
expect(ids, isNot(contains(kShortcutActionSwitchSides)));
expect(ids, isNot(contains(kShortcutActionPinToolbar)));
expect(ids, isNot(contains(kShortcutActionViewModeOriginal)));
expect(ids, isNot(contains(kShortcutActionViewModeAdaptive)));
expect(ids, isNot(contains(kShortcutActionSwitchTabNext)));
expect(ids, isNot(contains(kShortcutActionSwitchTabPrev)));
// Cross-platform actions survive.
expect(ids, contains(kShortcutActionSendCtrlAltDel));
expect(ids, contains(kShortcutActionInsertLock));
expect(ids, contains(kShortcutActionRestartRemote));
expect(ids, contains(kShortcutActionSwitchDisplayNext));
expect(ids, contains(kShortcutActionToggleRecording));
expect(ids, contains(kShortcutActionResetCanvas));
expect(ids, contains(kShortcutActionToggleMute));
});
test(
'filterKeyboardShortcutActionGroupsForPlatform hides Toggle Recording on Web/iOS',
() {
final groups = filterKeyboardShortcutActionGroupsForPlatform(
capabilities(includeRecordingShortcut: false),
);
final ids = idSet(groups);
expect(ids, isNot(contains(kShortcutActionToggleRecording)));
// Other Session Control entries unaffected.
expect(ids, contains(kShortcutActionSendCtrlAltDel));
expect(ids, contains(kShortcutActionInsertLock));
});
test(
'filterKeyboardShortcutActionGroupsForPlatform keeps full set on desktop',
() {
final groups =
filterKeyboardShortcutActionGroupsForPlatform(capabilities());
expect(idSet(groups), equals(idSet(kKeyboardShortcutActionGroups)));
});
test('shortcut action groups follow toolbar menu order', () {
final groups = kKeyboardShortcutActionGroups;
// Top-level groups in toolbar order.
expect(
groups.map((g) => g.titleKey).toList(),
['Monitor', 'Control Actions', 'Display', 'Keyboard', 'Chat', 'Other'],
);
// Display: subgroups (View Mode → Image Quality → Codec → Virtual
// display) first, then direct items (cursor toggles + display toggles),
// then Privacy mode subgroup last — exactly matching `_DisplayMenu`.
expect(childTokens(groups, 'Display'), [
'group:View Mode',
' $kShortcutActionViewModeOriginal',
' $kShortcutActionViewModeAdaptive',
' $kShortcutActionViewModeCustom',
'group:Image Quality',
' $kShortcutActionImageQualityBest',
' $kShortcutActionImageQualityBalanced',
' $kShortcutActionImageQualityLow',
'group:Codec',
' $kShortcutActionCodecAuto',
' $kShortcutActionCodecVp8',
' $kShortcutActionCodecVp9',
' $kShortcutActionCodecAv1',
' $kShortcutActionCodecH264',
' $kShortcutActionCodecH265',
'group:Virtual display',
' $kShortcutActionPlugOutAllVirtualDisplays',
kShortcutActionToggleShowRemoteCursor,
kShortcutActionToggleFollowRemoteCursor,
kShortcutActionToggleFollowRemoteWindow,
kShortcutActionToggleZoomCursor,
kShortcutActionToggleQualityMonitor,
kShortcutActionToggleMute,
kShortcutActionToggleEnableFileCopyPaste,
kShortcutActionToggleDisableClipboard,
kShortcutActionToggleLockAfterSessionEnd,
kShortcutActionToggleTrueColor,
'group:Privacy mode',
' $kShortcutActionPrivacyMode1',
' $kShortcutActionPrivacyMode2',
]);
// Privacy mode is the last child under Display (matching the toolbar's
// submenu order — `_DisplayMenu` adds Privacy mode after the toggles).
final displayChildren =
groups.firstWhere((g) => g.titleKey == 'Display').children;
expect(displayChildren.last, isA<KeyboardShortcutActionSubgroup>());
expect(
(displayChildren.last as KeyboardShortcutActionSubgroup).titleKey,
'Privacy mode',
);
// Keyboard: Keyboard mode subgroup first, then direct items —
// matching `_KeyboardMenu`.
expect(childTokens(groups, 'Keyboard'), [
'group:Keyboard mode',
' $kShortcutActionKeyboardModeLegacy',
' $kShortcutActionKeyboardModeMap',
' $kShortcutActionKeyboardModeTranslate',
kShortcutActionToggleInputSource,
kShortcutActionToggleViewOnly,
kShortcutActionToggleShowMyCursor,
kShortcutActionToggleSwapCtrlCmd,
kShortcutActionToggleRelativeMouseMode,
kShortcutActionToggleReverseMouseWheel,
kShortcutActionToggleSwapLeftRightMouse,
]);
});
test('filterKeyboardShortcutActionGroupsForPlatform drops empty groups', () {
// Sanity: KeyboardShortcutActionGroup ctor still accepts a single direct
// entry as a child.
final original = [
KeyboardShortcutActionGroup('TestGroup', [
KeyboardShortcutActionEntry(kShortcutActionCloseTab, 'Close Tab'),
]),
];
expect(original.first.children, hasLength(1));
// With every capability flag off, groups whose items are all behind
// those flags get dropped. Display / Keyboard parent groups still carry
// cross-platform direct items so they survive even when the gated
// subgroups thin out.
final groups = filterKeyboardShortcutActionGroupsForPlatform(
capabilities(
includeFullscreenShortcut: false,
includeScreenshotShortcut: false,
includeTabShortcuts: false,
includeToolbarShortcut: false,
includeCloseTabShortcut: false,
includeSwitchSidesShortcut: false,
includeRecordingShortcut: false,
includeResetCanvasShortcut: false,
includePinToolbarShortcut: false,
includeViewModeShortcut: false,
includeInputSourceShortcut: false,
includeVoiceCallShortcut: false,
),
);
final titles = groups.map((g) => g.titleKey).toList();
// "Other" has nothing but platform-gated entries → dropped entirely.
expect(titles, isNot(contains('Other')));
// Parent groups with cross-platform direct items survive.
expect(titles, contains('Display'));
expect(titles, contains('Keyboard'));
// The "View Mode" subgroup under Display is gated by includeViewModeShortcut,
// so it must be absent from Display's surviving children.
final displayChildren =
groups.firstWhere((g) => g.titleKey == 'Display').children;
final subgroupTitles = displayChildren
.whereType<KeyboardShortcutActionSubgroup>()
.map((s) => s.titleKey)
.toList();
expect(subgroupTitles, isNot(contains('View Mode')));
// No surviving group is empty either way.
expect(groups.every((g) => g.children.isNotEmpty), isTrue);
// No surviving subgroup is empty.
for (final group in groups) {
for (final child in group.children) {
if (child is KeyboardShortcutActionSubgroup) {
expect(child.entries, isNotEmpty,
reason: 'subgroup "${child.titleKey}" should not be empty');
}
}
}
});
test('logicalKeyName covers the supported-keys fixture', () {
// The fixture is the cross-language source of truth for the full set of
// shortcut-bindable key names. Rust has a mirror test against the same
// file (`supported_keys_match_fixture` in src/keyboard/shortcuts.rs).
// Drift on either side breaks one of the two tests.
final fixturePath = 'test/fixtures/supported_shortcut_keys.json';
final fixture =
(jsonDecode(File(fixturePath).readAsStringSync()) as List<dynamic>)
.cast<String>()
.toSet();
// Hand-rolled (LogicalKeyboardKey, name) round-trip table. Adding a key
// requires updates in three places: the fixture, this table, and Rust's
// matching table — that's the price of the parity guarantee.
final mappings = <(LogicalKeyboardKey, String)>[
for (var c = 0; c < 26; c++)
(
LogicalKeyboardKey(0x00000000061 + c),
String.fromCharCode(0x61 + c),
),
for (var d = 0; d < 10; d++)
(LogicalKeyboardKey(0x00000000030 + d), 'digit$d'),
(LogicalKeyboardKey.f1, 'f1'),
(LogicalKeyboardKey.f2, 'f2'),
(LogicalKeyboardKey.f3, 'f3'),
(LogicalKeyboardKey.f4, 'f4'),
(LogicalKeyboardKey.f5, 'f5'),
(LogicalKeyboardKey.f6, 'f6'),
(LogicalKeyboardKey.f7, 'f7'),
(LogicalKeyboardKey.f8, 'f8'),
(LogicalKeyboardKey.f9, 'f9'),
(LogicalKeyboardKey.f10, 'f10'),
(LogicalKeyboardKey.f11, 'f11'),
(LogicalKeyboardKey.f12, 'f12'),
(LogicalKeyboardKey.delete, 'delete'),
(LogicalKeyboardKey.backspace, 'backspace'),
(LogicalKeyboardKey.tab, 'tab'),
(LogicalKeyboardKey.space, 'space'),
(LogicalKeyboardKey.enter, 'enter'),
(LogicalKeyboardKey.numpadEnter, 'enter'),
(LogicalKeyboardKey.arrowLeft, 'arrow_left'),
(LogicalKeyboardKey.arrowRight, 'arrow_right'),
(LogicalKeyboardKey.arrowUp, 'arrow_up'),
(LogicalKeyboardKey.arrowDown, 'arrow_down'),
(LogicalKeyboardKey.home, 'home'),
(LogicalKeyboardKey.end, 'end'),
(LogicalKeyboardKey.pageUp, 'page_up'),
(LogicalKeyboardKey.pageDown, 'page_down'),
(LogicalKeyboardKey.insert, 'insert'),
];
// Round-trip: every (key, name) pair must agree with logicalKeyName.
for (final (key, name) in mappings) {
expect(logicalKeyName(key), equals(name),
reason: 'logicalKeyName($key) should be "$name"');
}
// The set of names produced by the table must equal the fixture.
final namesFromTable = mappings.map((e) => e.$2).toSet();
expect(namesFromTable, equals(fixture),
reason: 'logicalKeyName vocabulary drifted from $fixturePath — update '
'shortcut_utils.dart::logicalKeyName, the fixture, and Rust '
'event_to_key_name together');
// Modifier-only / unsupported keys must return null.
expect(logicalKeyName(LogicalKeyboardKey.shift), isNull);
expect(logicalKeyName(LogicalKeyboardKey.escape), isNull);
expect(logicalKeyName(LogicalKeyboardKey.f13), isNull);
});
test('configurable shortcut list does not include known-removed action IDs',
() {
// These IDs were briefly defined without handlers (a "ghost action"
// footgun). If you intend to re-add one of these as a real action,
// wire up its handler and add a constant + group entry — do not just
// resurrect the literal string below.
//
// Note: `toggle_privacy_mode` was once on this list but is now a real
// implemented action (registered in shortcut_model.dart). The other
// legacy IDs (toggle_audio, view_mode_shrink/stretch, view_mode_1_to_1)
// were renamed: their replacements are kShortcutActionToggleMute and
// kShortcutActionViewModeOriginal/Adaptive/Custom.
const knownRemoved = [
'toggle_audio',
'view_mode_1_to_1',
'view_mode_shrink',
'view_mode_stretch',
];
final actions = idSet(kKeyboardShortcutActionGroups);
for (final id in knownRemoved) {
expect(actions, isNot(contains(id)),
reason:
'"$id" was a known ghost action — wire a real handler before re-adding it');
}
});
}