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

4
.gitignore vendored
View File

@@ -55,4 +55,6 @@ examples/**/target/
vcpkg_installed
flutter/lib/generated_plugin_registrant.dart
libsciter.dylib
flutter/web/
flutter/web/
# Local git worktrees
.worktrees/

View File

@@ -53,6 +53,30 @@
* Use `spawn_blocking` or dedicated threads for blocking work.
* Do not use `std::thread::sleep()` in async code.
## Flutter Rust Bridge
* Do **not** run `flutter_rust_bridge_codegen` — it requires a specific pinned version that is not easy to set up locally.
* When adding new FFI functions in `src/flutter_ffi.rs`, hand-write the corresponding Dart wrappers instead of regenerating.
* Web bridge (committed): edit `flutter/lib/web/bridge.dart` directly. Follow the existing patterns there for `SyncReturn<T>` / `Future<T>` and the `dart:js` glue.
* Native bridge (`flutter/lib/generated_bridge.dart`, `src/bridge_generated.rs`, `src/bridge_generated.io.rs`): these are gitignored and regenerated by the project's CI codegen. Manually editing them locally is fine for development testing, but those edits do not persist into commits.
## Web (Flutter Web) Architecture
Flutter Web in this repo is **not** "Dart compiled to JS via Flutter alone". The runtime is split:
* **Native targets (Win/Mac/Linux/Android/iOS)**: Rust drives sessions via `flutter_rust_bridge`; Dart only renders UI.
* **Web target**: Rust does **not** run. There is a separate hand-written TypeScript / JavaScript client at `flutter/web/js/` (gitignored — not present in this repo, lives in the maintainer's local tree). It owns connection, codec, keyboard, clipboard, etc. — basically a JS port of the Rust client. The Dart UI talks to it through `flutter/lib/web/bridge.dart`, which uses `dart:js` to call JS-side functions and to register Dart-side callbacks on `window.*`.
Implications when adding any session-runtime feature (keyboard, clipboard, audio, …):
* The Rust implementation in `src/` is for **native only**. Don't try to compile it to wasm.
* The matching Web-side logic must be written in TS/JS under `flutter/web/js/src/`. It's a translation of the Rust logic, usually simpler — Web is single-window, so any per-session-id plumbing in Rust collapses to a single global on Web.
* `flutter/lib/web/bridge.dart` is the only place where Dart sees JS. Other Dart code stays platform-agnostic and goes through `bind`. Don't sprinkle `if (isWeb)` runtime branches in shared Dart files to call Web-specific logic — put the platform divergence in the bridge.
* For JS → Dart events (e.g., a Web matcher firing), the convention is: Dart sets `js.context['onFooBar'] = (...) {...}` once at startup (typically in `mainInit`); the JS side calls `window.onFooBar(...)`. See `onLoadAbFinished`, `onLoadGroupFinished` for reference.
* The maintainer cannot easily run `flutter_rust_bridge_codegen`, so when a new FFI function lands in `src/flutter_ffi.rs`:
1. add the Web counterpart to `flutter/lib/web/bridge.dart` by hand;
2. note that on the Web target it may need to be a no-op or a JS bridge call rather than a real Rust invocation.
## Editing Hygiene
* Change only what is required.

View File

@@ -0,0 +1,110 @@
// flutter/lib/common/widgets/keyboard_shortcuts/display.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../../consts.dart';
import '../../../models/platform_model.dart';
/// Read the bindings JSON and produce a human-readable shortcut string for
/// `actionId`, formatted for the current OS. Returns null if unbound, or —
/// when [requireEnabled] is true (the default) — when the master toggle is
/// off. The configuration page passes `requireEnabled: false` so users still
/// see what they have bound while the feature is disabled.
class ShortcutDisplay {
// Cache parsed JSON keyed by the raw string — called per visible action on
// every menu rebuild, so the jsonDecode is the real cost. Invalidation is
// automatic: a write changes the raw and we re-parse.
static String? _cachedRaw;
static Map<String, dynamic>? _cachedParsed;
@visibleForTesting
static void resetCache() {
_cachedRaw = null;
_cachedParsed = null;
}
static String? formatFor(String actionId, {bool requireEnabled = true}) {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return null;
Map<String, dynamic>? parsed;
if (raw == _cachedRaw) {
parsed = _cachedParsed;
} else {
try {
parsed = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
parsed = null;
}
_cachedRaw = raw;
_cachedParsed = parsed;
}
if (parsed == null) return null;
if (requireEnabled && parsed['enabled'] != true) return null;
// When pass-through is on, the matcher returns early on every keystroke.
// Showing the bound combo next to a menu item would lie to the user — they
// would press it expecting the local action and instead the keys would go
// to the remote. Treat as unbound for display purposes.
if (requireEnabled && parsed['pass_through'] == true) return null;
final list = (parsed['bindings'] as List? ?? []).cast<Map<String, dynamic>>();
final found = list.firstWhere(
(b) => b['action'] == actionId,
orElse: () => {},
);
if (found.isEmpty) return null;
// Guard against a hand-edited / corrupt config where `key` is missing or
// not a string — silently treat the binding as unbound rather than
// crashing the toolbar render.
final keyValue = found['key'];
if (keyValue is! String) return null;
final isMac = defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.iOS;
// `mods` similarly may be malformed; treat a non-list as no modifiers.
final modsRaw = found['mods'];
final mods = modsRaw is List
? modsRaw.whereType<String>().toList()
: const <String>[];
// Plain-text labels (Cmd / Ctrl / Alt / Shift) instead of Unicode glyphs
// (⌘ ⌃ ⌥ ⇧). Flutter Web's CanvasKit bundled fonts don't always carry the
// macOS modifier symbols, which renders as garbled boxes on Mac browsers;
// text is portable and readable on every platform.
//
// Order matches the canonical macOS order (Cmd, Control, Option, Shift)
// so the rendered hint reads naturally. `ctrl` only ever appears in
// saved bindings on macOS — Win/Linux collapses Ctrl into `primary`.
final parts = <String>[];
for (final m in ['primary', 'ctrl', 'alt', 'shift']) {
if (!mods.contains(m)) continue;
switch (m) {
case 'primary': parts.add(isMac ? 'Cmd' : 'Ctrl'); break;
case 'ctrl': parts.add(isMac ? 'Control' : 'Ctrl'); break;
case 'alt': parts.add(isMac ? 'Option' : 'Alt'); break;
case 'shift': parts.add('Shift'); break;
}
}
parts.add(_keyDisplay(keyValue));
return parts.join('+');
}
static String _keyDisplay(String key) {
switch (key) {
case 'delete': return 'Del';
case 'backspace': return 'Backspace';
case 'enter': return 'Enter';
case 'tab': return 'Tab';
case 'space': return 'Space';
case 'arrow_left': return 'Left';
case 'arrow_right':return 'Right';
case 'arrow_up': return 'Up';
case 'arrow_down': return 'Down';
case 'home': return 'Home';
case 'end': return 'End';
case 'page_up': return 'PgUp';
case 'page_down': return 'PgDn';
case 'insert': return 'Ins';
}
if (key.startsWith('digit')) return key.substring(5);
// F-keys ("f1".."f12") and single letters fall through to uppercase.
return key.toUpperCase();
}
}

View File

@@ -0,0 +1,484 @@
// flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart
//
// Shared body widget for the Keyboard Shortcuts configuration page. Both the
// desktop (`desktop/pages/desktop_keyboard_shortcuts_page.dart`) and mobile
// (`mobile/pages/mobile_keyboard_shortcuts_page.dart`) pages render this
// widget inside their own platform-styled Scaffold + AppBar shell.
//
// The body owns:
// * the top-level enable/disable toggle (mirrors the General-tab toggle —
// same JSON key, same semantics);
// * a grouped list of actions, each with its current binding plus
// edit / clear icons;
// * the JSON read/write helpers under [kShortcutLocalConfigKey] in the
// canonical {enabled, bindings:[{action,mods,key}]} shape;
// * the recording-dialog round-trip and conflict-replace bookkeeping;
// * "Reset to defaults" (called from the platform AppBar).
//
// Platform shells supply only:
// * the AppBar (with a "Reset to defaults" action that calls
// [KeyboardShortcutsPageBodyState.resetToDefaultsWithConfirm]);
// * surrounding padding / list-tile vs. dense-row visuals via the
// [compact] flag.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../common.dart';
import '../../../consts.dart';
import '../../../models/platform_model.dart';
import '../../../models/shortcut_model.dart';
import 'display.dart';
import 'recording_dialog.dart';
import 'shortcut_actions.dart';
import 'shortcut_utils.dart';
/// The shared body widget. Render this inside a platform-styled Scaffold.
///
/// [compact] toggles the desktop dense-row layout (`true`) versus the mobile
/// touch-friendly ListTile layout (`false`).
///
/// [editButtonHint] is shown as the tooltip on the Edit icon. Mobile shells
/// use this to clarify that recording requires a physical keyboard.
///
/// [headerBanner] is an optional widget rendered above the toggle. Mobile
/// uses this to show the "Recording requires a physical keyboard" hint.
class KeyboardShortcutsPageBody extends StatefulWidget {
final bool compact;
final String? editButtonHint;
final Widget? headerBanner;
/// Whether to render the master Enable + Pass-through toggles inside the
/// body. Desktop shells set this to false because the General settings tab
/// already exposes both checkboxes (and is the only entry point to this
/// page on desktop). Mobile defaults to true: its entry point is a plain
/// nav tile in Settings, so this page is the only place the user can
/// flip the master switches.
final bool showMasterToggles;
const KeyboardShortcutsPageBody({
Key? key,
this.compact = true,
this.editButtonHint,
this.headerBanner,
this.showMasterToggles = true,
}) : super(key: key);
@override
State<KeyboardShortcutsPageBody> createState() =>
KeyboardShortcutsPageBodyState();
}
/// Public state so platform shells can call [resetToDefaultsWithConfirm] from
/// their AppBar action.
class KeyboardShortcutsPageBodyState extends State<KeyboardShortcutsPageBody> {
// ----- Persistence helpers -----
Map<String, dynamic> _readJson() {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return {'enabled': false, 'bindings': <dynamic>[]};
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
parsed['bindings'] ??= <dynamic>[];
parsed['enabled'] ??= false;
return parsed;
} catch (_) {
return {'enabled': false, 'bindings': <dynamic>[]};
}
}
Future<void> _writeJson(Map<String, dynamic> json) async {
await bind.mainSetLocalOption(
key: kShortcutLocalConfigKey, value: jsonEncode(json));
// Refresh the matcher cache so writes take effect immediately. On native
// this hits the Rust matcher; on Web the bridge forwards to the JS-side
// matcher in flutter/web/js/.
bind.mainReloadKeyboardShortcuts();
if (mounted) setState(() {});
}
/// Replace the bindings entry for [actionId] with [binding]. If [binding]
/// is null, removes the existing entry. If the user is replacing a
/// conflicting binding, [clearActionId] points at the action whose
/// (now-stale) binding should be removed in the same write.
Future<void> _setBinding(
String actionId, {
Map<String, dynamic>? binding,
String? clearActionId,
}) async {
final json = _readJson();
final list = ((json['bindings'] as List?) ?? <dynamic>[])
.cast<Map<String, dynamic>>()
.toList();
list.removeWhere((b) {
final a = b['action'];
return a == actionId || (clearActionId != null && a == clearActionId);
});
if (binding != null) {
list.add(binding);
}
json['bindings'] = list;
await _writeJson(json);
}
Future<void> _setEnabled(bool v) async {
await ShortcutModel.setEnabled(v);
if (mounted) setState(() {});
}
Future<void> _setPassThrough(bool v) async {
await ShortcutModel.setPassThrough(v);
if (mounted) setState(() {});
}
Future<void> _resetToDefaults() async {
final json = _readJson();
// Single source of truth lives in `ShortcutModel.currentPlatformCapabilities`
// — the same helper feeds the first-enable seed pass, this Reset action,
// and the action-list filter below, so the three can never disagree on
// which actions belong on this platform.
json['bindings'] = filterDefaultBindingsForPlatform(
jsonDecode(bind.mainGetDefaultKeyboardShortcuts()) as List,
ShortcutModel.currentPlatformCapabilities(),
);
await _writeJson(json);
}
String _labelFor(String actionId) {
// Intentionally walks the unfiltered list (via the recursive helper, so
// both direct entries and subgroup entries are covered) — a stale
// cross-platform binding (e.g. Toggle Toolbar carried over from
// desktop) should still resolve to its human-readable label in conflict
// warnings.
for (final entry in allActionEntries(kKeyboardShortcutActionGroups)) {
if (entry.id == actionId) return translate(entry.labelKey);
}
return actionId;
}
/// Action groups visible on the current platform. Reads the same
/// capability set as the seed-defaults / reset-to-defaults paths from
/// `ShortcutModel.currentPlatformCapabilities`, so the UI lists exactly
/// the actions whose handlers the matcher can dispatch here.
List<KeyboardShortcutActionGroup> _groupsForCurrentPlatform() {
return filterKeyboardShortcutActionGroupsForPlatform(
ShortcutModel.currentPlatformCapabilities(),
);
}
// ----- UI handlers -----
Future<void> _onEdit(KeyboardShortcutActionEntry entry) async {
final json = _readJson();
final bindings = ((json['bindings'] as List?) ?? <dynamic>[])
.cast<Map<String, dynamic>>();
final result = await showRecordingDialog(
context: context,
actionId: entry.id,
actionLabel: translate(entry.labelKey),
existingBindings: bindings,
actionLabelLookup: _labelFor,
);
if (result == null) return;
await _setBinding(
entry.id,
binding: result.binding,
clearActionId: result.clearActionId,
);
}
Future<void> _onClear(KeyboardShortcutActionEntry entry) async {
await _setBinding(entry.id, binding: null);
}
/// Public — invoked from the platform AppBar action.
Future<void> resetToDefaultsWithConfirm() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(translate('Reset to defaults')),
content: Text(translate('shortcut-reset-confirm-tip')),
actions: [
dialogButton('Cancel',
onPressed: () => Navigator.of(ctx).pop(false), isOutline: true),
dialogButton('OK', onPressed: () => Navigator.of(ctx).pop(true)),
],
),
);
if (confirmed == true) {
await _resetToDefaults();
}
}
// ----- Build -----
@override
Widget build(BuildContext context) {
final enabled = ShortcutModel.isEnabled();
final theme = Theme.of(context);
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (widget.headerBanner != null) ...[
widget.headerBanner!,
const SizedBox(height: 12),
],
if (widget.showMasterToggles) ...[
_toggleRow(
enabled,
'Enable keyboard shortcuts in remote session',
(v) => _setEnabled(v),
),
if (enabled)
_toggleRow(
ShortcutModel.isPassThrough(),
'Pass-through to remote',
(v) => _setPassThrough(v),
),
],
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
translate('shortcut-page-description'),
style: TextStyle(color: theme.hintColor),
),
),
const SizedBox(height: 16),
// Bindings list and configuration entry only show when shortcuts are
// enabled — there is nothing to configure while the matcher is off.
if (enabled)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final group in _groupsForCurrentPlatform())
_buildGroup(context, group),
],
),
],
);
}
Widget _toggleRow(
bool value, String labelKey, Future<void> Function(bool) onChanged,
{String? tooltipKey}) {
return Row(
children: [
Checkbox(
value: value,
onChanged: (v) async {
if (v == null) return;
await onChanged(v);
},
),
const SizedBox(width: 4),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(!value),
child: Text(translate(labelKey)),
),
),
if (tooltipKey != null) InfoTooltipIcon(tipKey: tooltipKey),
],
);
}
// One indent unit per nesting level. Both "top item under top heading"
// and "subgroup heading under top group" are *one* level deeper than the
// top heading, so they share this indent — meaning a top-level direct
// item and a sibling subgroup heading line up at exactly the same x.
// Subgroup items are *two* levels deeper.
static const double _kIndentStep = 16.0;
/// Top-level group: heading at zero indent, then walk `children` in
/// declaration order. Direct entries get [_kIndentStep] of indent so
/// they read as "items under this heading"; subgroup headings sit at
/// the same indent (a subgroup is a sibling of the direct items, just
/// with its own nested entries below).
Widget _buildGroup(BuildContext context, KeyboardShortcutActionGroup group) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
_buildHeading(context, group.titleKey, isSub: false),
const SizedBox(height: 4),
for (final child in group.children)
switch (child) {
KeyboardShortcutActionEntry() => Padding(
padding: const EdgeInsets.only(left: _kIndentStep),
child: _buildEntryRow(context, child),
),
KeyboardShortcutActionSubgroup() =>
_buildSubgroup(context, child),
},
],
);
}
Widget _buildSubgroup(
BuildContext context, KeyboardShortcutActionSubgroup subgroup) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
_buildHeading(context, subgroup.titleKey, isSub: true),
const SizedBox(height: 4),
for (final entry in subgroup.entries)
Padding(
// Two indent steps: one for "subgroup heading is nested under
// top heading" (matches the heading's own indent) and one for
// "this entry is under the subgroup heading".
padding: const EdgeInsets.only(left: _kIndentStep * 2),
child: _buildEntryRow(context, entry),
),
],
);
}
Widget _buildHeading(BuildContext context, String titleKey,
{required bool isSub}) {
// Subgroup heading nests one step under the top heading — same indent
// as a top-level direct item, so the two line up at the same x.
final indent = isSub ? _kIndentStep : 0.0;
return Padding(
padding: EdgeInsets.only(left: 8 + indent, right: 8),
child: Row(
children: [
Text(
translate(titleKey),
style: TextStyle(
fontWeight: isSub ? FontWeight.w500 : FontWeight.w600,
color: isSub
? Theme.of(context).hintColor
: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 8),
Expanded(child: Divider(thickness: isSub ? 0.5 : 1)),
],
),
);
}
Widget _buildEntryRow(
BuildContext context, KeyboardShortcutActionEntry entry) {
return widget.compact
? _buildCompactRow(context, entry)
: _buildTouchRow(context, entry);
}
/// Desktop dense row: label | shortcut | edit | clear, all in one Row.
Widget _buildCompactRow(
BuildContext context, KeyboardShortcutActionEntry entry) {
final shortcut = ShortcutDisplay.formatFor(entry.id, requireEnabled: false);
final hasBinding = shortcut != null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Expanded(
flex: 5,
child: Text(translate(entry.labelKey)),
),
Expanded(
flex: 4,
child: Text(
shortcut ?? '',
style: TextStyle(
fontFamily: defaultTargetPlatform == TargetPlatform.windows
? 'Consolas'
: 'monospace',
color: hasBinding ? null : Theme.of(context).hintColor,
),
),
),
IconButton(
tooltip: widget.editButtonHint ?? translate('Edit'),
onPressed: () => _onEdit(entry),
icon: const Icon(Icons.edit_outlined, size: 18),
),
SizedBox(
width: 40,
child: hasBinding
? IconButton(
tooltip: translate('Clear'),
onPressed: () => _onClear(entry),
icon: const Icon(Icons.close, size: 18),
)
: const SizedBox.shrink(),
),
],
),
);
}
/// Mobile touch row: ListTile with title + subtitle + trailing icons.
Widget _buildTouchRow(
BuildContext context, KeyboardShortcutActionEntry entry) {
final shortcut = ShortcutDisplay.formatFor(entry.id, requireEnabled: false);
final hasBinding = shortcut != null;
return ListTile(
dense: false,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(translate(entry.labelKey)),
subtitle: Text(
shortcut ?? '',
style: TextStyle(
fontFamily: defaultTargetPlatform == TargetPlatform.windows
? 'Consolas'
: 'monospace',
color: hasBinding ? null : Theme.of(context).hintColor,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: widget.editButtonHint ?? translate('Edit'),
onPressed: () => _onEdit(entry),
icon: const Icon(Icons.edit_outlined),
),
if (hasBinding)
IconButton(
tooltip: translate('Clear'),
onPressed: () => _onClear(entry),
icon: const Icon(Icons.close),
)
else
const SizedBox(width: 48),
],
),
);
}
}
/// Small help-icon tooltip used for inline explanations next to a checkbox /
/// row. Triggers on hover (desktop) and tap (mobile). Public so the desktop
/// General settings tab can reuse it.
class InfoTooltipIcon extends StatelessWidget {
final String tipKey;
const InfoTooltipIcon({Key? key, required this.tipKey}) : super(key: key);
@override
Widget build(BuildContext context) {
return Tooltip(
message: translate(tipKey),
triggerMode: TooltipTriggerMode.tap,
preferBelow: false,
waitDuration: const Duration(milliseconds: 250),
showDuration: const Duration(seconds: 6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Icon(
Icons.help_outline,
size: 16,
color: Theme.of(context).hintColor,
),
),
);
}
}

View File

@@ -0,0 +1,400 @@
// flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart
//
// Modal dialog used by the Keyboard Shortcuts settings page to capture a new
// key combination for a given action. The dialog listens for KeyDown events,
// extracts the modifier set + non-modifier key, validates against the
// "must include Ctrl+Alt+Shift (Cmd+Option+Shift on macOS)" rule, and reports
// any conflict with another already-bound action.
//
// On Save, returns the new binding map ({action, mods, key}) plus the
// optional id of the action whose binding should be cleared (the conflict
// "Replace" path). On Cancel, returns null.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../common.dart';
import 'shortcut_utils.dart';
/// Result of the recording dialog.
class RecordingResult {
/// The new binding map to write: {action, mods, key}.
final Map<String, dynamic> binding;
/// If the chosen combo conflicted with another action, the user chose
/// "Replace" — the caller must clear this action's binding before writing
/// the new one.
final String? clearActionId;
RecordingResult(this.binding, this.clearActionId);
}
/// Show the recording dialog.
///
/// [actionId] is the action being edited (used for the title and to detect
/// "binding to itself" — that's not a conflict).
/// [actionLabel] is the translated, user-facing action name.
/// [existingBindings] is the current bindings list (used for conflict detection).
/// [actionLabelLookup] resolves an actionId to its translated label, used in
/// the conflict warning.
Future<RecordingResult?> showRecordingDialog({
required BuildContext context,
required String actionId,
required String actionLabel,
required List<Map<String, dynamic>> existingBindings,
required String Function(String) actionLabelLookup,
}) {
return showDialog<RecordingResult>(
context: context,
barrierDismissible: false,
builder: (ctx) => _RecordingDialog(
actionId: actionId,
actionLabel: actionLabel,
existingBindings: existingBindings,
actionLabelLookup: actionLabelLookup,
),
);
}
class _RecordingDialog extends StatefulWidget {
final String actionId;
final String actionLabel;
final List<Map<String, dynamic>> existingBindings;
final String Function(String) actionLabelLookup;
const _RecordingDialog({
required this.actionId,
required this.actionLabel,
required this.existingBindings,
required this.actionLabelLookup,
});
@override
State<_RecordingDialog> createState() => _RecordingDialogState();
}
class _RecordingDialogState extends State<_RecordingDialog> {
final FocusNode _focusNode = FocusNode();
// Captured combo. null until the user presses something with a non-modifier.
Set<String> _mods = {};
String? _key;
// Human-readable label for the most recent press that we couldn't bind to
// (e.g. F13, media keys). null when the last press was either supported or
// a modifier-only press. Cleared whenever a supported key arrives, so a
// user who hits an unsupported key after a valid capture sees the warning
// until they press something else. Distinct from `_key == null` so the
// status line can tell the user *why* their press was ignored instead of
// silently doing nothing.
String? _unsupportedKey;
// Modifier LogicalKeyboardKeys we should *not* treat as "unsupported" when
// they fail to map to a key name. A modifier-only press is normal during
// combo capture (the user is building up their combo) — only non-modifier
// unmapped keys deserve the warning.
static final _modifierKeys = <LogicalKeyboardKey>{
LogicalKeyboardKey.shift,
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.shiftRight,
LogicalKeyboardKey.control,
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.controlRight,
LogicalKeyboardKey.alt,
LogicalKeyboardKey.altLeft,
LogicalKeyboardKey.altRight,
LogicalKeyboardKey.meta,
LogicalKeyboardKey.metaLeft,
LogicalKeyboardKey.metaRight,
LogicalKeyboardKey.capsLock,
LogicalKeyboardKey.numLock,
LogicalKeyboardKey.scrollLock,
LogicalKeyboardKey.fn,
LogicalKeyboardKey.fnLock,
};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
bool get _isMac =>
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.iOS;
/// True when the captured combo includes at least one modifier. Lower bound
/// for any sensible binding — pure single-key bindings would swallow normal
/// typing the moment shortcuts are enabled. Beyond one mod the user is on
/// their own; the in-session pass-through toggle is the escape hatch when
/// a chosen combo collides with something needed on the remote.
bool get _hasRequiredPrefix => _mods.isNotEmpty;
/// Return the actionId that this combo currently conflicts with, or null.
/// The action being edited is not a conflict with itself.
String? get _conflictActionId {
if (_key == null || !_hasRequiredPrefix) return null;
for (final b in widget.existingBindings) {
final otherAction = b['action'] as String?;
if (otherAction == null || otherAction == widget.actionId) continue;
final otherKey = b['key'] as String?;
final otherMods =
((b['mods'] as List?) ?? const []).cast<String>().toSet();
if (otherKey == _key &&
otherMods.length == _mods.length &&
otherMods.containsAll(_mods)) {
return otherAction;
}
}
return null;
}
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
return KeyEventResult.handled;
}
if (event is! KeyDownEvent) return KeyEventResult.handled;
// Ignore modifier-only KeyDowns: don't lock in a partial combo.
final logical = event.logicalKey;
final keyName = logicalKeyName(logical);
// Mirror of `normalize_modifiers` in src/keyboard/shortcuts.rs:
// * macOS: Cmd → primary, Ctrl → ctrl (distinct).
// * Win/Linux: Ctrl → primary, no separate Ctrl modifier.
// The two halves must agree on labels, otherwise saved bindings will not
// match the events the matcher sees at runtime.
final mods = <String>{};
if (HardwareKeyboard.instance.isAltPressed) mods.add('alt');
if (HardwareKeyboard.instance.isShiftPressed) mods.add('shift');
if (_isMac) {
if (HardwareKeyboard.instance.isMetaPressed) mods.add('primary');
if (HardwareKeyboard.instance.isControlPressed) mods.add('ctrl');
} else {
if (HardwareKeyboard.instance.isControlPressed) mods.add('primary');
}
setState(() {
_mods = mods;
// Only lock in the key when it's a non-modifier we recognize.
// Modifier-only KeyDowns (Shift, Ctrl, etc.) leave the captured key
// untouched, so the user can adjust modifiers after the fact.
if (keyName != null) {
_key = keyName;
_unsupportedKey = null;
} else if (!_modifierKeys.contains(logical)) {
// Non-modifier key we don't recognize (e.g. F13, media keys, IME
// compose keys). Surface a warning instead of silently dropping the
// press — the dialog otherwise looks unresponsive.
final label = logical.keyLabel.isNotEmpty
? logical.keyLabel
: (logical.debugName ?? 'this key');
_unsupportedKey = label;
}
});
return KeyEventResult.handled;
}
void _onSave() {
if (_key == null || !_hasRequiredPrefix) return;
final ordered = canonicalShortcutModsForSave(_mods);
final binding = <String, dynamic>{
'action': widget.actionId,
'mods': ordered,
'key': _key!,
};
Navigator.of(context).pop(RecordingResult(binding, _conflictActionId));
}
String _formatPrefix() {
// Used in the "must include..." validation row; lists the modifier set
// a binding can pick from. Localised modifier glyphs aren't used here so
// the names stay greppable for users searching for "Option" / "Cmd".
if (_isMac) return 'Cmd / Control / Option / Shift';
return 'Ctrl / Alt / Shift';
}
String _formatCombo() {
// Plain-text labels (see same rationale in display.dart::_keyDisplay).
final parts = <String>[];
for (final m in ['primary', 'ctrl', 'alt', 'shift']) {
if (!_mods.contains(m)) continue;
switch (m) {
case 'primary':
parts.add(_isMac ? 'Cmd' : 'Ctrl');
break;
case 'ctrl':
parts.add(_isMac ? 'Control' : 'Ctrl');
break;
case 'alt':
parts.add(_isMac ? 'Option' : 'Alt');
break;
case 'shift':
parts.add('Shift');
break;
}
}
if (_key != null) {
parts.add(_keyDisplay(_key!));
}
if (parts.isEmpty) return translate('shortcut-recording-press-keys-tip');
return parts.join('+');
}
String _keyDisplay(String key) {
switch (key) {
case 'delete': return 'Del';
case 'backspace': return 'Backspace';
case 'enter': return 'Enter';
case 'tab': return 'Tab';
case 'space': return 'Space';
case 'arrow_left': return 'Left';
case 'arrow_right':return 'Right';
case 'arrow_up': return 'Up';
case 'arrow_down': return 'Down';
case 'home': return 'Home';
case 'end': return 'End';
case 'page_up': return 'PgUp';
case 'page_down': return 'PgDn';
case 'insert': return 'Ins';
}
if (key.startsWith('digit')) return key.substring(5);
return key.toUpperCase();
}
@override
Widget build(BuildContext context) {
final hasKey = _key != null;
final conflictId = _conflictActionId;
final hasConflict = conflictId != null;
// The Save button still fires for the previously-captured combo even if
// the user just hit an unsupported key — the captured state is what gets
// saved, the warning is just feedback that the latest press was rejected.
final canSave = hasKey && _hasRequiredPrefix;
Widget statusLine;
if (_unsupportedKey != null) {
// Most recent press was unsupported. Take precedence over the
// captured-combo states so the user gets explicit feedback that their
// last keystroke was ignored, regardless of whether a previous combo
// is still captured.
statusLine = Row(
children: [
const Icon(Icons.close, size: 16, color: Colors.red),
const SizedBox(width: 6),
Flexible(
child: Text(
translate('shortcut-key-not-supported')
.replaceAll('{}', _unsupportedKey!),
style: const TextStyle(color: Colors.red),
),
),
],
);
} else if (!hasKey) {
statusLine = Text(
translate('shortcut-recording-press-keys-tip'),
style: TextStyle(color: Theme.of(context).hintColor),
);
} else if (!_hasRequiredPrefix) {
statusLine = Row(
children: [
Icon(Icons.close, size: 16, color: Colors.red),
const SizedBox(width: 6),
Flexible(
child: Text(
translate('shortcut-must-include-modifiers')
.replaceAll('{}', _formatPrefix()),
style: const TextStyle(color: Colors.red),
),
),
],
);
} else if (hasConflict) {
final otherLabel = widget.actionLabelLookup(conflictId);
statusLine = Row(
children: [
Icon(Icons.warning_amber_outlined,
size: 16, color: Colors.orange.shade700),
const SizedBox(width: 6),
Flexible(
child: Text(
'${translate('shortcut-already-bound-to')} "$otherLabel"',
style: TextStyle(color: Colors.orange.shade700),
),
),
],
);
} else {
statusLine = Row(
children: [
const Icon(Icons.check, size: 16, color: Colors.green),
const SizedBox(width: 6),
Text(translate('Valid'), style: const TextStyle(color: Colors.green)),
],
);
}
final saveLabel = hasConflict ? 'Replace' : 'Save';
return AlertDialog(
title: Text(
'${translate('Set Shortcut')}: ${widget.actionLabel}',
),
content: Focus(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: _onKeyEvent,
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 380),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(translate('shortcut-recording-instruction')),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(vertical: 18, horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_formatCombo(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: hasKey
? Theme.of(context).textTheme.titleLarge?.color
: Theme.of(context).hintColor,
),
),
),
const SizedBox(height: 12),
statusLine,
],
),
),
),
actions: [
dialogButton('Cancel',
onPressed: () => Navigator.of(context).pop(), isOutline: true),
dialogButton(saveLabel, onPressed: canSave ? _onSave : null),
],
);
}
}

View File

@@ -0,0 +1,288 @@
import 'shortcut_constants.dart';
import 'shortcut_utils.dart';
/// Marker for the union of [KeyboardShortcutActionEntry] /
/// [KeyboardShortcutActionSubgroup] — anything a top-level
/// [KeyboardShortcutActionGroup] can directly contain. Sealed so renderers
/// and filters can `switch` on it without a default branch.
sealed class KeyboardShortcutActionGroupChild {
const KeyboardShortcutActionGroupChild();
}
/// One configurable action — id + i18n key for its label.
class KeyboardShortcutActionEntry extends KeyboardShortcutActionGroupChild {
final String id;
final String labelKey;
const KeyboardShortcutActionEntry(this.id, this.labelKey);
}
/// A nested subgroup (e.g. "View Mode" under "Display"). Renders with extra
/// indent so its items are visually distinguished from the parent group's
/// direct items.
class KeyboardShortcutActionSubgroup extends KeyboardShortcutActionGroupChild {
final String titleKey;
final List<KeyboardShortcutActionEntry> entries;
const KeyboardShortcutActionSubgroup(this.titleKey, this.entries);
}
/// A top-level group ("Display", "Keyboard", "Chat", …). `children` is an
/// *ordered* mix of direct entries and subgroups, so layouts like
/// "subgroups first → direct items → trailing subgroup" — exactly the
/// shape `_DisplayMenu` uses (Privacy mode lives after the cursor / display
/// toggles direct items) — are first-class instead of needing a wrapper
/// "Display Settings" subgroup just to insert the items.
class KeyboardShortcutActionGroup {
final String titleKey;
final List<KeyboardShortcutActionGroupChild> children;
const KeyboardShortcutActionGroup(this.titleKey, this.children);
}
/// Canonical action group definitions used by both the desktop and mobile
/// configuration pages. The order of groups, subgroups, and entries here
/// is the order the user sees in the UI, and mirrors the corresponding
/// toolbar submenu (`_DisplayMenu` / `_KeyboardMenu` in
/// `desktop/widgets/remote_toolbar.dart`) child order — modulo entries
/// without shortcut counterparts (e.g. `_screenAdjustor.adjustWindow`,
/// `scrollStyle`, `_ResolutionsMenu`, `localKeyboardType`).
final List<KeyboardShortcutActionGroup> kKeyboardShortcutActionGroups = [
KeyboardShortcutActionGroup('Monitor', [
KeyboardShortcutActionEntry(
kShortcutActionSwitchDisplayNext, 'Switch to next display'),
KeyboardShortcutActionEntry(
kShortcutActionSwitchDisplayPrev, 'Switch to previous display'),
KeyboardShortcutActionEntry(
kShortcutActionSwitchDisplayAll, 'All monitors'),
]),
KeyboardShortcutActionGroup('Control Actions', [
KeyboardShortcutActionEntry(
kShortcutActionSendClipboardKeystrokes, 'Send clipboard keystrokes'),
KeyboardShortcutActionEntry(kShortcutActionResetCanvas, 'Reset canvas'),
KeyboardShortcutActionEntry(
kShortcutActionSendCtrlAltDel, 'Insert Ctrl + Alt + Del'),
KeyboardShortcutActionEntry(
kShortcutActionRestartRemote, 'Restart remote device'),
KeyboardShortcutActionEntry(kShortcutActionInsertLock, 'Insert Lock'),
KeyboardShortcutActionEntry(
kShortcutActionToggleBlockInput, 'Block user input'),
KeyboardShortcutActionEntry(kShortcutActionSwitchSides, 'Switch Sides'),
KeyboardShortcutActionEntry(kShortcutActionRefresh, 'Refresh'),
KeyboardShortcutActionEntry(
kShortcutActionToggleRecording, 'Toggle session recording'),
KeyboardShortcutActionEntry(kShortcutActionScreenshot, 'Take screenshot'),
]),
// Display: subgroups (View Mode → Image Quality → Codec → Virtual display)
// first, then the direct items (cursor toggles + display toggles), then
// Privacy mode subgroup last — matching `_DisplayMenu.menuChildrenGetter`
// exactly. Rebalancing this order should also rebalance the toolbar.
KeyboardShortcutActionGroup('Display', [
KeyboardShortcutActionSubgroup('View Mode', [
KeyboardShortcutActionEntry(
kShortcutActionViewModeOriginal, 'Scale original'),
KeyboardShortcutActionEntry(
kShortcutActionViewModeAdaptive, 'Scale adaptive'),
KeyboardShortcutActionEntry(
kShortcutActionViewModeCustom, 'Scale custom'),
]),
KeyboardShortcutActionSubgroup('Image Quality', [
KeyboardShortcutActionEntry(
kShortcutActionImageQualityBest, 'Good image quality'),
KeyboardShortcutActionEntry(
kShortcutActionImageQualityBalanced, 'Balanced'),
KeyboardShortcutActionEntry(
kShortcutActionImageQualityLow, 'Optimize reaction time'),
]),
KeyboardShortcutActionSubgroup('Codec', [
KeyboardShortcutActionEntry(kShortcutActionCodecAuto, 'Auto'),
KeyboardShortcutActionEntry(kShortcutActionCodecVp8, 'VP8'),
KeyboardShortcutActionEntry(kShortcutActionCodecVp9, 'VP9'),
KeyboardShortcutActionEntry(kShortcutActionCodecAv1, 'AV1'),
KeyboardShortcutActionEntry(kShortcutActionCodecH264, 'H264'),
KeyboardShortcutActionEntry(kShortcutActionCodecH265, 'H265'),
]),
KeyboardShortcutActionSubgroup('Virtual display', [
KeyboardShortcutActionEntry(
kShortcutActionPlugOutAllVirtualDisplays, 'Plug out all'),
]),
// Direct items: cursorToggles + display toggles, in toolbar order.
KeyboardShortcutActionEntry(
kShortcutActionToggleShowRemoteCursor, 'Show remote cursor'),
KeyboardShortcutActionEntry(
kShortcutActionToggleFollowRemoteCursor, 'Follow remote cursor'),
KeyboardShortcutActionEntry(
kShortcutActionToggleFollowRemoteWindow, 'Follow remote window focus'),
KeyboardShortcutActionEntry(
kShortcutActionToggleZoomCursor, 'Zoom cursor'),
KeyboardShortcutActionEntry(
kShortcutActionToggleQualityMonitor, 'Show quality monitor'),
KeyboardShortcutActionEntry(kShortcutActionToggleMute, 'Mute'),
KeyboardShortcutActionEntry(
kShortcutActionToggleEnableFileCopyPaste, 'Enable file copy and paste'),
KeyboardShortcutActionEntry(
kShortcutActionToggleDisableClipboard, 'Disable clipboard'),
KeyboardShortcutActionEntry(
kShortcutActionToggleLockAfterSessionEnd, 'Lock after session end'),
KeyboardShortcutActionEntry(
kShortcutActionToggleTrueColor, 'True color (4:4:4)'),
// Privacy mode at the bottom — mirrors `_DisplayMenu` where it's the
// last submenu added (line ~1023 of remote_toolbar.dart, after toggles).
KeyboardShortcutActionSubgroup('Privacy mode', [
// Reuse toolbar's existing impl-name i18n keys. The handler at
// runtime matches `privacy_mode_impl_mag_tip` /
// `privacy_mode_impl_virtual_display_tip` against the peer's
// advertised impls — same logic the toolbar's `toolbarPrivacyMode`
// submenu uses.
KeyboardShortcutActionEntry(
kShortcutActionPrivacyMode1, 'privacy_mode_impl_mag_tip'),
KeyboardShortcutActionEntry(
kShortcutActionPrivacyMode2, 'privacy_mode_impl_virtual_display_tip'),
]),
]),
// Keyboard: Keyboard mode subgroup first, then direct items
// (inputSource → viewMode → showMyCursor → toolbarKeyboardToggles),
// matching `_KeyboardMenu.menuChildrenGetter`.
KeyboardShortcutActionGroup('Keyboard', [
KeyboardShortcutActionSubgroup('Keyboard mode', [
KeyboardShortcutActionEntry(
kShortcutActionKeyboardModeLegacy, 'Legacy mode'),
KeyboardShortcutActionEntry(kShortcutActionKeyboardModeMap, 'Map mode'),
KeyboardShortcutActionEntry(
kShortcutActionKeyboardModeTranslate, 'Translate mode'),
]),
KeyboardShortcutActionEntry(
kShortcutActionToggleInputSource, 'Toggle input source'),
KeyboardShortcutActionEntry(kShortcutActionToggleViewOnly, 'View Mode'),
KeyboardShortcutActionEntry(
kShortcutActionToggleShowMyCursor, 'Show my cursor'),
KeyboardShortcutActionEntry(
kShortcutActionToggleSwapCtrlCmd, 'Swap control-command key'),
KeyboardShortcutActionEntry(
kShortcutActionToggleRelativeMouseMode, 'Relative mouse mode'),
KeyboardShortcutActionEntry(
kShortcutActionToggleReverseMouseWheel, 'Reverse mouse wheel'),
KeyboardShortcutActionEntry(
kShortcutActionToggleSwapLeftRightMouse, 'swap-left-right-mouse'),
]),
KeyboardShortcutActionGroup('Chat', [
KeyboardShortcutActionEntry(kShortcutActionToggleChat, 'Text chat'),
KeyboardShortcutActionEntry(kShortcutActionToggleVoiceCall, 'Voice call'),
]),
// "Other" collects single-icon toolbar buttons that have no dropdown
// (Pin, Close), plus actions with no toolbar entry at all (Fullscreen —
// driven by callback, not menu; Toggle Toolbar / tab navigation — tab
// right-click menu, not toolbar). Combined into one group rather than
// several 1-item groups for cleaner visual hierarchy.
KeyboardShortcutActionGroup('Other', [
KeyboardShortcutActionEntry(kShortcutActionPinToolbar, 'Pin Toolbar'),
KeyboardShortcutActionEntry(
kShortcutActionToggleFullscreen, 'Toggle fullscreen'),
KeyboardShortcutActionEntry(kShortcutActionToggleToolbar, 'Toggle toolbar'),
KeyboardShortcutActionEntry(kShortcutActionCloseTab, 'Close tab'),
KeyboardShortcutActionEntry(
kShortcutActionSwitchTabNext, 'Switch to next tab'),
KeyboardShortcutActionEntry(
kShortcutActionSwitchTabPrev, 'Switch to previous tab'),
]),
];
/// Walk the (filtered or unfiltered) group tree and yield every
/// [KeyboardShortcutActionEntry], regardless of whether it sits as a direct
/// child of a top-level group or inside a subgroup. Useful for label
/// lookups, ghost-action tests, and any consumer that just wants the flat
/// list of action ids.
Iterable<KeyboardShortcutActionEntry> allActionEntries(
Iterable<KeyboardShortcutActionGroup> groups,
) sync* {
for (final group in groups) {
for (final child in group.children) {
switch (child) {
case KeyboardShortcutActionEntry():
yield child;
case KeyboardShortcutActionSubgroup():
yield* child.entries;
}
}
}
}
/// Return [kKeyboardShortcutActionGroups] with actions that aren't supported
/// on the current platform stripped out. Subgroups whose every entry was
/// filtered are dropped; top-level groups whose every child (direct entry
/// or subgroup) was dropped are themselves dropped.
///
/// Mirrors the capability flags used by [filterDefaultBindingsForPlatform]
/// so the configuration UI shows only what the matcher can actually
/// dispatch on this platform.
///
/// Note: callers should still walk the unfiltered
/// [kKeyboardShortcutActionGroups] for label lookups (e.g. conflict
/// warnings about a stale cross-platform binding), so an action bound on
/// desktop and carried over to mobile still has a human-readable name in
/// dialogs.
List<KeyboardShortcutActionGroup> filterKeyboardShortcutActionGroupsForPlatform(
ShortcutPlatformCapabilities cap,
) {
bool allowed(String id) {
if (!cap.includeFullscreenShortcut &&
id == kShortcutActionToggleFullscreen) {
return false;
}
if (!cap.includeScreenshotShortcut && id == kShortcutActionScreenshot) {
return false;
}
if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(id)) return false;
if (!cap.includeToolbarShortcut && id == kShortcutActionToggleToolbar) {
return false;
}
if (!cap.includeCloseTabShortcut && id == kShortcutActionCloseTab) {
return false;
}
if (!cap.includeSwitchSidesShortcut && id == kShortcutActionSwitchSides) {
return false;
}
if (!cap.includeRecordingShortcut && id == kShortcutActionToggleRecording) {
return false;
}
if (!cap.includeResetCanvasShortcut && id == kShortcutActionResetCanvas) {
return false;
}
if (!cap.includePinToolbarShortcut && id == kShortcutActionPinToolbar) {
return false;
}
if (!cap.includeViewModeShortcut &&
(id == kShortcutActionViewModeOriginal ||
id == kShortcutActionViewModeAdaptive ||
id == kShortcutActionViewModeCustom)) {
return false;
}
if (!cap.includeInputSourceShortcut &&
id == kShortcutActionToggleInputSource) {
return false;
}
if (!cap.includeVoiceCallShortcut && id == kShortcutActionToggleVoiceCall) {
return false;
}
return true;
}
final out = <KeyboardShortcutActionGroup>[];
for (final group in kKeyboardShortcutActionGroups) {
final filteredChildren = <KeyboardShortcutActionGroupChild>[];
for (final child in group.children) {
switch (child) {
case KeyboardShortcutActionEntry():
if (allowed(child.id)) filteredChildren.add(child);
case KeyboardShortcutActionSubgroup():
final entries =
child.entries.where((e) => allowed(e.id)).toList();
if (entries.isNotEmpty) {
filteredChildren.add(
KeyboardShortcutActionSubgroup(child.titleKey, entries));
}
}
}
if (filteredChildren.isNotEmpty) {
out.add(KeyboardShortcutActionGroup(group.titleKey, filteredChildren));
}
}
return out;
}

View File

@@ -0,0 +1,104 @@
/// Keyboard shortcut action IDs - must match
/// src/keyboard/shortcuts.rs::action_id.
const kShortcutActionSendCtrlAltDel = 'send_ctrl_alt_del';
const kShortcutActionToggleFullscreen = 'toggle_fullscreen';
const kShortcutActionSwitchDisplayNext = 'switch_display_next';
const kShortcutActionSwitchDisplayPrev = 'switch_display_prev';
const kShortcutActionSwitchDisplayAll = 'switch_display_all';
const kShortcutActionScreenshot = 'screenshot';
const kShortcutActionInsertLock = 'insert_lock';
const kShortcutActionRefresh = 'refresh';
const kShortcutActionToggleBlockInput = 'toggle_block_input';
const kShortcutActionToggleRecording = 'toggle_recording';
const kShortcutActionSwitchSides = 'switch_sides';
const kShortcutActionCloseTab = 'close_tab';
const kShortcutActionToggleToolbar = 'toggle_toolbar';
const kShortcutActionRestartRemote = 'restart_remote';
const kShortcutActionResetCanvas = 'reset_canvas';
const kShortcutActionSwitchTabNext = 'switch_tab_next';
const kShortcutActionSwitchTabPrev = 'switch_tab_prev';
const kShortcutActionToggleMute = 'toggle_mute';
const kShortcutActionPinToolbar = 'pin_toolbar';
const kShortcutActionViewModeOriginal = 'view_mode_original';
const kShortcutActionViewModeAdaptive = 'view_mode_adaptive';
const kShortcutActionToggleChat = 'toggle_chat';
const kShortcutActionToggleQualityMonitor = 'toggle_quality_monitor';
const kShortcutActionToggleShowRemoteCursor = 'toggle_show_remote_cursor';
const kShortcutActionToggleShowMyCursor = 'toggle_show_my_cursor';
const kShortcutActionToggleDisableClipboard = 'toggle_disable_clipboard';
const kShortcutActionPrivacyMode1 = 'privacy_mode_1';
const kShortcutActionPrivacyMode2 = 'privacy_mode_2';
// Keyboard mode (Map / Translate / Legacy).
const kShortcutActionKeyboardModeMap = 'keyboard_mode_map';
const kShortcutActionKeyboardModeTranslate = 'keyboard_mode_translate';
const kShortcutActionKeyboardModeLegacy = 'keyboard_mode_legacy';
// Codec preference (Auto + the four optional codecs the toolbar surfaces).
const kShortcutActionCodecAuto = 'codec_auto';
const kShortcutActionCodecVp8 = 'codec_vp8';
const kShortcutActionCodecVp9 = 'codec_vp9';
const kShortcutActionCodecAv1 = 'codec_av1';
const kShortcutActionCodecH264 = 'codec_h264';
const kShortcutActionCodecH265 = 'codec_h265';
// Plug out every virtual display in one shot — toolbar exposes this in
// both IDD modes (RustDesk and Amyuni). Per-index virtual-display toggles
// (RustDesk IDD's 4 checkboxes) and the +/- count buttons (Amyuni-only)
// are NOT exposed as shortcuts: per-index is too granular, and +/- has
// no toolbar counterpart on RustDesk IDD peers.
const kShortcutActionPlugOutAllVirtualDisplays =
'plug_out_all_virtual_displays';
const kShortcutActionToggleRelativeMouseMode = 'toggle_relative_mouse_mode';
const kShortcutActionToggleFollowRemoteCursor = 'toggle_follow_remote_cursor';
const kShortcutActionToggleFollowRemoteWindow = 'toggle_follow_remote_window';
const kShortcutActionToggleZoomCursor = 'toggle_zoom_cursor';
const kShortcutActionToggleReverseMouseWheel = 'toggle_reverse_mouse_wheel';
const kShortcutActionToggleSwapLeftRightMouse = 'toggle_swap_left_right_mouse';
const kShortcutActionToggleLockAfterSessionEnd = 'toggle_lock_after_session_end';
const kShortcutActionToggleTrueColor = 'toggle_true_color';
const kShortcutActionToggleSwapCtrlCmd = 'toggle_swap_ctrl_cmd';
const kShortcutActionToggleEnableFileCopyPaste = 'toggle_enable_file_copy_paste';
const kShortcutActionViewModeCustom = 'view_mode_custom';
const kShortcutActionImageQualityBest = 'image_quality_best';
const kShortcutActionImageQualityBalanced = 'image_quality_balanced';
const kShortcutActionImageQualityLow = 'image_quality_low';
const kShortcutActionSendClipboardKeystrokes = 'send_clipboard_keystrokes';
const kShortcutActionToggleInputSource = 'toggle_input_source';
const kShortcutActionToggleVoiceCall = 'toggle_voice_call';
const kShortcutActionToggleViewOnly = 'toggle_view_only';
const kShortcutLocalConfigKey = 'keyboard-shortcuts';
const kShortcutEventName = 'shortcut_triggered';
/// Canonical default keyboard-shortcut bindings, mirroring Rust's
/// `default_bindings()` in `src/keyboard/shortcuts.rs`. Used by:
/// * the Web bridge (`flutter/lib/web/bridge.dart::mainGetDefaultKeyboardShortcuts`)
/// — Web has no Rust at runtime, so the seed list is read from this Dart
/// constant instead of going through FFI.
/// * the configuration page when seeding defaults on first enable, after
/// [filterDefaultBindingsForPlatform] has trimmed platform-specific
/// entries.
///
/// Parity with Rust is unit-tested on both sides against
/// `flutter/test/fixtures/default_keyboard_shortcuts.json` — see the
/// `kDefaultShortcutBindings matches fixture` test in
/// `flutter/test/keyboard_shortcuts_test.dart` and
/// `default_bindings_match_fixture_json` in `src/keyboard/shortcuts.rs`.
/// Any change here MUST also update the fixture and the Rust source, or CI
/// will fail in the side that drifted.
final List<Map<String, Object>> kDefaultShortcutBindings = [
for (final entry in <List<Object>>[
[kShortcutActionSendCtrlAltDel, 'delete'],
[kShortcutActionToggleFullscreen, 'enter'],
[kShortcutActionSwitchDisplayNext, 'arrow_right'],
[kShortcutActionSwitchDisplayPrev, 'arrow_left'],
[kShortcutActionScreenshot, 'p'],
[kShortcutActionToggleShowRemoteCursor, 'm'],
[kShortcutActionToggleMute, 's'],
[kShortcutActionToggleBlockInput, 'i'],
[kShortcutActionToggleChat, 'c'],
])
{
'action': entry[0],
'mods': const ['primary', 'alt', 'shift'],
'key': entry[1],
},
];

View File

@@ -0,0 +1,200 @@
import 'package:flutter/services.dart';
import 'shortcut_constants.dart';
List<String> canonicalShortcutModsForSave(Set<String> mods) {
return <String>[
if (mods.contains('primary')) 'primary',
if (mods.contains('ctrl')) 'ctrl',
if (mods.contains('alt')) 'alt',
if (mods.contains('shift')) 'shift',
];
}
bool isSwitchTabShortcutAction(String? actionId) {
return actionId == kShortcutActionSwitchTabNext ||
actionId == kShortcutActionSwitchTabPrev;
}
/// Map a [LogicalKeyboardKey] to the canonical key name used in saved
/// bindings, or `null` for keys we don't accept as shortcuts.
///
/// Mirror of `event_to_key_name` in `src/keyboard/shortcuts.rs` and
/// `logicalToKeyName` in `flutter/web/js/src/shortcut_matcher.ts` — keep
/// the three in lockstep. Cross-language parity is enforced by:
/// * `flutter/test/fixtures/supported_shortcut_keys.json` — the
/// authoritative list of names this function must produce.
/// * Dart `supported keys` test in `keyboard_shortcuts_test.dart` —
/// asserts the (LogicalKeyboardKey → name) mapping covers the fixture.
/// * Rust `supported_keys_match_fixture` test in `shortcuts.rs` — the
/// Rust-side mirror against the same fixture.
/// A drift in any of the three breaks one of the two tests.
String? logicalKeyName(LogicalKeyboardKey k) {
// Singletons that map 1:1.
if (k == LogicalKeyboardKey.delete) return 'delete';
if (k == LogicalKeyboardKey.backspace) return 'backspace';
// Numpad Enter shares the "enter" name with the main Return key — matches
// the Rust matcher (`Return | KpReturn` → "enter") and matches user
// expectation that the two physical Enters are interchangeable.
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
return 'enter';
}
if (k == LogicalKeyboardKey.tab) return 'tab';
if (k == LogicalKeyboardKey.space) return 'space';
if (k == LogicalKeyboardKey.arrowLeft) return 'arrow_left';
if (k == LogicalKeyboardKey.arrowRight) return 'arrow_right';
if (k == LogicalKeyboardKey.arrowUp) return 'arrow_up';
if (k == LogicalKeyboardKey.arrowDown) return 'arrow_down';
if (k == LogicalKeyboardKey.home) return 'home';
if (k == LogicalKeyboardKey.end) return 'end';
if (k == LogicalKeyboardKey.pageUp) return 'page_up';
if (k == LogicalKeyboardKey.pageDown) return 'page_down';
if (k == LogicalKeyboardKey.insert) return 'insert';
// Letter / digit / F-key tables. `LogicalKeyboardKey` constants are
// `static final` (not `const`), so the maps can't be `const` — but they
// initialize once per process and the lookup is O(1).
final letters = <LogicalKeyboardKey, String>{
LogicalKeyboardKey.keyA: 'a', LogicalKeyboardKey.keyB: 'b',
LogicalKeyboardKey.keyC: 'c', LogicalKeyboardKey.keyD: 'd',
LogicalKeyboardKey.keyE: 'e', LogicalKeyboardKey.keyF: 'f',
LogicalKeyboardKey.keyG: 'g', LogicalKeyboardKey.keyH: 'h',
LogicalKeyboardKey.keyI: 'i', LogicalKeyboardKey.keyJ: 'j',
LogicalKeyboardKey.keyK: 'k', LogicalKeyboardKey.keyL: 'l',
LogicalKeyboardKey.keyM: 'm', LogicalKeyboardKey.keyN: 'n',
LogicalKeyboardKey.keyO: 'o', LogicalKeyboardKey.keyP: 'p',
LogicalKeyboardKey.keyQ: 'q', LogicalKeyboardKey.keyR: 'r',
LogicalKeyboardKey.keyS: 's', LogicalKeyboardKey.keyT: 't',
LogicalKeyboardKey.keyU: 'u', LogicalKeyboardKey.keyV: 'v',
LogicalKeyboardKey.keyW: 'w', LogicalKeyboardKey.keyX: 'x',
LogicalKeyboardKey.keyY: 'y', LogicalKeyboardKey.keyZ: 'z',
};
final letter = letters[k];
if (letter != null) return letter;
final digits = <LogicalKeyboardKey, String>{
LogicalKeyboardKey.digit0: 'digit0',
LogicalKeyboardKey.digit1: 'digit1',
LogicalKeyboardKey.digit2: 'digit2',
LogicalKeyboardKey.digit3: 'digit3',
LogicalKeyboardKey.digit4: 'digit4',
LogicalKeyboardKey.digit5: 'digit5',
LogicalKeyboardKey.digit6: 'digit6',
LogicalKeyboardKey.digit7: 'digit7',
LogicalKeyboardKey.digit8: 'digit8',
LogicalKeyboardKey.digit9: 'digit9',
};
final digit = digits[k];
if (digit != null) return digit;
final fkeys = <LogicalKeyboardKey, String>{
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',
};
return fkeys[k];
}
/// Bundle of "is this shortcut available on the current platform" flags.
///
/// Production code reaches a single source of truth via
/// [ShortcutModel.currentPlatformCapabilities] (which encodes the per-runtime
/// rules in one place); tests construct one directly with whichever flags
/// they want to exercise. Two filter functions consume this:
/// [filterDefaultBindingsForPlatform] (for trimming default-binding JSON
/// before it hits LocalConfig) and [filterKeyboardShortcutActionGroupsForPlatform]
/// (for trimming the configuration UI's action list). Both must agree on the
/// same capability set, otherwise a default binding could be seeded for an
/// action the user has no UI to manage.
class ShortcutPlatformCapabilities {
final bool includeFullscreenShortcut;
final bool includeScreenshotShortcut;
final bool includeTabShortcuts;
final bool includeToolbarShortcut;
final bool includeCloseTabShortcut;
final bool includeSwitchSidesShortcut;
final bool includeRecordingShortcut;
final bool includeResetCanvasShortcut;
final bool includePinToolbarShortcut;
final bool includeViewModeShortcut;
final bool includeInputSourceShortcut;
final bool includeVoiceCallShortcut;
const ShortcutPlatformCapabilities({
required this.includeFullscreenShortcut,
required this.includeScreenshotShortcut,
required this.includeTabShortcuts,
required this.includeToolbarShortcut,
required this.includeCloseTabShortcut,
required this.includeSwitchSidesShortcut,
required this.includeRecordingShortcut,
required this.includeResetCanvasShortcut,
required this.includePinToolbarShortcut,
required this.includeViewModeShortcut,
required this.includeInputSourceShortcut,
required this.includeVoiceCallShortcut,
});
}
List<Map<String, dynamic>> filterDefaultBindingsForPlatform(
Iterable<dynamic> bindings,
ShortcutPlatformCapabilities cap,
) {
final filtered = <Map<String, dynamic>>[];
for (final raw in bindings) {
if (raw is! Map) continue;
final binding = Map<String, dynamic>.from(raw);
final action = binding['action'] as String?;
if (!cap.includeFullscreenShortcut &&
action == kShortcutActionToggleFullscreen) {
continue;
}
if (!cap.includeScreenshotShortcut && action == kShortcutActionScreenshot) {
continue;
}
if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(action)) {
continue;
}
if (!cap.includeToolbarShortcut &&
action == kShortcutActionToggleToolbar) {
continue;
}
if (!cap.includeCloseTabShortcut && action == kShortcutActionCloseTab) {
continue;
}
if (!cap.includeSwitchSidesShortcut &&
action == kShortcutActionSwitchSides) {
continue;
}
if (!cap.includeRecordingShortcut &&
action == kShortcutActionToggleRecording) {
continue;
}
if (!cap.includeResetCanvasShortcut &&
action == kShortcutActionResetCanvas) {
continue;
}
if (!cap.includePinToolbarShortcut && action == kShortcutActionPinToolbar) {
continue;
}
if (!cap.includeViewModeShortcut &&
(action == kShortcutActionViewModeOriginal ||
action == kShortcutActionViewModeAdaptive ||
action == kShortcutActionViewModeCustom)) {
continue;
}
if (!cap.includeInputSourceShortcut &&
action == kShortcutActionToggleInputSource) {
continue;
}
if (!cap.includeVoiceCallShortcut &&
action == kShortcutActionToggleVoiceCall) {
continue;
}
filtered.add(binding);
}
return filtered;
}

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

View File

@@ -0,0 +1,63 @@
// flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart
//
// Desktop shell for the Keyboard Shortcuts configuration page. Users land
// here from the General settings tab. The page exposes:
// * A top-level enable/disable toggle (mirrors the General-tab toggle —
// same JSON key, same semantics).
// * A grouped, scrollable list of actions, each with a current binding and
// edit / clear icons.
// * An AppBar "Reset to defaults" action with a confirmation dialog.
//
// All edits write back to LocalConfig under [kShortcutLocalConfigKey] in the
// canonical {enabled, bindings:[{action,mods,key}]} shape that the Rust and
// Web matchers consume.
//
// The body — group definitions, JSON I/O, conflict-replace flow,
// recording-dialog round-trip — lives in
// `common/widgets/keyboard_shortcuts/page_body.dart` and is shared with the
// mobile shell at `mobile/pages/mobile_keyboard_shortcuts_page.dart`.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../common/widgets/keyboard_shortcuts/page_body.dart';
class DesktopKeyboardShortcutsPage extends StatefulWidget {
const DesktopKeyboardShortcutsPage({Key? key}) : super(key: key);
@override
State<DesktopKeyboardShortcutsPage> createState() =>
_DesktopKeyboardShortcutsPageState();
}
class _DesktopKeyboardShortcutsPageState
extends State<DesktopKeyboardShortcutsPage> {
final GlobalKey<KeyboardShortcutsPageBodyState> _bodyKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(translate('Keyboard Shortcuts')),
actions: [
TextButton.icon(
onPressed: () =>
_bodyKey.currentState?.resetToDefaultsWithConfirm(),
icon: const Icon(Icons.restore),
label: Text(translate('Reset to defaults')),
).marginOnly(right: 12),
],
),
body: KeyboardShortcutsPageBody(
key: _bodyKey,
compact: true,
// Desktop's General settings tab already exposes the Enable +
// Pass-through checkboxes (it's the only entry point to this page),
// so we hide the duplicates here. Mobile shells keep the default
// (true) because their entry tile doesn't carry the toggles.
showMasterToggles: false,
),
);
}
}

View File

@@ -10,6 +10,7 @@ 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/common/widgets/keyboard_shortcuts/page_body.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';
@@ -459,6 +460,7 @@ class _GeneralState extends State<_General> {
await ShortcutModel.setPassThrough(v);
setLocalState(() {});
},
trailing: const InfoTooltipIcon(tipKey: 'shortcut-passthrough-tip'),
),
_ShortcutsConfigureRow(),
],
@@ -2532,6 +2534,8 @@ Widget _OptionCheckBox(
bool isServer = true,
bool Function()? optGetter,
Future<void> Function(String, bool)? optSetter,
// Optional widget rendered between the label and the trailing space.
Widget? trailing,
}) {
getOpt() => optGetter != null
? optGetter()
@@ -2575,11 +2579,23 @@ Widget _OptionCheckBox(
offstage: !ref.value || checkedIcon == null,
child: checkedIcon?.marginOnly(right: 5),
),
Expanded(
// Without `trailing`, keep the original Expanded(Text) layout.
if (trailing == null)
Expanded(
child: Text(
translate(label),
style: TextStyle(color: disabledTextColor(context, enabled)),
))
else ...[
Flexible(
child: Text(
translate(label),
style: TextStyle(color: disabledTextColor(context, enabled)),
))
translate(label),
style: TextStyle(color: disabledTextColor(context, enabled)),
),
),
trailing,
const Spacer(),
],
],
),
).marginOnly(left: _kCheckBoxLeftMargin),

View File

@@ -134,12 +134,10 @@ class _RemotePageState extends State<RemotePage>
// 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);
registerToolbarShortcuts(context, widget.id, _ffi);
}
});
_ffi.canvasModel.initializeEdgeScrollFallback(this);

View File

@@ -611,8 +611,9 @@ class _MonitorMenu extends StatelessWidget {
tooltip: isMulti
? ''
: isAllMonitors
? 'all monitors'
: '#${i + 1} monitor',
? translate('All monitors')
: translate('Monitor #{}')
.replaceAll('{}', '${i + 1}'),
hMargin: isMulti ? null : 6,
vMargin: isMulti ? null : 12,
topLevel: false,

View File

@@ -0,0 +1,95 @@
// flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart
//
// Mobile shell for the Keyboard Shortcuts configuration page. Mirrors
// `desktop/pages/desktop_keyboard_shortcuts_page.dart` but with a touch-
// friendly layout (ListTile rows instead of dense rows) and a hint banner
// that explains the recording flow only works with a physical keyboard.
//
// All actual logic — group definitions, JSON I/O, conflict-replace flow,
// recording-dialog round-trip, "Reset to defaults" — lives in the shared
// `common/widgets/keyboard_shortcuts/page_body.dart`. This file only
// supplies the AppBar, the AppBar action, and the platform hint banner.
//
// Mobile keyboard detection limitation: Flutter has no reliable
// "is a physical keyboard attached?" API on iOS or Android. Soft keyboards
// don't generate the `KeyDownEvent`s the recording dialog listens for, so
// in practice the dialog only does anything useful when the user actually
// has a hardware keyboard plugged in (USB / Bluetooth / Smart Connector).
// For V1 we don't try to detect attachment — we just surface the
// requirement as an in-page hint instead of disabling the Edit button.
import 'package:flutter/material.dart';
import '../../common.dart';
import '../../common/widgets/keyboard_shortcuts/page_body.dart';
class MobileKeyboardShortcutsPage extends StatefulWidget {
const MobileKeyboardShortcutsPage({Key? key}) : super(key: key);
@override
State<MobileKeyboardShortcutsPage> createState() =>
_MobileKeyboardShortcutsPageState();
}
class _MobileKeyboardShortcutsPageState
extends State<MobileKeyboardShortcutsPage> {
final GlobalKey<KeyboardShortcutsPageBodyState> _bodyKey = GlobalKey();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(translate('Keyboard Shortcuts')),
actions: [
IconButton(
tooltip: translate('Reset to defaults'),
onPressed: () =>
_bodyKey.currentState?.resetToDefaultsWithConfirm(),
icon: const Icon(Icons.restore),
),
],
),
body: KeyboardShortcutsPageBody(
key: _bodyKey,
compact: false,
editButtonHint: translate('shortcut-mobile-physical-keyboard-tip'),
headerBanner: _PhysicalKeyboardHintBanner(theme: theme),
),
);
}
}
/// A muted info banner shown above the master toggle on mobile. We can't
/// reliably detect whether a physical keyboard is attached, so instead of
/// disabling the Edit button we surface the requirement up front.
class _PhysicalKeyboardHintBanner extends StatelessWidget {
final ThemeData theme;
const _PhysicalKeyboardHintBanner({required this.theme});
@override
Widget build(BuildContext context) {
final color = theme.colorScheme.primary.withOpacity(0.08);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline,
size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
translate('shortcut-mobile-physical-keyboard-tip'),
style: TextStyle(color: theme.colorScheme.onSurface),
),
),
],
),
);
}
}

View File

@@ -127,10 +127,10 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
// 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).
// Mobile has no DesktopTabController, so tab-switch shortcuts will
// log a no-handler debug line if a user binds one.
registerSessionShortcutActions(gFFI);
registerToolbarShortcuts(context, widget.id, gFFI);
}
});
WidgetsBinding.instance.addObserver(this);

View File

@@ -0,0 +1,536 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../common.dart';
import '../common/shared_state.dart' show PrivacyModeState;
import '../common/widgets/dialog.dart'
show desktopTryShowTabAuditDialogCloseCancelled;
import '../common/widgets/keyboard_shortcuts/shortcut_utils.dart';
import '../consts.dart';
import '../desktop/widgets/remote_toolbar.dart' show ToolbarState;
import 'chat_model.dart' show VoiceCallStatus;
import '../desktop/widgets/tabbar_widget.dart' show DesktopTabController;
import '../models/model.dart';
import '../models/platform_model.dart';
import '../models/state_model.dart';
/// Per-session shortcut dispatcher. Attached to FFI when a session is created.
///
/// The Rust matcher (src/keyboard/shortcuts.rs) emits `shortcut_triggered`
/// session events containing the matched `action` id. The session event
/// listener in [FfiModel.startEventListener] forwards those to this model
/// via [onTriggered], which runs whatever callback the toolbar / menu
/// builders previously registered for that action id.
class ShortcutModel {
final WeakReference<FFI> parent;
final Map<String, VoidCallback> _callbacks = {};
ShortcutModel(this.parent);
/// Called by toolbar / menu builders to register what to do when the
/// matched shortcut fires.
void register(String actionId, VoidCallback callback) {
_callbacks[actionId] = callback;
}
void unregister(String actionId) {
_callbacks.remove(actionId);
}
/// Called by the session event listener when a `shortcut_triggered` event
/// arrives for this session.
void onTriggered(String actionId) {
final cb = _callbacks[actionId];
if (cb != null) {
cb();
} else {
debugPrint('shortcut_triggered: no handler for $actionId');
}
}
/// Read the bindings JSON from LocalConfig.
static List<Map<String, dynamic>> readBindings() {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return [];
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
final list = (parsed['bindings'] as List?) ?? [];
return list.cast<Map<String, dynamic>>();
} catch (_) {
return [];
}
}
static bool isEnabled() {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return false;
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
return parsed['enabled'] == true;
} catch (_) {
return false;
}
}
static bool isPassThrough() {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return false;
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
return parsed['pass_through'] == true;
} catch (_) {
return false;
}
}
/// Persistent companion to [isEnabled]: when on, the matchers return early
/// and every keystroke flows through to the remote (i.e. all bindings are
/// suspended). Stored in the same JSON blob so a single reload refreshes
/// both flags on every active matcher.
static Future<void> setPassThrough(bool v) async {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
Map<String, dynamic> json = {};
if (raw.isNotEmpty) {
try {
json = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
json = {};
}
}
json['pass_through'] = v;
await bind.mainSetLocalOption(
key: kShortcutLocalConfigKey, value: jsonEncode(json));
bind.mainReloadKeyboardShortcuts();
}
/// Flip the master `enabled` flag and persist. On the first enable we seed
/// the default bindings so common combos work out of the box; otherwise we
/// preserve whatever the user already has. Refreshes the matcher cache so
/// the change takes effect immediately (Rust on native, JS via the bridge
/// on Web).
static Future<void> setEnabled(bool v) async {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
Map<String, dynamic> json = {};
if (raw.isNotEmpty) {
try {
json = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
json = {};
}
}
json['enabled'] = v;
final list = (json['bindings'] as List?) ?? const [];
if (v && list.isEmpty) {
json['bindings'] = filterDefaultBindingsForPlatform(
jsonDecode(bind.mainGetDefaultKeyboardShortcuts()) as List,
currentPlatformCapabilities(),
);
} else {
json['bindings'] ??= <dynamic>[];
}
await bind.mainSetLocalOption(
key: kShortcutLocalConfigKey, value: jsonEncode(json));
bind.mainReloadKeyboardShortcuts();
}
/// Single source of truth for the per-platform "is this shortcut applicable"
/// decisions. Both [setEnabled]'s default-seeding pass and the configuration
/// page's reset / list-rendering paths read from here, so the seed list and
/// the visible action list can never disagree on which platform a given
/// action belongs to.
///
/// Capability rationale:
/// * Fullscreen / Toolbar / Pin / View Mode: rendered wherever the
/// desktop layout applies (native desktop + Web). Native mobile is
/// permanently full-screen and doesn't have a desktop-style toolbar.
/// * Screenshot / Switch Sides: native desktop only. The Web bridge
/// throws UnimplementedError for `sessionTakeScreenshot`; mobile
/// toolbars don't surface either action.
/// * Tab navigation / Close Tab: only native desktop ships
/// `DesktopTabController`; Web's `RemotePage` is invoked without one.
/// * Recording: native desktop has the `_RecordMenu` widget +
/// `registerSessionShortcutActions` registration; native Android has
/// the `toolbarControls` entry; iOS short-circuits inside
/// `recordingModel.toggle()`; Web has no implementation.
/// * Reset Canvas: only the mobile toolbar builds the menu entry
/// (`isDefaultConn && isMobile` in `toolbarControls`).
/// * Input Source: Web only ships a single source so toggling is a
/// no-op; the toolbar menu hides itself when fewer than 2 sources are
/// advertised.
/// * Voice Call: Web bridge throws `UnimplementedError` for both
/// `sessionRequestVoiceCall` and `sessionCloseVoiceCall`.
static ShortcutPlatformCapabilities currentPlatformCapabilities() {
final desktopLayout = isDesktop || isWeb;
return ShortcutPlatformCapabilities(
includeFullscreenShortcut: desktopLayout,
includeScreenshotShortcut: isDesktop,
includeTabShortcuts: isDesktop,
includeToolbarShortcut: desktopLayout,
includeCloseTabShortcut: isDesktop,
includeSwitchSidesShortcut: isDesktop,
includeRecordingShortcut: !isWeb && !isIOS,
includeResetCanvasShortcut: isMobile,
includePinToolbarShortcut: desktopLayout,
includeViewModeShortcut: desktopLayout,
includeInputSourceShortcut: !isWeb,
includeVoiceCallShortcut: !isWeb,
);
}
}
/// Register the default-bound shortcut actions that aren't already wired by
/// `toolbarControls(...)` (which handles things like Ctrl+Alt+Shift+Del and the
/// screenshot action). Called once per session from the desktop / mobile
/// remote page, after the toolbar registrations have run.
///
/// We register unconditionally — even when shortcuts are master-disabled —
/// because the matcher (Rust + JS) gates dispatch via the `enabled` flag,
/// so registered closures are functionally invisible until the user flips
/// shortcuts on. This keeps the wiring simple (no rebind callbacks across
/// sessions) and lets the user toggle shortcuts mid-session without
/// reconnecting.
///
/// [tabController] is the desktop window's tab controller; `null` on mobile /
/// web (where tab-switch shortcuts don't apply).
///
/// Each callback below is a no-op when the underlying state required to
/// service the action isn't available (e.g. only one display, only one tab).
void registerSessionShortcutActions(
FFI ffi, {
DesktopTabController? tabController,
ToolbarState? toolbarState,
}) {
final sessionId = ffi.sessionId;
// Note on disposal: every closure registered below captures `ffi` via
// closure environment, so the FFI object stays alive for the duration of
// the closure's execution — even across awaits, even if the session is
// closed mid-execution. We therefore don't add per-closure liveness
// guards: a `WeakReference<FFI>` check would never go null while the
// closure is on the call stack, and the underlying `bind.session*` /
// model setters tolerate stale-session calls (they no-op on torn-down
// sessions). ShortcutModel.onTriggered's existing entry guard
// (`_callbacks` lookup returning null after disposal) is the actual
// liveness gate.
// Toggle Fullscreen — available wherever the desktop layout renders
// (native desktop + every Web browser, since Web uses the desktop
// RemotePage). `stateGlobal.setFullscreen` handles native window vs.
// browser fullscreen. Native mobile is permanently full-screen, so the
// action is intentionally not registered there.
if (isDesktop || isWeb) {
ffi.shortcutModel.register(kShortcutActionToggleFullscreen, () {
stateGlobal.setFullscreen(!stateGlobal.fullscreen.value);
});
}
// Toggle Recording — desktop only here. Mobile already wires this through
// `toolbarControls` (which adds a recording entry on `!(isDesktop||isWeb)`),
// but the desktop toolbar uses a separate `_RecordMenu` widget that has no
// `actionId`. Without this explicit registration a desktop user could bind
// Toggle Recording in settings and the press would have no handler.
// `recordingModel.toggle()` itself short-circuits on iOS and on sessions
// without recording permission.
if (isDesktop) {
ffi.shortcutModel.register(kShortcutActionToggleRecording, () {
ffi.recordingModel.toggle();
});
}
// Switch Display Next / Prev — requires the peer to have at least 2
// displays. From the "All displays" merged view, Next jumps to display 0
// and Prev to the last display, so the user can always escape the merged
// view via these shortcuts.
void switchDisplayBy(int delta) {
final pi = ffi.ffiModel.pi;
final count = pi.displays.length;
if (count <= 1) return;
final current = pi.currentDisplay;
final int next;
if (current == kAllDisplayValue) {
next = delta > 0 ? 0 : count - 1;
} else {
next = ((current + delta) % count + count) % count;
}
bind.sessionSwitchDisplay(
isDesktop: isDesktop,
sessionId: sessionId,
value: Int32List.fromList([next]),
);
if (pi.isSupportMultiUiSession) {
// On multi-ui-session peers no switch-display message is sent back, so
// update the local state directly (mirrors `model.dart` handling).
ffi.ffiModel.switchToNewDisplay(next, sessionId, ffi.id);
}
}
ffi.shortcutModel.register(kShortcutActionSwitchDisplayNext, () {
switchDisplayBy(1);
});
ffi.shortcutModel.register(kShortcutActionSwitchDisplayPrev, () {
switchDisplayBy(-1);
});
// Switch to all-monitors view — mirrors the toolbar Monitor menu's
// "all monitors" button (only built when peer has >1 display). Not a
// toggle: the toolbar button just sets the merged view; another action
// (Switch to next/previous display, or another monitor button) takes
// you back to a single display.
//
// Use `openMonitorInTheSameTab(kAllDisplayValue, ...)` rather than calling
// `sessionSwitchDisplay` with `[kAllDisplayValue]` directly — the toolbar
// path treats `kAllDisplayValue` as a UI sentinel and expands it to the
// real display index list (`[0, 1, ...]`) before sending, then updates
// local FfiModel state. Sending `[-1]` raw produces a wire value the
// remote can't act on and skips the local state update, so the merged
// view never engages.
ffi.shortcutModel.register(kShortcutActionSwitchDisplayAll, () {
final pi = ffi.ffiModel.pi;
if (pi.displays.length <= 1) return;
if (pi.currentDisplay == kAllDisplayValue) return;
openMonitorInTheSameTab(kAllDisplayValue, ffi, pi);
});
// Switch tab next / prev — desktop only. The remote-screen tabs live in
// the window-scoped DesktopTabController, not on the FFI itself, so we
// need the controller from the page that owns this session. We
// intentionally don't expose positional ("Switch to tab N") shortcuts:
// counting tabs in a long list is impractical, and AnyDesk / Chrome
// standard practice is to favour next/prev navigation.
if (tabController != null) {
void switchTabBy(int delta) {
final tabs = tabController.state.value.tabs;
if (tabs.length <= 1) return;
final cur = tabs.indexWhere((t) => t.key == ffi.id);
if (cur < 0) return;
final next = (cur + delta + tabs.length) % tabs.length;
tabController.jumpTo(next);
}
ffi.shortcutModel
.register(kShortcutActionSwitchTabNext, () => switchTabBy(1));
ffi.shortcutModel
.register(kShortcutActionSwitchTabPrev, () => switchTabBy(-1));
// Close Tab — desktop only. Mirrors the tab right-click "Close" entry,
// including the audit-log confirmation dialog so a shortcut close goes
// through the same path as a menu close.
ffi.shortcutModel.register(kShortcutActionCloseTab, () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: ffi.id,
tabController: tabController,
)) {
return;
}
tabController.closeBy(ffi.id);
});
}
// Toggle Toolbar — desktop only. ToolbarState is window/session-scoped,
// owned by the RemotePage that hosts this session.
if (toolbarState != null) {
ffi.shortcutModel.register(kShortcutActionToggleToolbar, () {
toolbarState.switchHide(sessionId);
});
ffi.shortcutModel.register(kShortcutActionPinToolbar, () {
toolbarState.switchPin();
});
}
// Toggle Chat overlay (open/close the chat panel for this session).
// _ChatMenu is a standalone toolbar icon — not part of any toolbar
// helper that returns a TToggleMenu list — so its handler is wired
// here rather than picked up by helper auto-register.
ffi.shortcutModel.register(kShortcutActionToggleChat, () {
ffi.chatModel.toggleChatOverlay();
});
// Toggle Voice Call — start when idle, hang up when active. Mirrors the
// toolbar's `_VoiceCallMenu` state-driven button. Web bridge throws
// UnimplementedError on both sessionRequestVoiceCall and
// sessionCloseVoiceCall, so we don't register on web.
if (!isWeb) {
ffi.shortcutModel.register(kShortcutActionToggleVoiceCall, () {
final status = ffi.chatModel.voiceCallStatus.value;
if (status == VoiceCallStatus.connected ||
status == VoiceCallStatus.waitingForResponse) {
bind.sessionCloseVoiceCall(sessionId: sessionId);
} else {
bind.sessionRequestVoiceCall(sessionId: sessionId);
}
});
}
// ── Inline _KeyboardMenu items + actions with no toolbar TToggleMenu/TRadioMenu ─
// The toolbar's TToggleMenu / TRadioMenu helpers (toolbarDisplayToggle,
// toolbarCursor, toolbarKeyboardToggles, toolbarCodec, toolbarPrivacyMode,
// toolbarViewStyle, toolbarImageQuality) auto-register their tagged entries
// from the bottom of each helper. The handlers below cover what those
// helpers DON'T own:
// * Show my cursor / Keyboard mode (Map/Translate/Legacy) / View Only
// (desktop) — built as widgets directly in `_KeyboardMenu`, not as
// TToggleMenu lists. (Mobile View Only IS in toolbarDisplayToggle and
// auto-registers; the desktop session-start handler below registers
// first and the helper's auto-register on mobile takes over after its
// unawaited future resolves.)
// * Plug out all virtual displays — built in `getVirtualDisplayMenuChildren`
// as a MenuButton, not a TToggleMenu.
// * Toggle Input Source — cycle action; the toolbar exposes per-source
// radios but no single "cycle to next source" entry.
// Show my cursor — toolbar (`_KeyboardMenu.showMyCursor`) pushes the new
// value into FfiModel.setShowMyCursor and auto-enables view-only when the
// toggle goes on, so the user can never control the remote with their own
// cursor visible.
ffi.shortcutModel.register(kShortcutActionToggleShowMyCursor, () async {
await bind.sessionToggleOption(
sessionId: sessionId, value: kOptionToggleShowMyCursor);
final showMyCursor = await bind.sessionGetToggleOption(
sessionId: sessionId, arg: kOptionToggleShowMyCursor) ??
false;
ffi.ffiModel.setShowMyCursor(showMyCursor);
if (showMyCursor && !ffi.ffiModel.viewOnly) {
await bind.sessionToggleOption(
sessionId: sessionId, value: kOptionToggleViewOnly);
final viewOnly = await bind.sessionGetToggleOption(
sessionId: sessionId, arg: kOptionToggleViewOnly) ??
false;
ffi.ffiModel.setViewOnly(ffi.id, viewOnly);
}
});
// Keyboard mode (Map / Translate / Legacy). Mirrors the radio buttons in
// `_KeyboardMenu.keyboardMode()` (built as RdoMenuButton, not TRadioMenu).
void registerKeyboardMode(String actionId, String mode) {
ffi.shortcutModel.register(actionId, () async {
await bind.sessionSetKeyboardMode(sessionId: sessionId, value: mode);
await ffi.inputModel.updateKeyboardMode();
});
}
registerKeyboardMode(kShortcutActionKeyboardModeMap, kKeyMapMode);
registerKeyboardMode(kShortcutActionKeyboardModeTranslate, kKeyTranslateMode);
registerKeyboardMode(kShortcutActionKeyboardModeLegacy, kKeyLegacyMode);
// Plug out all virtual displays (Windows + IDD only). Mirrors the toolbar's
// "Plug out all" button — present in both IDD modes (RustDesk + Amyuni),
// built as a MenuButton inside `getVirtualDisplayMenuChildren`.
ffi.shortcutModel.register(kShortcutActionPlugOutAllVirtualDisplays, () {
bind.sessionToggleVirtualDisplay(
sessionId: sessionId,
index: kAllVirtualDisplay,
on: false,
);
});
// Privacy mode 1 / 2 — fallback handlers for the single-impl and null-impls
// branches of `toolbarPrivacyMode`. The multi-impl branch tags each entry
// with the matching actionId and `_registerToggleMenuShortcuts` overrides
// these closures with the toolbar's own onChanged. But when the peer only
// advertises a single impl (older Linux peers, certain platform configs)
// toolbarPrivacyMode returns a `getDefaultMenu` entry without an actionId,
// so the auto-register pass skips it — these fallbacks are what actually
// wire the shortcut in that case.
String? findPrivacyImpl(String nameKey) {
final impls = ffi.ffiModel.pi
.platformAdditions[kPlatformAdditionsSupportedPrivacyModeImpl]
as List<dynamic>?;
if (impls == null) return null;
for (final e in impls) {
if (e is List && e.length >= 2 && e[1] == nameKey) return e[0] as String;
}
return null;
}
// Match the multi-impl branch of `toolbarPrivacyMode`: turn this impl on iff
// the active impl isn't already this one. Comparing `.value == implKey`
// (rather than `.value.isEmpty`) means pressing the mode-1 shortcut while
// mode 2 is on correctly turns mode 1 ON, instead of misreading the
// "any-mode-active" state as "this-mode-active" and toggling OFF.
ffi.shortcutModel.register(kShortcutActionPrivacyMode1, () {
final implKey = findPrivacyImpl('privacy_mode_impl_mag_tip');
if (implKey == null) return;
bind.sessionTogglePrivacyMode(
sessionId: sessionId,
implKey: implKey,
on: PrivacyModeState.find(ffi.id).value != implKey,
);
});
ffi.shortcutModel.register(kShortcutActionPrivacyMode2, () {
final implKey = findPrivacyImpl('privacy_mode_impl_virtual_display_tip');
if (implKey == null) return;
bind.sessionTogglePrivacyMode(
sessionId: sessionId,
implKey: implKey,
on: PrivacyModeState.find(ffi.id).value != implKey,
);
});
// View Only — desktop toolbar exposes this inline in `_KeyboardMenu.viewMode`
// (mobile is in toolbarDisplayToggle and goes through helper auto-register).
// Mirrors the desktop callback: toggle + sync FfiModel.viewOnly +
// FfiModel.showMyCursor (the toolbar keeps these in step).
ffi.shortcutModel.register(kShortcutActionToggleViewOnly, () async {
await bind.sessionToggleOption(
sessionId: sessionId, value: kOptionToggleViewOnly);
final viewOnly = await bind.sessionGetToggleOption(
sessionId: sessionId, arg: kOptionToggleViewOnly) ??
false;
ffi.ffiModel.setViewOnly(ffi.id, viewOnly);
final showMyCursor = await bind.sessionGetToggleOption(
sessionId: sessionId, arg: kOptionToggleShowMyCursor) ??
false;
ffi.ffiModel.setShowMyCursor(showMyCursor);
});
// Toggle Reverse mouse wheel — read current 'Y'/'N' (falling back to user
// default), flip, write back.
ffi.shortcutModel.register(kShortcutActionToggleReverseMouseWheel, () async {
var cur = bind.sessionGetReverseMouseWheelSync(sessionId: sessionId) ?? '';
if (cur == '') {
cur = bind.mainGetUserDefaultOption(key: kKeyReverseMouseWheel);
}
final next = cur == 'Y' ? 'N' : 'Y';
await bind.sessionSetReverseMouseWheel(sessionId: sessionId, value: next);
});
// Toggle Relative mouse mode (gaming mode). Desktop only.
if (isDesktop && !isWeb) {
ffi.shortcutModel.register(kShortcutActionToggleRelativeMouseMode, () {
ffi.inputModel.toggleRelativeMouseMode();
});
}
// Toggle Input Source — flips between the available keyboard-event capture
// backends (e.g. JS vs Flutter on desktop). Mirrors the radio menu in
// remote_toolbar.dart::inputSource(); when fewer than 2 sources are
// available the menu hides itself, so this handler is a no-op too.
// Useful for accessibility: screen-reader users sometimes need to swap
// sources to regain control of the local keyboard (discussion #1933).
// Web only ships a single source, so we don't register on web.
if (!isWeb) {
ffi.shortcutModel.register(kShortcutActionToggleInputSource, () async {
final raw = bind.mainSupportedInputSource();
if (raw.isEmpty) return;
final List<dynamic> list;
try {
list = jsonDecode(raw) as List<dynamic>;
} catch (_) {
return;
}
if (list.length < 2) return;
final ids = list
.map((e) => (e is List && e.isNotEmpty) ? e[0] as String : '')
.where((s) => s.isNotEmpty)
.toList();
if (ids.length < 2) return;
final current = stateGlobal.getInputSource();
final idx = ids.indexOf(current);
final next = ids[(idx < 0 ? 0 : idx + 1) % ids.length];
await stateGlobal.setInputSource(sessionId, next);
await ffi.ffiModel.checkDesktopKeyboardMode();
await ffi.inputModel.updateKeyboardMode();
});
}
}

View File

@@ -0,0 +1,11 @@
[
{"action": "send_ctrl_alt_del", "mods": ["primary", "alt", "shift"], "key": "delete"},
{"action": "toggle_fullscreen", "mods": ["primary", "alt", "shift"], "key": "enter"},
{"action": "switch_display_next", "mods": ["primary", "alt", "shift"], "key": "arrow_right"},
{"action": "switch_display_prev", "mods": ["primary", "alt", "shift"], "key": "arrow_left"},
{"action": "screenshot", "mods": ["primary", "alt", "shift"], "key": "p"},
{"action": "toggle_show_remote_cursor", "mods": ["primary", "alt", "shift"], "key": "m"},
{"action": "toggle_mute", "mods": ["primary", "alt", "shift"], "key": "s"},
{"action": "toggle_block_input", "mods": ["primary", "alt", "shift"], "key": "i"},
{"action": "toggle_chat", "mods": ["primary", "alt", "shift"], "key": "c"}
]

View File

@@ -0,0 +1,11 @@
[
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
"digit0", "digit1", "digit2", "digit3", "digit4",
"digit5", "digit6", "digit7", "digit8", "digit9",
"f1", "f2", "f3", "f4", "f5", "f6",
"f7", "f8", "f9", "f10", "f11", "f12",
"delete", "backspace", "tab", "space", "enter",
"arrow_left", "arrow_right", "arrow_up", "arrow_down",
"home", "end", "page_up", "page_down", "insert"
]

View File

@@ -0,0 +1,426 @@
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');
}
});
}

View File

@@ -575,6 +575,7 @@ pub fn session_handle_flutter_key_event(
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
let keyboard_mode = session.get_keyboard_mode();
session.handle_flutter_key_event(
session_id,
&keyboard_mode,
&character,
usb_hid,
@@ -595,6 +596,7 @@ pub fn session_handle_flutter_raw_key_event(
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
let keyboard_mode = session.get_keyboard_mode();
session.handle_flutter_raw_key_event(
session_id,
&keyboard_mode,
&name,
platform_code,
@@ -1728,6 +1730,7 @@ pub fn cm_get_clients_length() -> usize {
pub fn main_init(app_dir: String, custom_client_config: String) {
initialize(&app_dir, &custom_client_config);
crate::keyboard::shortcuts::reload_from_config();
}
pub fn main_device_id(id: String) {
@@ -2247,6 +2250,17 @@ pub fn main_init_input_source() -> SyncReturn<()> {
SyncReturn(())
}
pub fn main_reload_keyboard_shortcuts() -> SyncReturn<()> {
crate::keyboard::shortcuts::reload_from_config();
SyncReturn(())
}
pub fn main_get_default_keyboard_shortcuts() -> SyncReturn<String> {
let bindings = crate::keyboard::shortcuts::default_bindings();
let json = serde_json::to_string(&bindings).unwrap_or_default();
SyncReturn(json)
}
pub fn main_is_installed_lower_version() -> SyncReturn<bool> {
SyncReturn(is_installed_lower_version())
}

View File

@@ -10,6 +10,7 @@ use crate::{client::get_key_state, common::GrabState};
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use hbb_common::log;
use hbb_common::message_proto::*;
use hbb_common::SessionID;
#[cfg(any(target_os = "windows", target_os = "macos"))]
use rdev::KeyCode;
use rdev::{Event, EventType, Key};
@@ -79,6 +80,8 @@ lazy_static::lazy_static! {
};
}
pub mod shortcuts;
pub mod client {
use super::*;
@@ -319,6 +322,32 @@ pub mod client {
}
pub fn process_event(keyboard_mode: &str, event: &Event, lock_modes: Option<i32>) {
// Shortcut intercept — must come before any wire encoding.
// Only fires on KeyPress (event_to_key_name in shortcuts.rs returns None
// for KeyRelease and other non-press events), so flushed releases from
// release_remote_keys pass straight through to the encode/forward path.
//
// NOTE: Shortcut matching intentionally happens BEFORE any key swapping
// (swap_modifier_key) so that shortcuts bind to the physical keys pressed,
// not the swapped keys. This makes shortcut setup intuitive: users bind
// shortcuts to the actual keys they press, regardless of swap settings.
// Key swapping only affects what gets sent to the remote.
//
// Gated on `feature = "flutter"` because the dispatch target
// (`flutter::push_session_event`) is Flutter-only. Sciter builds never
// call `reload_from_config`, so the cache stays disabled and the
// matcher would no-op anyway — but we still skip the call entirely so
// a hand-edited config can't silently swallow keys on a UI that has
// no way to surface the action.
//
// `None` for session_id makes the helper resolve through
// `flutter::get_cur_session_id()` — the rdev grab loop is process-wide
// and has no per-event session context to thread.
#[cfg(feature = "flutter")]
if crate::keyboard::shortcuts::try_dispatch(None, event) {
return;
}
let keyboard_mode = get_keyboard_mode_enum(keyboard_mode);
if is_long_press(&event) {
return;
@@ -334,7 +363,20 @@ pub mod client {
event: &Event,
lock_modes: Option<i32>,
session: &Session<T>,
session_id: SessionID,
) {
// Shortcut intercept — see the long comment in `process_event` above
// for the KeyPress-only / feature-gate rationale. The only difference
// here is that the Flutter FFI path threads an explicit SessionID
// through, so dispatch targets the exact tab the keystroke originated
// from — no dependency on the global focus tracker.
#[cfg(feature = "flutter")]
if crate::keyboard::shortcuts::try_dispatch(Some(&session_id), event) {
return;
}
#[cfg(not(feature = "flutter"))]
let _ = session_id;
let keyboard_mode = get_keyboard_mode_enum(keyboard_mode);
if is_long_press(&event) {
return;

707
src/keyboard/shortcuts.rs Normal file
View File

@@ -0,0 +1,707 @@
//! Keyboard shortcuts for triggering session actions locally.
use std::sync::{Arc, RwLock};
use serde::{Deserialize, Serialize};
const LOCAL_CONFIG_KEY: &str = "keyboard-shortcuts";
lazy_static::lazy_static! {
static ref CACHE: RwLock<Arc<Bindings>> = RwLock::new(Arc::new(Bindings::default()));
}
/// Registry of all valid action ids that may appear in `Binding.action`.
/// Source-of-truth lives on the Flutter side (`flutter/lib/consts.dart`,
/// `kShortcutAction*`); these mirror that vocabulary so Rust code can reach
/// for them without re-stringifying.
#[allow(dead_code)]
pub mod action_id {
pub const SEND_CTRL_ALT_DEL: &str = "send_ctrl_alt_del";
pub const TOGGLE_FULLSCREEN: &str = "toggle_fullscreen";
pub const SWITCH_DISPLAY_NEXT: &str = "switch_display_next";
pub const SWITCH_DISPLAY_PREV: &str = "switch_display_prev";
pub const SWITCH_DISPLAY_ALL: &str = "switch_display_all";
pub const SCREENSHOT: &str = "screenshot";
pub const INSERT_LOCK: &str = "insert_lock";
pub const REFRESH: &str = "refresh";
pub const TOGGLE_BLOCK_INPUT: &str = "toggle_block_input";
pub const TOGGLE_RECORDING: &str = "toggle_recording";
pub const SWITCH_SIDES: &str = "switch_sides";
pub const CLOSE_TAB: &str = "close_tab";
pub const TOGGLE_TOOLBAR: &str = "toggle_toolbar";
pub const RESTART_REMOTE: &str = "restart_remote";
pub const RESET_CANVAS: &str = "reset_canvas";
pub const TOGGLE_MUTE: &str = "toggle_mute";
pub const PIN_TOOLBAR: &str = "pin_toolbar";
pub const VIEW_MODE_ORIGINAL: &str = "view_mode_original";
pub const VIEW_MODE_ADAPTIVE: &str = "view_mode_adaptive";
pub const TOGGLE_CHAT: &str = "toggle_chat";
pub const TOGGLE_QUALITY_MONITOR: &str = "toggle_quality_monitor";
pub const TOGGLE_SHOW_REMOTE_CURSOR: &str = "toggle_show_remote_cursor";
pub const TOGGLE_SHOW_MY_CURSOR: &str = "toggle_show_my_cursor";
pub const TOGGLE_DISABLE_CLIPBOARD: &str = "toggle_disable_clipboard";
pub const PRIVACY_MODE_1: &str = "privacy_mode_1";
pub const PRIVACY_MODE_2: &str = "privacy_mode_2";
pub const KEYBOARD_MODE_MAP: &str = "keyboard_mode_map";
pub const KEYBOARD_MODE_TRANSLATE: &str = "keyboard_mode_translate";
pub const KEYBOARD_MODE_LEGACY: &str = "keyboard_mode_legacy";
pub const CODEC_AUTO: &str = "codec_auto";
pub const CODEC_VP8: &str = "codec_vp8";
pub const CODEC_VP9: &str = "codec_vp9";
pub const CODEC_AV1: &str = "codec_av1";
pub const CODEC_H264: &str = "codec_h264";
pub const CODEC_H265: &str = "codec_h265";
pub const PLUG_OUT_ALL_VIRTUAL_DISPLAYS: &str = "plug_out_all_virtual_displays";
pub const TOGGLE_RELATIVE_MOUSE_MODE: &str = "toggle_relative_mouse_mode";
pub const TOGGLE_FOLLOW_REMOTE_CURSOR: &str = "toggle_follow_remote_cursor";
pub const TOGGLE_FOLLOW_REMOTE_WINDOW: &str = "toggle_follow_remote_window";
pub const TOGGLE_ZOOM_CURSOR: &str = "toggle_zoom_cursor";
pub const TOGGLE_REVERSE_MOUSE_WHEEL: &str = "toggle_reverse_mouse_wheel";
pub const TOGGLE_SWAP_LEFT_RIGHT_MOUSE: &str = "toggle_swap_left_right_mouse";
pub const TOGGLE_LOCK_AFTER_SESSION_END: &str = "toggle_lock_after_session_end";
pub const TOGGLE_TRUE_COLOR: &str = "toggle_true_color";
pub const TOGGLE_SWAP_CTRL_CMD: &str = "toggle_swap_ctrl_cmd";
pub const TOGGLE_ENABLE_FILE_COPY_PASTE: &str = "toggle_enable_file_copy_paste";
pub const VIEW_MODE_CUSTOM: &str = "view_mode_custom";
pub const IMAGE_QUALITY_BEST: &str = "image_quality_best";
pub const IMAGE_QUALITY_BALANCED: &str = "image_quality_balanced";
pub const IMAGE_QUALITY_LOW: &str = "image_quality_low";
pub const SEND_CLIPBOARD_KEYSTROKES: &str = "send_clipboard_keystrokes";
pub const TOGGLE_INPUT_SOURCE: &str = "toggle_input_source";
pub const SWITCH_TAB_NEXT: &str = "switch_tab_next";
pub const SWITCH_TAB_PREV: &str = "switch_tab_prev";
pub const TOGGLE_VOICE_CALL: &str = "toggle_voice_call";
pub const TOGGLE_VIEW_ONLY: &str = "toggle_view_only";
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Modifier {
Primary,
Ctrl,
Alt,
Shift,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Binding {
pub action: String,
pub mods: Vec<Modifier>,
pub key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Bindings {
#[serde(default)]
pub enabled: bool,
/// Persistent companion to `enabled`: when true, the matcher returns early
/// and every keystroke flows through to the remote (i.e. all bindings are
/// suspended). Stored alongside `enabled` and `bindings` so a single
/// reload refreshes both flags.
#[serde(default)]
pub pass_through: bool,
#[serde(default)]
pub bindings: Vec<Binding>,
}
pub fn default_bindings() -> Vec<Binding> {
let prefix = || vec![Modifier::Primary, Modifier::Alt, Modifier::Shift];
// Defaults align with AnyDesk's M/S/I/C/Delete/Arrow/Digit conventions
// where applicable; "P" for screenshot also matches AnyDesk.
vec![
Binding { action: action_id::SEND_CTRL_ALT_DEL.into(), mods: prefix(), key: "delete".into() },
Binding { action: action_id::TOGGLE_FULLSCREEN.into(), mods: prefix(), key: "enter".into() },
Binding { action: action_id::SWITCH_DISPLAY_NEXT.into(), mods: prefix(), key: "arrow_right".into() },
Binding { action: action_id::SWITCH_DISPLAY_PREV.into(), mods: prefix(), key: "arrow_left".into() },
Binding { action: action_id::SCREENSHOT.into(), mods: prefix(), key: "p".into() },
Binding { action: action_id::TOGGLE_SHOW_REMOTE_CURSOR.into(), mods: prefix(), key: "m".into() },
Binding { action: action_id::TOGGLE_MUTE.into(), mods: prefix(), key: "s".into() },
Binding { action: action_id::TOGGLE_BLOCK_INPUT.into(), mods: prefix(), key: "i".into() },
Binding { action: action_id::TOGGLE_CHAT.into(), mods: prefix(), key: "c".into() },
]
}
/// Match a normalized (key, modifiers) pair against the given bindings.
/// Returns the matched action ID, or None when the matcher is off
/// (`enabled == false`), suspended (`pass_through == true`), or no binding
/// fires for this combo.
///
/// Defense-in-depth: bindings with an empty modifier list are skipped here
/// even though the recording dialog refuses to save them. A hand-edited
/// config (or a future writer-side bug) that lets an empty-mods binding
/// through would otherwise turn that key's every press into a swallowed
/// shortcut, breaking normal typing in the remote session — a much worse
/// failure than the binding simply not firing.
pub fn match_normalized<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> {
if !b.enabled || b.pass_through {
return None;
}
for binding in &b.bindings {
if binding.mods.is_empty() {
continue;
}
if binding.key == key && mods_equal(&binding.mods, mods) {
return Some(binding.action.as_str());
}
}
None
}
pub fn normalize_modifiers(alt: bool, ctrl: bool, shift: bool, command: bool) -> Vec<Modifier> {
// iOS shares Apple's keyboard semantics with macOS — recording dialog
// already treats iOS as `_isMac`, so the matcher must too.
//
// AltGr conflation: `get_modifiers_state` ORs Alt and AltGr, so an
// AltGr+key press satisfies `Modifier::Alt`. Theoretical collision only;
// fix at `get_modifiers_state` if a real bug surfaces.
let mut v = Vec::new();
if cfg!(any(target_os = "macos", target_os = "ios")) {
if command { v.push(Modifier::Primary); }
if ctrl { v.push(Modifier::Ctrl); }
} else {
if ctrl { v.push(Modifier::Primary); }
}
if alt { v.push(Modifier::Alt); }
if shift { v.push(Modifier::Shift); }
v
}
/// Map an rdev::Event to a string key name, matching the storage schema.
/// Returns None for events we don't intercept (modifier-only presses, releases, etc.).
pub fn event_to_key_name(event: &rdev::Event) -> Option<String> {
use rdev::{EventType, Key};
let key = match event.event_type {
EventType::KeyPress(k) => k,
_ => return None,
};
Some(match key {
Key::Delete => "delete".into(),
Key::Backspace => "backspace".into(),
Key::Tab => "tab".into(),
Key::Space => "space".into(),
Key::Home => "home".into(),
Key::End => "end".into(),
Key::PageUp => "page_up".into(),
Key::PageDown => "page_down".into(),
Key::Insert => "insert".into(),
// Numpad Enter (`KpReturn`) shares the "enter" name with the main
// Return key — matches the Web matcher (`NumpadEnter` -> "enter") and
// matches user expectation that the two physical Enters are
// interchangeable for shortcuts.
Key::Return | Key::KpReturn => "enter".into(),
Key::LeftArrow => "arrow_left".into(),
Key::RightArrow => "arrow_right".into(),
Key::UpArrow => "arrow_up".into(),
Key::DownArrow => "arrow_down".into(),
Key::KeyA => "a".into(),
Key::KeyB => "b".into(),
Key::KeyC => "c".into(),
Key::KeyD => "d".into(),
Key::KeyE => "e".into(),
Key::KeyF => "f".into(),
Key::KeyG => "g".into(),
Key::KeyH => "h".into(),
Key::KeyI => "i".into(),
Key::KeyJ => "j".into(),
Key::KeyK => "k".into(),
Key::KeyL => "l".into(),
Key::KeyM => "m".into(),
Key::KeyN => "n".into(),
Key::KeyO => "o".into(),
Key::KeyP => "p".into(),
Key::KeyQ => "q".into(),
Key::KeyR => "r".into(),
Key::KeyS => "s".into(),
Key::KeyT => "t".into(),
Key::KeyU => "u".into(),
Key::KeyV => "v".into(),
Key::KeyW => "w".into(),
Key::KeyX => "x".into(),
Key::KeyY => "y".into(),
Key::KeyZ => "z".into(),
Key::Num0 => "digit0".into(),
Key::Num1 => "digit1".into(),
Key::Num2 => "digit2".into(),
Key::Num3 => "digit3".into(),
Key::Num4 => "digit4".into(),
Key::Num5 => "digit5".into(),
Key::Num6 => "digit6".into(),
Key::Num7 => "digit7".into(),
Key::Num8 => "digit8".into(),
Key::Num9 => "digit9".into(),
Key::F1 => "f1".into(),
Key::F2 => "f2".into(),
Key::F3 => "f3".into(),
Key::F4 => "f4".into(),
Key::F5 => "f5".into(),
Key::F6 => "f6".into(),
Key::F7 => "f7".into(),
Key::F8 => "f8".into(),
Key::F9 => "f9".into(),
Key::F10 => "f10".into(),
Key::F11 => "f11".into(),
Key::F12 => "f12".into(),
_ => return None,
})
}
/// Read keyboard-shortcut bindings from `LocalConfig` and refresh the cache.
///
/// Empty or invalid JSON falls back to `Bindings::default()` (disabled, no
/// bindings). Call this once at startup and again whenever the config is
/// written.
pub fn reload_from_config() {
let raw = hbb_common::config::LocalConfig::get_option(LOCAL_CONFIG_KEY);
let parsed = if raw.is_empty() {
Bindings::default()
} else {
serde_json::from_str(&raw).unwrap_or_default()
};
if let Ok(mut w) = CACHE.write() {
*w = Arc::new(parsed);
}
}
/// Snapshot of the currently cached bindings. Cheap (one atomic increment) —
/// safe to call on every keystroke.
pub fn current() -> Arc<Bindings> {
CACHE
.read()
.map(|b| Arc::clone(&b))
.unwrap_or_else(|_| Arc::new(Bindings::default()))
}
/// Match an `rdev::Event` against the cached bindings. Returns the matched
/// action id, or `None` if no binding fires. The Flutter side ignores unknown
/// action ids (logged as "no handler"), so no whitelist check is needed here.
///
/// ── Two known minor warts. DO NOT add global state to "fix" either: ──
///
/// 1. Orphan KeyRelease forwarded to peer.
/// When a shortcut matches we eat the KeyPress, but the matching
/// KeyRelease (whose `event_type` returns None from `event_to_key_name`)
/// still flows through to the peer. The remote sees a release for a
/// press it never received. Every input server we forward to ignores
/// releases for unpressed keys, so user-visible impact is nil — the
/// pre-existing hard-coded screenshot-shortcut path had the same shape
/// for years without a single bug report.
///
/// 2. OS auto-repeat re-dispatches a held shortcut.
/// rdev does not expose an `is_repeat` flag, so a held combo
/// (Cmd+Alt+Shift+P) would dispatch every ~30-50ms while the keys are
/// down — toggle actions oscillate, screenshot fires many times. In
/// practice the OS initial auto-repeat delay is ~250ms and a normal
/// shortcut press is 50-100ms, so the user has to *deliberately* hold
/// the combo to hit this. The Web side gets a free fix via the
/// browser's `KeyboardEvent.repeat`; on native we accept the wart.
///
/// The "fix" for either would be a process-global `HashSet<rdev::Key>` (or
/// equivalent) with paired insert-on-press / remove-on-release logic in
/// both `process_event*` paths plus a clear-on-leave hook. The cost:
///
/// * Lock contention on the hot keystroke path.
/// * Three input sources (rdev grab, Flutter raw key, Flutter USB HID)
/// all converge to `rdev::Key`, so correctness depends on
/// `rdev::key_from_code` / `rdev::usb_hid_key_from_code` /
/// `rdev::get_win_key` agreeing on the same physical key — the project
/// already has scattered swap_modifier_key / ControlLeft↔MetaLeft
/// fixups for places where they historically *didn't* agree. Any new
/// mismatch silently leaks the set; "shortcut stopped responding"
/// after a stuck entry is a worse failure mode than "shortcut fired
/// twice."
/// * Leak risk on focus loss / disconnect, requiring a clear hook the
/// callers must remember to invoke.
/// * Two new code paths to keep in lockstep with two existing keyboard
/// pipelines.
///
/// For two warts whose user-visible impact is nil-to-marginal, that
/// trade-off goes the wrong way. Leave it. If a real user bug shows up
/// here, revisit then with concrete repro — not pre-emptively.
pub fn match_event(event: &rdev::Event) -> Option<String> {
let bindings = current();
if !bindings.enabled || bindings.pass_through {
return None;
}
// Note: `match_normalized` re-checks both flags below — this short-circuit
// is just to avoid the `event_to_key_name` + `get_modifiers_state` work
// in the common bypass case.
let key_name = event_to_key_name(event)?;
let (alt, ctrl, shift, command) =
crate::keyboard::client::get_modifiers_state(false, false, false, false);
let mods = normalize_modifiers(alt, ctrl, shift, command);
match_normalized(&key_name, &mods, &bindings).map(str::to_owned)
}
/// Match `event` against the cached bindings; if it matched, push a
/// `shortcut_triggered` Flutter session event and return `true` so the caller
/// can `return` early. Returns `false` when no shortcut fired (caller should
/// continue with normal key handling).
///
/// `session_id`:
/// * `Some(&id)` — Flutter FFI path: dispatch to the exact session whose key
/// event we're processing. No dependence on the global focus tracker.
/// * `None` — rdev grab loop: the loop is process-wide and has no way to know
/// which Flutter session id the keystroke was meant for, so route to the
/// globally-current session via `flutter::get_cur_session_id()`.
#[cfg(feature = "flutter")]
pub fn try_dispatch(session_id: Option<&hbb_common::SessionID>, event: &rdev::Event) -> bool {
let Some(action_id) = match_event(event) else {
return false;
};
let resolved;
let sid = match session_id {
Some(id) => id,
None => {
resolved = crate::flutter::get_cur_session_id();
&resolved
}
};
crate::flutter::push_session_event(sid, "shortcut_triggered", vec![("action", &action_id)]);
true
}
fn mods_bits(m: &[Modifier]) -> u8 {
let mut bits = 0u8;
for x in m {
bits |= match x {
Modifier::Primary => 1,
Modifier::Alt => 2,
Modifier::Shift => 4,
// macOS users can bind shortcuts that use Control independently
// of Command. On Win/Linux this variant should never appear in a
// saved binding (`normalize_modifiers` collapses Ctrl into
// Primary), but we still give it a distinct bit so a hand-edited
// config can't accidentally collide with another modifier.
Modifier::Ctrl => 8,
};
}
bits
}
fn mods_equal(a: &[Modifier], b: &[Modifier]) -> bool {
mods_bits(a) == mods_bits(b)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_press(k: rdev::Key) -> rdev::Event {
rdev::Event {
time: std::time::SystemTime::now(),
unicode: None,
platform_code: 0,
position_code: 0,
event_type: rdev::EventType::KeyPress(k),
usb_hid: 0,
#[cfg(any(target_os = "windows", target_os = "macos"))]
extra_data: 0,
}
}
#[test]
fn event_to_key_name_handles_f_keys() {
use rdev::Key;
assert_eq!(event_to_key_name(&make_press(Key::F1)), Some("f1".into()));
assert_eq!(event_to_key_name(&make_press(Key::F5)), Some("f5".into()));
assert_eq!(event_to_key_name(&make_press(Key::F12)), Some("f12".into()));
}
/// Cross-language parity for default bindings. The fixture file is the
/// shared source of truth — Dart has a mirror test against the same file
/// (`kDefaultShortcutBindings matches fixture` in
/// `flutter/test/keyboard_shortcuts_test.dart`). Any drift on either
/// side breaks one of the two tests.
#[test]
fn default_bindings_match_fixture_json() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../../flutter/test/fixtures/default_keyboard_shortcuts.json"
))
.expect("fixture is valid JSON");
let actual: serde_json::Value =
serde_json::to_value(default_bindings()).expect("serialize defaults");
assert_eq!(
fixture, actual,
"default_bindings() drifted from \
flutter/test/fixtures/default_keyboard_shortcuts.json — update \
shortcuts.rs, the fixture, and Dart kDefaultShortcutBindings together"
);
}
#[test]
fn event_to_key_name_treats_numpad_enter_as_enter() {
use rdev::{Event, EventType, Key};
let make = |k: Key| Event {
time: std::time::SystemTime::now(),
unicode: None,
platform_code: 0,
position_code: 0,
event_type: EventType::KeyPress(k),
usb_hid: 0,
#[cfg(any(target_os = "windows", target_os = "macos"))]
extra_data: 0,
};
assert_eq!(event_to_key_name(&make(Key::Return)), Some("enter".into()));
assert_eq!(event_to_key_name(&make(Key::KpReturn)), Some("enter".into()));
}
#[test]
fn bindings_round_trip_json() {
let json = r#"{
"enabled": true,
"bindings": [
{"action": "send_ctrl_alt_del", "mods": ["primary","alt","shift"], "key": "delete"},
{"action": "toggle_fullscreen", "mods": ["primary","alt","shift"], "key": "enter"}
]
}"#;
let parsed: Bindings = serde_json::from_str(json).expect("parse");
assert!(parsed.enabled);
assert_eq!(parsed.bindings.len(), 2);
assert_eq!(parsed.bindings[0].action, "send_ctrl_alt_del");
assert_eq!(parsed.bindings[0].key, "delete");
let serialized = serde_json::to_string(&parsed).expect("serialize");
let reparsed: Bindings = serde_json::from_str(&serialized).expect("reparse");
assert_eq!(parsed, reparsed);
}
#[test]
fn defaults_match_design_doc() {
let defaults = default_bindings();
let actions: Vec<&str> = defaults.iter().map(|b| b.action.as_str()).collect();
assert!(actions.contains(&action_id::SEND_CTRL_ALT_DEL));
assert!(actions.contains(&action_id::TOGGLE_FULLSCREEN));
assert!(actions.contains(&action_id::SWITCH_DISPLAY_NEXT));
assert!(actions.contains(&action_id::SWITCH_DISPLAY_PREV));
assert!(actions.contains(&action_id::SCREENSHOT));
assert!(actions.contains(&action_id::TOGGLE_SHOW_REMOTE_CURSOR));
assert!(actions.contains(&action_id::TOGGLE_MUTE));
assert!(actions.contains(&action_id::TOGGLE_BLOCK_INPUT));
assert!(actions.contains(&action_id::TOGGLE_CHAT));
// every default binding includes the three-modifier prefix
for b in &defaults {
assert!(b.mods.contains(&Modifier::Primary));
assert!(b.mods.contains(&Modifier::Alt));
assert!(b.mods.contains(&Modifier::Shift));
}
}
fn match_for_test<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> {
match_normalized(key, mods, b)
}
#[test]
fn match_returns_none_when_pass_through() {
let bindings = Bindings {
enabled: true,
pass_through: true,
bindings: default_bindings(),
};
let result = match_normalized(
"p",
&[Modifier::Primary, Modifier::Alt, Modifier::Shift],
&bindings,
);
assert_eq!(result, None);
}
#[test]
fn match_returns_none_when_disabled() {
let bindings = Bindings { enabled: false, pass_through: false, bindings: default_bindings() };
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
assert_eq!(result, None);
}
#[test]
fn match_screenshot_when_enabled() {
let bindings = Bindings { enabled: true, pass_through: false, bindings: default_bindings() };
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
assert_eq!(result, Some(action_id::SCREENSHOT));
}
#[test]
fn match_returns_none_when_modifiers_partial() {
let bindings = Bindings { enabled: true, pass_through: false, bindings: default_bindings() };
// missing Shift
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt], &bindings);
assert_eq!(result, None);
}
#[test]
fn match_does_not_fire_on_extra_unbound_keys() {
let bindings = Bindings { enabled: true, pass_through: false, bindings: default_bindings() };
let result = match_for_test("z", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
assert_eq!(result, None);
}
#[test]
fn match_handles_duplicate_modifiers_in_input() {
// A user-edited config could contain duplicate modifiers; the matcher must
// treat the modifier list as a set, not a multiset.
let bindings = Bindings {
enabled: true,
pass_through: false,
bindings: vec![Binding {
action: "x".into(),
mods: vec![Modifier::Primary, Modifier::Alt],
key: "a".into(),
}],
};
// Caller passes Primary twice — must not match a binding with Primary+Alt.
assert_eq!(
match_normalized("a", &[Modifier::Primary, Modifier::Primary], &bindings),
None,
);
// Caller passes Primary+Alt with one duplicate — should still match.
assert_eq!(
match_normalized("a", &[Modifier::Primary, Modifier::Alt, Modifier::Alt], &bindings),
Some("x"),
);
}
#[test]
fn modifier_normalization_primary_resolves_per_os() {
// On Win/Linux: pressing Ctrl satisfies Primary
let mods = normalize_modifiers(/*alt=*/true, /*ctrl=*/true, /*shift=*/true, /*command=*/false);
if cfg!(any(target_os = "macos", target_os = "ios")) {
// On Apple platforms Ctrl is NOT primary
assert!(!mods.contains(&Modifier::Primary));
assert!(mods.contains(&Modifier::Ctrl));
} else {
assert!(mods.contains(&Modifier::Primary));
}
assert!(mods.contains(&Modifier::Alt));
assert!(mods.contains(&Modifier::Shift));
}
#[test]
fn modifier_normalization_command_is_primary_on_apple() {
let mods = normalize_modifiers(true, false, true, /*command=*/true);
if cfg!(any(target_os = "macos", target_os = "ios")) {
assert!(mods.contains(&Modifier::Primary));
} else {
// On Win/Linux Command/Meta is NOT primary
assert!(!mods.contains(&Modifier::Primary));
}
}
#[test]
fn match_refuses_zero_modifier_bindings() {
// Defense-in-depth: a hand-edited config with empty `mods` must NOT
// turn every plain "P" press into a screenshot shortcut, which would
// swallow all typing in the remote session. The recording dialog
// already refuses to save such bindings, but the matcher must hold
// the line independently.
let bindings = Bindings {
enabled: true,
pass_through: false,
bindings: vec![Binding {
action: "screenshot".into(),
mods: vec![],
key: "p".into(),
}],
};
assert_eq!(match_normalized("p", &[], &bindings), None);
// Even with extra modifiers held by the user, a zero-mod binding
// still doesn't match (no shape of held modifiers can equal the
// empty saved set after the empty-check skips the entry).
assert_eq!(
match_normalized("p", &[Modifier::Primary], &bindings),
None,
);
}
/// Cross-language parity for the full set of shortcut-bindable key
/// names (not just the defaults). The fixture lists every name the
/// matcher accepts; this test verifies the (rdev::Key → name) round-trip
/// covers exactly that set. Dart has a mirror test against the same
/// fixture (`logicalKeyName covers the supported-keys fixture` in
/// `flutter/test/keyboard_shortcuts_test.dart`).
///
/// Adding a key requires updates in three places: the fixture, this
/// table, and the Dart `logicalKeyName` — that's the price of the
/// parity guarantee. Drift on any side breaks one of the two tests.
#[test]
fn supported_keys_match_fixture() {
use rdev::Key;
use std::collections::BTreeSet;
let table: &[(&str, Key)] = &[
("a", Key::KeyA), ("b", Key::KeyB), ("c", Key::KeyC),
("d", Key::KeyD), ("e", Key::KeyE), ("f", Key::KeyF),
("g", Key::KeyG), ("h", Key::KeyH), ("i", Key::KeyI),
("j", Key::KeyJ), ("k", Key::KeyK), ("l", Key::KeyL),
("m", Key::KeyM), ("n", Key::KeyN), ("o", Key::KeyO),
("p", Key::KeyP), ("q", Key::KeyQ), ("r", Key::KeyR),
("s", Key::KeyS), ("t", Key::KeyT), ("u", Key::KeyU),
("v", Key::KeyV), ("w", Key::KeyW), ("x", Key::KeyX),
("y", Key::KeyY), ("z", Key::KeyZ),
("digit0", Key::Num0), ("digit1", Key::Num1),
("digit2", Key::Num2), ("digit3", Key::Num3),
("digit4", Key::Num4), ("digit5", Key::Num5),
("digit6", Key::Num6), ("digit7", Key::Num7),
("digit8", Key::Num8), ("digit9", Key::Num9),
("f1", Key::F1), ("f2", Key::F2), ("f3", Key::F3),
("f4", Key::F4), ("f5", Key::F5), ("f6", Key::F6),
("f7", Key::F7), ("f8", Key::F8), ("f9", Key::F9),
("f10", Key::F10), ("f11", Key::F11), ("f12", Key::F12),
("delete", Key::Delete),
("backspace", Key::Backspace),
("tab", Key::Tab),
("space", Key::Space),
("enter", Key::Return),
("enter", Key::KpReturn),
("arrow_left", Key::LeftArrow),
("arrow_right", Key::RightArrow),
("arrow_up", Key::UpArrow),
("arrow_down", Key::DownArrow),
("home", Key::Home),
("end", Key::End),
("page_up", Key::PageUp),
("page_down", Key::PageDown),
("insert", Key::Insert),
];
// Round-trip: every entry in the table must map through
// event_to_key_name to its declared name.
for (name, key) in table {
assert_eq!(
event_to_key_name(&make_press(*key)).as_deref(),
Some(*name),
"rdev::Key::{:?} should map to {:?}",
key, name,
);
}
// The set of names produced by the table must equal the fixture.
let actual: BTreeSet<&str> = table.iter().map(|(n, _)| *n).collect();
let fixture_raw: Vec<String> = serde_json::from_str(include_str!(
"../../flutter/test/fixtures/supported_shortcut_keys.json"
))
.expect("fixture is valid JSON");
let expected: BTreeSet<&str> =
fixture_raw.iter().map(String::as_str).collect();
assert_eq!(
actual, expected,
"event_to_key_name vocabulary drifted from \
flutter/test/fixtures/supported_shortcut_keys.json — update \
shortcuts.rs, the fixture, and Dart logicalKeyName together"
);
}
#[test]
fn reload_handles_missing_and_invalid_json() {
// empty (no value set) → defaults
hbb_common::config::LocalConfig::set_option(LOCAL_CONFIG_KEY.into(), String::new());
reload_from_config();
let b = current();
assert!(!b.enabled);
assert!(b.bindings.is_empty());
// invalid JSON → defaults (no panic)
hbb_common::config::LocalConfig::set_option(LOCAL_CONFIG_KEY.into(), "not json".into());
reload_from_config();
let b = current();
assert!(!b.enabled);
}
}

View File

@@ -743,5 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "显示名称"),
("password-hidden-tip", "永久密码已设置(已隐藏)"),
("preset-password-in-use-tip", "当前使用预设密码"),
("Keyboard Shortcuts", "键盘快捷键"),
("Configure shortcuts...", "配置快捷键..."),
("Enable keyboard shortcuts in remote session", "在远程会话中启用键盘快捷键"),
("shortcut-page-description", "为下列每项会话操作绑定一个组合键。每个绑定至少需要包含一个修饰符。"),
("shortcut-passthrough-tip", "开启后,所有已绑定的组合键都会原样转发到远端。适合在某个组合键与远端需要使用的快捷键冲突时打开。"),
("Pass-through to remote", "穿透到远端"),
("Reset to defaults", "恢复默认设置"),
("shortcut-reset-confirm-tip", "这将以默认快捷键替换所有当前绑定。是否继续?"),
("Monitor", "显示器"),
("Keyboard", "键盘"),
("Toggle fullscreen", "切换全屏"),
("Switch to next display", "切换到下一个显示器"),
("Switch to previous display", "切换到上一个显示器"),
("All monitors", "所有显示器"),
("Monitor #{}", "{} 号显示器"),
("Switch to next tab", "切换到下一个标签"),
("Switch to previous tab", "切换到上一个标签"),
("Toggle session recording", "切换会话录制"),
("Close tab", "关闭标签页"),
("Toggle toolbar", "切换工具栏可见性"),
("Toggle input source", "切换输入源"),
("Edit", "编辑"),
("Save", "保存"),
("Set Shortcut", "设置快捷键"),
("shortcut-recording-instruction", "请按下您想使用的组合键。"),
("shortcut-recording-press-keys-tip", "请按下组合键..."),
("shortcut-must-include-modifiers", "必须至少包含一个修饰符:{}"),
("shortcut-already-bound-to", "已绑定到"),
("Replace", "替换"),
("Valid", "有效"),
("shortcut-mobile-physical-keyboard-tip", "录制需要使用物理键盘,不支持软键盘。"),
("shortcut-key-not-supported", "“{}” 不能用作快捷键。"),
("On", ""),
("Off", ""),
].iter().cloned().collect();
}

View File

@@ -274,5 +274,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("keep-awake-during-incoming-sessions-label", "Keep screen awake during incoming sessions"),
("password-hidden-tip", "Permanent password is set (hidden)."),
("preset-password-in-use-tip", "Preset password is currently in use."),
("shortcut-page-description", "Bind a key combination to each session action below. Each binding must include at least one modifier."),
("shortcut-passthrough-tip", "When on, every bound combination is forwarded to the remote. Useful when a binding collides with something you need on the remote."),
("shortcut-reset-confirm-tip", "This will replace all current bindings with the default set. Continue?"),
("shortcut-recording-instruction", "Press the key combination you want to use."),
("shortcut-recording-press-keys-tip", "Press a key combination..."),
("shortcut-must-include-modifiers", "Must include at least one modifier: {}"),
("shortcut-already-bound-to", "Already bound to"),
("shortcut-mobile-physical-keyboard-tip", "Recording requires a physical keyboard. Soft keyboards are not supported."),
("shortcut-key-not-supported", "\"{}\" can't be used as a shortcut."),
].iter().cloned().collect();
}

View File

@@ -23,7 +23,7 @@ use hbb_common::{
sync::mpsc,
time::{Duration as TokioDuration, Instant},
},
whoami, Stream,
whoami, SessionID, Stream,
};
use rdev::{Event, EventType::*, KeyCode};
#[cfg(all(feature = "vram", feature = "flutter"))]
@@ -913,6 +913,7 @@ impl<T: InvokeUiSession> Session<T> {
#[cfg(any(target_os = "ios"))]
pub fn handle_flutter_raw_key_event(
&self,
_session_id: SessionID,
_keyboard_mode: &str,
_name: &str,
_platform_code: i32,
@@ -925,6 +926,7 @@ impl<T: InvokeUiSession> Session<T> {
#[cfg(not(any(target_os = "ios")))]
pub fn handle_flutter_raw_key_event(
&self,
session_id: SessionID,
keyboard_mode: &str,
name: &str,
platform_code: i32,
@@ -936,6 +938,7 @@ impl<T: InvokeUiSession> Session<T> {
self._handle_key_flutter_simulation(keyboard_mode, platform_code, down_or_up);
} else {
self._handle_raw_key_non_flutter_simulation(
session_id,
keyboard_mode,
platform_code,
position_code,
@@ -948,6 +951,7 @@ impl<T: InvokeUiSession> Session<T> {
#[cfg(not(any(target_os = "ios")))]
fn _handle_raw_key_non_flutter_simulation(
&self,
session_id: SessionID,
keyboard_mode: &str,
platform_code: i32,
position_code: i32,
@@ -981,11 +985,18 @@ impl<T: InvokeUiSession> Session<T> {
#[cfg(any(target_os = "windows", target_os = "macos"))]
extra_data: 0,
};
keyboard::client::process_event_with_session(keyboard_mode, &event, Some(lock_modes), self);
keyboard::client::process_event_with_session(
keyboard_mode,
&event,
Some(lock_modes),
self,
session_id,
);
}
pub fn handle_flutter_key_event(
&self,
session_id: SessionID,
keyboard_mode: &str,
character: &str,
usb_hid: i32,
@@ -996,6 +1007,7 @@ impl<T: InvokeUiSession> Session<T> {
self._handle_key_flutter_simulation(keyboard_mode, usb_hid, down_or_up);
} else {
self._handle_key_non_flutter_simulation(
session_id,
keyboard_mode,
character,
usb_hid,
@@ -1031,6 +1043,7 @@ impl<T: InvokeUiSession> Session<T> {
fn _handle_key_non_flutter_simulation(
&self,
session_id: SessionID,
keyboard_mode: &str,
character: &str,
usb_hid: i32,
@@ -1092,7 +1105,13 @@ impl<T: InvokeUiSession> Session<T> {
#[cfg(any(target_os = "windows", target_os = "macos"))]
extra_data: 0,
};
keyboard::client::process_event_with_session(keyboard_mode, &event, Some(lock_modes), self);
keyboard::client::process_event_with_session(
keyboard_mode,
&event,
Some(lock_modes),
self,
session_id,
);
}
// flutter only TODO new input